fix(environment): share exact recorded timestamp interpretation

This commit is contained in:
morluto
2026-10-24 11:08:20 +08:00
parent f3269feaad
commit 80b7bbc8bd
8 changed files with 314 additions and 77 deletions
@@ -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)",
@@ -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,
@@ -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();
}
},
);
@@ -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<RecordedRequestLine, { readonly kind: "line" }>,
): 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 = <Kind extends AnalysisViewResult["kind"]>(
@@ -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;
};
@@ -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<ClockReading, "ms" | "subMillisecond">,
): 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,
},
};
};
+135 -1
View File
@@ -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<ReturnType<typeof readRecordedDateTime>>;
}>([
{
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")
+41
View File
@@ -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,
@@ -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",