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:
morluto
2026-10-23 23:36:21 +08:00
parent 3d24683b06
commit a06dbd509d
5 changed files with 292 additions and 13 deletions
+7 -1
View File
@@ -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`,
+112
View File
@@ -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" }),
]),
});
});
});
+94 -12
View File
@@ -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
+17
View File
@@ -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);