diff --git a/src/application/environment/environmentCompare.test.ts b/src/application/environment/environmentCompare.test.ts index 7c2676ce4f..d1436ba915 100644 --- a/src/application/environment/environmentCompare.test.ts +++ b/src/application/environment/environmentCompare.test.ts @@ -686,6 +686,10 @@ describe("probe request chronology", () => { ["2026-10-20T09:00:00.0002Z", "2026-10-20T09:00:00.0001Z"], ["2026-10-20T09:00:00.0000000002Z", "2026-10-20T09:00:00.0000000001Z"], ["0001-01-01T00:00:00.0002Z", "0001-01-01T01:00:00.0001+01:00"], + ["2026-10-20 09:00:00.0002Z", "2026-10-20 09:00:00.0001Z"], + ["2026-10-20T10:00:00.0002+0100", "2026-10-20T09:00:00.0001Z"], + ["2026-10-20 10:00:00.0002+0100", "2026-10-20 09:00:00.0001+0000"], + ["2026-10-20T09:00:00.0002", "2026-10-20T09:00:00.0001"], ])( "replays an earlier delete before a later submillisecond read (%s)", (later, earlier) => { @@ -730,6 +734,7 @@ describe("probe request chronology", () => { it.each([ ["2026-10-20T09:00:00.000100Z", "2026-10-20T10:00:00.0001+01:00"], + ["2026-10-20T09:00:00.0001Z", "2026-10-20T09:00:00.000100"], ["Tue, 20 Oct 2026 09:00:00 GMT", "2026-10-20T09:00:00Z"], ])( "keeps ordinal order for equal instants and native-Date spelling compatibility (%s)", diff --git a/src/application/environment/environmentCompareProbe.ts b/src/application/environment/environmentCompareProbe.ts index da08ef12d4..cdfb7ce423 100644 --- a/src/application/environment/environmentCompareProbe.ts +++ b/src/application/environment/environmentCompareProbe.ts @@ -15,8 +15,7 @@ import type { JsonValue } from "../../domain/jsonValue.js"; import { compareOffsetDateTimeInstants, offsetDateTimeInstantFromEpochMilliseconds, - offsetDateTimeSchema, - parseOffsetDateTimeInstant, + readRecordedDateTime, type OffsetDateTimeInstant, } from "../../domain/offsetDateTime.js"; import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js"; @@ -58,10 +57,16 @@ const startedInstant = ( ): OffsetDateTimeInstant | undefined => { const started = startedSchema.safeParse(record.reported); if (!started.success) return undefined; - const timestamp = offsetDateTimeSchema.safeParse( - started.data.startedDateTime, - ); - if (timestamp.success) return parseOffsetDateTimeInstant(timestamp.data); + const reading = readRecordedDateTime(started.data.startedDateTime); + if (reading !== undefined) { + const { spelling } = reading; + const offsetTimestamp = + spelling.separator === "T" && + (spelling.zone === "Z" || spelling.zone.includes(":")); + const clockTimestamp = + spelling.secondsPresent && spelling.fractionDigits <= 9; + if (offsetTimestamp || clockTimestamp) return reading.instant; + } // Preserve native-Date admission for legacy spellings, at its original precision. const ms = Date.parse(started.data.startedDateTime); return Number.isFinite(ms) @@ -153,16 +158,16 @@ export const probeRequests = ( const origin = probeOrigin(lines); for (const record of ordered) { const line = lines.get(record.ordinal); - const exchange = recordedExchange(record); const { ordinal } = record; if (line?.kind === "line" && line.origin !== origin) unobserved.push({ ordinal, reason: "other-origin" }); - else if (line?.kind !== "line" || exchange === undefined) + else if (line?.kind !== "line") unobserved.push({ ordinal, reason: line?.kind === "excluded" ? line.reason : "request-unavailable", }); else { + const exchange = recordedExchange(record, line); const request = { method: line.method, path: exchange.path, diff --git a/src/application/environment/environmentSource.test.ts b/src/application/environment/environmentSource.test.ts index ad94c40f73..af8e925a11 100644 --- a/src/application/environment/environmentSource.test.ts +++ b/src/application/environment/environmentSource.test.ts @@ -7,6 +7,7 @@ import { timedCaptureEvidence, } from "../../../tests/fixtures/analysisView.js"; import { scenarioCaptureEvidence } from "../../../tests/fixtures/browserScenarioCapture.js"; +import { readRecordedRequestLine } from "../../domain/analysisView/webNetworkRouteTemplates.js"; import { environmentServeFixture, environmentServeSource, @@ -67,8 +68,10 @@ it.each([ redactions: [], limitations: [], }; - const exchange = recordedExchange(record); - if (exchange === undefined) throw new Error("Expected a recorded exchange"); + const line = readRecordedRequestLine(record.reported, true, () => true); + if (line?.kind !== "line") + throw new Error("Expected a recorded request line"); + const exchange = recordedExchange(record, line); expect(exchange.bodyText).toBe(expected); expect(replayedBody(exchange)).toEqual( expected === null ? { kind: "none" } : { kind: "text", text: expected }, @@ -84,7 +87,7 @@ it.each([ reason: "explicit-sensitive-value", scope: "property-name", }); - expect(recordedExchange(record)?.bodyText).toBeNull(); + expect(recordedExchange(record, line).bodyText).toBeNull(); } }, ); diff --git a/src/application/environment/environmentSource.ts b/src/application/environment/environmentSource.ts index e109f6ea59..df5db267b8 100644 --- a/src/application/environment/environmentSource.ts +++ b/src/application/environment/environmentSource.ts @@ -13,6 +13,7 @@ import { isJsonLike } from "../../domain/analysisView/EnvironmentManifestAccumul import { mediaTypeEssence, readRecordedRequestLine, + type RecordedRequestLine, } from "../../domain/analysisView/webNetworkRouteTemplates.js"; import { retainedBrowserScenarioCaptureSchema } from "../../domain/browserScenarioCapture.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -170,21 +171,18 @@ const schemaInputError = ( })), ); -/** One capture record reduced to its request line and retained response body, or undefined without a request line. */ +/** Combine an already parsed request line with its record's retained response body. */ export const recordedExchange = ( record: WebNetworkCaptureRecord, -): RecordedExchange | undefined => { - const line = readRecordedRequestLine(record.reported, true, () => true); - if (line === null || line.kind !== "line") return undefined; - return { - ordinal: record.ordinal, - path: line.path, - query: line.query === undefined || line.query === "" ? null : line.query, - status: line.status, - contentType: line.rawMime === "" ? null : line.rawMime, - ...readRecordedResponseBody(record, REPLAY_BODY_LIMIT_BYTES), - }; -}; + line: Extract, +): RecordedExchange => ({ + ordinal: record.ordinal, + path: line.path, + query: line.query === undefined || line.query === "" ? null : line.query, + status: line.status, + contentType: line.rawMime === "" ? null : line.rawMime, + ...readRecordedResponseBody(record, REPLAY_BODY_LIMIT_BYTES), +}); /** Authenticate a view Evidence file and require one view kind; a refusal names the file role. */ export const parseViewEvidence = ( @@ -416,9 +414,10 @@ const indexExchanges = ( for (const ordinal of tool.record_ordinals) { if (exchanges.has(ordinal)) continue; const record = byOrdinal.get(ordinal); - const exchange = - record === undefined ? undefined : recordedExchange(record); - if (exchange !== undefined) exchanges.set(ordinal, exchange); + if (record === undefined) continue; + const line = readRecordedRequestLine(record.reported, true, () => true); + if (line?.kind === "line") + exchanges.set(ordinal, recordedExchange(record, line)); } return exchanges; }; diff --git a/src/domain/analysisView/environmentStateWriteValues.ts b/src/domain/analysisView/environmentStateWriteValues.ts index c9eb0eb3e2..564e62d33b 100644 --- a/src/domain/analysisView/environmentStateWriteValues.ts +++ b/src/domain/analysisView/environmentStateWriteValues.ts @@ -1,9 +1,6 @@ import { createHash } from "node:crypto"; import type { JsonValue } from "../jsonValue.js"; -import { - offsetDateTimeSchema, - parseOffsetDateTimeInstant, -} from "../offsetDateTime.js"; +import { readRecordedDateTime } from "../offsetDateTime.js"; /** How a recorded clock value is spelled: ISO-8601 with its separator, fraction digits and zone, or an epoch number. */ export type ClockFormat = @@ -28,8 +25,6 @@ export const compareClockInstants = ( right: Pick, ): number => left.ms - right.ms || left.subMillisecond - right.subMillisecond; -const ISO_INSTANT = - /^\d{4}-\d{2}-\d{2}([T ])\d{2}:\d{2}:\d{2}(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/u; const MINUTE_MS = 60_000; /** Minutes east of UTC that a zone suffix names; none and `Z` are UTC. */ @@ -57,30 +52,23 @@ const zoneMinutesForFormat = (zone: string): number => { /** The instant of an ISO-8601 date-time and how it is spelled, or undefined when it is not one. */ const readIsoClock = (value: string): ClockReading | undefined => { - const match = ISO_INSTANT.exec(value); - if (match === null) return undefined; - const [, separator, fraction = "", zone = ""] = match; - // Clock spelling also permits a space, compact offsets and implicit UTC. - // Normalize those spellings before the shared owner validates the calendar. - const offset = - zone === "" - ? "Z" - : zone === "Z" || zone.includes(":") - ? zone - : `${zone.slice(0, 3)}:${zone.slice(3)}`; - const timestamp = offsetDateTimeSchema.safeParse( - `${value.slice(0, 10)}T${value.slice(11, 19)}${fraction === "" ? "" : `.${fraction}`}${offset}`, - ); - if (!timestamp.success) return undefined; - const instant = parseOffsetDateTimeInstant(timestamp.data); + const reading = readRecordedDateTime(value); + if ( + reading === undefined || + !reading.spelling.secondsPresent || + reading.spelling.fractionDigits > 9 + ) + return undefined; + const { instant, spelling } = reading; return { - ms: instant.epochSecond * 1000 + Number(`${fraction}000`.slice(0, 3)), - subMillisecond: Number(fraction.padEnd(9, "0").slice(3)), + ms: + instant.epochSecond * 1000 + Number(`${instant.fraction}000`.slice(0, 3)), + subMillisecond: Number(instant.fraction.padEnd(9, "0").slice(3)), format: { kind: "iso", - separator: separator as "T" | " ", - fraction: fraction.length, - zone, + separator: spelling.separator, + fraction: spelling.fractionDigits, + zone: spelling.zone, }, }; }; diff --git a/src/domain/offsetDateTime.test.ts b/src/domain/offsetDateTime.test.ts index 6d334109f2..783710a63c 100644 --- a/src/domain/offsetDateTime.test.ts +++ b/src/domain/offsetDateTime.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { offsetDateTimeSchema } from "./offsetDateTime.js"; +import { + offsetDateTimeSchema, + readRecordedDateTime, +} from "./offsetDateTime.js"; /** The validator this schema replaces; it remains the oracle for the accepted set. */ const replaced = z.string().datetime({ offset: true }); @@ -78,6 +81,137 @@ describe("offset datetime schema", () => { }); }); +describe("recorded datetime facts", () => { + const epochSecond = Date.UTC(2026, 9, 20, 9) / 1000; + + it.each<{ + text: string; + expected: NonNullable>; + }>([ + { + text: "2026-10-20T09:00:00.000100Z", + expected: { + instant: { epochSecond, fraction: "0001" }, + spelling: { + separator: "T", + secondsPresent: true, + fractionDigits: 6, + zone: "Z", + }, + }, + }, + { + text: "2026-10-20 10:00:00.000100+0100", + expected: { + instant: { epochSecond, fraction: "0001" }, + spelling: { + separator: " ", + secondsPresent: true, + fractionDigits: 6, + zone: "+0100", + }, + }, + }, + { + text: "2026-10-20T09:00:00.0000000002", + expected: { + instant: { epochSecond, fraction: "0000000002" }, + spelling: { + separator: "T", + secondsPresent: true, + fractionDigits: 10, + zone: "", + }, + }, + }, + { + text: "2026-10-20T08:30:00.1234567890123456789000-00:30", + expected: { + instant: { epochSecond, fraction: "1234567890123456789" }, + spelling: { + separator: "T", + secondsPresent: true, + fractionDigits: 22, + zone: "-00:30", + }, + }, + }, + { + text: "2026-10-20T09:00-00:00", + expected: { + instant: { epochSecond, fraction: "" }, + spelling: { + separator: "T", + secondsPresent: false, + fractionDigits: 0, + zone: "-00:00", + }, + }, + }, + { + text: "2026-10-20 10:00+0100", + expected: { + instant: { epochSecond, fraction: "" }, + spelling: { + separator: " ", + secondsPresent: false, + fractionDigits: 0, + zone: "+0100", + }, + }, + }, + { + text: "0001-01-01T01:00:00.0000000002+0100", + expected: { + instant: { epochSecond: -62_135_596_800, fraction: "0000000002" }, + spelling: { + separator: "T", + secondsPresent: true, + fractionDigits: 10, + zone: "+0100", + }, + }, + }, + { + text: "0000-02-29 00:00:00.000Z", + expected: { + instant: { epochSecond: -62_162_121_600, fraction: "" }, + spelling: { + separator: " ", + secondsPresent: true, + fractionDigits: 3, + zone: "Z", + }, + }, + }, + ])( + "preserves the exact instant and source spelling of $text", + ({ text, expected }) => { + expect(readRecordedDateTime(text)).toEqual(expected); + }, + ); + + it.each([ + "0001-02-29T00:00:00Z", + "1900-02-29 00:00:00", + "2026-04-31 00:00:00+0100", + "2026-13-01T00:00:00Z", + "2026-10-20T24:00:00Z", + "2026-10-20T09:60:00Z", + "2026-10-20T09:00:60Z", + "2026-10-20T09:00:00+24:00", + "2026-10-20 09:00:00+0060", + "2026-10-20T09:00:00+01", + "2026-10-20T09:00.1Z", + "2026-10-20t09:00:00Z", + "2026-10-20T09:00:00z", + "2026-10-20", + "Tue, 20 Oct 2026 09:00:00 GMT", + ])("does not invent ISO facts for %s", (text) => { + expect(readRecordedDateTime(text)).toBeUndefined(); + }); +}); + const findPattern = (value: unknown): string | undefined => { if (value === null || typeof value !== "object") return undefined; if ("pattern" in value && typeof value.pattern === "string") diff --git a/src/domain/offsetDateTime.ts b/src/domain/offsetDateTime.ts index b7d38550f2..801aeb3ca5 100644 --- a/src/domain/offsetDateTime.ts +++ b/src/domain/offsetDateTime.ts @@ -50,6 +50,47 @@ export const parseOffsetDateTimeInstant = ( }; }; +/** Parse recorded ISO spelling once, retaining its exact instant and formatting facts. */ +export const readRecordedDateTime = ( + value: string, +): + | { + readonly instant: OffsetDateTimeInstant; + readonly spelling: { + readonly separator: "T" | " "; + readonly secondsPresent: boolean; + readonly fractionDigits: number; + readonly zone: string; + }; + } + | undefined => { + 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; + const [, date, separator, time, seconds, fraction = "", zone = ""] = match; + const offset = + zone === "" + ? "Z" + : zone === "Z" || zone.includes(":") + ? zone + : `${zone.slice(0, 3)}:${zone.slice(3)}`; + const normalized = offsetDateTimeSchema.safeParse( + `${date}T${time}${seconds === undefined ? "" : `:${seconds}`}${fraction === "" ? "" : `.${fraction}`}${offset}`, + ); + if (!normalized.success) return undefined; + return { + instant: parseOffsetDateTimeInstant(normalized.data), + spelling: { + separator: separator as "T" | " ", + secondsPresent: seconds !== undefined, + fractionDigits: fraction.length, + zone, + }, + }; +}; + /** Convert a Date.now-style integer into the same exact comparison shape. */ export const offsetDateTimeInstantFromEpochMilliseconds = ( epochMilliseconds: number, diff --git a/tests/boundary/cli/environmentCompareCli.test.ts b/tests/boundary/cli/environmentCompareCli.test.ts index e8d2308939..a965ca12d3 100644 --- a/tests/boundary/cli/environmentCompareCli.test.ts +++ b/tests/boundary/cli/environmentCompareCli.test.ts @@ -55,31 +55,40 @@ const writeExactClockFiles = async ( ["GET", "/api/books/1", BOOK_NOT_FOUND, 404], ]); const fixture = environmentStateServeFixture(capture); - const ordered = analysisViewCaptureEvidence( - environmentManifestCaptureFixture([ - { - startedDateTime: "0001-01-01T00:00:00.0000000002Z", - request: { method: "GET", url: "http://app.example/api/books/1" }, - response: { - status: 404, - content: { - mimeType: "application/json", - text: JSON.stringify(BOOK_NOT_FOUND), + const chronologyProbe = (later: string, earlier: string) => + analysisViewCaptureEvidence( + environmentManifestCaptureFixture([ + { + startedDateTime: later, + request: { method: "GET", url: "http://app.example/api/books/1" }, + response: { + status: 404, + content: { + mimeType: "application/json", + text: JSON.stringify(BOOK_NOT_FOUND), + }, }, }, - }, - { - startedDateTime: "0001-01-01T01:00:00.0000000001+01:00", - request: { - method: "DELETE", - url: "http://app.example/api/books/1", + { + startedDateTime: earlier, + request: { + method: "DELETE", + url: "http://app.example/api/books/1", + }, + response: { + status: 204, + content: { mimeType: "application/json", text: "" }, + }, }, - response: { - status: 204, - content: { mimeType: "application/json", text: "" }, - }, - }, - ]), + ]), + ); + const ordered = chronologyProbe( + "0001-01-01T00:00:00.0000000002Z", + "0001-01-01T01:00:00.0000000001+01:00", + ); + const legacyOrdered = chronologyProbe( + "2026-10-20 10:00:00.0002+0100", + "2026-10-20 09:00:00.0001+0000", ); const clocks = timedCaptureEvidence([ [ @@ -93,6 +102,7 @@ const writeExactClockFiles = async ( ["manifest.json", fixture.manifest], ["capture.json", capture], ["order.json", ordered], + ["legacy-order.json", legacyOrdered], ["clocks.json", clocks], ] as const) { const path = join(root, name); @@ -338,6 +348,58 @@ describe("rea environment-compare value context", () => { ); }); +describe("rea environment-compare recorded clock spelling", () => { + cliTest( + "replays compact-offset submillisecond probe timestamps in exact order", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-environment-legacy-chronology-", + ); + const originals = await writeExactClockFiles(root); + const result = await cli.run({ + arguments: [ + "environment-compare", + join(root, "manifest.json"), + "--evidence", + join(root, "capture.json"), + "--probe", + join(root, "legacy-order.json"), + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + status: "complete", + environment: { state: "served" }, + summary: { probes: 1, steps: 2, equal: 2, agreement: 1 }, + probes: [ + { + label: "legacy-order.json", + results: [ + { + ordinal: 1, + method: "DELETE", + live_status: 204, + env_status: 204, + equal: true, + }, + { + ordinal: 0, + method: "GET", + live_status: 404, + env_status: 404, + equal: true, + }, + ], + }, + ], + }); + for (const [path, text] of originals) + expect(await readFile(path, "utf8")).toBe(text); + }, + ); +}); + describe("rea environment-compare replay and chronology", () => { cliTest( "preserves exact probe chronology and first-century clock-format differences",