* feat(browser): record when scenario requests were observed and how long they took Request events carry observed_at, the wall-clock time the provider observed the request start, and finished or failed events carry the browser's elapsed_ms when it reports one. Both are optional, so earlier captures still parse, and scenario comparisons leave them out as a committed built-in rule. The real-Chrome network lane checks every request and finished response is timed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(environment): time browser scenario records so their state learns a write clock Scenario records take startedDateTime from when each request was observed and time from the browser's duration, as HAR entries are timed. A timed scenario of the book session learns the same clock and serves the same writes as its HAR; an untimed one keeps record order and learns no clock, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(environment): document scenario request times and timed scenario records Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(environment): start scenario records at the browser's own request start The provider observes a request up to tens of milliseconds after the browser starts it while it reads other responses, so a record starting then and ending after the browser's duration mixed two clocks. Finished and failed events now carry the browser's started_at, records prefer it and fall back to observed_at, and comparisons drop it too. Tests cover the fallback for a request that never ended or ended twice, a partly untimed capture keeping record order, comparing an untimed capture with a timed one, and the real-Chrome lane checks every finished request carries the browser's start and duration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(environment): document the browser start time, its fallback lag and older readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(environment): record the scenario timing verification Gates, the real-Chrome lane, prompt-recorded and re-recorded published scenario captures built with and without their times, the stale published environments left unrepublished, and the review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(browser): compare timed completion events in the request-time comparison test The comparison test's captures had no finished or failed events, so dropping started_at and elapsed_ms from them went untested. The captures now end one request and fail the other, the test asserts both carry times, compares them shifted by seven seconds and against an untimed capture, and a control shows a changed failure on a timed completion is still reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
1428 lines
42 KiB
TypeScript
1428 lines
42 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { compareBrowserScenarios } from "./browserScenarioDiff.js";
|
|
import {
|
|
compareBrowserScenariosInputSchema,
|
|
browserScenarioDiffSchema,
|
|
} from "./browserScenarioDiffValues.js";
|
|
import {
|
|
browserScenarioCaptureSchema,
|
|
retainedBrowserScenarioCaptureSchema,
|
|
type BrowserScenarioCapture,
|
|
} from "./browserScenarioCapture.js";
|
|
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "./browserScenarioCaptureLimits.js";
|
|
import { BROWSER_STORAGE_FINGERPRINT_VERSION } from "./browserScenarioCaptureValues.js";
|
|
|
|
const noEventOmissions = {
|
|
by_kind: [],
|
|
network_transactions: [],
|
|
network_transaction_range: null,
|
|
} as const;
|
|
|
|
it("admits legacy event-family omission only for retained capture consumers", () => {
|
|
const current = scenarioCapture({});
|
|
const { event_families: _selection, ...scenario } = current.scenario;
|
|
const legacy = { ...current, scenario };
|
|
expect(browserScenarioCaptureSchema.safeParse(legacy).success).toBe(false);
|
|
const admitted = retainedBrowserScenarioCaptureSchema.parse(legacy);
|
|
expect(admitted.scenario.event_families).toBeUndefined();
|
|
for (const [before_scenario, after_scenario] of [
|
|
[current, legacy],
|
|
[legacy, current],
|
|
[legacy, legacy],
|
|
]) {
|
|
const result = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario,
|
|
after_scenario,
|
|
}),
|
|
);
|
|
expect(result.overall_status).toBe("unknown");
|
|
expect(
|
|
result.steps
|
|
.flatMap((step) => step.artifact_diffs)
|
|
.filter(({ artifact }) => artifact === "events"),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
status: "unknown",
|
|
reason: expect.stringContaining("not recorded"),
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
const networkCapture = (sourceSequence: number, receiptStep: number) => {
|
|
const base = scenarioCapture({});
|
|
const events = [
|
|
{
|
|
sequence: sourceSequence,
|
|
step_index: 0,
|
|
kind: "request",
|
|
method: "POST",
|
|
url: {
|
|
url: "https://app.example.test/api",
|
|
origin: "https://app.example.test",
|
|
query_parameter_names: [],
|
|
redacted: false,
|
|
},
|
|
resource_type: "fetch",
|
|
transaction_id: "request-1",
|
|
redirected_from_transaction_id: null,
|
|
redirected_from_transaction_state: "root",
|
|
header_names: [],
|
|
status: null,
|
|
failure: null,
|
|
},
|
|
{
|
|
sequence: sourceSequence + 1,
|
|
step_index: receiptStep,
|
|
kind: "network-content",
|
|
transaction_id: "request-1",
|
|
phase: "request",
|
|
source_event_sequence: sourceSequence,
|
|
headers: { state: "not_requested" },
|
|
body: {
|
|
state: "captured",
|
|
representation: "browser-exposed-request-bytes",
|
|
encoding: "base64",
|
|
content: "eA==",
|
|
bytes: 1,
|
|
sha256: createHash("sha256").update("x").digest("hex"),
|
|
media_type: null,
|
|
redacted: false,
|
|
},
|
|
},
|
|
];
|
|
return browserScenarioCaptureSchema.parse({
|
|
...base,
|
|
scenario: {
|
|
...base.scenario,
|
|
network_content: {
|
|
request_body: true,
|
|
response_body: false,
|
|
header_values: false,
|
|
},
|
|
event_families: ["network"],
|
|
},
|
|
events: {
|
|
items: events,
|
|
retained: 2,
|
|
dropped: 0,
|
|
omissions: noEventOmissions,
|
|
},
|
|
});
|
|
};
|
|
|
|
it("normalizes network content anchors and attributes late content to its source step", () => {
|
|
const before = networkCapture(3, 0);
|
|
const after = networkCapture(90, 1);
|
|
expect(
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
}),
|
|
).overall_status,
|
|
).toBe("unchanged");
|
|
const content = after.events.items[1];
|
|
if (content?.kind !== "network-content")
|
|
throw new Error("Expected content fixture");
|
|
for (const mutation of [
|
|
{ transaction_id: "another-request" },
|
|
{ source_event_sequence: 91 },
|
|
{ phase: "response" },
|
|
])
|
|
expect(
|
|
browserScenarioCaptureSchema.safeParse({
|
|
...after,
|
|
events: {
|
|
...after.events,
|
|
items: [after.events.items[0], { ...content, ...mutation }],
|
|
},
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("reports content-selection mismatch as unknown instead of a website change", () => {
|
|
const withBody = networkCapture(1, 0);
|
|
const metadataOnly = browserScenarioCaptureSchema.parse({
|
|
...withBody,
|
|
scenario: {
|
|
...withBody.scenario,
|
|
network_content: {
|
|
request_body: false,
|
|
response_body: false,
|
|
header_values: false,
|
|
},
|
|
},
|
|
events: {
|
|
...withBody.events,
|
|
items: withBody.events.items.filter(
|
|
(event) => event.kind !== "network-content",
|
|
),
|
|
retained: 1,
|
|
},
|
|
});
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: metadataOnly,
|
|
after_scenario: withBody,
|
|
}),
|
|
);
|
|
expect(compared.overall_status).toBe("unknown");
|
|
expect(compared.steps.flatMap((step) => step.artifact_diffs)).toContainEqual(
|
|
expect.objectContaining({
|
|
artifact: "events",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("content selections differ"),
|
|
}),
|
|
);
|
|
for (const networkContent of [undefined, {}, { request_body: false }])
|
|
expect(
|
|
compareBrowserScenariosInputSchema.safeParse({
|
|
before_scenario: {
|
|
...metadataOnly,
|
|
scenario: {
|
|
...metadataOnly.scenario,
|
|
network_content: networkContent,
|
|
},
|
|
},
|
|
after_scenario: withBody,
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("reports event-family selection mismatch as unknown", () => {
|
|
const consoleOnly = scenarioCapture({});
|
|
const withFamilies = (
|
|
capture: BrowserScenarioCapture,
|
|
families: BrowserScenarioCapture["scenario"]["event_families"],
|
|
) =>
|
|
browserScenarioCaptureSchema.parse({
|
|
...capture,
|
|
scenario: { ...capture.scenario, event_families: families },
|
|
});
|
|
const compare = (
|
|
before: BrowserScenarioCapture,
|
|
after: BrowserScenarioCapture,
|
|
) =>
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
}),
|
|
);
|
|
const eventDiffs = (compared: ReturnType<typeof compare>) =>
|
|
compared.steps
|
|
.flatMap((step) => step.artifact_diffs)
|
|
.filter(({ artifact }) => artifact === "events");
|
|
|
|
const widened = withFamilies(consoleOnly, ["page-errors", "console"]);
|
|
const mismatched = compare(consoleOnly, widened);
|
|
expect(mismatched.overall_status).toBe("unknown");
|
|
expect(eventDiffs(mismatched)).toContainEqual(
|
|
expect.objectContaining({
|
|
status: "unknown",
|
|
before_state: "captured",
|
|
after_state: "captured",
|
|
reason: expect.stringContaining(
|
|
"Event family selections differ (console before, console, page-errors after)",
|
|
),
|
|
}),
|
|
);
|
|
expect(
|
|
compare(
|
|
withFamilies(consoleOnly, ["console", "page-errors"]),
|
|
withFamilies(consoleOnly, ["page-errors", "console"]),
|
|
).overall_status,
|
|
).toBe("unchanged");
|
|
|
|
expect(
|
|
compareBrowserScenariosInputSchema.safeParse({
|
|
before_scenario: {
|
|
...consoleOnly,
|
|
scenario: { ...consoleOnly.scenario, event_families: ["dialogs"] },
|
|
},
|
|
after_scenario: consoleOnly,
|
|
}).success,
|
|
).toBe(false);
|
|
|
|
const quiet = browserScenarioCaptureSchema.parse({
|
|
...withFamilies(consoleOnly, []),
|
|
events: { retained: 0, dropped: 0, items: [], omissions: noEventOmissions },
|
|
});
|
|
const differentEvents = compare(quiet, widened);
|
|
expect(differentEvents.overall_status).toBe("unknown");
|
|
expect(eventDiffs(differentEvents).map(({ status }) => status)).toEqual([
|
|
"unknown",
|
|
"unknown",
|
|
]);
|
|
|
|
const both = eventDiffs(compare(networkCapture(1, 0), consoleOnly));
|
|
expect(both).toContainEqual(
|
|
expect.objectContaining({
|
|
reason: expect.stringMatching(
|
|
/Event family selections differ.*content selections differ/u,
|
|
),
|
|
}),
|
|
);
|
|
});
|
|
|
|
const concurrentCapture = (
|
|
requestOrder: readonly ("a" | "b")[],
|
|
statuses: Readonly<Record<"a" | "b", number>>,
|
|
responseStep = 0,
|
|
responseOrder: readonly ("a" | "b")[] = requestOrder,
|
|
redirect = false,
|
|
) => {
|
|
const base = scenarioCapture({});
|
|
const network = (path: "a" | "b") => ({
|
|
step_index: 0,
|
|
transaction_id: `request-${String(requestOrder.indexOf(path) + 1)}`,
|
|
redirected_from_transaction_id:
|
|
redirect && path === "b"
|
|
? `request-${String(requestOrder.indexOf("a") + 1)}`
|
|
: null,
|
|
...(!redirect || path !== "b"
|
|
? { redirected_from_transaction_state: "root" as const }
|
|
: {}),
|
|
method: "GET",
|
|
url: {
|
|
url: `https://app.example.test/${path}.js`,
|
|
origin: "https://app.example.test",
|
|
query_parameter_names: [],
|
|
redacted: false,
|
|
},
|
|
resource_type: "script",
|
|
header_names: [],
|
|
failure: null,
|
|
});
|
|
const request = (path: "a" | "b") => ({
|
|
...network(path),
|
|
kind: "request",
|
|
status: null,
|
|
});
|
|
const response = (path: "a" | "b") => ({
|
|
...network(path),
|
|
step_index: responseStep,
|
|
kind: "response",
|
|
status: statuses[path],
|
|
});
|
|
const events = (
|
|
redirect
|
|
? requestOrder.flatMap((path) => [request(path), response(path)])
|
|
: [...requestOrder.map(request), ...responseOrder.map(response)]
|
|
).map((event, index) => ({ ...event, sequence: index + 1 }));
|
|
return browserScenarioCaptureSchema.parse({
|
|
...base,
|
|
events: {
|
|
items: events,
|
|
retained: events.length,
|
|
dropped: 0,
|
|
omissions: noEventOmissions,
|
|
},
|
|
});
|
|
};
|
|
|
|
const redirectChainCapture = (swapLeaves = false, reverseReceipt = false) => {
|
|
const base = scenarioCapture({});
|
|
const nodes = [
|
|
{ id: "root-a", parent: null, path: "first-a", status: 301 },
|
|
{ id: "root-b", parent: null, path: "first-b", status: 302 },
|
|
{ id: "middle-a", parent: "root-a", path: "same-middle", status: 307 },
|
|
{ id: "middle-b", parent: "root-b", path: "same-middle", status: 307 },
|
|
{
|
|
id: "leaf-a",
|
|
parent: swapLeaves ? "middle-b" : "middle-a",
|
|
path: "last-a",
|
|
status: 200,
|
|
},
|
|
{
|
|
id: "leaf-b",
|
|
parent: swapLeaves ? "middle-a" : "middle-b",
|
|
path: "last-b",
|
|
status: 200,
|
|
},
|
|
];
|
|
const events = (reverseReceipt ? [...nodes].reverse() : nodes)
|
|
.flatMap((node) => {
|
|
const network = {
|
|
step_index: 0,
|
|
transaction_id: (reverseReceipt ? "renamed-" : "") + node.id,
|
|
redirected_from_transaction_id:
|
|
node.parent === null
|
|
? null
|
|
: (reverseReceipt ? "renamed-" : "") + node.parent,
|
|
...(node.parent === null
|
|
? { redirected_from_transaction_state: "root" as const }
|
|
: {}),
|
|
method: "GET",
|
|
url: {
|
|
url: `https://app.example.test/${node.path}`,
|
|
origin: "https://app.example.test",
|
|
query_parameter_names: [],
|
|
redacted: false,
|
|
},
|
|
resource_type: "document",
|
|
header_names: [],
|
|
failure: null,
|
|
};
|
|
return [
|
|
{ ...network, kind: "request", status: null },
|
|
{ ...network, kind: "response", status: node.status },
|
|
];
|
|
})
|
|
.map((event, index) => ({ ...event, sequence: index + 1 }));
|
|
return browserScenarioCaptureSchema.parse({
|
|
...base,
|
|
events: {
|
|
items: events,
|
|
retained: events.length,
|
|
dropped: 0,
|
|
omissions: noEventOmissions,
|
|
},
|
|
});
|
|
};
|
|
|
|
describe("browser scenario network event order", () => {
|
|
it("retains multi-hop redirect ancestry while ignoring IDs and concurrent receipt order", () => {
|
|
const compare = (after: BrowserScenarioCapture) =>
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: redirectChainCapture(),
|
|
after_scenario: after,
|
|
}),
|
|
);
|
|
expect(compare(redirectChainCapture(false, true)).overall_status).toBe(
|
|
"unchanged",
|
|
);
|
|
expect(compare(redirectChainCapture(true)).overall_status).toBe("changed");
|
|
});
|
|
|
|
it("keeps missing and cyclic redirect ancestry incomparable", () => {
|
|
for (const predecessor of ["missing", "middle-a", null]) {
|
|
const capture = redirectChainCapture();
|
|
const events = capture.events.items.map((event) =>
|
|
"transaction_id" in event && event.transaction_id === "root-a"
|
|
? {
|
|
...event,
|
|
redirected_from_transaction_id: predecessor,
|
|
redirected_from_transaction_state: undefined,
|
|
}
|
|
: event,
|
|
);
|
|
const broken = browserScenarioCaptureSchema.parse({
|
|
...capture,
|
|
events: { ...capture.events, items: events },
|
|
});
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: broken,
|
|
after_scenario: broken,
|
|
}),
|
|
);
|
|
expect(compared.overall_status).toBe("unknown");
|
|
expect(
|
|
compared.steps.flatMap((step) => step.artifact_diffs),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
artifact: "events",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("Redirect"),
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("keeps contradictory phase ancestry unknown", () => {
|
|
const capture = concurrentCapture(["a", "b"], { a: 200, b: 404 });
|
|
const events = capture.events.items.map((event) =>
|
|
event.kind === "response" && event.transaction_id === "request-2"
|
|
? {
|
|
...event,
|
|
redirected_from_transaction_id: "request-1",
|
|
redirected_from_transaction_state: undefined,
|
|
}
|
|
: event,
|
|
);
|
|
const malformed = browserScenarioCaptureSchema.parse({
|
|
...capture,
|
|
events: { ...capture.events, items: events },
|
|
});
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: malformed,
|
|
after_scenario: malformed,
|
|
}),
|
|
);
|
|
expect(compared.overall_status).toBe("unknown");
|
|
expect(
|
|
compared.steps.flatMap((step) => step.artifact_diffs),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
artifact: "events",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("Redirect predecessor"),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("browser scenario network comparison stability", () => {
|
|
it("keeps normalization away from generated network fingerprints", () => {
|
|
const rules = [..."0123456789abcdef"].map((match, index) => ({
|
|
rule_id: `remove-fingerprint-hex-${String(index)}`,
|
|
artifacts: ["events"] as const,
|
|
match,
|
|
replacement: "",
|
|
}));
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: concurrentCapture(["a", "b"], {
|
|
a: 200,
|
|
b: 404,
|
|
}),
|
|
after_scenario: concurrentCapture(["a", "b"], {
|
|
a: 201,
|
|
b: 404,
|
|
}),
|
|
normalization: { rules },
|
|
}),
|
|
);
|
|
expect(compared.overall_status).toBe("changed");
|
|
});
|
|
|
|
it("reports changed ordinary events despite incomplete redirect ancestry", () => {
|
|
const withGap = (text: string) => {
|
|
const base = scenarioCapture({});
|
|
const consoleEvent = base.events.items[0];
|
|
if (consoleEvent?.kind !== "console")
|
|
throw new Error("Expected console fixture");
|
|
return browserScenarioCaptureSchema.parse({
|
|
...base,
|
|
scenario: {
|
|
...base.scenario,
|
|
event_families: ["console", "network"],
|
|
},
|
|
events: {
|
|
retained: 2,
|
|
dropped: 0,
|
|
omissions: noEventOmissions,
|
|
items: [
|
|
{ ...consoleEvent, text },
|
|
{
|
|
sequence: 2,
|
|
step_index: 0,
|
|
kind: "request",
|
|
transaction_id: "orphan-request",
|
|
redirected_from_transaction_id: null,
|
|
method: "GET",
|
|
url: {
|
|
url: "https://app.example.test/orphan",
|
|
origin: "https://app.example.test",
|
|
query_parameter_names: [],
|
|
redacted: false,
|
|
},
|
|
resource_type: "fetch",
|
|
header_names: [],
|
|
status: null,
|
|
failure: null,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
};
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: withGap("settings-ready"),
|
|
after_scenario: withGap("settings-failed"),
|
|
}),
|
|
);
|
|
expect(compared.overall_status).toBe("changed");
|
|
});
|
|
|
|
it("compares concurrent network transactions without receipt order, receipt step or capture-local IDs", () => {
|
|
const compare = (
|
|
before: BrowserScenarioCapture,
|
|
after: BrowserScenarioCapture,
|
|
) =>
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
}),
|
|
).overall_status;
|
|
const statuses = { a: 200, b: 404 };
|
|
|
|
expect(
|
|
compare(
|
|
concurrentCapture(["a", "b"], statuses),
|
|
concurrentCapture(["b", "a"], statuses, 1, ["a", "b"]),
|
|
),
|
|
).toBe("unchanged");
|
|
expect(
|
|
compare(
|
|
concurrentCapture(["a", "b"], statuses),
|
|
concurrentCapture(["b", "a"], { a: 404, b: 200 }),
|
|
),
|
|
).toBe("changed");
|
|
expect(
|
|
compare(
|
|
concurrentCapture(["a", "b"], statuses, 0, ["a", "b"]),
|
|
concurrentCapture(["a", "b"], statuses, 0, ["b", "a"]),
|
|
),
|
|
).toBe("unchanged");
|
|
// Redirect linkage is distinct from independent concurrent transactions.
|
|
expect(
|
|
compare(
|
|
concurrentCapture(["a", "b"], statuses),
|
|
concurrentCapture(["a", "b"], statuses, 0, ["a", "b"], true),
|
|
),
|
|
).toBe("changed");
|
|
});
|
|
});
|
|
|
|
describe("browser scenario network request times", () => {
|
|
/** A two-request capture whose `a` request finished and whose `b` request failed with `failure`. */
|
|
const ended = (failure = "net::ERR_ABORTED"): BrowserScenarioCapture => {
|
|
const capture = concurrentCapture(["a", "b"], { a: 200, b: 404 });
|
|
let sequence = Math.max(...capture.events.items.map((e) => e.sequence));
|
|
const terminal = capture.events.items.flatMap((event) =>
|
|
event.kind === "request"
|
|
? [
|
|
event.url.url.endsWith("/a.js")
|
|
? {
|
|
...event,
|
|
sequence: ++sequence,
|
|
kind: "request-finished" as const,
|
|
}
|
|
: {
|
|
...event,
|
|
sequence: ++sequence,
|
|
kind: "request-failed" as const,
|
|
failure,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
return browserScenarioCaptureSchema.parse({
|
|
...capture,
|
|
events: {
|
|
...capture.events,
|
|
retained: capture.events.items.length + terminal.length,
|
|
items: [...capture.events.items, ...terminal],
|
|
},
|
|
});
|
|
};
|
|
|
|
/** The same capture with every request observed, started and timed `offset` ms later. */
|
|
const timed = (
|
|
capture: BrowserScenarioCapture,
|
|
offset: number,
|
|
): BrowserScenarioCapture =>
|
|
browserScenarioCaptureSchema.parse({
|
|
...capture,
|
|
events: {
|
|
...capture.events,
|
|
items: capture.events.items.map((event) =>
|
|
event.kind === "request"
|
|
? {
|
|
...event,
|
|
observed_at: new Date(
|
|
Date.parse("2026-10-24T05:00:00.000Z") + offset,
|
|
).toISOString(),
|
|
}
|
|
: event.kind === "request-finished" ||
|
|
event.kind === "request-failed"
|
|
? {
|
|
...event,
|
|
started_at: new Date(
|
|
Date.parse("2026-10-24T04:59:59.990Z") + offset,
|
|
).toISOString(),
|
|
elapsed_ms: 3 + offset,
|
|
}
|
|
: event,
|
|
),
|
|
},
|
|
});
|
|
|
|
const compare = (
|
|
before: BrowserScenarioCapture,
|
|
after: BrowserScenarioCapture,
|
|
) =>
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
}),
|
|
);
|
|
|
|
it("ignores when requests were observed, started and how long they took", () => {
|
|
const early = timed(ended(), 0);
|
|
const late = timed(ended(), 7_000);
|
|
const terminal = early.events.items.filter(
|
|
(event) =>
|
|
event.kind === "request-finished" || event.kind === "request-failed",
|
|
);
|
|
expect(terminal.map(({ kind }) => kind).sort()).toEqual([
|
|
"request-failed",
|
|
"request-finished",
|
|
]);
|
|
for (const event of terminal)
|
|
expect(event).toMatchObject({
|
|
started_at: expect.any(String),
|
|
elapsed_ms: expect.any(Number),
|
|
});
|
|
const compared = compare(early, late);
|
|
expect(compared.overall_status).toBe("unchanged");
|
|
expect(compared.normalization.built_in_rules).toContain(
|
|
"ignore_network_request_timing",
|
|
);
|
|
// A capture made before request times were recorded compares with a timed one.
|
|
expect(compare(ended(), late).overall_status).toBe("unchanged");
|
|
});
|
|
|
|
it("still compares the rest of a timed completion event", () => {
|
|
expect(
|
|
compare(
|
|
timed(ended(), 0),
|
|
timed(ended("net::ERR_CONNECTION_RESET"), 7_000),
|
|
).overall_status,
|
|
).toBe("changed");
|
|
});
|
|
});
|
|
|
|
describe("scenario comparison recovery guidance", () => {
|
|
it("retains network guidance beside a changed console event", () => {
|
|
const before = scenarioCapture({});
|
|
const afterBase = scenarioCapture({});
|
|
const consoleEvent = afterBase.events.items[0];
|
|
if (consoleEvent?.kind !== "console")
|
|
throw new Error("Expected console fixture");
|
|
const after = browserScenarioCaptureSchema.parse({
|
|
...afterBase,
|
|
scenario: {
|
|
...afterBase.scenario,
|
|
network_content: {
|
|
...afterBase.scenario.network_content,
|
|
response_body: true,
|
|
},
|
|
},
|
|
events: {
|
|
...afterBase.events,
|
|
items: [{ ...consoleEvent, text: "settings-ready-again" }],
|
|
},
|
|
});
|
|
const compare = (left: typeof before, right: typeof before) =>
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: left,
|
|
after_scenario: right,
|
|
}),
|
|
);
|
|
|
|
const changed = compare(before, after);
|
|
expect(changed.overall_status).toBe("changed");
|
|
expect(changed.steps.flatMap((step) => step.artifact_diffs)).toContainEqual(
|
|
expect.objectContaining({
|
|
artifact: "events",
|
|
status: "changed",
|
|
reason: expect.stringContaining("Network content selections differ"),
|
|
}),
|
|
);
|
|
const sameSelection = compare(before, scenarioCapture({}));
|
|
expect(sameSelection.overall_status).toBe("unchanged");
|
|
expect(
|
|
sameSelection.steps.flatMap((step) => step.artifact_diffs),
|
|
).not.toContainEqual(expect.objectContaining({ artifact: "events" }));
|
|
const quietMismatch = compare(
|
|
before,
|
|
browserScenarioCaptureSchema.parse({ ...after, events: before.events }),
|
|
);
|
|
expect(
|
|
quietMismatch.steps.flatMap((step) => step.artifact_diffs),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
artifact: "events",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("Network content selections differ"),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("browser scenario comparison", () => {
|
|
it("records canonical literal normalization and ignores declared volatile fields", () => {
|
|
const before = scenarioCapture({
|
|
domText: "<main data-build='build-123'>Settings</main>",
|
|
elapsedMs: 11,
|
|
eventSequence: 3,
|
|
});
|
|
const after = scenarioCapture({
|
|
domText: "<main data-build='build-456'>Settings</main>",
|
|
elapsedMs: 900,
|
|
eventSequence: 88,
|
|
});
|
|
const rules = [
|
|
{
|
|
rule_id: "new-build",
|
|
artifacts: ["dom"] as const,
|
|
match: "build-456",
|
|
replacement: "[BUILD]",
|
|
},
|
|
{
|
|
rule_id: "old-build",
|
|
artifacts: ["dom"] as const,
|
|
match: "build-123",
|
|
replacement: "[BUILD]",
|
|
},
|
|
];
|
|
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
normalization: { rules },
|
|
}),
|
|
);
|
|
const reordered = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
normalization: {
|
|
rules: [...rules].reverse().map((rule) => ({
|
|
...rule,
|
|
artifacts: [...rule.artifacts].reverse(),
|
|
})),
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(compared.overall_status).toBe("unchanged");
|
|
expect(compared.alignment.status).toBe("aligned");
|
|
expect(compared.artifact_diffs.total).toBe(0);
|
|
expect(compared.normalization).toMatchObject({
|
|
rules: [
|
|
{ rule_id: "new-build", artifacts: ["dom"] },
|
|
{ rule_id: "old-build", artifacts: ["dom"] },
|
|
],
|
|
});
|
|
expect(reordered.normalization.sha256).toBe(compared.normalization.sha256);
|
|
});
|
|
|
|
it("exposes action mismatches and normalized artifact-level changes", () => {
|
|
const before = scenarioCapture({
|
|
domText: "<main>Settings</main>",
|
|
});
|
|
const after = scenarioCapture({
|
|
action: "dblclick",
|
|
domText: "<main>Advanced settings</main>",
|
|
});
|
|
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
}),
|
|
);
|
|
const step = compared.steps.find(
|
|
({ step_id: stepId }) => stepId === "open-settings",
|
|
);
|
|
|
|
expect(compared.overall_status).toBe("changed");
|
|
expect(compared.alignment).toMatchObject({
|
|
status: "partial",
|
|
failures: [
|
|
expect.objectContaining({
|
|
code: "action_mismatch",
|
|
step_id: "open-settings",
|
|
}),
|
|
],
|
|
});
|
|
expect(step).toMatchObject({
|
|
status: "changed",
|
|
artifact_diffs: [
|
|
expect.objectContaining({
|
|
artifact: "action_state",
|
|
status: "changed",
|
|
}),
|
|
expect.objectContaining({ artifact: "dom", status: "changed" }),
|
|
],
|
|
});
|
|
expect(compared.artifact_diffs).toEqual({ total: 2 });
|
|
});
|
|
});
|
|
|
|
describe("browser scenario comparison text values", () => {
|
|
it("accepts long normalization values and comparison diagnostics", () => {
|
|
const longText = "x".repeat(5_000);
|
|
const ruleId = "r".repeat(100);
|
|
const capture = scenarioCapture({});
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: capture,
|
|
after_scenario: capture,
|
|
normalization: {
|
|
rules: [
|
|
{
|
|
rule_id: ruleId,
|
|
artifacts: ["dom"],
|
|
match: longText,
|
|
replacement: longText,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
const stepId = "s".repeat(100);
|
|
const diagnostic = browserScenarioDiffSchema.parse({
|
|
...compared,
|
|
alignment: {
|
|
...compared.alignment,
|
|
before_only: [stepId],
|
|
after_only: [stepId],
|
|
failures: [
|
|
{
|
|
code: "missing_before_step",
|
|
step_id: stepId,
|
|
reason: longText,
|
|
},
|
|
],
|
|
},
|
|
steps: compared.steps.map((step) => ({
|
|
...step,
|
|
step_id: stepId,
|
|
artifact_diffs: [
|
|
{
|
|
artifact: "dom",
|
|
status: "unknown",
|
|
before_state: "missing",
|
|
after_state: "missing",
|
|
before_sha256: null,
|
|
after_sha256: null,
|
|
reason: longText,
|
|
},
|
|
],
|
|
})),
|
|
limitations: [longText],
|
|
});
|
|
|
|
expect(diagnostic.normalization.rules[0]).toMatchObject({
|
|
rule_id: ruleId,
|
|
match: longText,
|
|
replacement: longText,
|
|
});
|
|
expect(diagnostic.alignment.failures[0]?.reason).toBe(longText);
|
|
expect(diagnostic.steps[0]?.artifact_diffs[0]?.reason).toBe(longText);
|
|
expect(diagnostic.limitations).toEqual([longText]);
|
|
});
|
|
});
|
|
|
|
describe("browser scenario comparison validation", () => {
|
|
it("does not accept caller limits that would omit difference records", () => {
|
|
const capture = scenarioCapture({});
|
|
expect(
|
|
compareBrowserScenariosInputSchema.safeParse({
|
|
before_scenario: capture,
|
|
after_scenario: capture,
|
|
max_changes: 1,
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns every aligned step beyond the former capture ceiling", () => {
|
|
const before = multiStepScenarioCapture(130, "before");
|
|
const after = multiStepScenarioCapture(130, "after");
|
|
const result = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
}),
|
|
);
|
|
|
|
expect(result.alignment).toMatchObject({
|
|
status: "aligned",
|
|
aligned_steps: 131,
|
|
});
|
|
expect(result.steps).toHaveLength(131);
|
|
expect(result.steps.at(-1)).toMatchObject({
|
|
before_step_index: 130,
|
|
after_step_index: 130,
|
|
status: "changed",
|
|
});
|
|
expect(result.artifact_diffs.total).toBe(130);
|
|
});
|
|
|
|
it("reports missing aligned steps without claiming equality", () => {
|
|
const before = scenarioCapture({});
|
|
const after = scenarioCapture({ stepId: "open-profile" });
|
|
|
|
const compared = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
}),
|
|
);
|
|
|
|
expect(compared.overall_status).toBe("unknown");
|
|
expect(compared.alignment).toMatchObject({
|
|
status: "partial",
|
|
aligned_steps: 1,
|
|
before_only: ["open-settings"],
|
|
after_only: ["open-profile"],
|
|
failures: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "missing_before_step",
|
|
step_id: "open-profile",
|
|
}),
|
|
expect.objectContaining({
|
|
code: "missing_after_step",
|
|
step_id: "open-settings",
|
|
}),
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("rejects duplicate normalization rule IDs", () => {
|
|
const capture = scenarioCapture({});
|
|
expect(
|
|
compareBrowserScenariosInputSchema.safeParse({
|
|
before_scenario: capture,
|
|
after_scenario: capture,
|
|
normalization: {
|
|
rules: [
|
|
{
|
|
rule_id: "volatile",
|
|
artifacts: ["dom"],
|
|
match: "one",
|
|
replacement: "[VALUE]",
|
|
},
|
|
{
|
|
rule_id: "volatile",
|
|
artifacts: ["url"],
|
|
match: "two",
|
|
replacement: "[VALUE]",
|
|
},
|
|
],
|
|
},
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
const multiStepScenarioCapture = (
|
|
actionCount: number,
|
|
content: string,
|
|
): BrowserScenarioCapture => {
|
|
const capture = scenarioCapture({});
|
|
const template = capture.steps[1];
|
|
if (template === undefined) throw new Error("Expected scenario action step");
|
|
const steps = [
|
|
capture.steps[0],
|
|
...Array.from({ length: actionCount }, (_, index) => ({
|
|
...template,
|
|
step_index: index + 1,
|
|
step_id: `step-${String(index + 1)}`,
|
|
event_sequence_start: index + 1,
|
|
event_sequence_end: index + 1,
|
|
artifacts: {
|
|
...template.artifacts,
|
|
dom: {
|
|
state: "captured" as const,
|
|
value: textArtifact(`${content}-${String(index + 1)}`),
|
|
},
|
|
},
|
|
})),
|
|
];
|
|
return browserScenarioCaptureSchema.parse({
|
|
...capture,
|
|
scenario: { ...capture.scenario, action_count: actionCount },
|
|
steps,
|
|
});
|
|
};
|
|
|
|
describe("storage fingerprint provenance", () => {
|
|
const withStorage = (value: unknown) => {
|
|
const base = scenarioCapture({});
|
|
return browserScenarioCaptureSchema.parse({
|
|
...base,
|
|
steps: base.steps.map((step) => ({
|
|
...step,
|
|
artifacts: {
|
|
...step.artifacts,
|
|
storage: { state: "captured", value },
|
|
},
|
|
})),
|
|
});
|
|
};
|
|
const compare = (before: unknown, after: unknown) =>
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: withStorage(before),
|
|
after_scenario: withStorage(after),
|
|
}),
|
|
);
|
|
const empty = { cookies: [], local_storage: [], session_storage: [] };
|
|
|
|
it.each(["cookies", "local_storage", "session_storage"])(
|
|
"withholds legacy %s value identity on either side",
|
|
(kind) => {
|
|
const entry = {
|
|
name: "value",
|
|
value_state: "hashed",
|
|
value_sha256: "a".repeat(64),
|
|
};
|
|
const value = {
|
|
...empty,
|
|
[kind]: [
|
|
kind === "cookies"
|
|
? {
|
|
...entry,
|
|
domain: "app.example.test",
|
|
path: "/",
|
|
secure: false,
|
|
http_only: false,
|
|
same_site: "Lax",
|
|
}
|
|
: entry,
|
|
],
|
|
};
|
|
const current = { ...value, fingerprint_version: 2 };
|
|
for (const [before, after] of [
|
|
[value, value],
|
|
[value, current],
|
|
[current, value],
|
|
]) {
|
|
const result = compare(before, after);
|
|
expect(result.overall_status).toBe("unknown");
|
|
expect(result.steps.flatMap((step) => step.artifact_diffs)).toEqual([
|
|
expect.objectContaining({
|
|
artifact: "storage",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("Legacy storage fingerprints"),
|
|
}),
|
|
expect.objectContaining({
|
|
artifact: "storage",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("Legacy storage fingerprints"),
|
|
}),
|
|
]);
|
|
}
|
|
expect(compare(current, current).overall_status).toBe("unchanged");
|
|
},
|
|
);
|
|
|
|
it("conservatively withholds legacy comparisons even when digests differ", () => {
|
|
const entry = {
|
|
name: "value",
|
|
value_state: "hashed",
|
|
value_sha256: "a".repeat(64),
|
|
};
|
|
const left = { ...empty, local_storage: [entry] };
|
|
const right = {
|
|
...empty,
|
|
local_storage: [{ ...entry, value_sha256: "b".repeat(64) }],
|
|
};
|
|
for (const [before, after] of [
|
|
[left, right],
|
|
[left, { ...right, fingerprint_version: 2 }],
|
|
[{ ...left, fingerprint_version: 2 }, right],
|
|
])
|
|
expect(compare(before, after).overall_status).toBe("unknown");
|
|
});
|
|
|
|
it("checks legacy provenance before applying storage normalization", () => {
|
|
const legacy = {
|
|
...empty,
|
|
local_storage: [
|
|
{
|
|
name: "value",
|
|
value_state: "hashed",
|
|
value_sha256: "a".repeat(64),
|
|
},
|
|
],
|
|
};
|
|
const result = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: withStorage(legacy),
|
|
after_scenario: withStorage({ ...legacy, fingerprint_version: 2 }),
|
|
normalization: {
|
|
rules: [
|
|
{
|
|
rule_id: "rewrite-value-state",
|
|
artifacts: ["storage"],
|
|
match: "hashed",
|
|
replacement: "redacted-secret",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
expect(result.overall_status).toBe("unknown");
|
|
expect(result.steps.flatMap((step) => step.artifact_diffs)).toEqual([
|
|
expect.objectContaining({
|
|
artifact: "storage",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("Legacy storage fingerprints"),
|
|
}),
|
|
expect.objectContaining({
|
|
artifact: "storage",
|
|
status: "unknown",
|
|
reason: expect.stringContaining("Legacy storage fingerprints"),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"does not turn encoding metadata alone into a storage change (redacted=%s)",
|
|
(redacted) => {
|
|
const entries = redacted
|
|
? [
|
|
{
|
|
name: "secret",
|
|
value_state: "redacted-secret",
|
|
value_sha256: null,
|
|
},
|
|
]
|
|
: [];
|
|
const legacy = { ...empty, local_storage: entries };
|
|
const current = { ...legacy, fingerprint_version: 2 };
|
|
expect(compare(legacy, current).overall_status).toBe("unchanged");
|
|
expect(compare(current, legacy).overall_status).toBe("unchanged");
|
|
},
|
|
);
|
|
});
|
|
|
|
interface ScenarioCaptureOptions {
|
|
readonly stepId?: string;
|
|
readonly action?: string;
|
|
readonly domText?: string;
|
|
readonly elapsedMs?: number;
|
|
readonly eventSequence?: number;
|
|
}
|
|
|
|
const scenarioCapture = (
|
|
options: ScenarioCaptureOptions,
|
|
): BrowserScenarioCapture => {
|
|
const url = {
|
|
url: "https://app.example.test/settings",
|
|
origin: "https://app.example.test",
|
|
query_parameter_names: [],
|
|
redacted: false,
|
|
};
|
|
const completeness = {
|
|
status: "complete",
|
|
equality_eligible: true,
|
|
missing_sections: [],
|
|
truncated_sections: [],
|
|
};
|
|
const artifacts = (text: string) => ({
|
|
screenshot: { state: "not_requested" },
|
|
dom: { state: "captured", value: textArtifact(text) },
|
|
accessibility: { state: "not_requested" },
|
|
url: { state: "captured", value: url },
|
|
history: { state: "not_requested" },
|
|
storage: { state: "not_requested" },
|
|
});
|
|
const eventSequence = options.eventSequence ?? 1;
|
|
return browserScenarioCaptureSchema.parse({
|
|
browser: {
|
|
mode: "launch",
|
|
process_ownership: "provider-owned",
|
|
cleanup: "terminated-owned-process",
|
|
product: "Chromium",
|
|
version: "149.0",
|
|
},
|
|
scenario: {
|
|
start_origin: "https://app.example.test",
|
|
action_count: 1,
|
|
secret_references: [],
|
|
network_content: {
|
|
request_body: false,
|
|
response_body: false,
|
|
header_values: false,
|
|
},
|
|
event_families: ["console"],
|
|
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
|
},
|
|
duration_ms: options.elapsedMs ?? 10,
|
|
steps: [
|
|
{
|
|
step_index: 0,
|
|
step_id: "scenario_start",
|
|
action: "scenario_start",
|
|
status: "completed",
|
|
elapsed_ms: 0,
|
|
before_url: url,
|
|
after_url: url,
|
|
error: null,
|
|
event_sequence_start: 1,
|
|
event_sequence_end: 0,
|
|
artifacts: artifacts("<main>Ready</main>"),
|
|
completeness,
|
|
},
|
|
{
|
|
step_index: 1,
|
|
step_id: options.stepId ?? "open-settings",
|
|
action: options.action ?? "click",
|
|
status: "completed",
|
|
elapsed_ms: options.elapsedMs ?? 10,
|
|
before_url: url,
|
|
after_url: url,
|
|
error: null,
|
|
event_sequence_start: eventSequence,
|
|
event_sequence_end: eventSequence,
|
|
artifacts: artifacts(options.domText ?? "<main>Settings</main>"),
|
|
completeness,
|
|
},
|
|
],
|
|
events: {
|
|
retained: 1,
|
|
dropped: 0,
|
|
items: [
|
|
{
|
|
sequence: eventSequence,
|
|
step_index: 1,
|
|
kind: "console",
|
|
level: "info",
|
|
text: "settings-ready",
|
|
url: null,
|
|
},
|
|
],
|
|
omissions: noEventOmissions,
|
|
},
|
|
completeness,
|
|
limitations: [],
|
|
});
|
|
};
|
|
|
|
const textArtifact = (text: string) => {
|
|
const bytes = Buffer.from(text);
|
|
return {
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
bytes: bytes.byteLength,
|
|
text,
|
|
};
|
|
};
|
|
|
|
const storageScenarioCapture = (
|
|
family: "cookies" | "local_storage" | "session_storage",
|
|
entries: readonly { name: string; value_sha256: string }[],
|
|
): BrowserScenarioCapture => {
|
|
const capture = scenarioCapture({});
|
|
const values = entries.map((entry) => ({
|
|
...(family === "cookies"
|
|
? {
|
|
domain: "app.example.test",
|
|
path: "/",
|
|
secure: true,
|
|
http_only: false,
|
|
same_site: "Lax",
|
|
}
|
|
: {}),
|
|
...entry,
|
|
value_state: "hashed",
|
|
}));
|
|
return browserScenarioCaptureSchema.parse({
|
|
...capture,
|
|
steps: capture.steps.map((step) => ({
|
|
...step,
|
|
artifacts: {
|
|
...step.artifacts,
|
|
storage: {
|
|
state: "captured",
|
|
value: {
|
|
fingerprint_version: BROWSER_STORAGE_FINGERPRINT_VERSION,
|
|
cookies: [],
|
|
local_storage: [],
|
|
session_storage: [],
|
|
[family]: values,
|
|
},
|
|
},
|
|
},
|
|
})),
|
|
});
|
|
};
|
|
|
|
it.each(["cookies", "local_storage", "session_storage"] as const)(
|
|
"compares %s contents independently of enumeration order without losing edits or multiplicity",
|
|
(family) => {
|
|
const entries = [
|
|
{ name: "theme", value_sha256: "a".repeat(64) },
|
|
{ name: "locale", value_sha256: "b".repeat(64) },
|
|
] as const;
|
|
const before = storageScenarioCapture(family, entries);
|
|
const after = storageScenarioCapture(family, entries.toReversed());
|
|
const retained = structuredClone(after);
|
|
const compare = (right: BrowserScenarioCapture) =>
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: right,
|
|
}),
|
|
);
|
|
expect(compare(after).overall_status).toBe("unchanged");
|
|
expect(compare(after).normalization.built_in_rules).toContain(
|
|
"compare_storage_entries_as_unordered_collection",
|
|
);
|
|
expect(after).toEqual(retained);
|
|
const normalized = compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
normalization: {
|
|
rules: [
|
|
{
|
|
rule_id: "names",
|
|
artifacts: ["storage"],
|
|
match: "theme",
|
|
replacement: "locale",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
expect(normalized.overall_status).toBe("unchanged");
|
|
for (const changed of [
|
|
[{ ...entries[0], value_sha256: "c".repeat(64) }, entries[1]],
|
|
[entries[0]],
|
|
[...entries, entries[0]],
|
|
]) {
|
|
const result = compare(storageScenarioCapture(family, changed));
|
|
expect(result.overall_status).toBe("changed");
|
|
expect(result.steps[0]?.artifact_diffs).toContainEqual(
|
|
expect.objectContaining({ artifact: "storage", status: "changed" }),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("retains cookie scope and flags when canonicalizing storage inventories", () => {
|
|
const before = storageScenarioCapture("cookies", [
|
|
{ name: "session", value_sha256: "a".repeat(64) },
|
|
]);
|
|
for (const change of [
|
|
{ path: "/profile" },
|
|
{ domain: ".example.test" },
|
|
{ secure: false },
|
|
{ http_only: true },
|
|
{ same_site: "Strict" },
|
|
]) {
|
|
const after = structuredClone(before);
|
|
const storage = after.steps[0]?.artifacts.storage;
|
|
if (storage?.state !== "captured" || storage.value.cookies[0] === undefined)
|
|
throw new Error("Expected captured cookie fixture");
|
|
Object.assign(storage.value.cookies[0], change);
|
|
expect(
|
|
compareBrowserScenarios(
|
|
compareBrowserScenariosInputSchema.parse({
|
|
before_scenario: before,
|
|
after_scenario: after,
|
|
}),
|
|
).overall_status,
|
|
).toBe("changed");
|
|
}
|
|
});
|