fix(browser): ignore scenario storage enumeration order (#5035)

* fix(browser): ignore scenario storage enumeration order

* perf(browser): sort storage entries without serialized copies

* style(browser): format storage normalization comparator

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Devulapalli Naga Sri Vaishnaviandmorluto authored and GitHub committed 2026-10-23 02:26:41 +08:00
1 parent 9a21cd78ce
commit 809f3b7b5b
6 files changed
+230 -3

No files matched your search

+9
View File
@@ -437,6 +437,15 @@ sorted canonically before application, making the same policy reproducible
regardless of input order. Rules operate only on already-redacted durable
capture fields and are preserved in the result.
Cookies, local storage, and session storage are compared as unordered inventories
after caller literal rules apply. Enumeration order alone does not establish a
storage change. Canonical ordering uses the complete entry, preserving cookie
scope/flags, value fingerprints, and duplicate entries even when redaction or
literal rules make names collide. Captured storage arrays retain their original
order. The `compare_storage_entries_as_unordered_collection` built-in rule
participates in the normalization commitment; new storage comparison digests
therefore differ from earlier order-sensitive comparisons.
Each aligned step reports changed, unchanged, or unknown. Changed and unknown
action, screenshot, DOM, accessibility, URL, history, storage, and event
artifacts carry their normalized before/after digests and capture states.
+118
View File
@@ -1162,3 +1162,121 @@ const textArtifact = (text: string) => {
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: {
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");
}
});
+1 -1
View File
@@ -137,7 +137,7 @@ export const compareBrowserScenarios = (
limitations: [
"Steps align only by exact, unique step_id; missing, duplicate, or action-mismatched steps are reported and prevent an unchanged claim.",
"Only declared literal normalization rules are applied, in canonical rule_id order, to already-redacted durable capture fields.",
"Elapsed time, event sequence/index values and capture-local transaction IDs are excluded as volatile; network transactions are compared as an unordered collection in the step that sent their request; screenshot bytes are compared by content digest.",
"Elapsed time, event sequence/index values and capture-local transaction IDs are excluded as volatile; network transactions are compared as an unordered collection in the step that sent their request; cookies, local storage and session storage entries are compared as unordered collections after literal normalization; screenshot bytes are compared by content digest.",
"A changed status proves an observed normalized difference. Missing or truncated evidence, incompatible capture context, or alignment failure prevents an unchanged claim.",
],
});
+1
View File
@@ -129,6 +129,7 @@ export const BROWSER_SCENARIO_BUILT_IN_NORMALIZATION_RULES = [
"ignore_event_step_index",
"compare_screenshot_content_digest",
"compare_normalized_text_content",
"compare_storage_entries_as_unordered_collection",
"ignore_capture_local_network_transaction_ids",
"attribute_network_transactions_to_request_step",
"compare_network_transactions_as_unordered_collection",
+53 -2
View File
@@ -1,4 +1,5 @@
import { digestCanonicalValue } from "./canonicalDigest.js";
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
import {
BROWSER_SCENARIO_BUILT_IN_NORMALIZATION_RULES,
@@ -56,8 +57,17 @@ export const normalizeScenarioValue = (
normalized.split(rule.match).join(rule.replacement),
value,
);
if (Array.isArray(value))
return value.map((item) => normalizeScenarioValue(item, artifact, rules));
if (Array.isArray(value)) {
const normalized = value.map((item) =>
normalizeScenarioValue(item, artifact, rules),
);
// Storage arrays are inventories, not ordered events. Sort complete entries
// after literal rules; names alone can collide after redaction/normalization.
// Keep multiplicity and every cookie attribute or value fingerprint.
return artifact === "storage"
? normalized.sort(compareStorageEntries)
: normalized;
}
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
@@ -66,3 +76,44 @@ export const normalizeScenarioValue = (
]),
);
};
/** Compare parsed storage records by their fixed fields without serializing them. */
const compareStorageEntries = (left: unknown, right: unknown): number => {
if (!isStorageRecord(left) || !isStorageRecord(right))
return compareUnicodeCodePoints(
digestCanonicalValue(left, "Browser scenario"),
digestCanonicalValue(right, "Browser scenario"),
);
const fields =
Object.hasOwn(left, "domain") || Object.hasOwn(right, "domain")
? [
"domain",
"http_only",
"name",
"path",
"same_site",
"secure",
"value_sha256",
"value_state",
]
: ["name", "value_sha256", "value_state"];
for (const field of fields) {
const compared = compareStorageField(left[field], right[field]);
if (compared !== 0) return compared;
}
return 0;
};
const isStorageRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const compareStorageField = (left: unknown, right: unknown): number => {
if (left === right) return 0;
if (left === null) return -1;
if (right === null) return 1;
if (typeof left === "string" && typeof right === "string")
return compareUnicodeCodePoints(left, right);
if (typeof left === "boolean" && typeof right === "boolean")
return left ? 1 : -1;
return compareUnicodeCodePoints(String(left), String(right));
};
+48
View File
@@ -688,6 +688,54 @@ const completeScenarioCapture = () => {
};
};
it(
"compares reordered scenario storage through the public CLI and commits the rule",
async () => {
const capture = completeScenarioCapture();
const entries = [
{ name: "theme", value_state: "hashed", value_sha256: "a".repeat(64) },
{ name: "locale", value_state: "hashed", value_sha256: "b".repeat(64) },
];
const withStorage = (values: typeof entries) => ({
...capture,
steps: capture.steps.map((step) => ({
...step,
artifacts: {
...step.artifacts,
storage: {
state: "captured",
value: { cookies: [], local_storage: values, session_storage: [] },
},
},
})),
});
const compared = await compareCapturesCli(
withStorage(entries),
withStorage(entries.toReversed()),
);
expect(compared).toMatchObject({
operation: "compare_web_captures",
normalized_result: {
comparison_kind: "browser_scenario",
overall_status: "unchanged",
normalization: {
built_in_rules: expect.arrayContaining([
"compare_storage_entries_as_unordered_collection",
]),
},
},
});
const changed = await compareCapturesCli(
withStorage(entries),
withStorage(entries.slice(1)),
);
expect(changed).toMatchObject({
normalized_result: { overall_status: "changed" },
});
},
INTEGRATION_TEST_TIMEOUT_MS,
);
const scenarioCaptureWithConsole = (
text: string,
networkContent: {