From dd7a2da5bb0c2e91d300dbaebd3a7aca8379a965 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:29:11 +0800 Subject: [PATCH] fix(environment): keep invalid recorded dates unknown Distinguish invalid supported timestamp spellings from unrecognized legacy formats at the recorded-date owner. Probe replay retains ordinal order for invalid dates without changing the existing legacy fallback. Validated date parsing, state ordering and comparison workflows; the prior implementation reorders the invalid-date regression. --- .../environment/environmentCompare.test.ts | 51 ++++++++++-------- .../environment/environmentCompareProbe.ts | 7 +-- src/domain/offsetDateTime.test.ts | 13 +++++ src/domain/offsetDateTime.ts | 53 ++++++++++++------- 4 files changed, 80 insertions(+), 44 deletions(-) diff --git a/src/application/environment/environmentCompare.test.ts b/src/application/environment/environmentCompare.test.ts index d5335e2180..a3f94550c5 100644 --- a/src/application/environment/environmentCompare.test.ts +++ b/src/application/environment/environmentCompare.test.ts @@ -841,28 +841,37 @@ const registerProbeCoverageCases = () => { }; const registerProbeOrderingAndCaptureCases = () => { - it("keeps the recorded order when a record has no start time, as state extraction does", () => { - const { startedDateTime: _undated, ...read } = { - ...exchange(1, "GET", "http://app.example/api/books/2", 404), - response: { - status: 404, - content: { - mimeType: "application/json", - text: JSON.stringify(BOOK_NOT_FOUND), + it.each([undefined, "2026-02-30T09:00:00Z"])( + "keeps replay in recorded order when a start is missing or invalid: %s", + (startedDateTime) => { + const { startedDateTime: _undated, ...read } = { + ...exchange(1, "GET", "http://app.example/api/books/2", 404), + response: { + status: 404, + content: { + mimeType: "application/json", + text: JSON.stringify(BOOK_NOT_FOUND), + }, }, - }, - }; - const report = run( - analysisViewCaptureEvidence( - environmentManifestCaptureFixture([ - exchange(0, "DELETE", "http://app.example/api/books/2", 204), - read, - ]), - ), - ); - if (!report.ok) throw report.error; - expect(report.value.summary).toMatchObject({ steps: 2, equal: 2 }); - }); + }; + const report = run( + analysisViewCaptureEvidence( + environmentManifestCaptureFixture([ + exchange(0, "DELETE", "http://app.example/api/books/2", 204), + { + ...read, + ...(startedDateTime === undefined ? {} : { startedDateTime }), + }, + ]), + ), + ); + if (!report.ok) throw report.error; + expect(report.value.summary).toMatchObject({ steps: 2, equal: 2 }); + expect( + report.value.probes[0]?.results.map(({ ordinal }) => ordinal), + ).toEqual([0, 1]); + }, + ); it.each([ ["2026-10-20T09:00:00.0002Z", "2026-10-20T09:00:00.0001Z"], diff --git a/src/application/environment/environmentCompareProbe.ts b/src/application/environment/environmentCompareProbe.ts index 8def1fa49a..298401b3d3 100644 --- a/src/application/environment/environmentCompareProbe.ts +++ b/src/application/environment/environmentCompareProbe.ts @@ -19,7 +19,7 @@ import type { JsonValue } from "../../domain/jsonValue.js"; import { compareOffsetDateTimeInstants, offsetDateTimeInstantFromEpochMilliseconds, - readRecordedDateTime, + inspectRecordedDateTime, type OffsetDateTimeInstant, } from "../../domain/offsetDateTime.js"; import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js"; @@ -55,8 +55,9 @@ const startedInstant = ( ): OffsetDateTimeInstant | undefined => { const started = startedSchema.safeParse(record.reported); if (!started.success) return undefined; - const reading = readRecordedDateTime(started.data.startedDateTime); - if (reading !== undefined) return reading.instant; + const reading = inspectRecordedDateTime(started.data.startedDateTime); + if (reading.kind === "valid") return reading.reading.instant; + if (reading.kind === "invalid") return undefined; // Preserve native-Date admission for legacy spellings, at its original precision. const ms = Date.parse(started.data.startedDateTime); return Number.isFinite(ms) diff --git a/src/domain/offsetDateTime.test.ts b/src/domain/offsetDateTime.test.ts index 60d65d7f72..eac1722b07 100644 --- a/src/domain/offsetDateTime.test.ts +++ b/src/domain/offsetDateTime.test.ts @@ -2,11 +2,24 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { + inspectRecordedDateTime, offsetDateTimeSchema, readRecordedDateTime, utcDateTimeSchema, } from "./offsetDateTime.js"; +it.each([ + ["2026-02-30T09:00:00Z", "invalid"], + ["1900-02-29 09:00:00", "invalid"], + ["2026-10-20T24:00:00Z", "invalid"], + ["2026-10-20T09:00:00+24:00", "invalid"], + ["2026-10-20 09:00:00.0000000001", "valid"], + ["Tue, 20 Oct 2026 09:00:00 GMT", "unrecognized"], + ["2026-10-20", "unrecognized"], +])("classifies recorded spelling %s as %s", (text, kind) => { + expect(inspectRecordedDateTime(text).kind).toBe(kind); +}); + const ACCEPTED: readonly (readonly [string, string])[] = [ ["a UTC timestamp", "2026-10-09T04:00:00Z"], ["a positive offset", "2026-10-09T04:00:00+02:00"], diff --git a/src/domain/offsetDateTime.ts b/src/domain/offsetDateTime.ts index c1ba19f360..c713d76fa5 100644 --- a/src/domain/offsetDateTime.ts +++ b/src/domain/offsetDateTime.ts @@ -55,25 +55,27 @@ export const parseOffsetDateTimeInstant = ( }; }; -/** Parse recorded ISO spelling once, retaining its exact instant and formatting facts. */ -export const readRecordedDateTime = ( +export interface RecordedDateTimeReading { + readonly instant: OffsetDateTimeInstant; + readonly spelling: { + readonly separator: "T" | " "; + readonly secondsPresent: boolean; + readonly fractionDigits: number; + readonly zone: string; + }; +} + +/** Distinguish invalid supported spellings from formats this reader does not interpret. */ +export const inspectRecordedDateTime = ( value: string, ): - | { - readonly instant: OffsetDateTimeInstant; - readonly spelling: { - readonly separator: "T" | " "; - readonly secondsPresent: boolean; - readonly fractionDigits: number; - readonly zone: string; - }; - } - | undefined => { + | { readonly kind: "valid"; readonly reading: RecordedDateTimeReading } + | { readonly kind: "invalid" | "unrecognized" } => { const match = /^(\d{4}-\d{2}-\d{2})([T ])(\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?$/u.exec( value, ); - if (match === null) return undefined; + if (match === null) return { kind: "unrecognized" }; const [, date, separator, time, seconds, fraction = "", zone = ""] = match; const offset = zone === "" @@ -84,18 +86,29 @@ export const readRecordedDateTime = ( const normalized = offsetDateTimeSchema.safeParse( `${date}T${time}${seconds === undefined ? "" : `:${seconds}`}${fraction === "" ? "" : `.${fraction}`}${offset}`, ); - if (!normalized.success) return undefined; + if (!normalized.success) return { kind: "invalid" }; return { - instant: parseOffsetDateTimeInstant(normalized.data), - spelling: { - separator: separator as "T" | " ", - secondsPresent: seconds !== undefined, - fractionDigits: fraction.length, - zone, + kind: "valid", + reading: { + instant: parseOffsetDateTimeInstant(normalized.data), + spelling: { + separator: separator as "T" | " ", + secondsPresent: seconds !== undefined, + fractionDigits: fraction.length, + zone, + }, }, }; }; +/** Parse recorded ISO spelling once, retaining its exact instant and formatting facts. */ +export const readRecordedDateTime = ( + value: string, +): RecordedDateTimeReading | undefined => { + const inspected = inspectRecordedDateTime(value); + return inspected.kind === "valid" ? inspected.reading : undefined; +}; + /** Convert a Date.now-style integer into the same exact comparison shape. */ export const offsetDateTimeInstantFromEpochMilliseconds = ( epochMilliseconds: number,