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:
1 parent
9a21cd78ce
commit
809f3b7b5b
6 files changed
+230
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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.",
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user