From a06dbd509df8516079027a6188cf3fa10e4bf6ec Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:36:21 +0800 Subject: [PATCH] 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 --- docs/browser-observation.md | 8 +- src/domain/webCaptureDiff.resources.test.ts | 112 ++++++++++++++++++ src/domain/webCaptureDiff.ts | 106 +++++++++++++++-- src/domain/webInventory.ts | 17 +++ .../accessibilityCaptureComparison.test.ts | 62 ++++++++++ 5 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 src/domain/webCaptureDiff.resources.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 7c006b24fd..042afabfc9 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -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`, diff --git a/src/domain/webCaptureDiff.resources.test.ts b/src/domain/webCaptureDiff.resources.test.ts new file mode 100644 index 0000000000..f710f07279 --- /dev/null +++ b/src/domain/webCaptureDiff.resources.test.ts @@ -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[]) => { + 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" }), + ]), + }); + }); +}); diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 33a16f859e..7bad18e855 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -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> => { + const groups = new Map>(); + 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, + right: ReadonlySet, +): boolean => + left.size === right.size && [...left].every((size) => right.has(size)); + const keyed = ( 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 diff --git a/src/domain/webInventory.ts b/src/domain/webInventory.ts index ebce288c3c..92ee43ff2f 100644 --- a/src/domain/webInventory.ts +++ b/src/domain/webInventory.ts @@ -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[], diff --git a/tests/boundary/cli/accessibilityCaptureComparison.test.ts b/tests/boundary/cli/accessibilityCaptureComparison.test.ts index 61dc571ac7..d9d4e531a7 100644 --- a/tests/boundary/cli/accessibilityCaptureComparison.test.ts +++ b/tests/boundary/cli/accessibilityCaptureComparison.test.ts @@ -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);