mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
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.
This commit is contained in:
1 parent
4e86bd0c1c
commit
dd7a2da5bb
4 files changed
+80
-44
No files matched your search
@@ -841,28 +841,37 @@ const registerProbeCoverageCases = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const registerProbeOrderingAndCaptureCases = () => {
|
const registerProbeOrderingAndCaptureCases = () => {
|
||||||
it("keeps the recorded order when a record has no start time, as state extraction does", () => {
|
it.each([undefined, "2026-02-30T09:00:00Z"])(
|
||||||
const { startedDateTime: _undated, ...read } = {
|
"keeps replay in recorded order when a start is missing or invalid: %s",
|
||||||
...exchange(1, "GET", "http://app.example/api/books/2", 404),
|
(startedDateTime) => {
|
||||||
response: {
|
const { startedDateTime: _undated, ...read } = {
|
||||||
status: 404,
|
...exchange(1, "GET", "http://app.example/api/books/2", 404),
|
||||||
content: {
|
response: {
|
||||||
mimeType: "application/json",
|
status: 404,
|
||||||
text: JSON.stringify(BOOK_NOT_FOUND),
|
content: {
|
||||||
|
mimeType: "application/json",
|
||||||
|
text: JSON.stringify(BOOK_NOT_FOUND),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
const report = run(
|
||||||
const report = run(
|
analysisViewCaptureEvidence(
|
||||||
analysisViewCaptureEvidence(
|
environmentManifestCaptureFixture([
|
||||||
environmentManifestCaptureFixture([
|
exchange(0, "DELETE", "http://app.example/api/books/2", 204),
|
||||||
exchange(0, "DELETE", "http://app.example/api/books/2", 204),
|
{
|
||||||
read,
|
...read,
|
||||||
]),
|
...(startedDateTime === undefined ? {} : { startedDateTime }),
|
||||||
),
|
},
|
||||||
);
|
]),
|
||||||
if (!report.ok) throw report.error;
|
),
|
||||||
expect(report.value.summary).toMatchObject({ steps: 2, equal: 2 });
|
);
|
||||||
});
|
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([
|
it.each([
|
||||||
["2026-10-20T09:00:00.0002Z", "2026-10-20T09:00:00.0001Z"],
|
["2026-10-20T09:00:00.0002Z", "2026-10-20T09:00:00.0001Z"],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type { JsonValue } from "../../domain/jsonValue.js";
|
|||||||
import {
|
import {
|
||||||
compareOffsetDateTimeInstants,
|
compareOffsetDateTimeInstants,
|
||||||
offsetDateTimeInstantFromEpochMilliseconds,
|
offsetDateTimeInstantFromEpochMilliseconds,
|
||||||
readRecordedDateTime,
|
inspectRecordedDateTime,
|
||||||
type OffsetDateTimeInstant,
|
type OffsetDateTimeInstant,
|
||||||
} from "../../domain/offsetDateTime.js";
|
} from "../../domain/offsetDateTime.js";
|
||||||
import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js";
|
import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js";
|
||||||
@@ -55,8 +55,9 @@ const startedInstant = (
|
|||||||
): OffsetDateTimeInstant | undefined => {
|
): OffsetDateTimeInstant | undefined => {
|
||||||
const started = startedSchema.safeParse(record.reported);
|
const started = startedSchema.safeParse(record.reported);
|
||||||
if (!started.success) return undefined;
|
if (!started.success) return undefined;
|
||||||
const reading = readRecordedDateTime(started.data.startedDateTime);
|
const reading = inspectRecordedDateTime(started.data.startedDateTime);
|
||||||
if (reading !== undefined) return reading.instant;
|
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.
|
// Preserve native-Date admission for legacy spellings, at its original precision.
|
||||||
const ms = Date.parse(started.data.startedDateTime);
|
const ms = Date.parse(started.data.startedDateTime);
|
||||||
return Number.isFinite(ms)
|
return Number.isFinite(ms)
|
||||||
|
|||||||
@@ -2,11 +2,24 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
inspectRecordedDateTime,
|
||||||
offsetDateTimeSchema,
|
offsetDateTimeSchema,
|
||||||
readRecordedDateTime,
|
readRecordedDateTime,
|
||||||
utcDateTimeSchema,
|
utcDateTimeSchema,
|
||||||
} from "./offsetDateTime.js";
|
} 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])[] = [
|
const ACCEPTED: readonly (readonly [string, string])[] = [
|
||||||
["a UTC timestamp", "2026-10-09T04:00:00Z"],
|
["a UTC timestamp", "2026-10-09T04:00:00Z"],
|
||||||
["a positive offset", "2026-10-09T04:00:00+02:00"],
|
["a positive offset", "2026-10-09T04:00:00+02:00"],
|
||||||
|
|||||||
@@ -55,25 +55,27 @@ export const parseOffsetDateTimeInstant = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Parse recorded ISO spelling once, retaining its exact instant and formatting facts. */
|
export interface RecordedDateTimeReading {
|
||||||
export const readRecordedDateTime = (
|
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,
|
value: string,
|
||||||
):
|
):
|
||||||
| {
|
| { readonly kind: "valid"; readonly reading: RecordedDateTimeReading }
|
||||||
readonly instant: OffsetDateTimeInstant;
|
| { readonly kind: "invalid" | "unrecognized" } => {
|
||||||
readonly spelling: {
|
|
||||||
readonly separator: "T" | " ";
|
|
||||||
readonly secondsPresent: boolean;
|
|
||||||
readonly fractionDigits: number;
|
|
||||||
readonly zone: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| undefined => {
|
|
||||||
const match =
|
const match =
|
||||||
/^(\d{4}-\d{2}-\d{2})([T ])(\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?$/u.exec(
|
/^(\d{4}-\d{2}-\d{2})([T ])(\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?$/u.exec(
|
||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
if (match === null) return undefined;
|
if (match === null) return { kind: "unrecognized" };
|
||||||
const [, date, separator, time, seconds, fraction = "", zone = ""] = match;
|
const [, date, separator, time, seconds, fraction = "", zone = ""] = match;
|
||||||
const offset =
|
const offset =
|
||||||
zone === ""
|
zone === ""
|
||||||
@@ -84,18 +86,29 @@ export const readRecordedDateTime = (
|
|||||||
const normalized = offsetDateTimeSchema.safeParse(
|
const normalized = offsetDateTimeSchema.safeParse(
|
||||||
`${date}T${time}${seconds === undefined ? "" : `:${seconds}`}${fraction === "" ? "" : `.${fraction}`}${offset}`,
|
`${date}T${time}${seconds === undefined ? "" : `:${seconds}`}${fraction === "" ? "" : `.${fraction}`}${offset}`,
|
||||||
);
|
);
|
||||||
if (!normalized.success) return undefined;
|
if (!normalized.success) return { kind: "invalid" };
|
||||||
return {
|
return {
|
||||||
instant: parseOffsetDateTimeInstant(normalized.data),
|
kind: "valid",
|
||||||
spelling: {
|
reading: {
|
||||||
separator: separator as "T" | " ",
|
instant: parseOffsetDateTimeInstant(normalized.data),
|
||||||
secondsPresent: seconds !== undefined,
|
spelling: {
|
||||||
fractionDigits: fraction.length,
|
separator: separator as "T" | " ",
|
||||||
zone,
|
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. */
|
/** Convert a Date.now-style integer into the same exact comparison shape. */
|
||||||
export const offsetDateTimeInstantFromEpochMilliseconds = (
|
export const offsetDateTimeInstantFromEpochMilliseconds = (
|
||||||
epochMilliseconds: number,
|
epochMilliseconds: number,
|
||||||
|
|||||||
Reference in new issue
Block a user