fix(browser): distinguish SVG links from HTML XLink attributes (#2480)
* fix(browser): respect SVG desc and title HTML integration points * fix(browser): preserve moved SVG XLink destinations * fix(browser): bound concurrent SVG identity reads
This commit is contained in:
parent
5eb296649d
commit
84ec40f1af
|
|
@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
import { captureDom } from "./CdpCaptureDocuments.js";
|
||||
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
const documentUrl = `${origin}/screens/app`;
|
||||
|
|
@ -50,8 +52,11 @@ const snapshot = (
|
|||
],
|
||||
});
|
||||
|
||||
const capture = (value: ReturnType<typeof snapshot>) =>
|
||||
captureDom(value, new Set([origin]), input);
|
||||
const capture = (value: ReturnType<typeof snapshot>) => {
|
||||
const step = captureDom(value, new Set([origin]), input).next();
|
||||
if (!step.done) throw new Error("Unexpected SVG identity query");
|
||||
return step.value;
|
||||
};
|
||||
|
||||
describe("DOM metadata document base URLs", () => {
|
||||
it("resolves links, actions and agent hints against the captured document base", () => {
|
||||
|
|
@ -282,6 +287,7 @@ interface SvgSnapshotNode {
|
|||
readonly name: string;
|
||||
readonly parent: number;
|
||||
readonly attributes?: readonly (readonly [string, string])[];
|
||||
readonly svg?: boolean;
|
||||
}
|
||||
|
||||
const svgDocument = (
|
||||
|
|
@ -300,7 +306,13 @@ const svgDocument = (
|
|||
const nodeValue = [intern("")];
|
||||
const parentIndex = [-1];
|
||||
const attributes: number[][] = [[]];
|
||||
const backendNodeId = [1];
|
||||
const svgNodeKinds = new Map<string, boolean>();
|
||||
for (const element of elements) {
|
||||
const id = backendNodeId.length + 1;
|
||||
backendNodeId.push(id);
|
||||
if (element.svg !== undefined)
|
||||
svgNodeKinds.set(`${String(id)}:${element.name}`, element.svg);
|
||||
nodeType.push(1);
|
||||
nodeName.push(intern(element.name));
|
||||
nodeValue.push(intern(""));
|
||||
|
|
@ -316,14 +328,35 @@ const svgDocument = (
|
|||
{
|
||||
documentURL: 0,
|
||||
baseURL: 1,
|
||||
nodes: { nodeType, nodeName, nodeValue, parentIndex, attributes },
|
||||
nodes: {
|
||||
nodeType,
|
||||
nodeName,
|
||||
nodeValue,
|
||||
parentIndex,
|
||||
attributes,
|
||||
backendNodeId,
|
||||
},
|
||||
svgNodeKinds,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const captureSvg = (value: ReturnType<typeof svgDocument>) =>
|
||||
captureDom(value, new Set([origin]), input);
|
||||
const captureSvg = (value: ReturnType<typeof svgDocument>) => {
|
||||
const kinds = new Map(
|
||||
value.documents.flatMap((document) => [...document.svgNodeKinds]),
|
||||
);
|
||||
const capture = captureDom(value, new Set([origin]), input);
|
||||
let step = capture.next();
|
||||
while (!step.done) {
|
||||
step = capture.next(
|
||||
step.value.map((query) =>
|
||||
kinds.get(`${String(query.backendNodeId)}:${query.nodeName}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
return step.value;
|
||||
};
|
||||
|
||||
const urlFor = (
|
||||
result: ReturnType<typeof captureSvg>,
|
||||
|
|
@ -331,6 +364,136 @@ const urlFor = (
|
|||
): (typeof result.urls)[number] | undefined =>
|
||||
result.urls.find((url) => url.node_index === nodeIndex);
|
||||
|
||||
describe("browser SVG identity", () => {
|
||||
const query = { backendNodeId: 42, nodeName: "a" };
|
||||
const node = { ...query, nodeType: 1 };
|
||||
|
||||
it("uses only a matching described element's SVG flag", () => {
|
||||
expect(capturedSvgNodeKind(query, { node: { ...node, isSVG: true } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
capturedSvgNodeKind(query, { node: { ...node, isSVG: false } }),
|
||||
).toBe(false);
|
||||
expect(capturedSvgNodeKind(query, { node })).toBe(false);
|
||||
for (const invalid of [
|
||||
{},
|
||||
{ node: { ...node, backendNodeId: 43, isSVG: true } },
|
||||
{ node: { ...node, nodeName: "A", isSVG: true } },
|
||||
{ node: { ...node, nodeType: 3, isSVG: true } },
|
||||
{ node: { ...node, isSVG: "true" } },
|
||||
])
|
||||
expect(capturedSvgNodeKind(query, invalid)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("distinguishes identically named SVG and MathML elements regardless of parent", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "desc", parent: 1 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/svg"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/math"]],
|
||||
},
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "/html"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 0,
|
||||
attributes: [["xlink:href", "/orphan"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls.map(({ url }) => url)).toEqual([
|
||||
`${origin}/svg`,
|
||||
`${origin}/orphan`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("records unresolved identity without inventing a destination or hiding attributes", () => {
|
||||
const value = svgDocument([
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/unknown"]] },
|
||||
]);
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureDom(value, new Set([origin]), input, completeness);
|
||||
expect(capture.next()).toEqual({
|
||||
done: false,
|
||||
value: [{ backendNodeId: 2, nodeName: "a" }],
|
||||
});
|
||||
const step = capture.next([undefined]);
|
||||
expect(step.done).toBe(true);
|
||||
if (!step.done) throw new Error("Unexpected second query");
|
||||
expect(step.value.unresolvedSvgNodes).toBe(1);
|
||||
expect(step.value.urls).toEqual([]);
|
||||
expect(step.value.nodes[1]?.attribute_names).toEqual(["xlink:href"]);
|
||||
expect(completeness.snapshot().excluded).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
section: "metadata",
|
||||
reason: "provider_unavailable",
|
||||
count: 1,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps batched SVG identities aligned around missing IDs and ordinary links", () => {
|
||||
const value = svgDocument([
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/svg"]] },
|
||||
{ name: "A", parent: 0, attributes: [["href", "/ordinary"]] },
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/missing-id"]] },
|
||||
{ name: "a", parent: 0, attributes: [["xlink:href", "/non-svg"]] },
|
||||
]);
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const capture = captureDom(
|
||||
{
|
||||
...value,
|
||||
documents: value.documents.map((document) => ({
|
||||
...document,
|
||||
nodes: { ...document.nodes, backendNodeId: [1, 2, 3, null, 5] },
|
||||
})),
|
||||
},
|
||||
new Set([origin]),
|
||||
input,
|
||||
completeness,
|
||||
);
|
||||
expect(capture.next()).toEqual({
|
||||
done: false,
|
||||
value: [
|
||||
{ backendNodeId: 2, nodeName: "a" },
|
||||
{ backendNodeId: 5, nodeName: "a" },
|
||||
],
|
||||
});
|
||||
const step = capture.next([true, false]);
|
||||
if (!step.done) throw new Error("Unexpected second batch");
|
||||
expect(
|
||||
step.value.urls.map(({ node_index, url }) => [node_index, url]),
|
||||
).toEqual([
|
||||
[1, `${origin}/svg`],
|
||||
[2, `${origin}/ordinary`],
|
||||
]);
|
||||
expect(step.value.unresolvedSvgNodes).toBe(1);
|
||||
expect(completeness.snapshot().excluded).toContainEqual({
|
||||
section: "metadata",
|
||||
reason: "provider_unavailable",
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe("SVG xlink:href destinations", () => {
|
||||
it("reports an SVG anchor that only has xlink:href, keeping the first value", () => {
|
||||
const result = captureSvg(
|
||||
|
|
@ -339,6 +502,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "g", parent: 1 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [
|
||||
["xlink:href", "/xl-s2h"],
|
||||
|
|
@ -371,6 +535,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [
|
||||
[first, valueFor(first)],
|
||||
|
|
@ -395,18 +560,21 @@ describe("SVG xlink:href destinations", () => {
|
|||
svgDocument([
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 0,
|
||||
attributes: [["xlink:href", "/xl-html"]],
|
||||
},
|
||||
{ name: "math", parent: 0 },
|
||||
{
|
||||
name: "mrow",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-math"]],
|
||||
},
|
||||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-s2h"]],
|
||||
},
|
||||
|
|
@ -425,6 +593,7 @@ describe("SVG xlink:href destinations", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "https://cdn.example.test/out"]],
|
||||
},
|
||||
|
|
@ -444,8 +613,18 @@ describe("SVG xlink:href destinations", () => {
|
|||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "image", parent: 1, attributes: [["xlink:href", "guide"]] },
|
||||
{ name: "use", parent: 1, attributes: [["xlink:href", "icon.svg"]] },
|
||||
{
|
||||
name: "image",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "guide"]],
|
||||
},
|
||||
{
|
||||
name: "use",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "icon.svg"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls).toEqual([
|
||||
|
|
@ -466,14 +645,24 @@ describe("SVG xlink:href destinations", () => {
|
|||
});
|
||||
|
||||
describe("SVG xlink:href context", () => {
|
||||
it("keeps forward ancestor context local to each document", () => {
|
||||
it("binds SVG identity to the selected element across documents", () => {
|
||||
const svg = svgDocument([
|
||||
{ name: "a", parent: 2, attributes: [["xlink:href", "/svg-link"]] },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/svg-link"]],
|
||||
},
|
||||
{ name: "g", parent: 3 },
|
||||
{ name: "svg", parent: 0 },
|
||||
]);
|
||||
const html = svgDocument([
|
||||
{ name: "A", parent: 2, attributes: [["xlink:href", "/html-link"]] },
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/html-link"]],
|
||||
},
|
||||
{ name: "DIV", parent: 3 },
|
||||
{ name: "BODY", parent: 0 },
|
||||
]);
|
||||
|
|
@ -507,32 +696,39 @@ describe("SVG xlink:href context", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("stops SVG context at foreignObject and resumes it for a nested svg", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: "foreignObject", parent: 1 },
|
||||
{
|
||||
name: "A",
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
{ name: "svg", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-nested"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(urlFor(result, 3)).toBeUndefined();
|
||||
expect(urlFor(result, 5)).toMatchObject({
|
||||
attribute: "href",
|
||||
url: `${origin}/xl-nested`,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
});
|
||||
it.each(["foreignObject", "desc", "title"])(
|
||||
"preserves browser SVG identity across %s HTML integration points",
|
||||
(integrationPoint) => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "svg", parent: 0 },
|
||||
{ name: integrationPoint, parent: 1 },
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
{ name: "svg", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 4,
|
||||
attributes: [["xlink:href", "/xl-nested"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(urlFor(result, 3)).toBeUndefined();
|
||||
expect(urlFor(result, 5)).toMatchObject({
|
||||
attribute: "href",
|
||||
url: `${origin}/xl-nested`,
|
||||
destination_scope: "approved",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("SVG XLink precedence and unknown identity", () => {
|
||||
it("keeps an empty href ahead of xlink:href", () => {
|
||||
const baseUrl = `${origin}/screens/assets/`;
|
||||
const result = captureSvg(
|
||||
|
|
@ -541,6 +737,7 @@ describe("SVG xlink:href context", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [
|
||||
["href", ""],
|
||||
|
|
@ -567,6 +764,7 @@ describe("SVG xlink:href context", () => {
|
|||
{ name: "svg", parent: 0 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "javascript:void(0)"]],
|
||||
},
|
||||
|
|
@ -582,7 +780,7 @@ describe("SVG xlink:href context", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("does not treat a cyclic or dangling parent chain as SVG", () => {
|
||||
it("keeps an unavailable SVG identity unknown despite ancestor names", () => {
|
||||
const cyclic = captureSvg(
|
||||
svgDocument([
|
||||
{ name: "g", parent: 2 },
|
||||
|
|
@ -652,7 +850,12 @@ describe("XML SVG roots and namespace scope restoration", () => {
|
|||
["xmlns:p", XLINK_NAMESPACE],
|
||||
],
|
||||
},
|
||||
{ name: "a", parent: 1, attributes: [["p:href", "/linked"]] },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 1,
|
||||
attributes: [["p:href", "/linked"]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.urls).toEqual([approvedHref(2, `${origin}/linked`)]);
|
||||
|
|
@ -764,6 +967,7 @@ describe("XHTML and XML SVG namespace prefixes", () => {
|
|||
{ name: "svg:foreignObject", parent: 2 },
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 3,
|
||||
attributes: [["xlink:href", "/xl-html-fo"]],
|
||||
},
|
||||
|
|
@ -799,6 +1003,7 @@ describe("XHTML and XML SVG namespace prefixes", () => {
|
|||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [
|
||||
["xlink:href", "/not-xlink"],
|
||||
|
|
@ -918,11 +1123,13 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["xlink:href", "/xl-literal"]],
|
||||
},
|
||||
{
|
||||
name: "a",
|
||||
svg: true,
|
||||
parent: 2,
|
||||
attributes: [["x:href", "/xl-custom-html"]],
|
||||
},
|
||||
|
|
@ -937,6 +1144,7 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
},
|
||||
{
|
||||
name: "A",
|
||||
svg: false,
|
||||
parent: 1,
|
||||
attributes: [["xlink:href", "/xl-html-anchor"]],
|
||||
},
|
||||
|
|
@ -944,7 +1152,9 @@ describe("XHTML SVG exclusions, HTML documents, and prefix scope", () => {
|
|||
);
|
||||
expect(result.urls).toEqual([approvedHref(3, `${origin}/xl-literal`)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("XML namespace resolution ordering and capacity", () => {
|
||||
it("applies an element's own xmlns declarations to its name and href", () => {
|
||||
const result = captureSvg(
|
||||
svgDocument([
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import {
|
|||
} from "./CdpCaptureValues.js";
|
||||
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
||||
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { createDocumentSvgAncestry, domSvgLink } from "./CdpCaptureDomSvg.js";
|
||||
import {
|
||||
createDocumentSvgAncestry,
|
||||
domSvgLink,
|
||||
type DomSvgNodeQuery,
|
||||
} from "./CdpCaptureDomSvg.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
export type CapturedResource = Omit<
|
||||
|
|
@ -135,18 +139,39 @@ export const walkFrameTrees = function* (
|
|||
}
|
||||
};
|
||||
|
||||
export const captureDom = (
|
||||
interface DomMetadataCapture {
|
||||
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
||||
readonly excludedUrls: number;
|
||||
readonly unresolvedSvgNodes: number;
|
||||
}
|
||||
|
||||
export interface DomCapture extends DomMetadataCapture {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["dom"]["nodes"];
|
||||
}
|
||||
|
||||
// Bound retained projections and outstanding CDP reads independently of page size.
|
||||
// Eight leaves room for the session's other cancellation listeners.
|
||||
const DOM_METADATA_BATCH_SIZE = 8;
|
||||
|
||||
interface PendingDomMetadata {
|
||||
readonly options: DomMetadataOptions;
|
||||
// Undefined means no query is needed; null means the candidate has no valid ID.
|
||||
readonly svgQuery: DomSvgNodeQuery | null | undefined;
|
||||
}
|
||||
|
||||
/** Project one snapshot, requesting bounded batches of SVG identity facts. */
|
||||
export function* captureDom(
|
||||
result: unknown,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
input: InspectWebPageInput,
|
||||
completeness?: CdpCaptureCompleteness,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["dom"]["nodes"];
|
||||
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
||||
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
||||
readonly excludedUrls: number;
|
||||
} => {
|
||||
): Generator<
|
||||
readonly DomSvgNodeQuery[],
|
||||
DomCapture,
|
||||
readonly (boolean | undefined)[]
|
||||
> {
|
||||
const root = requiredRecord(result);
|
||||
const strings = Array.isArray(root.strings)
|
||||
? root.strings.map((value) => cdpStringValue(value) ?? "")
|
||||
|
|
@ -156,6 +181,7 @@ export const captureDom = (
|
|||
const agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
|
||||
let excludedUrls = 0;
|
||||
let total = 0;
|
||||
let unresolvedSvgNodes = 0;
|
||||
for (const document of recordsValue(root.documents)) {
|
||||
const documentUrl = indexedString(strings, document.documentURL);
|
||||
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
|
||||
|
|
@ -172,6 +198,9 @@ export const captureDom = (
|
|||
const attributes = Array.isArray(documentNodes.attributes)
|
||||
? documentNodes.attributes
|
||||
: [];
|
||||
const backendNodeIds = Array.isArray(documentNodes.backendNodeId)
|
||||
? documentNodes.backendNodeId
|
||||
: [];
|
||||
const baseIndex = nodes.length;
|
||||
const ancestry = createDocumentSvgAncestry({
|
||||
nodeTypes,
|
||||
|
|
@ -181,12 +210,29 @@ export const captureDom = (
|
|||
attributes,
|
||||
});
|
||||
total += nodeTypes.length;
|
||||
const pendingMetadata: PendingDomMetadata[] = [];
|
||||
for (let index = 0; index < nodeTypes.length; index += 1) {
|
||||
const attributeIndexes = numberArray(attributes[index]);
|
||||
const parent = Math.trunc(parents[index] ?? -1);
|
||||
const nodeIndex = nodes.length;
|
||||
const nodeName = indexedString(strings, nodeNames[index]);
|
||||
const attributeNames = uniqueAttributeNames(attributeIndexes, strings);
|
||||
const svgLink = domSvgLink(ancestry, index);
|
||||
let svgQuery: DomSvgNodeQuery | null | undefined;
|
||||
if (
|
||||
!svgLink.xmlDocument &&
|
||||
nodeTypes[index] === 1 &&
|
||||
attributeNames.includes("xlink:href") &&
|
||||
!attributeNames.includes("href")
|
||||
) {
|
||||
const backendNodeId = numberValue(backendNodeIds[index]);
|
||||
svgQuery =
|
||||
backendNodeId !== undefined &&
|
||||
Number.isSafeInteger(backendNodeId) &&
|
||||
backendNodeId > 0
|
||||
? { backendNodeId, nodeName }
|
||||
: null;
|
||||
}
|
||||
nodes.push({
|
||||
index: nodeIndex,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
|
|
@ -195,30 +241,34 @@ export const captureDom = (
|
|||
node_value_length: indexedString(strings, nodeValues[index]).length,
|
||||
attribute_names: attributeNames,
|
||||
});
|
||||
const metadata = domMetadata({
|
||||
strings,
|
||||
attributes: attributeIndexes,
|
||||
// The BASE element itself resolves against the document fallback URL.
|
||||
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
allowedOrigins,
|
||||
...domSvgLink(ancestry, index),
|
||||
pendingMetadata.push({
|
||||
svgQuery,
|
||||
options: {
|
||||
strings,
|
||||
attributes: attributeIndexes,
|
||||
// The BASE element itself resolves against the document fallback URL.
|
||||
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
||||
documentUrl,
|
||||
nodeIndex,
|
||||
nodeName,
|
||||
allowedOrigins,
|
||||
...svgLink,
|
||||
},
|
||||
});
|
||||
for (const url of metadata.urls) {
|
||||
urls.push(url);
|
||||
if (url.destination_scope !== "approved") {
|
||||
excludedUrls += 1;
|
||||
completeness?.exclude(
|
||||
"metadata",
|
||||
url.destination_scope === "outside_policy"
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url",
|
||||
);
|
||||
}
|
||||
if (
|
||||
pendingMetadata.length === DOM_METADATA_BATCH_SIZE ||
|
||||
index === nodeTypes.length - 1
|
||||
) {
|
||||
const metadata = yield* captureDomMetadata(
|
||||
pendingMetadata,
|
||||
completeness,
|
||||
);
|
||||
urls.push(...metadata.urls);
|
||||
agentHints.push(...metadata.agentHints);
|
||||
excludedUrls += metadata.excludedUrls;
|
||||
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
|
||||
pendingMetadata.length = 0;
|
||||
}
|
||||
agentHints.push(...metadata.agentHints);
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -227,8 +277,54 @@ export const captureDom = (
|
|||
urls,
|
||||
agentHints,
|
||||
excludedUrls,
|
||||
unresolvedSvgNodes,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function* captureDomMetadata(
|
||||
pending: readonly PendingDomMetadata[],
|
||||
completeness: CdpCaptureCompleteness | undefined,
|
||||
): Generator<
|
||||
readonly DomSvgNodeQuery[],
|
||||
DomMetadataCapture,
|
||||
readonly (boolean | undefined)[]
|
||||
> {
|
||||
const queries = pending.flatMap(({ svgQuery }) =>
|
||||
svgQuery == null ? [] : [svgQuery],
|
||||
);
|
||||
const kinds = queries.length > 0 ? yield queries : [];
|
||||
const urls: DomMetadataCapture["urls"] = [];
|
||||
const agentHints: DomMetadataCapture["agentHints"] = [];
|
||||
let queryIndex = 0;
|
||||
let unresolvedSvgNodes = 0;
|
||||
let excludedUrls = 0;
|
||||
for (const { options, svgQuery } of pending) {
|
||||
let svgElement = options.svgElement;
|
||||
if (svgQuery !== undefined) {
|
||||
const kind = svgQuery === null ? undefined : kinds[queryIndex++];
|
||||
svgElement = kind === true;
|
||||
if (kind === undefined) {
|
||||
unresolvedSvgNodes += 1;
|
||||
completeness?.exclude("metadata", "provider_unavailable");
|
||||
}
|
||||
}
|
||||
const metadata = domMetadata({ ...options, svgElement });
|
||||
for (const url of metadata.urls) {
|
||||
urls.push(url);
|
||||
if (url.destination_scope !== "approved") {
|
||||
excludedUrls += 1;
|
||||
completeness?.exclude(
|
||||
"metadata",
|
||||
url.destination_scope === "outside_policy"
|
||||
? "disallowed_origin"
|
||||
: "unsupported_url",
|
||||
);
|
||||
}
|
||||
}
|
||||
agentHints.push(...metadata.agentHints);
|
||||
}
|
||||
return { urls, agentHints, unresolvedSvgNodes, excludedUrls };
|
||||
}
|
||||
|
||||
export const captureAccessibility = (
|
||||
results: readonly unknown[],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { numberValue } from "./CdpCaptureValues.js";
|
||||
import { numberValue, recordValue } from "./CdpCaptureValues.js";
|
||||
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
|
||||
|
|
@ -18,11 +18,34 @@ interface XmlSvgLink {
|
|||
|
||||
interface DocumentSvgAncestry {
|
||||
readonly xml: boolean;
|
||||
readonly nodes: DomSnapshotNodes;
|
||||
readonly svgContexts: Map<number, boolean>;
|
||||
readonly xmlLinks: Map<number, XmlSvgLink>;
|
||||
}
|
||||
|
||||
export interface DomSvgNodeQuery {
|
||||
readonly backendNodeId: number;
|
||||
readonly nodeName: string;
|
||||
}
|
||||
|
||||
/** Bind the browser's SVG flag to the exact snapshotted element. */
|
||||
export const capturedSvgNodeKind = (
|
||||
query: DomSvgNodeQuery,
|
||||
result: unknown,
|
||||
): boolean | undefined => {
|
||||
const node = recordValue(recordValue(result)?.node);
|
||||
if (
|
||||
node?.backendNodeId !== query.backendNodeId ||
|
||||
node.nodeName !== query.nodeName ||
|
||||
node.nodeType !== 1
|
||||
)
|
||||
return undefined;
|
||||
// CDP omits isSVG for non-SVG nodes. Other values are malformed, not false.
|
||||
return node.isSVG === undefined
|
||||
? false
|
||||
: typeof node.isSVG === "boolean"
|
||||
? node.isSVG
|
||||
: undefined;
|
||||
};
|
||||
|
||||
interface DomSvgLink {
|
||||
readonly svgElement: boolean;
|
||||
readonly xmlDocument: boolean;
|
||||
|
|
@ -79,8 +102,6 @@ export const createDocumentSvgAncestry = (
|
|||
const xml = xmlDocument(nodes);
|
||||
return {
|
||||
xml,
|
||||
nodes,
|
||||
svgContexts: new Map(),
|
||||
xmlLinks: xml ? resolveXmlElements(nodes) : new Map(),
|
||||
};
|
||||
};
|
||||
|
|
@ -91,11 +112,7 @@ export const domSvgLink = (
|
|||
): DomSvgLink => {
|
||||
if (!ancestry.xml) {
|
||||
return {
|
||||
svgElement: elementInSvgContext(
|
||||
index,
|
||||
ancestry.nodes,
|
||||
ancestry.svgContexts,
|
||||
),
|
||||
svgElement: false,
|
||||
xmlDocument: false,
|
||||
xlinkHref: undefined,
|
||||
};
|
||||
|
|
@ -108,43 +125,6 @@ export const domSvgLink = (
|
|||
};
|
||||
};
|
||||
|
||||
// Chrome's HTML parser records SVG local names (`svg`, `foreignObject`).
|
||||
// Descendants of foreignObject are HTML until a nested `svg`.
|
||||
const elementInSvgContext = (
|
||||
index: number,
|
||||
nodes: DomSnapshotNodes,
|
||||
contexts: Map<number, boolean>,
|
||||
): boolean => {
|
||||
let current = index;
|
||||
const seen = new Set<number>();
|
||||
let inSvg = false;
|
||||
while (
|
||||
current >= 0 &&
|
||||
current < nodes.nodeNames.length &&
|
||||
!seen.has(current)
|
||||
) {
|
||||
const cached = contexts.get(current);
|
||||
if (cached !== undefined) {
|
||||
inSvg = cached;
|
||||
break;
|
||||
}
|
||||
seen.add(current);
|
||||
if (stringAt(nodes.strings, nodes.nodeNames[current]) === "svg") {
|
||||
inSvg = true;
|
||||
break;
|
||||
}
|
||||
const parent = Math.trunc(nodes.parents[current] ?? -1);
|
||||
if (parent < 0 || parent === current) break;
|
||||
if (stringAt(nodes.strings, nodes.nodeNames[parent]) === "foreignObject")
|
||||
break;
|
||||
current = parent;
|
||||
}
|
||||
// Cache the entire examined chain, including forward references and cycles,
|
||||
// so shared ancestors are examined once per document.
|
||||
for (const node of seen) contexts.set(node, inSvg);
|
||||
return inSvg;
|
||||
};
|
||||
|
||||
interface NamespaceRestore {
|
||||
readonly prefix: string;
|
||||
readonly previous: string | undefined;
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ import {
|
|||
captureFrames,
|
||||
captureResources,
|
||||
type CapturedResource,
|
||||
type DomCapture,
|
||||
mainFrameUrl,
|
||||
} from "./CdpCaptureDocuments.js";
|
||||
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
delayWithCancellation,
|
||||
|
|
@ -52,7 +54,7 @@ interface CaptureState {
|
|||
interface CapturedSections {
|
||||
readonly attachedUrl: string;
|
||||
readonly frames: WebPageInspection["frames"];
|
||||
readonly dom: ReturnType<typeof captureDom>;
|
||||
readonly dom: DomCapture;
|
||||
readonly accessibility: ReturnType<typeof captureAccessibility>;
|
||||
readonly scripts: WebPageInspection["scripts"];
|
||||
readonly resources: WebPageInspection["resources"];
|
||||
|
|
@ -260,9 +262,7 @@ const authorizeObservationWindow = async (
|
|||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
};
|
||||
|
||||
const capturePageDom = async (
|
||||
state: CaptureState,
|
||||
): Promise<ReturnType<typeof captureDom>> => {
|
||||
const capturePageDom = async (state: CaptureState): Promise<DomCapture> => {
|
||||
const { context, allowedOrigins } = state;
|
||||
const result = await context.connection.send(
|
||||
"DOMSnapshot.captureSnapshot",
|
||||
|
|
@ -274,12 +274,56 @@ const capturePageDom = async (
|
|||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
return captureDom(
|
||||
const capture = captureDom(
|
||||
result,
|
||||
allowedOrigins,
|
||||
context.input,
|
||||
state.events.completeness,
|
||||
);
|
||||
let step = capture.next();
|
||||
let descriptionUnavailable = false;
|
||||
const pendingController = new AbortController();
|
||||
const signal =
|
||||
context.signal === undefined
|
||||
? pendingController.signal
|
||||
: AbortSignal.any([context.signal, pendingController.signal]);
|
||||
while (!step.done) {
|
||||
const pending = step.value.map(async (query) => {
|
||||
if (descriptionUnavailable) return undefined;
|
||||
try {
|
||||
return capturedSvgNodeKind(
|
||||
query,
|
||||
await context.connection.send(
|
||||
"DOM.describeNode",
|
||||
{ backendNodeId: query.backendNodeId, depth: 0 },
|
||||
context.sessionId,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// A detached node can disappear between snapshot and description.
|
||||
// Preserve partial metadata, but propagate transport/cancellation errors.
|
||||
if (!(cause instanceof CdpCommandRejection)) throw cause;
|
||||
if (cause.code === -32_601) descriptionUnavailable = true;
|
||||
else if (cause.code !== -32_000) throw cause;
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Promise.all preserves snapshot order even when CDP replies out of order.
|
||||
step = capture.next(await Promise.all(pending));
|
||||
} catch (cause: unknown) {
|
||||
// Stop and settle this bounded batch before the session can be detached.
|
||||
pendingController.abort();
|
||||
await Promise.allSettled(pending);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
if (step.value.unresolvedSvgNodes > 0)
|
||||
state.limitations.push(
|
||||
`SVG identity was unavailable for ${String(step.value.unresolvedSvgNodes)} snapshotted XLink candidate nodes; their URL destinations remain unknown.`,
|
||||
);
|
||||
return step.value;
|
||||
};
|
||||
|
||||
const normalizedInspection = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,315 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
|
||||
import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
type FakeOptions,
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
const startBrowser = async (options: FakeOptions = {}) => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
...options,
|
||||
commandResult(command, origin, frameReads) {
|
||||
const override = options.commandResult?.(command, origin, frameReads);
|
||||
if (override !== undefined) return override;
|
||||
if (command.method === "DOMSnapshot.captureSnapshot") {
|
||||
const nodes = {
|
||||
nodeType: [9, 1, 1, 1, 1],
|
||||
nodeName: [1, 2, 3, 3, 3],
|
||||
nodeValue: [4, 4, 4, 4, 4],
|
||||
parentIndex: [-1, 0, 1, 1, 1],
|
||||
backendNodeId: [1, 2, 3, 4, 5],
|
||||
attributes: [[], [], [5, 6], [5, 7], [5, 6, 8, 9]],
|
||||
};
|
||||
return {
|
||||
strings: [
|
||||
`${origin}/app`,
|
||||
"#document",
|
||||
"HTML",
|
||||
"a",
|
||||
"",
|
||||
"xlink:href",
|
||||
"/first",
|
||||
"/second",
|
||||
"href",
|
||||
"/ordinary",
|
||||
"https://excluded.example/page",
|
||||
],
|
||||
documents: [
|
||||
{ documentURL: 0, nodes },
|
||||
{
|
||||
documentURL: 10,
|
||||
nodes: { ...nodes, backendNodeId: [11, 12, 13, 14, 15] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (command.method === "DOM.describeNode")
|
||||
return {
|
||||
node: {
|
||||
backendNodeId: command.params.backendNodeId,
|
||||
nodeType: 1,
|
||||
nodeName: "a",
|
||||
isSVG: true,
|
||||
},
|
||||
};
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
trackBrowser(browser);
|
||||
return browser;
|
||||
};
|
||||
|
||||
const inspect = (browser: FakeCdpBrowser, signal?: AbortSignal) =>
|
||||
new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
observation_ms: 0,
|
||||
}),
|
||||
signal === undefined ? undefined : { signal },
|
||||
);
|
||||
|
||||
describe("CDP SVG identity at the snapshot boundary", () => {
|
||||
it("describes only in-scope XLink candidates without a winning href", async () => {
|
||||
const browser = await startBrowser();
|
||||
const result = await inspect(browser);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.metadata.dom_urls.map(({ url }) => url)).toEqual(
|
||||
["/first", "/second", "/ordinary"].map(
|
||||
(path) => browser.allowedOrigin + path,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
browser.commands
|
||||
.filter(({ method }) => method === "DOM.describeNode")
|
||||
.map(({ params }) => params),
|
||||
).toEqual([
|
||||
{ backendNodeId: 3, depth: 0 },
|
||||
{ backendNodeId: 4, depth: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["removed", "mismatched"])(
|
||||
"retains partial metadata when a candidate is %s",
|
||||
async (failure) => {
|
||||
const browser = await startBrowser({
|
||||
commandError: (command) =>
|
||||
failure === "removed" &&
|
||||
command.method === "DOM.describeNode" &&
|
||||
command.params.backendNodeId === 3
|
||||
? { code: -32_000, message: "No node found for given backend id" }
|
||||
: undefined,
|
||||
commandResult: (command) =>
|
||||
failure === "mismatched" &&
|
||||
command.method === "DOM.describeNode" &&
|
||||
command.params.backendNodeId === 3
|
||||
? {
|
||||
node: {
|
||||
backendNodeId: 99,
|
||||
nodeType: 1,
|
||||
nodeName: "a",
|
||||
isSVG: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const result = await inspect(browser);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.metadata.dom_urls.map(({ url }) => url)).toEqual(
|
||||
["/second", "/ordinary"].map((path) => browser.allowedOrigin + path),
|
||||
);
|
||||
expect(result.value.completeness.excluded).toContainEqual({
|
||||
section: "metadata",
|
||||
reason: "provider_unavailable",
|
||||
count: 1,
|
||||
});
|
||||
expect(result.value.limitations).toContain(
|
||||
"SVG identity was unavailable for 1 snapshotted XLink candidate nodes; their URL destinations remain unknown.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("stops querying after the first batch reports an unsupported method", async () => {
|
||||
const browser = await startBrowser({
|
||||
commandResult: manyCandidates,
|
||||
unsupportedMethods: ["DOM.describeNode"],
|
||||
});
|
||||
const result = await inspect(browser);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.metadata.dom_urls).toEqual([]);
|
||||
expect(result.value.completeness.excluded).toContainEqual({
|
||||
section: "metadata",
|
||||
reason: "provider_unavailable",
|
||||
count: 25,
|
||||
});
|
||||
expect(descriptions(browser).length).toBeGreaterThan(0);
|
||||
expect(descriptions(browser).length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ options: { closeOnMethod: "DOM.describeNode" }, reason: "disconnected" },
|
||||
{
|
||||
options: {
|
||||
commandError: (command: { method: string }) =>
|
||||
command.method === "DOM.describeNode"
|
||||
? { code: -32_602, message: "Invalid parameters" }
|
||||
: undefined,
|
||||
},
|
||||
reason: "protocol_error",
|
||||
},
|
||||
])(
|
||||
"propagates $reason during node description",
|
||||
async ({ options, reason }) => {
|
||||
const browser = await startBrowser(options);
|
||||
expect(await inspect(browser)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels a pending description and detaches without closing the target", async () => {
|
||||
const browser = await startBrowser({ hangOnMethod: "DOM.describeNode" });
|
||||
const controller = new AbortController();
|
||||
const pending = inspect(browser, controller.signal);
|
||||
await expect.poll(() => descriptions(browser).length).toBeGreaterThan(1);
|
||||
controller.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", operation: "inspect_web_page" },
|
||||
});
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
});
|
||||
});
|
||||
|
||||
const manyCandidates: NonNullable<FakeOptions["commandResult"]> = (
|
||||
command,
|
||||
origin,
|
||||
) => {
|
||||
if (command.method !== "DOMSnapshot.captureSnapshot") return undefined;
|
||||
const count = 25;
|
||||
return {
|
||||
strings: [
|
||||
origin,
|
||||
"#document",
|
||||
"HTML",
|
||||
"a",
|
||||
"",
|
||||
"xlink:href",
|
||||
...Array.from({ length: count }, (_, index) => `/link-${String(index)}`),
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
documentURL: 0,
|
||||
nodes: {
|
||||
nodeType: [9, 1, ...Array<number>(count).fill(1)],
|
||||
nodeName: [1, 2, ...Array<number>(count).fill(3)],
|
||||
nodeValue: Array<number>(count + 2).fill(4),
|
||||
parentIndex: [-1, 0, ...Array<number>(count).fill(1)],
|
||||
backendNodeId: Array.from(
|
||||
{ length: count + 2 },
|
||||
(_, index) => index + 1,
|
||||
),
|
||||
attributes: [
|
||||
[],
|
||||
[],
|
||||
...Array.from({ length: count }, (_, index) => [5, index + 6]),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const descriptions = (browser: FakeCdpBrowser) =>
|
||||
browser.commands.filter(({ method }) => method === "DOM.describeNode");
|
||||
|
||||
describe("CDP SVG query concurrency", () => {
|
||||
it("bounds in-flight reads and preserves metadata order across reversed replies", async () => {
|
||||
const browser = await startBrowser({
|
||||
commandResult: manyCandidates,
|
||||
hangOnMethod: "DOM.describeNode",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = inspect(browser, controller.signal);
|
||||
try {
|
||||
await expect.poll(() => descriptions(browser).length).toBeGreaterThan(1);
|
||||
let replied = 0;
|
||||
while (replied < 25) {
|
||||
await expect
|
||||
.poll(() => descriptions(browser).length)
|
||||
.toBeGreaterThan(replied);
|
||||
const batch = descriptions(browser).slice(replied);
|
||||
expect(batch.length).toBeLessThanOrEqual(8);
|
||||
for (const command of [...batch].reverse()) {
|
||||
const id = Number(command.params.backendNodeId);
|
||||
browser.emitRawMessage(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
result: {
|
||||
node: {
|
||||
backendNodeId: id,
|
||||
nodeType: 1,
|
||||
nodeName: "a",
|
||||
isSVG: id % 2 === 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
replied += batch.length;
|
||||
}
|
||||
const result = await pending;
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.metadata.dom_urls.map(({ url }) => url)).toEqual(
|
||||
Array.from(
|
||||
{ length: 13 },
|
||||
(_, index) => `${browser.allowedOrigin}/link-${String(index * 2)}`,
|
||||
),
|
||||
);
|
||||
expect(descriptions(browser)).toHaveLength(25);
|
||||
} finally {
|
||||
controller.abort();
|
||||
await pending;
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels sibling reads on a fatal reply without waiting for hung nodes", async () => {
|
||||
const browser = await startBrowser({
|
||||
commandResult: manyCandidates,
|
||||
hangOnMethod: "DOM.describeNode",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = inspect(browser, controller.signal);
|
||||
try {
|
||||
await expect.poll(() => descriptions(browser).length).toBeGreaterThan(1);
|
||||
const batch = descriptions(browser);
|
||||
browser.emitRawMessage(
|
||||
JSON.stringify({
|
||||
id: batch.at(-1)?.id,
|
||||
error: { code: -32_602, message: "Invalid parameters" },
|
||||
}),
|
||||
);
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "BrowserObservationError", reason: "protocol_error" },
|
||||
});
|
||||
expect(descriptions(browser)).toHaveLength(batch.length);
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).toContain("Target.detachFromTarget");
|
||||
expect(methods).not.toContain("Target.closeTarget");
|
||||
expect(methods).not.toContain("Browser.close");
|
||||
} finally {
|
||||
controller.abort();
|
||||
await pending;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -45,8 +45,38 @@ const pageHtml = `<!doctype html><title>x</title>
|
|||
<image id="img" xlink:href="/xl-image" />
|
||||
<use id="use" xlink:href="/xl-use" />
|
||||
<a id="out" xlink:href="https://cdn.example.test/out"><text>out</text></a>
|
||||
<a id="move-desc" xlink:href="/xl-moved-desc"><text>move desc</text></a>
|
||||
<a id="move-title" xlink:href="/xl-moved-title"><text>move title</text></a>
|
||||
<desc id="desc-container">
|
||||
<a xlink:href="/xl-html-desc">not an SVG link</a>
|
||||
<a href="/html-desc">HTML link</a>
|
||||
<svg><a xlink:href="/xl-nested-desc"><text>nested</text></a></svg>
|
||||
</desc>
|
||||
<title id="title-container">
|
||||
<a xlink:href="/xl-html-title">not an SVG link</a>
|
||||
<a href="/html-title">HTML link</a>
|
||||
<svg><a xlink:href="/xl-nested-title"><text>nested</text></a></svg>
|
||||
</title>
|
||||
</svg>
|
||||
<a id="html" xlink:href="/xl-html">html</a>
|
||||
<script>
|
||||
for (const kind of ['desc', 'title']) {
|
||||
const container = document.getElementById(kind + '-container');
|
||||
container.append(document.getElementById('move-' + kind));
|
||||
for (const nested of [false, true]) {
|
||||
const anchor = document.createElementNS('http://www.w3.org/2000/svg', 'a');
|
||||
anchor.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href',
|
||||
'/xl-created-' + (nested ? 'nested-' : '') + kind);
|
||||
const parent = nested
|
||||
? container.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'g'))
|
||||
: container;
|
||||
parent.append(anchor);
|
||||
}
|
||||
const math = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'a');
|
||||
math.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '/xl-math-' + kind);
|
||||
container.append(math);
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
it.skipIf(chromeExecutable === undefined)(
|
||||
|
|
@ -143,17 +173,36 @@ it.skipIf(chromeExecutable === undefined)(
|
|||
url: null,
|
||||
destination_scope: "outside_policy",
|
||||
}),
|
||||
...[
|
||||
"/html-desc",
|
||||
"/html-title",
|
||||
"/xl-nested-desc",
|
||||
"/xl-nested-title",
|
||||
"/xl-moved-desc",
|
||||
"/xl-moved-title",
|
||||
"/xl-created-desc",
|
||||
"/xl-created-title",
|
||||
"/xl-created-nested-desc",
|
||||
"/xl-created-nested-title",
|
||||
].map((path) =>
|
||||
expect.objectContaining({
|
||||
attribute: "href",
|
||||
url: `${origin}${path}`,
|
||||
destination_scope: "approved",
|
||||
}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
urls.some(
|
||||
(url) =>
|
||||
url.url?.endsWith("/xl-html") === true ||
|
||||
url.url?.includes("/xl-html") === true ||
|
||||
url.url?.includes("/xl-math") === true ||
|
||||
url.url?.endsWith("/xlink-loses") === true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(urls.every((url) => url.attribute === "href")).toBe(true);
|
||||
expect(urls).toHaveLength(5);
|
||||
expect(urls).toHaveLength(15);
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
if (!(await waitForExit(child, 2_000))) child.kill("SIGKILL");
|
||||
|
|
@ -173,6 +222,8 @@ const xhtmlPage = `<?xml version="1.0" encoding="UTF-8"?>
|
|||
<svg:svg width="10" height="10">
|
||||
<svg:a id="pa1" xlink:href="/xl-pa1"><svg:text>p</svg:text></svg:a>
|
||||
<svg:image id="img" xlink:href="/xl-image"/>
|
||||
<svg:desc><svg:a xlink:href="/xl-xml-desc"><svg:text>desc</svg:text></svg:a></svg:desc>
|
||||
<svg:title><svg:a xlink:href="/xl-xml-title"><svg:text>title</svg:text></svg:a></svg:title>
|
||||
<svg:foreignObject width="10" height="10">
|
||||
<a id="fo-a" xlink:href="/xl-html-fo">no</a>
|
||||
<h:a xmlns:h="http://www.w3.org/1999/xhtml" id="fo-h" xlink:href="/xl-h">no</h:a>
|
||||
|
|
@ -202,6 +253,8 @@ for (const { format, page, contentType, nodeNames, link, destinations } of [
|
|||
destinations: [
|
||||
"/xl-pa1",
|
||||
"/xl-image",
|
||||
"/xl-xml-desc",
|
||||
"/xl-xml-title",
|
||||
"/xl-fo-svg",
|
||||
"/xl-orphan",
|
||||
"/xl-custom",
|
||||
|
|
|
|||
Loading…
Reference in New Issue