fix(browser): retain malformed inventory uncertainty

Distinguish missing CDP inventories from observed empty arrays, retain valid
accessibility and worker siblings, and admit required fields before scope
inference. Stop interpreting DOM documents without a usable string table.

Validated producer semantics and simulated CDP provider boundaries; this does
not establish real Chrome or Electron engine behavior.
Refs #3376, #3379, #3850.

Co-authored-by: 李云周 <154221037+YunzhouLi-hub@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
committed 2026-10-16 05:17:50 +08:00
1 parent 5a590e61b1
commit 4cc044be68
8 files changed
+349 -25

No files matched your search

+87 -1
View File
@@ -299,6 +299,90 @@ describe("CDP frame capture", () => {
});
describe("CDP accessibility capture", () => {
it("keeps an explicit empty accessibility inventory complete", () => {
const completeness = new CdpCaptureCompleteness();
const capture = captureAccessibility([{ nodes: [] }], {
includeText: true,
completeness,
});
expect(capture).toMatchObject({
total: 0,
nodes: [],
hasUnobservedChildren: false,
});
expect(completeness.snapshot()).toMatchObject({
unavailable_sections: [],
excluded: [],
});
});
it.each([undefined, { length: 1 }])(
"keeps an omitted or non-array node inventory unknown: %s",
(nodes) => {
const completeness = new CdpCaptureCompleteness();
const capture = captureAccessibility([{ nodes }], {
includeText: true,
completeness,
});
expect(capture).toMatchObject({
total: 0,
nodes: [],
hasUnobservedChildren: false,
});
expect(completeness.snapshot()).toMatchObject({
unavailable_sections: ["accessibility"],
excluded: [
{
section: "accessibility",
reason: "invalid_protocol_value",
count: null,
},
],
});
},
);
it("retains valid nodes and counts malformed inventory entries", () => {
const completeness = new CdpCaptureCompleteness();
const capture = captureAccessibility(
[
{
nodes: [
null,
{},
{ nodeId: "missing-ignored" },
{ nodeId: "invalid-ignored", ignored: "false" },
{ nodeId: "invalid-parent", ignored: false, parentId: 3 },
{ nodeId: "invalid-children", ignored: false, childIds: "bad" },
{ nodeId: "node-1", ignored: false, role: { value: "button" } },
"malformed",
],
},
],
{ includeText: true, completeness },
);
expect(capture.total).toBe(3);
expect(capture.nodes.map((node) => node.node_id)).toEqual([
"invalid-parent",
"invalid-children",
"node-1",
]);
expect(capture.nodes[2]).toMatchObject({ role: "button", ignored: false });
expect(completeness.snapshot()).toMatchObject({
unavailable_sections: ["accessibility"],
excluded: [
{
section: "accessibility",
reason: "invalid_protocol_value",
count: 7,
},
],
});
});
it("retains full approved accessibility text", () => {
const capture = captureAccessibility(
[
@@ -306,6 +390,7 @@ describe("CDP accessibility capture", () => {
nodes: [
{
nodeId: "node-1",
ignored: false,
role: { value: "button" },
name: { value: "😀a" },
description: { value: "éé" },
@@ -328,7 +413,7 @@ describe("CDP accessibility capture", () => {
description: "éé",
states: [{ name: "disabled", value: true }],
});
expect(capture.treeIncomplete).toBe(true);
expect(capture.hasUnobservedChildren).toBe(true);
expect(capture.textCapture).toEqual({
status: "included",
retained_bytes: 9,
@@ -343,6 +428,7 @@ describe("CDP accessibility capture", () => {
nodes: [
{
nodeId: "node-1",
ignored: false,
name: { value: "private name" },
description: { value: "private description" },
},
+53 -5
View File
@@ -2,6 +2,7 @@ import type { InspectWebPageInput } from "../domain/browserObservation.js";
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import {
allowedSanitizedUrl,
admitRecords,
numberValue,
recordValue,
recordsValue,
@@ -505,16 +506,63 @@ export const captureAccessibility = (
options: {
readonly includeText: boolean;
readonly unavailable?: boolean;
readonly completeness?: CdpCaptureCompleteness;
},
): {
readonly total: number;
readonly nodes: WebPageInspection["accessibility"]["nodes"];
readonly textCapture: WebPageInspection["accessibility"]["text_capture"];
readonly treeIncomplete: boolean;
readonly hasUnobservedChildren: boolean;
} => {
const all = results.flatMap((result) =>
recordsValue(requiredRecord(result).nodes),
);
const all = results.flatMap((result) => {
const inventory = admitRecords(requiredRecord(result).nodes);
if (inventory === undefined) {
options.completeness?.exclude(
"accessibility",
"invalid_protocol_value",
null,
);
return [];
}
const nodes: UnknownRecord[] = [];
let malformedCount = 0;
for (const entry of inventory) {
const node = recordValue(entry);
if (
node === undefined ||
cdpStringValue(node.nodeId) === undefined ||
cdpStringValue(node.nodeId)?.length === 0 ||
typeof node.ignored !== "boolean"
) {
malformedCount += 1;
continue;
}
const parentId = cdpStringValue(node.parentId);
if (
node.parentId !== undefined &&
(parentId === undefined || parentId.length === 0)
)
malformedCount += 1;
if (
node.childIds !== undefined &&
(!Array.isArray(node.childIds) ||
node.childIds.some(
(childId) =>
cdpStringValue(childId) === undefined ||
cdpStringValue(childId)?.length === 0,
))
)
malformedCount += 1;
nodes.push(node);
}
if (malformedCount > 0)
options.completeness?.exclude(
"accessibility",
"invalid_protocol_value",
malformedCount,
);
return nodes;
});
const nodeIds = new Set(
all.flatMap((node) => {
const nodeId = cdpStringValue(node.nodeId);
@@ -545,7 +593,7 @@ export const captureAccessibility = (
return {
total: all.length,
nodes,
treeIncomplete: all.some(
hasUnobservedChildren: all.some(
(node) =>
Array.isArray(node.childIds) &&
node.childIds.some((childId) => {
+4
View File
@@ -12,6 +12,10 @@ const isRecord = (value: unknown): value is UnknownRecord =>
export const recordValue = (value: unknown): UnknownRecord | undefined =>
isRecord(value) ? value : undefined;
/** Preserve an omitted producer inventory as unknown rather than empty. */
export const admitRecords = (value: unknown): readonly unknown[] | undefined =>
Array.isArray(value) ? value : undefined;
export const recordsValue = (value: unknown): readonly UnknownRecord[] =>
Array.isArray(value)
? value.flatMap((item) => {
+7 -4
View File
@@ -328,10 +328,13 @@ const captureDom = async (
);
const invalid = (count: number | null) =>
completeness.exclude("dom", "invalid_protocol_value", count);
if (!Array.isArray(result.strings)) invalid(null);
const strings = Array.isArray(result.strings)
? result.strings.map((value) => (typeof value === "string" ? value : ""))
: [];
if (!Array.isArray(result.strings)) {
invalid(null);
return { total_nodes: 0, nodes: [] };
}
const strings = result.strings.map((value) =>
typeof value === "string" ? value : "",
);
const nodes: ElectronPageInspection["dom"]["nodes"] = [];
let total = 0;
for (const document of snapshotDocuments(result.documents, invalid)) {
+3 -1
View File
@@ -414,6 +414,7 @@ const accessibilityForFrames = async (
const capture = captureAccessibility(results, {
includeText: context.input.include_accessibility_text,
...(results.length === 0 && unavailable ? { unavailable: true } : {}),
completeness: events.completeness,
});
if (!context.input.include_accessibility_text)
events.completeness.exclude(
@@ -421,7 +422,8 @@ const accessibilityForFrames = async (
"not_approved",
capture.textCapture.excluded_fields,
);
if (capture.treeIncomplete) events.completeness.truncate("accessibility");
if (capture.hasUnobservedChildren)
events.completeness.truncate("accessibility");
return capture;
};
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
import type { CdpConnection } from "./CdpConnection.js";
import type { CdpEndpointTarget } from "./CdpEndpoint.js";
import { captureWorkers } from "./CdpPageCaptureWorkers.js";
import type { CaptureContext } from "./CdpPageCapture.js";
const origin = "http://127.0.0.1:43127";
const capture = async (inventory: unknown) => {
const input = inspectWebPageInputSchema.parse({
cdp_endpoint: origin,
allowed_origins: [origin],
target_id: "page",
});
const events = new CdpCaptureEvents(input, new Set([origin]));
const context = {
connection: {
async send() {
return inventory;
},
} as unknown as CdpConnection,
sessionId: undefined,
target: {
id: "page",
type: "page",
title: "Fixture",
url: `${origin}/index.html`,
attached: false,
} satisfies CdpEndpointTarget,
} as unknown as CaptureContext;
const workers = await captureWorkers(
{
context,
allowedOrigins: new Set([origin]),
limitations: [],
events,
},
new Set(),
);
return { workers, completeness: events.completeness.snapshot() };
};
describe("web worker target inventory", () => {
it("preserves an explicit empty inventory", async () => {
const result = await capture({ targetInfos: [] });
expect(result.workers).toEqual([]);
expect(result.completeness.unavailable_sections).not.toContain("workers");
});
it.each([undefined, { length: 1 }])(
"reports a missing or non-array target inventory as unknown: %s",
async (targetInfos) => {
const result = await capture({ targetInfos });
expect(result.workers).toEqual([]);
expect(result.completeness).toMatchObject({
unavailable_sections: ["workers"],
excluded: [
{
section: "workers",
reason: "invalid_protocol_value",
count: null,
},
],
});
},
);
it("retains valid worker records beside malformed entries", async () => {
const result = await capture({
targetInfos: [
null,
{
targetId: "worker-1",
type: "service_worker",
url: `${origin}/worker.js`,
attached: true,
openerId: "page",
},
{
targetId: "worker-missing-url",
type: "worker",
attached: true,
openerId: "another-page",
},
{
type: "worker",
url: `${origin}/missing-id.js`,
attached: true,
openerId: "page",
},
{
targetId: "worker-invalid-attached",
type: "worker",
url: `${origin}/invalid-attached.js`,
attached: "true",
openerId: "page",
},
"malformed",
],
});
expect(result.workers.map((worker) => worker.target_id)).toEqual([
"worker-1",
]);
expect(result.completeness).toMatchObject({
unavailable_sections: ["workers"],
excluded: [
{
section: "workers",
reason: "invalid_protocol_value",
count: 5,
},
],
});
expect(result.completeness.excluded).not.toContainEqual(
expect.objectContaining({
section: "workers",
reason: "out_of_target_scope",
}),
);
});
});
+51 -14
View File
@@ -1,10 +1,10 @@
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import type { CdpCaptureEvents } from "./CdpCaptureEvents.js";
import {
admitRecords,
allowedSanitizedUrl,
isHttpUrl,
recordValue,
recordsValue,
cdpStringValue,
} from "./CdpCaptureValues.js";
import type { CaptureContext } from "./CdpPageCapture.js";
@@ -31,14 +31,53 @@ export const captureWorkers = async (
);
const items: WebPageInspection["workers"] = [];
if (result === undefined) completeness.unavailable("workers");
for (const target of recordsValue(recordValue(result)?.targetInfos)) {
const type = cdpStringValue(target.type) ?? "";
const targets = admitRecords(recordValue(result)?.targetInfos);
if (result !== undefined && targets === undefined) {
completeness.exclude("workers", "invalid_protocol_value", null);
return items;
}
let malformedEntries = 0;
for (const entry of targets ?? []) {
const target = recordValue(entry);
if (target === undefined) {
malformedEntries += 1;
continue;
}
const type = cdpStringValue(target.type);
if (type === undefined || type.length === 0) {
malformedEntries += 1;
continue;
}
if (!type.includes("worker")) continue;
const targetId = cdpStringValue(target.targetId);
if (
targetId === undefined ||
targetId.length === 0 ||
typeof target.attached !== "boolean" ||
typeof target.url !== "string"
) {
malformedEntries += 1;
continue;
}
const openerId =
target.openerId === undefined
? undefined
: cdpStringValue(target.openerId);
const parentFrameId =
target.parentFrameId === undefined
? undefined
: cdpStringValue(target.parentFrameId);
if (
(target.openerId !== undefined && openerId === undefined) ||
(target.parentFrameId !== undefined && parentFrameId === undefined)
) {
malformedEntries += 1;
continue;
}
const url = allowedSanitizedUrl(target.url, allowedOrigins);
const relatedToPage =
cdpStringValue(target.openerId) === context.target.id ||
(cdpStringValue(target.parentFrameId) !== undefined &&
frameIds.has(cdpStringValue(target.parentFrameId) ?? ""));
if (!type.includes("worker")) continue;
openerId === context.target.id ||
(parentFrameId !== undefined && frameIds.has(parentFrameId));
if (!relatedToPage) {
completeness.exclude("workers", "out_of_target_scope");
continue;
@@ -55,19 +94,17 @@ export const captureWorkers = async (
);
continue;
}
if (typeof target.attached !== "boolean") {
completeness.exclude("workers", "invalid_protocol_value");
continue;
}
items.push({
target_id: cdpStringValue(target.targetId) ?? "",
target_id: targetId,
type,
url: url.url,
origin: url.origin,
attached: target.attached,
opener_target_id: cdpStringValue(target.openerId) ?? null,
parent_frame_id: cdpStringValue(target.parentFrameId) ?? null,
opener_target_id: openerId ?? null,
parent_frame_id: parentFrameId ?? null,
});
}
if (malformedEntries > 0)
completeness.exclude("workers", "invalid_protocol_value", malformedEntries);
return items;
};
@@ -372,6 +372,23 @@ describe("CdpElectronProvider malformed inventory replies", () => {
return result.value;
};
it("keeps documents unknown when the DOM string table is missing", async () => {
const inspected = await inspectWith("DOMSnapshot.captureSnapshot", () => ({
documents: [{ documentURL: 0, nodes: { nodeType: [] } }],
}));
expect(inspected.dom).toEqual({ total_nodes: 0, nodes: [] });
expect(inspected.completeness.unavailable_sections).toContain("dom");
expect(inspected.completeness.excluded).toContainEqual({
section: "dom",
reason: "invalid_protocol_value",
count: null,
});
expect(inspected.completeness.policy_filtered_sections).not.toContain(
"dom",
);
});
it.each([
["dom", "DOMSnapshot.captureSnapshot", () => ({ strings: [] })],
[