mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): retain unknown resource sizes and accessibility roles
Preserve format and Evidence semantics at their owning boundaries, with focused regression coverage and documentation where applicable. Fixes #6773 Fixes #6786
This commit is contained in:
@@ -349,7 +349,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
|
||||
- URL observations preserve the complete local URL, including query values, fragments, duplicate parameters, and their order. Only username/password userinfo is removed; malformed original URL strings remain available with `origin: null`.
|
||||
- DOM snapshots retain node types, node names, value lengths, and attribute names, but not text or attribute values.
|
||||
- Supported DOM URL attributes are projected separately as resolved destinations. Empty `action` on HTML or XHTML forms and empty `formaction` on HTML or XHTML submit controls resolve to the document URL; other relative URL attributes resolve against the captured document base.
|
||||
- Accessibility structure and roles are retained by default, while names and descriptions require `include_accessibility_text: true`.
|
||||
- Accessibility structure and reported roles are retained by default, while names and descriptions require `include_accessibility_text: true`. The browser protocol may omit an individual role; that role remains unknown for comparison. Other observed accessibility differences can still establish a change.
|
||||
- Network observations retain method, status, MIME type, size, type, initiator stack location, and complete URLs with userinfo credentials removed. Redirects reported by CDP are attached as ordered `redirects` on the one request ID, with the prior request URL, CDP response URL, method/type, response status/MIME/encoded length, request timestamp, and the next `requestWillBeSent` event timestamp. CDP delivers `redirectResponse` with that next request event, so the latter is an event boundary rather than an exact response timestamp. Redirect response headers and bodies are discarded; hops outside the selected origins are discarded with the request chain. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained.
|
||||
- Request/response bodies are not requested or parsed by default. When selected, only allowed-origin JSON media types are read, converted immediately to complete value-free typed paths, types and observation counts, and discarded. Each path segment is either `{ "kind": "property", "name": "raw property name" }` or `{ "kind": "array-element" }`; array-element types include primitives and nested arrays. Literal `*` properties remain distinct from array elements. `Network.getResponseBody` is never sent unless JSON body shape capture is selected. A JSON-typed request body that CDP reports through `hasPostData` but omits from `postData`, or that cannot be parsed as JSON, makes `json_body_shapes` unavailable. That request's `body_shapes.status` is `partial`, not `included`, when only its response shape was captured. Comparison uses body shapes only when both captures selected them, and compares request/response shapes only where both observations provide those facts. Unavailable or truncated selected coverage makes equivalence unknown; observed differences still report changed.
|
||||
For normalized passive inputs with complete request inventories on both sides,
|
||||
@@ -464,6 +464,12 @@ An explicitly empty MIME type is valid. The optional content size may be absent;
|
||||
an invalid supplied size remains unknown with incomplete resource coverage while
|
||||
the resource's other facts are retained. Zero and nonnegative fractional sizes
|
||||
are preserved.
|
||||
`resource_key` remains the exact observation identity used by script
|
||||
reconciliation. Capture comparison groups resources by URL, origin, type and
|
||||
MIME type, then compares known sizes within a group. If a matched group contains
|
||||
an unknown size, comparison reports unknown rather than treating the observation
|
||||
as a resource addition or removal; complete inventory coverage still establishes
|
||||
when an entire group is absent.
|
||||
|
||||
CDP discovery has a 10-second response deadline. HTTP discovery documents
|
||||
use an 8 MiB budget. Browser-socket discovery (`Browser.getVersion`,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
|
||||
import {
|
||||
browserCaptureComparisonInputSchema,
|
||||
compareBrowserCaptures,
|
||||
} from "./browserCaptureComparison.js";
|
||||
import type { WebPageInspection } from "./browserObservationSchemas.js";
|
||||
import { webCaptureDiffSchema } from "./webCaptureDiffSchemas.js";
|
||||
import { stableWebResources } from "./webInventory.js";
|
||||
|
||||
const resource = (
|
||||
content_size: number | null,
|
||||
url = "https://app.example.test/app.css",
|
||||
) => ({
|
||||
url,
|
||||
origin: "https://app.example.test",
|
||||
type: "Stylesheet",
|
||||
mime_type: "text/css",
|
||||
content_size,
|
||||
});
|
||||
|
||||
const capture = (resources: readonly ReturnType<typeof resource>[]) => {
|
||||
const inspection: WebPageInspection = passiveNetworkCapture();
|
||||
inspection.resources = stableWebResources(resources);
|
||||
return inspection;
|
||||
};
|
||||
|
||||
const compareResources = (
|
||||
before: WebPageInspection,
|
||||
after: WebPageInspection,
|
||||
) =>
|
||||
webCaptureDiffSchema.parse(
|
||||
compareBrowserCaptures(
|
||||
browserCaptureComparisonInputSchema.parse({
|
||||
before: { inspection: before },
|
||||
after: { inspection: after },
|
||||
}),
|
||||
),
|
||||
).dimensions.resources;
|
||||
|
||||
describe("web resource comparison identity and optional size", () => {
|
||||
it.each([
|
||||
[[128], [null]],
|
||||
[[128, 256], [null]],
|
||||
[
|
||||
[128, 256],
|
||||
[128, null],
|
||||
],
|
||||
])("does not infer membership from unknown size: %j vs %j", (left, right) => {
|
||||
expect(
|
||||
compareResources(
|
||||
capture(left.map((size) => resource(size))),
|
||||
capture(right.map((size) => resource(size))),
|
||||
),
|
||||
).toMatchObject({ status: "unknown", total_changes: 0, changes: [] });
|
||||
});
|
||||
|
||||
it("compares distinct all-known size variants and keeps exact deduplication", () => {
|
||||
const before = stableWebResources([resource(128), resource(256)]);
|
||||
const after = stableWebResources([resource(128), resource(512)]);
|
||||
expect(before).toHaveLength(2);
|
||||
expect(after).toHaveLength(2);
|
||||
expect(before.map(({ resource_key }) => resource_key)).not.toEqual(
|
||||
after.map(({ resource_key }) => resource_key),
|
||||
);
|
||||
expect(
|
||||
compareResources(
|
||||
capture([resource(128), resource(256)]),
|
||||
capture([resource(128), resource(512)]),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "changed",
|
||||
total_changes: 1,
|
||||
changes: [{ change: "modified" }],
|
||||
});
|
||||
expect(stableWebResources([resource(128), resource(128)])).toHaveLength(1);
|
||||
expect(
|
||||
compareResources(
|
||||
capture([resource(128), resource(128)]),
|
||||
capture([resource(128)]),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unchanged",
|
||||
total_changes: 0,
|
||||
changes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains complete group removal and distinct known identities", () => {
|
||||
expect(
|
||||
compareResources(capture([resource(128)]), capture([])),
|
||||
).toMatchObject({
|
||||
status: "changed",
|
||||
total_changes: 1,
|
||||
changes: [{ change: "removed" }],
|
||||
});
|
||||
expect(
|
||||
compareResources(
|
||||
capture([resource(128)]),
|
||||
capture([resource(128, "https://app.example.test/other.css")]),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "changed",
|
||||
total_changes: 2,
|
||||
changes: expect.arrayContaining([
|
||||
expect.objectContaining({ change: "removed" }),
|
||||
expect.objectContaining({ change: "added" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
compositeKey,
|
||||
} from "./unicodeCodePointOrder.js";
|
||||
import type { WebPageInspection } from "./browserObservationSchemas.js";
|
||||
import { webResourceComparisonIdentity } from "./webInventory.js";
|
||||
import {
|
||||
webCaptureDiffSchema,
|
||||
type CompareWebCapturesInput,
|
||||
@@ -41,9 +42,9 @@ export const compareWebCaptures = (
|
||||
},
|
||||
dimensions,
|
||||
limitations: [
|
||||
"A changed status proves an observed difference; an unknown status means absence could not be established from capture completeness.",
|
||||
"A changed status proves an observed difference; an unknown status means necessary comparison facts or coverage were unavailable.",
|
||||
"Network comparison covers only activity observed after each CDP attachment. WebSocket connections with the same URL are compared as an unordered collection of ordered frame streams, retaining connection counts; their individual identities are not stable across captures.",
|
||||
"Accessibility roles, ignored state, text, and hierarchy are compared only when the accessibility tree was fully captured and text capture was selected and not truncated.",
|
||||
"Accessibility roles, ignored state, text, and hierarchy are compared only when the accessibility tree was fully captured and text capture was selected and not truncated. A missing role leaves that node's role comparison unknown; independently observed differences can still establish a change.",
|
||||
"Storage key inventories are compared only when selected and complete; usage and quota are compared only when reported. Redacted content is compared through complete SHA-256 fingerprints.",
|
||||
],
|
||||
});
|
||||
@@ -114,8 +115,11 @@ const accessibilityDimension = (
|
||||
const afterAccess = accessibilityComparable(after);
|
||||
const textComparable = beforeAccess.text && afterAccess.text;
|
||||
const nodesComparable = beforeAccess.nodes && afterAccess.nodes;
|
||||
const rolesComplete =
|
||||
before.accessibility.nodes.every((node) => node.role !== null) &&
|
||||
after.accessibility.nodes.every((node) => node.role !== null);
|
||||
const reason =
|
||||
"Accessibility tree or text capture was incomplete in at least one observation.";
|
||||
"Accessibility tree, role, or text capture was incomplete in at least one observation.";
|
||||
if (!nodesComparable)
|
||||
return {
|
||||
status: "unknown",
|
||||
@@ -127,16 +131,24 @@ const accessibilityDimension = (
|
||||
singleton(
|
||||
"accessibility_tree",
|
||||
digestCanonicalValue(
|
||||
accessibilityProjection(before.accessibility, textComparable),
|
||||
accessibilityProjection(
|
||||
before.accessibility,
|
||||
after.accessibility,
|
||||
textComparable,
|
||||
),
|
||||
),
|
||||
),
|
||||
singleton(
|
||||
"accessibility_tree",
|
||||
digestCanonicalValue(
|
||||
accessibilityProjection(after.accessibility, textComparable),
|
||||
accessibilityProjection(
|
||||
after.accessibility,
|
||||
before.accessibility,
|
||||
textComparable,
|
||||
),
|
||||
),
|
||||
),
|
||||
beforeAccess.complete && afterAccess.complete,
|
||||
beforeAccess.complete && afterAccess.complete && rolesComplete,
|
||||
reason,
|
||||
);
|
||||
};
|
||||
@@ -195,12 +207,12 @@ const compareWebCaptureDimensions = (
|
||||
sectionsComplete(after, ["scripts"]),
|
||||
"Script inventory was incomplete in at least one observation.",
|
||||
),
|
||||
resources: compareDimension(
|
||||
keyed(before.resources, (item) => item.resource_key),
|
||||
keyed(after.resources, (item) => item.resource_key),
|
||||
resources: compareResources(
|
||||
before.resources,
|
||||
after.resources,
|
||||
sectionsComplete(before, ["resources"]) &&
|
||||
sectionsComplete(after, ["resources"]),
|
||||
"Resource inventory was incomplete in at least one observation.",
|
||||
"Resource inventory or content-size evidence was incomplete in at least one observation.",
|
||||
),
|
||||
network: networkDimension(before, after),
|
||||
metadata: compareAggregate(
|
||||
@@ -217,6 +229,72 @@ const compareWebCaptureDimensions = (
|
||||
};
|
||||
};
|
||||
|
||||
const compareResources = (
|
||||
before: WebPageInspection["resources"],
|
||||
after: WebPageInspection["resources"],
|
||||
complete: boolean,
|
||||
reason: string,
|
||||
): Dimension => {
|
||||
const left = resourceGroups(before);
|
||||
const right = resourceGroups(after);
|
||||
const changes: Change[] = [];
|
||||
let unresolved = false;
|
||||
for (const identity of new Set([...left.keys(), ...right.keys()])) {
|
||||
const leftSizes = left.get(identity);
|
||||
const rightSizes = right.get(identity);
|
||||
if (leftSizes === undefined || rightSizes === undefined) {
|
||||
if (!complete) unresolved = true;
|
||||
else
|
||||
changes.push({
|
||||
identity,
|
||||
change: leftSizes === undefined ? "added" : "removed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Stable inventories deduplicate identical observations. A null size also
|
||||
// loses multiplicity, so do not infer modifications from the remaining
|
||||
// known sizes in a group that contains unknown size evidence.
|
||||
if (leftSizes.has(null) || rightSizes.has(null)) {
|
||||
unresolved = true;
|
||||
continue;
|
||||
}
|
||||
if (!sameResourceSizes(leftSizes, rightSizes))
|
||||
changes.push({ identity, change: "modified" });
|
||||
}
|
||||
if (changes.length > 0)
|
||||
return {
|
||||
status: "changed",
|
||||
total_changes: changes.length,
|
||||
changes: changes.sort((leftChange, rightChange) =>
|
||||
compareUnicodeCodePoints(leftChange.identity, rightChange.identity),
|
||||
),
|
||||
reason: null,
|
||||
};
|
||||
if (!complete || unresolved)
|
||||
return { status: "unknown", total_changes: 0, changes: [], reason };
|
||||
return { status: "unchanged", total_changes: 0, changes: [], reason: null };
|
||||
};
|
||||
|
||||
const resourceGroups = (
|
||||
resources: WebPageInspection["resources"],
|
||||
): Map<string, Set<number | null>> => {
|
||||
const groups = new Map<string, Set<number | null>>();
|
||||
for (const resource of resources) {
|
||||
const identity = webResourceComparisonIdentity(resource);
|
||||
const sizes = groups.get(identity);
|
||||
if (sizes === undefined)
|
||||
groups.set(identity, new Set([resource.content_size]));
|
||||
else sizes.add(resource.content_size);
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
|
||||
const sameResourceSizes = (
|
||||
left: ReadonlySet<number | null>,
|
||||
right: ReadonlySet<number | null>,
|
||||
): boolean =>
|
||||
left.size === right.size && [...left].every((size) => right.has(size));
|
||||
|
||||
const keyed = <T>(
|
||||
values: readonly T[],
|
||||
identity: (value: T) => string,
|
||||
@@ -896,6 +974,7 @@ const accessibilityComparable = (
|
||||
|
||||
const accessibilityProjection = (
|
||||
accessibility: WebPageInspection["accessibility"],
|
||||
other: WebPageInspection["accessibility"],
|
||||
includeText: boolean,
|
||||
): unknown => {
|
||||
const nodeIndexes = new Map(
|
||||
@@ -903,12 +982,15 @@ const accessibilityProjection = (
|
||||
);
|
||||
return {
|
||||
total_nodes: accessibility.total_nodes,
|
||||
nodes: accessibility.nodes.map((node) => ({
|
||||
nodes: accessibility.nodes.map((node, index) => ({
|
||||
parent_index:
|
||||
node.parent_id === null
|
||||
? null
|
||||
: (nodeIndexes.get(node.parent_id) ?? -1),
|
||||
role: node.role,
|
||||
role:
|
||||
node.role === null || other.nodes[index]?.role === null
|
||||
? null
|
||||
: node.role,
|
||||
ignored: node.ignored,
|
||||
states: node.states,
|
||||
...(includeText
|
||||
|
||||
@@ -28,6 +28,23 @@ export type WebResourceReconciliation =
|
||||
| { readonly status: "ambiguous"; readonly candidate_resource_keys: string[] }
|
||||
| { readonly status: "unmatched"; readonly reason: "no_exact_sanitized_url" };
|
||||
|
||||
/**
|
||||
* Comparison identity excludes optional size evidence while preserving the
|
||||
* source inventory key.
|
||||
*/
|
||||
export const webResourceComparisonIdentity = (
|
||||
resource: WebInventoryResourceInput,
|
||||
): string =>
|
||||
`resg_${digestCanonicalValue(
|
||||
{
|
||||
url: resource.url,
|
||||
origin: resource.origin,
|
||||
type: resource.type,
|
||||
mime_type: resource.mime_type,
|
||||
},
|
||||
"Web resource comparison identity",
|
||||
)}`;
|
||||
|
||||
/** Assign deterministic identities to a bounded resource inventory. */
|
||||
export const stableWebResources = (
|
||||
resources: readonly WebInventoryResourceInput[],
|
||||
|
||||
@@ -144,6 +144,68 @@ it("keeps a count-mismatched tree unknown without a completeness marker", async
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["known to missing role", "button", null, "unknown"],
|
||||
["missing to known role", null, "button", "unknown"],
|
||||
["both roles missing", null, null, "unknown"],
|
||||
["known roles differ", "button", "link", "changed"],
|
||||
] as const)(
|
||||
"does not infer role equality or change when %s",
|
||||
async (_label, beforeRole, afterRole, status) => {
|
||||
const before = accessibleCapture();
|
||||
const after = structuredClone(before);
|
||||
before.accessibility.nodes[1]!.role = beforeRole;
|
||||
after.accessibility.nodes[1]!.role = afterRole;
|
||||
|
||||
const result = await compare(before, after);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
normalized_result: {
|
||||
dimensions: {
|
||||
accessibility: {
|
||||
status,
|
||||
...(status === "unknown"
|
||||
? {
|
||||
total_changes: 0,
|
||||
changes: [],
|
||||
reason: expect.stringContaining("incomplete"),
|
||||
}
|
||||
: {
|
||||
total_changes: 1,
|
||||
changes: [
|
||||
{ identity: "accessibility_tree", change: "modified" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
it("retains an independent accessibility difference when another role is missing", async () => {
|
||||
const before = accessibleCapture();
|
||||
const after = structuredClone(before);
|
||||
before.accessibility.nodes[1]!.role = null;
|
||||
after.accessibility.nodes[1]!.role = "button";
|
||||
after.accessibility.nodes[1]!.name = "Send";
|
||||
|
||||
const result = await compare(before, after);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
normalized_result: {
|
||||
dimensions: {
|
||||
accessibility: {
|
||||
status: "changed",
|
||||
total_changes: 1,
|
||||
changes: [{ identity: "accessibility_tree", change: "modified" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("compares known structure when only text was not approved", async () => {
|
||||
const before = accessibleCapture();
|
||||
const after = structuredClone(before);
|
||||
|
||||
Reference in New Issue
Block a user