mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(environment): preserve clock years and exact probe chronology
Reuse the shared offset-instant owner for calendar validation and exact probe ordering. Preserve first-century years, arbitrary recorded fraction digits in the supported offset profile, ordinal ties and legacy native-Date fallback. Keep original clock spelling when conversion leaves the supported four-digit year range, and refuse unrepresentable generated clocks before committing state. Validation: focused clock/comparison and public CLI regressions cover leap years, offset equivalence, precise DELETE/GET order, spelling classification and refusal atomicity. Replay/state compatibility checks pass. Numeric state intervals and generated-clock progression retain their documented millisecond limits. Fixes #6835 Fixes #6643
This commit is contained in:
@@ -774,7 +774,16 @@ or `page`. Each call is checked against the served tool's input schema first, as
|
||||
`at`, `time`, `date`, `timestamp`, `created`, `updated` or `modified` field names, including snake-case and camel-case forms), and two different values each side generated on its own compare equal: both UUIDs, or one length of
|
||||
eight or more with the same character classes and a digit. The report counts each kind of normalised value.
|
||||
ISO clock offsets are accepted only within RFC 3339's `00`–`23` hour and `00`–`59` minute ranges.
|
||||
Timestamp ordering retains submillisecond precision when the source records it.
|
||||
Probe ordering retains every recorded fractional-second digit in ISO timestamps
|
||||
with a four-digit year, `T`, and a `Z` or `±HH:MM` suffix. Equal instants use ordinal order. Other native-Date
|
||||
spellings keep their existing millisecond interpretation; an absent or unreadable
|
||||
start time makes the whole probe use record order. Original capture timestamps
|
||||
remain unchanged. Clock-format comparison and respelling recognize four-digit
|
||||
ISO years `0000`–`0099` as well as later years, with normal calendar validation.
|
||||
Respelling preserves the original value when the target zone would require a year
|
||||
outside `0000`–`9999`; generated writes refuse a clock outside that format.
|
||||
Capture-state extraction still uses millisecond coordinates for request intervals
|
||||
and their ordering, and generated-clock progression compares milliseconds.
|
||||
|
||||
Comparison keeps response representations distinct: JSON values compare structurally, text compares as text, and a
|
||||
retained base64 body compares by bytes (including with UTF-8 text carrying those same bytes), never by its base64
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
parameterScopes,
|
||||
} from "./environmentCompareMatch.js";
|
||||
import type { EnvironmentManifestTool } from "./environmentSource.js";
|
||||
import { isCut } from "./environmentCompareProbe.js";
|
||||
import { isCut, probeRequests } from "./environmentCompareProbe.js";
|
||||
import { parseCaptureEvidence } from "./environmentSource.js";
|
||||
|
||||
const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS));
|
||||
@@ -644,29 +644,99 @@ describe("browser probes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a probe did not observe", () => {
|
||||
const at = (second: number): string =>
|
||||
new Date(
|
||||
Date.parse("2026-10-20T09:00:00.000Z") + second * 1000,
|
||||
).toISOString();
|
||||
const exchange = (
|
||||
second: number,
|
||||
method: string,
|
||||
url: string | null,
|
||||
status: number,
|
||||
) => ({
|
||||
startedDateTime: at(second),
|
||||
request: { method, url },
|
||||
response: { status, content: { mimeType: "application/json" } },
|
||||
const at = (second: number): string =>
|
||||
new Date(
|
||||
Date.parse("2026-10-20T09:00:00.000Z") + second * 1000,
|
||||
).toISOString();
|
||||
const exchange = (
|
||||
second: number,
|
||||
method: string,
|
||||
url: string | null,
|
||||
status: number,
|
||||
) => ({
|
||||
startedDateTime: at(second),
|
||||
request: { method, url },
|
||||
response: { status, content: { mimeType: "application/json" } },
|
||||
});
|
||||
const run = (value: unknown) =>
|
||||
compareEnvironment({
|
||||
source,
|
||||
captureValue: fixture.capture,
|
||||
probes: [{ label: "probe-1", value }],
|
||||
operation: "environment-compare",
|
||||
});
|
||||
const run = (value: unknown) =>
|
||||
compareEnvironment({
|
||||
source,
|
||||
captureValue: fixture.capture,
|
||||
probes: [{ label: "probe-1", value }],
|
||||
operation: "environment-compare",
|
||||
});
|
||||
|
||||
describe("probe request chronology", () => {
|
||||
it.each([
|
||||
["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"],
|
||||
])(
|
||||
"replays an earlier delete before a later submillisecond read (%s)",
|
||||
(later, earlier) => {
|
||||
const report = run(
|
||||
analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture([
|
||||
{
|
||||
...exchange(1, "GET", "http://app.example/api/books/2", 404),
|
||||
startedDateTime: later,
|
||||
response: {
|
||||
status: 404,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(BOOK_NOT_FOUND),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...exchange(0, "DELETE", "http://app.example/api/books/2", 204),
|
||||
startedDateTime: earlier,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (!report.ok) throw report.error;
|
||||
expect(
|
||||
report.value.probes[0]?.results.map(({ ordinal, env_status }) => [
|
||||
ordinal,
|
||||
env_status,
|
||||
]),
|
||||
).toEqual([
|
||||
[1, 204],
|
||||
[0, 404],
|
||||
]);
|
||||
expect(report.value.summary).toMatchObject({
|
||||
steps: 2,
|
||||
equal: 2,
|
||||
agreement: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["2026-10-20T09:00:00.000100Z", "2026-10-20T10:00:00.0001+01:00"],
|
||||
["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)",
|
||||
(left, right) => {
|
||||
const capture = environmentManifestCaptureFixture([
|
||||
{
|
||||
...exchange(0, "GET", "http://app.example/api/books/1", 200),
|
||||
startedDateTime: left,
|
||||
},
|
||||
{
|
||||
...exchange(1, "GET", "http://app.example/api/books/2", 200),
|
||||
startedDateTime: right,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
probeRequests(capture.records).steps.map(({ ordinal }) => ordinal),
|
||||
).toEqual([0, 1]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("what a probe did not observe", () => {
|
||||
it("compares an unrecorded body by status only and lists the records it could not compare", () => {
|
||||
const report = run(
|
||||
analysisViewCaptureEvidence(
|
||||
|
||||
@@ -16,6 +16,21 @@ const compare = (
|
||||
map,
|
||||
);
|
||||
|
||||
describe("early-year clock formats", () => {
|
||||
it.each(["0000", "0001", "0099", "0100"])(
|
||||
"classifies a year-%s clock spelling difference by format",
|
||||
(year) => {
|
||||
const result = compare(
|
||||
{ created_at: `${year}-01-01T00:00:00Z` },
|
||||
{ created_at: `${year}-01-01 00:00:00.000Z` },
|
||||
);
|
||||
expect(
|
||||
result.differences.map(({ category, pointer }) => [category, pointer]),
|
||||
).toEqual([["clock-format", "/created_at"]]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("compareAnswers", () => {
|
||||
it("agrees on mapped ids, clocks of one format and generated values, and counts each", () => {
|
||||
const map = new IdMap();
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
} from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { parseExactJson } from "../../domain/exactJsonNumber.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
compareOffsetDateTimeInstants,
|
||||
offsetDateTimeInstantFromEpochMilliseconds,
|
||||
offsetDateTimeSchema,
|
||||
parseOffsetDateTimeInstant,
|
||||
type OffsetDateTimeInstant,
|
||||
} from "../../domain/offsetDateTime.js";
|
||||
import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js";
|
||||
import type { Answer } from "./environmentCompareDiff.js";
|
||||
import type { ProbeRequest } from "./environmentCompareMatch.js";
|
||||
@@ -46,10 +53,20 @@ const postDataSchema = z.object({
|
||||
|
||||
const methodSchema = z.object({ request: z.object({ method: z.string() }) });
|
||||
|
||||
const startedMs = (record: WebNetworkCaptureRecord): number | undefined => {
|
||||
const startedInstant = (
|
||||
record: WebNetworkCaptureRecord,
|
||||
): OffsetDateTimeInstant | undefined => {
|
||||
const started = startedSchema.safeParse(record.reported);
|
||||
const ms = started.success ? Date.parse(started.data.startedDateTime) : NaN;
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
if (!started.success) return undefined;
|
||||
const timestamp = offsetDateTimeSchema.safeParse(
|
||||
started.data.startedDateTime,
|
||||
);
|
||||
if (timestamp.success) return parseOffsetDateTimeInstant(timestamp.data);
|
||||
// Preserve native-Date admission for legacy spellings, at its original precision.
|
||||
const ms = Date.parse(started.data.startedDateTime);
|
||||
return Number.isFinite(ms)
|
||||
? offsetDateTimeInstantFromEpochMilliseconds(ms)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Whether a probe makes calls that carry a request body: a POST, PUT or PATCH. */
|
||||
@@ -118,13 +135,17 @@ export const probeRequests = (
|
||||
const unobserved: UnobservedRecord[] = [];
|
||||
// Without a start time for every record the probe keeps its recorded order, as state extraction does.
|
||||
const starts = new Map(
|
||||
records.map((record) => [record.ordinal, startedMs(record)]),
|
||||
records.map((record) => [record.ordinal, startedInstant(record)]),
|
||||
);
|
||||
const timed = [...starts.values()].every((ms) => ms !== undefined);
|
||||
const timed = [...starts.values()].every((instant) => instant !== undefined);
|
||||
const ordered = [...records].sort(
|
||||
(left, right) =>
|
||||
(timed ? starts.get(left.ordinal)! - starts.get(right.ordinal)! : 0) ||
|
||||
left.ordinal - right.ordinal,
|
||||
(timed
|
||||
? compareOffsetDateTimeInstants(
|
||||
starts.get(left.ordinal)!,
|
||||
starts.get(right.ordinal)!,
|
||||
)
|
||||
: 0) || left.ordinal - right.ordinal,
|
||||
);
|
||||
const lines = new Map(
|
||||
records.map((record) => [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
analysisViewCaptureEvidence,
|
||||
@@ -183,6 +183,23 @@ describe("confirmed write response bodies", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses an unrepresentable generated clock without changing the held state", () => {
|
||||
const books = servedFrom();
|
||||
const pending = books.store.pendingWrite();
|
||||
const pendingWrite = books.store.pendingWrite.bind(books.store);
|
||||
vi.spyOn(books.store, "pendingWrite").mockImplementation(() => ({
|
||||
...pendingWrite(),
|
||||
clockMs: Date.parse("0000-01-01T00:00:00+01:00"),
|
||||
}));
|
||||
const before = books.snapshot();
|
||||
const refusal = books.refusal("post_books", CREATE_NEW_BOOK);
|
||||
expect(JSON.stringify(refusal)).toContain(
|
||||
"outside its recorded four-digit year format",
|
||||
);
|
||||
expect(books.snapshot()).toBe(before);
|
||||
expect(books.store.pendingWrite().count).toBe(pending.count);
|
||||
});
|
||||
|
||||
describe("served creates", () => {
|
||||
it("refuses an exhausted generated-value collision search without changing state", () => {
|
||||
const existing = {
|
||||
|
||||
@@ -201,8 +201,14 @@ const generator = (
|
||||
const seed = (attempt: number) =>
|
||||
`${store.state.evidenceId}:${request.tool.name}:${rule.field}:${String(write.count)}:${String(attempt)}`;
|
||||
switch (rule.rule) {
|
||||
case "clock":
|
||||
return ok(formatClock(write.clockMs, rule.format));
|
||||
case "clock": {
|
||||
const value = formatClock(write.clockMs, rule.format);
|
||||
return value === undefined
|
||||
? err(
|
||||
`The generated clock for ${rule.field} is outside its recorded four-digit year format.`,
|
||||
)
|
||||
: ok(value);
|
||||
}
|
||||
case "sequence":
|
||||
return ok(
|
||||
spelledLike(
|
||||
|
||||
@@ -33,6 +33,27 @@ const seeds = (count: number): string[] =>
|
||||
Array.from({ length: count }, (_, index) => `seed-${String(index)}`);
|
||||
|
||||
it.each<[string, number, ClockFormat, number?]>([
|
||||
[
|
||||
"0000-02-29T00:00:00.000123456Z",
|
||||
Date.parse("0000-02-29T00:00:00Z"),
|
||||
iso("T", 9, "Z"),
|
||||
123456,
|
||||
],
|
||||
[
|
||||
"0001-01-01T00:00:00Z",
|
||||
Date.parse("0001-01-01T00:00:00Z"),
|
||||
iso("T", 0, "Z"),
|
||||
],
|
||||
[
|
||||
"0099-01-01T00:00:00Z",
|
||||
Date.parse("0099-01-01T00:00:00Z"),
|
||||
iso("T", 0, "Z"),
|
||||
],
|
||||
[
|
||||
"0100-01-01T00:00:00Z",
|
||||
Date.parse("0100-01-01T00:00:00Z"),
|
||||
iso("T", 0, "Z"),
|
||||
],
|
||||
["2026-03-04T05:06:07Z", WHOLE_SECOND, iso("T", 0, "Z")],
|
||||
["2026-03-04 05:06:07.123Z", WITH_MS, iso(" ", 3, "Z")],
|
||||
["2026-03-04T05:06:07.123456Z", WITH_MS, iso("T", 6, "Z"), 456000],
|
||||
@@ -53,6 +74,8 @@ it.each<[string, number, ClockFormat, number?]>([
|
||||
);
|
||||
|
||||
it.each([
|
||||
"0001-02-29T00:00:00Z",
|
||||
"0100-02-29T00:00:00Z",
|
||||
"2026-02-30T00:00:00Z",
|
||||
"2026-02-29T00:00:00Z",
|
||||
"2026-13-01T00:00:00Z",
|
||||
@@ -72,6 +95,25 @@ it.each([
|
||||
expect(readClock(text)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["0000", "0001", "0099", "0100"])(
|
||||
"respells year %s without a numeric-year century adjustment",
|
||||
(year) => {
|
||||
expect(respellClock(`${year}-01-01T00:00:00Z`, iso(" ", 3, "Z"))).toBe(
|
||||
`${year}-01-01 00:00:00.000Z`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["0000-01-01T00:00:00+01:00", "9999-12-31T23:59:59-01:00"])(
|
||||
"keeps %s when conversion cannot fit the four-digit year format",
|
||||
(value) => {
|
||||
const clock = readClock(value)!;
|
||||
expect(respellClock(value, iso("T", 0, "Z"))).toBe(value);
|
||||
expect(formatClock(clock.ms, iso("T", 0, "Z"))).toBeUndefined();
|
||||
expect(formatClock(clock.ms, clock.format)).toBe(value);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<[JsonValue | undefined, ReturnType<typeof readClock>]>([
|
||||
[
|
||||
WHOLE_SECOND / 1000,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import {
|
||||
offsetDateTimeSchema,
|
||||
parseOffsetDateTimeInstant,
|
||||
} 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 =
|
||||
@@ -19,7 +23,7 @@ interface ClockReading {
|
||||
}
|
||||
|
||||
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;
|
||||
/^\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. */
|
||||
@@ -49,34 +53,22 @@ const zoneMinutesForFormat = (zone: string): number => {
|
||||
const readIsoClock = (value: string): ClockReading | undefined => {
|
||||
const match = ISO_INSTANT.exec(value);
|
||||
if (match === null) return undefined;
|
||||
const [year, month, day, separator, hour, minute, second] = match
|
||||
.slice(1, 8)
|
||||
.map((part) => (part === "T" || part === " " ? part : Number(part)));
|
||||
const fraction = match[8] ?? "";
|
||||
const zone = match[9] ?? "";
|
||||
const offsetMinutes = zoneMinutes(zone);
|
||||
if (offsetMinutes === undefined) return undefined;
|
||||
const local = Date.UTC(
|
||||
year as number,
|
||||
(month as number) - 1,
|
||||
day as number,
|
||||
hour as number,
|
||||
minute as number,
|
||||
second as number,
|
||||
Number(`${fraction}000`.slice(0, 3)),
|
||||
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}`,
|
||||
);
|
||||
// Date.UTC rolls invalid fields over; the round trip refuses them.
|
||||
const rolled = new Date(local);
|
||||
if (
|
||||
rolled.getUTCFullYear() !== year ||
|
||||
rolled.getUTCMonth() !== (month as number) - 1 ||
|
||||
rolled.getUTCDate() !== day ||
|
||||
rolled.getUTCHours() !== hour ||
|
||||
rolled.getUTCMinutes() !== minute
|
||||
)
|
||||
return undefined;
|
||||
if (!timestamp.success) return undefined;
|
||||
const instant = parseOffsetDateTimeInstant(timestamp.data);
|
||||
return {
|
||||
ms: local - offsetMinutes * MINUTE_MS,
|
||||
ms: instant.epochSecond * 1000 + Number(`${fraction}000`.slice(0, 3)),
|
||||
subMillisecond: Number(fraction.padEnd(9, "0").slice(3)),
|
||||
format: {
|
||||
kind: "iso",
|
||||
@@ -118,11 +110,17 @@ const isoText = (
|
||||
separator: string,
|
||||
digits: string,
|
||||
zone: string,
|
||||
): string =>
|
||||
`${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${digits === "" ? "" : `.${digits}`}${zone}`;
|
||||
): string | undefined => {
|
||||
const year = at.getUTCFullYear();
|
||||
if (!Number.isInteger(year) || year < 0 || year > 9999) return undefined;
|
||||
return `${pad(year, 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${digits === "" ? "" : `.${digits}`}${zone}`;
|
||||
};
|
||||
|
||||
/** An instant spelled in a recorded clock format; fraction digits past milliseconds are zeros. */
|
||||
export const formatClock = (ms: number, format: ClockFormat): JsonValue => {
|
||||
/** An instant in a recorded format; undefined outside its four-digit ISO year range. Fraction digits past milliseconds are zeros. */
|
||||
export const formatClock = (
|
||||
ms: number,
|
||||
format: ClockFormat,
|
||||
): JsonValue | undefined => {
|
||||
if (format.kind === "epoch")
|
||||
return format.unit === "s" ? Math.floor(ms / 1000) : ms;
|
||||
const at = new Date(ms + zoneMinutesForFormat(format.zone) * MINUTE_MS);
|
||||
@@ -144,7 +142,7 @@ export const respellClock = (
|
||||
): JsonValue => {
|
||||
const clock = readClock(value);
|
||||
if (clock === undefined) return value;
|
||||
if (format.kind === "epoch") return formatClock(clock.ms, format);
|
||||
if (format.kind === "epoch") return formatClock(clock.ms, format) ?? value;
|
||||
const millis = ((clock.ms % 1000) + 1000) % 1000;
|
||||
// An epoch value's fraction is its milliseconds, without trailing zeros.
|
||||
const digits =
|
||||
@@ -157,11 +155,13 @@ export const respellClock = (
|
||||
const at = new Date(
|
||||
clock.ms - millis + zoneMinutesForFormat(format.zone) * MINUTE_MS,
|
||||
);
|
||||
return isoText(
|
||||
at,
|
||||
format.separator,
|
||||
digits.padEnd(format.fraction, "0"),
|
||||
format.zone,
|
||||
return (
|
||||
isoText(
|
||||
at,
|
||||
format.separator,
|
||||
digits.padEnd(format.fraction, "0"),
|
||||
format.zone,
|
||||
) ?? value
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { timedCaptureEvidence } from "../../fixtures/analysisView.js";
|
||||
import {
|
||||
analysisViewCaptureEvidence,
|
||||
environmentManifestCaptureFixture,
|
||||
timedCaptureEvidence,
|
||||
} from "../../fixtures/analysisView.js";
|
||||
import { environmentStateServeFixture } from "../../fixtures/environmentServe.js";
|
||||
import {
|
||||
BOOK_NOT_FOUND,
|
||||
BOOK_ROWS,
|
||||
FIRST_BOOK,
|
||||
book,
|
||||
} from "../../fixtures/environmentStateWrites.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
@@ -34,7 +39,133 @@ const writeCompareFiles = async (root: string) => {
|
||||
return paths;
|
||||
};
|
||||
|
||||
/** The exact chronology and clock-format probes share one unchanged source capture. */
|
||||
const writeExactClockFiles = async (
|
||||
root: string,
|
||||
): Promise<Map<string, string>> => {
|
||||
const capture = timedCaptureEvidence([
|
||||
["GET", "/api/books", [FIRST_BOOK]],
|
||||
["GET", "/api/books/1", FIRST_BOOK],
|
||||
["DELETE", "/api/books/1", "", 204],
|
||||
["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),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
startedDateTime: "0001-01-01T01:00:00.0000000001+01:00",
|
||||
request: {
|
||||
method: "DELETE",
|
||||
url: "http://app.example/api/books/1",
|
||||
},
|
||||
response: {
|
||||
status: 204,
|
||||
content: { mimeType: "application/json", text: "" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
const clocks = timedCaptureEvidence([
|
||||
[
|
||||
"GET",
|
||||
"/api/books/1",
|
||||
{ ...FIRST_BOOK, created_at: "0001-01-01 00:00:00.000Z" },
|
||||
],
|
||||
]);
|
||||
const originals = new Map<string, string>();
|
||||
for (const [name, value] of [
|
||||
["manifest.json", fixture.manifest],
|
||||
["capture.json", capture],
|
||||
["order.json", ordered],
|
||||
["clocks.json", clocks],
|
||||
] as const) {
|
||||
const path = join(root, name);
|
||||
const text = JSON.stringify(value);
|
||||
originals.set(path, text);
|
||||
await writeFile(path, text);
|
||||
}
|
||||
return originals;
|
||||
};
|
||||
|
||||
describe("rea environment-compare", () => {
|
||||
cliTest(
|
||||
"preserves exact probe chronology and first-century clock-format differences",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-environment-exact-clock-",
|
||||
);
|
||||
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, "order.json"),
|
||||
"--probe",
|
||||
join(root, "clocks.json"),
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
status: "complete",
|
||||
environment: { state: "served" },
|
||||
summary: { probes: 2, steps: 3, equal: 2 },
|
||||
probes: [
|
||||
{
|
||||
label: "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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "clocks.json",
|
||||
results: [
|
||||
{
|
||||
categories: ["clock-format"],
|
||||
differences: [
|
||||
{
|
||||
category: "clock-format",
|
||||
pointer: "/created_at",
|
||||
live: '"0001-01-01 00:00:00.000Z"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const [path, text] of originals)
|
||||
expect(await readFile(path, "utf8")).toBe(text);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"compares the served environment with a probe of the re-seeded application and prints the report",
|
||||
async ({ cli }) => {
|
||||
|
||||
Reference in New Issue
Block a user