mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(vom): discover canvas candidates and resolve visible regions
This commit is contained in:
@@ -20,8 +20,13 @@ import type { CoordinateOwner, CssViewport, SnapshotProjectionResult } from "./c
|
||||
import { readSnapshotOwnerSizes } from "./snapshot-owner-sizes";
|
||||
|
||||
export interface LayoutMetrics {
|
||||
cssVisualViewport?: { zoom?: number; clientWidth?: number; clientHeight?: number };
|
||||
visualViewport?: { zoom?: number; clientWidth?: number; clientHeight?: number };
|
||||
cssVisualViewport?: {
|
||||
zoom?: number;
|
||||
scale?: number;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
};
|
||||
visualViewport?: { zoom?: number; scale?: number; clientWidth?: number; clientHeight?: number };
|
||||
cssLayoutViewport?: {
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { CdpRunner } from "../../shared";
|
||||
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
||||
import { buildSemanticGraph } from "../semantic-graph/build";
|
||||
import { REQUESTED_STYLES, type SnapshotReply } from "../snapshot";
|
||||
import { discoverVisualCandidates } from "../visual-discovery";
|
||||
|
||||
function fixture(
|
||||
options: {
|
||||
frames?: CdpFrame[];
|
||||
canvas?: boolean;
|
||||
after?: Record<string, { element?: number; missing?: boolean; unreadable?: boolean }>;
|
||||
fail?: string;
|
||||
missingIdentity?: boolean;
|
||||
@@ -78,7 +80,7 @@ function fixture(
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
result = {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000, scale: 1 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
@@ -88,13 +90,16 @@ function fixture(
|
||||
strings: [
|
||||
"#document",
|
||||
"html",
|
||||
"button",
|
||||
options.canvas ? "canvas" : "button",
|
||||
OVERLAY_HOST_MARKER_ATTR,
|
||||
"",
|
||||
"visible",
|
||||
"1",
|
||||
"static",
|
||||
"auto",
|
||||
"block",
|
||||
"none",
|
||||
"0px",
|
||||
],
|
||||
documents: frames
|
||||
.filter(
|
||||
@@ -144,9 +149,13 @@ function fixture(
|
||||
[0, 0, 1000, 800],
|
||||
[10, 20, 100, 40],
|
||||
],
|
||||
clientRects: [
|
||||
[0, 0, 1000, 800],
|
||||
[0, 0, 100, 40],
|
||||
],
|
||||
styles: [
|
||||
[7, 8, 8, 5, 6],
|
||||
[7, 8, 8, 5, 6],
|
||||
[7, 8, 8, 5, 6, 9, 5, 5, 10, 6, 10, 10, 10, 10, 10, 8, 10, 11],
|
||||
[7, 8, 8, 5, 6, 9, 5, 5, 10, 6, 10, 10, 10, 10, 10, 8, 10, 11],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -193,7 +202,7 @@ describe("captureObservationFacts", () => {
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
visualViewport: { clientWidth: 2000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000, scale: 1 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 999, pageY: 999 },
|
||||
} as never;
|
||||
const reply = await original(target, method, params);
|
||||
@@ -659,7 +668,7 @@ describe("OOPIF capture", () => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000, scale: 1 },
|
||||
cssLayoutViewport: { clientWidth: 300, clientHeight: 200, pageX: 0, pageY: 0 },
|
||||
};
|
||||
}
|
||||
@@ -685,7 +694,7 @@ describe("OOPIF capture", () => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000, scale: 1 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
}
|
||||
@@ -767,7 +776,7 @@ describe("OOPIF capture", () => {
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000, scale: 1 },
|
||||
cssLayoutViewport: { clientWidth: 300, clientHeight: 200 },
|
||||
};
|
||||
if (method === "DOMSnapshot.captureSnapshot")
|
||||
@@ -829,7 +838,7 @@ describe("OOPIF capture", () => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000, scale: 1 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
}
|
||||
@@ -919,7 +928,7 @@ function siblingCaptureFixture(
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000, scale: 1 },
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
if (method === "DOM.getBoxModel")
|
||||
@@ -1127,7 +1136,7 @@ describe("snapshot document provenance", () => {
|
||||
: method === "Page.getLayoutMetrics"
|
||||
? {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000 },
|
||||
cssVisualViewport: { clientWidth: 1000, scale: 1 },
|
||||
cssLayoutViewport: { clientWidth: 200, clientHeight: 100 },
|
||||
}
|
||||
: {}) as T,
|
||||
@@ -1363,3 +1372,28 @@ describe("AX frame scheduling", () => {
|
||||
expect(active).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visual facts integration", () => {
|
||||
it("passes actual snapshot styles, client units, projection and identity to pure discovery without additional CDP", async () => {
|
||||
const { cdp, logs } = fixture({
|
||||
canvas: true,
|
||||
frames: [{ frameId: "main", target: { tabId: 4 } }],
|
||||
});
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
const before = logs.length;
|
||||
const result = await discoverVisualCandidates(facts);
|
||||
expect(logs).toHaveLength(before);
|
||||
expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(1);
|
||||
expect(result.complete).toBe(true);
|
||||
expect(result.candidates).toHaveLength(1);
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
document: { frameId: "main", documentElementBackendNodeId: 1 },
|
||||
region: { crop: { x: 10, y: 20, width: 100, height: 40 } },
|
||||
});
|
||||
expect(facts.documents[0].index.nodes.get(2)?.layout?.clientRect).toEqual([0, 0, 100, 40]);
|
||||
expect(facts.documents[0].geometry?.pageScale).toBe(1);
|
||||
const baseline = fixture({ frames: [{ frameId: "main", target: { tabId: 4 } }] });
|
||||
await captureObservationFacts(baseline.cdp, 4);
|
||||
expect(logs.map((call) => call.method)).toEqual(baseline.logs.map((call) => call.method));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,19 @@ describe("document facts", () => {
|
||||
boundsSpace: "snapshot-document-layout",
|
||||
bounds: [10, 20, 120, 40],
|
||||
styles: {
|
||||
rotate: "auto",
|
||||
scale: "auto",
|
||||
perspective: "auto",
|
||||
clip: "auto",
|
||||
contain: "auto",
|
||||
"overflow-clip-margin": "auto",
|
||||
display: "auto",
|
||||
"overflow-x": "auto",
|
||||
"overflow-y": "auto",
|
||||
transform: "auto",
|
||||
zoom: "auto",
|
||||
"clip-path": "auto",
|
||||
"mask-image": "auto",
|
||||
position: "static",
|
||||
"pointer-events": "auto",
|
||||
cursor: "auto",
|
||||
@@ -125,3 +138,42 @@ describe("document facts", () => {
|
||||
expect(reads).toBeLessThanOrEqual(356);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visual ancestry facts", () => {
|
||||
it("distinguishes document roots, malformed roots, missing parents and cycles", async () => {
|
||||
const input = [
|
||||
{ ...node(0, null), nodeType: 9 },
|
||||
{ ...node(1, 0), nodeType: 1 },
|
||||
{ ...node(2, 1), nodeType: 11 },
|
||||
node(3, 2),
|
||||
node(4, 99),
|
||||
node(5, 6),
|
||||
node(6, 5),
|
||||
node(7, 5),
|
||||
{ ...node(8, null), nodeType: 1 },
|
||||
{ ...node(9, null), nodeType: 9, parentMissing: true },
|
||||
];
|
||||
const index = await buildDocumentIndex(input.reverse());
|
||||
for (const id of [0, 1, 2, 3]) expect(index.ancestryComplete.get(id)).toBe(true);
|
||||
for (const id of [4, 5, 6, 7, 8, 9]) expect(index.ancestryComplete.get(id)).toBe(false);
|
||||
});
|
||||
|
||||
it("retains client offsets in their source units and does not disguise missing parent indices", async () => {
|
||||
const decoded = await decodeDocument(
|
||||
{
|
||||
nodes: { backendNodeId: [1, 2], nodeType: [1, 1], nodeName: [0, 0], parentIndex: [99] },
|
||||
layout: {
|
||||
nodeIndex: [0],
|
||||
bounds: [[745, 455.625, 97, 59.5]],
|
||||
clientRects: [[5, 5, 68, 38]],
|
||||
},
|
||||
},
|
||||
["div"],
|
||||
);
|
||||
expect(decoded.nodes[0].layout?.clientRect).toEqual([5, 5, 68, 38]);
|
||||
expect(decoded.nodes[0].layout?.bounds).toEqual([745, 455.625, 97, 59.5]);
|
||||
expect(decoded.nodes.every((node) => node.parentMissing)).toBe(true);
|
||||
const index = await buildDocumentIndex(decoded.nodes);
|
||||
expect([...index.ancestryComplete.values()]).toEqual([false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CdpFrame } from "@/browser-driver/frame-graph";
|
||||
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
|
||||
import type { GeometryProjection } from "../../geometry";
|
||||
import { buildDocumentIndex, type DocumentFacts, type ObservationFacts } from "../facts";
|
||||
import type { FrameOwnedAxNode } from "../frame-document";
|
||||
import { normalizeDocument } from "../normalize";
|
||||
import { REQUESTED_STYLES } from "../snapshot";
|
||||
import { discoverVisualCandidates } from "../visual-discovery";
|
||||
|
||||
const defaults: Record<string, string> = {
|
||||
position: "static",
|
||||
"pointer-events": "auto",
|
||||
cursor: "auto",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
display: "block",
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "visible",
|
||||
transform: "none",
|
||||
zoom: "1",
|
||||
"clip-path": "none",
|
||||
"mask-image": "none",
|
||||
rotate: "none",
|
||||
scale: "none",
|
||||
perspective: "none",
|
||||
clip: "auto",
|
||||
contain: "none",
|
||||
"overflow-clip-margin": "0px",
|
||||
};
|
||||
const projection: GeometryProjection = {
|
||||
sourceClips: [],
|
||||
edges: [],
|
||||
topViewport: { width: 1000, height: 800 },
|
||||
};
|
||||
interface Spec {
|
||||
id: number;
|
||||
parent?: number;
|
||||
tag?: string;
|
||||
styles?: Record<string, string>;
|
||||
bounds?: number[] | null;
|
||||
client?: number[];
|
||||
attrs?: Record<string, string>;
|
||||
}
|
||||
async function document(
|
||||
nodes: Spec[],
|
||||
options: {
|
||||
frame?: CdpFrame;
|
||||
projection?: GeometryProjection;
|
||||
pageScale?: number;
|
||||
scrollY?: number;
|
||||
layoutScale?: number;
|
||||
} = {},
|
||||
): Promise<DocumentFacts<FrameOwnedAxNode>> {
|
||||
const frame = options.frame ?? { frameId: "main", target: { tabId: 1 } };
|
||||
const strings: string[] = [];
|
||||
const str = (value: string) => {
|
||||
const index = strings.indexOf(value);
|
||||
if (index >= 0) return index;
|
||||
strings.push(value);
|
||||
return strings.length - 1;
|
||||
};
|
||||
const input: Spec[] = [
|
||||
{ id: 0, tag: "#document", bounds: null },
|
||||
{ id: 1, parent: 0, tag: "html", bounds: [0, 0, 1000, 800] },
|
||||
...nodes,
|
||||
];
|
||||
const layouts = input.filter((node) => node.bounds !== null);
|
||||
const normalized = await normalizeDocument(
|
||||
{
|
||||
nodes: {
|
||||
backendNodeId: input.map((n) => n.id),
|
||||
nodeType: input.map((n) =>
|
||||
n.tag === "#document" ? 9 : n.tag === "#document-fragment" ? 11 : 1,
|
||||
),
|
||||
parentIndex: input.map((n) =>
|
||||
n.parent === undefined ? -1 : input.findIndex((p) => p.id === n.parent),
|
||||
),
|
||||
nodeName: input.map((n) => str(n.tag ?? "canvas")),
|
||||
attributes: input.map((n) =>
|
||||
Object.entries(n.attrs ?? {}).flatMap(([k, v]) => [str(k), str(v)]),
|
||||
),
|
||||
},
|
||||
layout: {
|
||||
nodeIndex: layouts.map((n) => input.indexOf(n)),
|
||||
bounds: layouts.map((n) => n.bounds ?? [10, 20, 120, 40]),
|
||||
clientRects: layouts.map((n) => n.client ?? [0, 0, 1000, 800]),
|
||||
styles: layouts.map((n) =>
|
||||
REQUESTED_STYLES.map((key) => str({ ...defaults, ...n.styles }[key])),
|
||||
),
|
||||
},
|
||||
},
|
||||
strings,
|
||||
{
|
||||
frameId: frame.frameId,
|
||||
ownerFrameBackendNodeId: frame.ownerBackendNodeId ?? null,
|
||||
target: frame.target,
|
||||
projection: {
|
||||
status: "available",
|
||||
projection: {
|
||||
source: { target: frame.target, frameId: frame.frameId },
|
||||
geometry: options.projection ?? projection,
|
||||
},
|
||||
},
|
||||
coordinates: {
|
||||
layoutUnitsPerCssPixel: options.layoutScale ?? 1,
|
||||
scrollCss: { x: 0, y: options.scrollY ?? 0 },
|
||||
},
|
||||
pageScale: options.pageScale ?? 1,
|
||||
},
|
||||
);
|
||||
return {
|
||||
frame,
|
||||
identity: {
|
||||
attachmentId: "attached",
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
documentElementBackendNodeId: 1,
|
||||
},
|
||||
index: normalized.index,
|
||||
geometry: normalized.geometry,
|
||||
domNodes: normalized.nodes,
|
||||
axNodes: [],
|
||||
};
|
||||
}
|
||||
function facts(
|
||||
documents: DocumentFacts<FrameOwnedAxNode>[],
|
||||
issues: ObservationFacts<FrameOwnedAxNode>["issues"] = [],
|
||||
): ObservationFacts<FrameOwnedAxNode> {
|
||||
return {
|
||||
rootFrameId: "main",
|
||||
documents,
|
||||
viewport: { width: 1000, height: 800 },
|
||||
issues,
|
||||
startedAt: 1,
|
||||
finishedAt: 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Canvas discovery", () => {
|
||||
it.each([
|
||||
0.8, 1, 1.25, 2,
|
||||
])("combines layout-unit bounds with CSS client clips at scale %s", async (scale) => {
|
||||
const doc = await document(
|
||||
[
|
||||
{
|
||||
id: 2,
|
||||
parent: 1,
|
||||
tag: "div",
|
||||
bounds: [100, 200, 64, 34].map((n) => n * scale),
|
||||
client: [2, 2, 60, 30],
|
||||
styles: { "overflow-x": "hidden", "overflow-y": "hidden" },
|
||||
},
|
||||
{ id: 3, parent: 2, bounds: [102, 202, 120, 40].map((n) => n * scale) },
|
||||
],
|
||||
{ layoutScale: scale, scrollY: 75 },
|
||||
);
|
||||
const result = await discoverVisualCandidates(facts([doc]));
|
||||
expect(result.issues).toEqual([]);
|
||||
const expected = {
|
||||
borderBox: { x: 102, y: 127, width: 120, height: 40 },
|
||||
crop: { x: 102, y: 127, width: 60, height: 30 },
|
||||
};
|
||||
for (const box of ["borderBox", "crop"] as const)
|
||||
for (const key of ["x", "y", "width", "height"] as const)
|
||||
expect(result.candidates[0].region[box][key]).toBeCloseTo(expected[box][key], 8);
|
||||
});
|
||||
|
||||
it("normalizes a zero-width ancestor origin before one-axis clipping", async () => {
|
||||
const doc = await document(
|
||||
[
|
||||
{
|
||||
id: 2,
|
||||
parent: 1,
|
||||
tag: "div",
|
||||
bounds: [200, 400, 0, 60],
|
||||
client: [0, 0, 0, 30],
|
||||
styles: { "overflow-y": "hidden" },
|
||||
},
|
||||
{ id: 3, parent: 2, bounds: [200, 400, 240, 80] },
|
||||
],
|
||||
{ layoutScale: 2, scrollY: 75 },
|
||||
);
|
||||
const result = await discoverVisualCandidates(facts([doc]));
|
||||
expect(result.issues).toEqual([]);
|
||||
expect(result.candidates[0].region.crop).toEqual({ x: 100, y: 125, width: 120, height: 30 });
|
||||
});
|
||||
|
||||
it("keeps unnamed, semantic, aria-hidden, inert, pointer-none and visibility override Canvas", async () => {
|
||||
const doc = await document([
|
||||
{ id: 2, parent: 1 },
|
||||
{ id: 3, parent: 1, attrs: { "aria-label": "Chart" } },
|
||||
{
|
||||
id: 4,
|
||||
parent: 1,
|
||||
attrs: { "aria-hidden": "true", inert: "" },
|
||||
styles: { "pointer-events": "none" },
|
||||
},
|
||||
{ id: 5, parent: 1, tag: "div", styles: { visibility: "hidden" } },
|
||||
{ id: 6, parent: 5, styles: { visibility: "visible" } },
|
||||
{ id: 7, parent: 1, attrs: { hidden: "" }, styles: { display: "block" } },
|
||||
]);
|
||||
doc.axNodes.push({ nodeId: "canvas", backendDOMNodeId: 2 } as FrameOwnedAxNode);
|
||||
const result = await discoverVisualCandidates(facts([doc]));
|
||||
expect(result.candidates.map((c) => c.backendNodeId)).toEqual([2, 3, 4, 6, 7]);
|
||||
expect(result.candidates[1].label).toBe("Chart");
|
||||
expect(result.complete).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes CSS transparent subtrees, hidden/zero/no-layout/fully clipped nodes and overlays", async () => {
|
||||
const doc = await document([
|
||||
{ id: 2, parent: 1, tag: "div", styles: { opacity: "0" } },
|
||||
{ id: 3, parent: 2 },
|
||||
{ id: 4, parent: 1, styles: { visibility: "hidden" } },
|
||||
{ id: 5, parent: 1, bounds: [0, 0, 0, 40] },
|
||||
{ id: 6, parent: 1, bounds: null },
|
||||
{ id: 7, parent: 1, bounds: [1200, 0, 100, 50] },
|
||||
{ id: 8, parent: 1, tag: "div", attrs: { [OVERLAY_HOST_MARKER_ATTR]: "" } },
|
||||
{ id: 9, parent: 8, tag: "#document-fragment", bounds: null },
|
||||
{ id: 10, parent: 9 },
|
||||
]);
|
||||
expect(await discoverVisualCandidates(facts([doc]))).toMatchObject({
|
||||
candidates: [],
|
||||
issues: [],
|
||||
complete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["both", "hidden", "hidden", [0, 0, 60, 30], [60, 30]],
|
||||
["x", "clip", "visible", [0, 0, 60, 20], [60, 40]],
|
||||
["border", "hidden", "hidden", [5, 5, 68, 38], [64, 34]],
|
||||
])("uses client box for %s clipping", async (_name, x, y, client, expected) => {
|
||||
const doc = await document([
|
||||
{
|
||||
id: 2,
|
||||
parent: 1,
|
||||
tag: "div",
|
||||
bounds: [0, 0, 78, 48],
|
||||
client: client as number[],
|
||||
styles: { "overflow-x": x as string, "overflow-y": y as string },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
parent: 2,
|
||||
bounds: x === "clip" || _name === "both" ? [0, 0, 120, 40] : [9, 9, 120, 40],
|
||||
},
|
||||
]);
|
||||
const [candidate] = (await discoverVisualCandidates(facts([doc]))).candidates;
|
||||
expect([candidate.region.crop.width, candidate.region.crop.height]).toEqual(expected);
|
||||
expect(candidate.region.clips?.backendNodeId).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps Canvas border-box and clips partial viewport intersections", async () => {
|
||||
const doc = await document([{ id: 2, parent: 1, bounds: [950, 20, 130, 50] }]);
|
||||
const [candidate] = (await discoverVisualCandidates(facts([doc]))).candidates;
|
||||
expect(candidate.region.borderBox.width).toBe(130);
|
||||
expect(candidate.region.crop).toEqual({ x: 950, y: 20, width: 50, height: 50 });
|
||||
});
|
||||
|
||||
it("propagates owner ancestry across targets, without depending on semantic parents", async () => {
|
||||
const parent = await document([
|
||||
{
|
||||
id: 2,
|
||||
parent: 1,
|
||||
tag: "div",
|
||||
bounds: [0, 0, 200, 100],
|
||||
client: [0, 0, 200, 100],
|
||||
styles: { "overflow-x": "hidden", "overflow-y": "hidden" },
|
||||
},
|
||||
{ id: 3, parent: 2, tag: "iframe", bounds: [100, 0, 200, 200] },
|
||||
]);
|
||||
parent.domNodes.splice(0); // Visual discovery never reads the semantic subset.
|
||||
const child = await document([{ id: 2, parent: 1, bounds: [0, 0, 200, 200] }], {
|
||||
frame: {
|
||||
frameId: "child",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 3,
|
||||
target: { tabId: 1, sessionId: "remote" },
|
||||
},
|
||||
projection: {
|
||||
...projection,
|
||||
edges: [
|
||||
{
|
||||
sourceViewport: { width: 200, height: 200 },
|
||||
destinationQuad: [
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 300, y: 0 },
|
||||
{ x: 300, y: 200 },
|
||||
{ x: 100, y: 200 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = await discoverVisualCandidates(facts([child, parent]));
|
||||
expect(result.complete).toBe(true);
|
||||
expect(result.candidates[0]).toMatchObject({
|
||||
document: { frameId: "child", target: { sessionId: "remote" } },
|
||||
region: { crop: { x: 100, y: 0, width: 100, height: 100 } },
|
||||
});
|
||||
const owner = parent.index.nodes.get(3)!;
|
||||
owner.layout!.styles = { ...owner.layout!.styles, visibility: "hidden" };
|
||||
expect((await discoverVisualCandidates(facts([parent, child]))).candidates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each<Record<string, string>>([
|
||||
{ transform: "matrix(0,1,-1,0,0,0)" },
|
||||
{ "clip-path": "circle(50%)" },
|
||||
{ "mask-image": "url(mask)" },
|
||||
{ zoom: "1.25", "overflow-x": "hidden" },
|
||||
])("reports unsupported ancestor geometry: %o", async (styles) => {
|
||||
const doc = await document([
|
||||
{ id: 2, parent: 1, tag: "div", styles },
|
||||
{ id: 3, parent: 2 },
|
||||
]);
|
||||
const result = await discoverVisualCandidates(facts([doc]));
|
||||
expect(result.candidates).toHaveLength(0);
|
||||
expect(result.issues[0].reason).toBe("geometry-unsupported");
|
||||
});
|
||||
|
||||
it("supports positive scale without overflow, rejects pinch and non-rectangular frame projection", async () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 2,
|
||||
parent: 1,
|
||||
styles: { transform: "matrix(1.25,0,0,1.25,0,0)" },
|
||||
bounds: [10, 20, 150, 50],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
(await discoverVisualCandidates(facts([await document(nodes)]))).candidates,
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
(await discoverVisualCandidates(facts([await document(nodes, { pageScale: 1.5 })]))).issues[0]
|
||||
.reason,
|
||||
).toBe("geometry-unsupported");
|
||||
const rotated = {
|
||||
...projection,
|
||||
edges: [
|
||||
{
|
||||
sourceViewport: { width: 200, height: 200 },
|
||||
destinationQuad: [
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 200, y: 10 },
|
||||
{ x: 190, y: 200 },
|
||||
{ x: 0, y: 190 },
|
||||
] as [
|
||||
{ x: number; y: number },
|
||||
{ x: number; y: number },
|
||||
{ x: number; y: number },
|
||||
{ x: number; y: number },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
(await discoverVisualCandidates(facts([await document(nodes, { projection: rotated })])))
|
||||
.issues[0].reason,
|
||||
).toBe("geometry-unsupported");
|
||||
});
|
||||
|
||||
it("keeps partial capture separate from valid geometry, rejects unverified identities", async () => {
|
||||
const doc = await document([{ id: 2, parent: 1 }]);
|
||||
const partial = facts(
|
||||
[doc],
|
||||
[{ target: doc.frame.target, frameId: "main", stage: "ax", reason: "capture-unavailable" }],
|
||||
);
|
||||
const result = await discoverVisualCandidates(partial);
|
||||
expect(result.candidates).toHaveLength(1);
|
||||
expect(result.captureIssues).toBe(partial.issues);
|
||||
expect(result.complete).toBe(false);
|
||||
const unverified = { ...doc, identity: undefined };
|
||||
const failed = await discoverVisualCandidates(facts([unverified]));
|
||||
expect(failed.candidates).toHaveLength(0);
|
||||
expect(failed.issues[0].reason).toBe("identity-unverified");
|
||||
});
|
||||
|
||||
it("does not trust orphan, cyclic, boxless or missing frame-owner ancestry", async () => {
|
||||
const doc = await document([
|
||||
{ id: 2, parent: 1, tag: "div" },
|
||||
{ id: 3, parent: 2 },
|
||||
]);
|
||||
for (const parent of [99, 3]) {
|
||||
doc.index.nodes.get(2)!.parentBackendNodeId = parent;
|
||||
const index = await buildDocumentIndex([...doc.index.nodes.values()]);
|
||||
const result = await discoverVisualCandidates(facts([{ ...doc, index }]));
|
||||
expect(result.candidates).toHaveLength(0);
|
||||
expect(result.issues[0].reason).toBe("ancestry-incomplete");
|
||||
}
|
||||
const boxless = await document([
|
||||
{ id: 2, parent: 1, tag: "div", bounds: null },
|
||||
{ id: 3, parent: 2 },
|
||||
]);
|
||||
expect((await discoverVisualCandidates(facts([boxless]))).issues[0].reason).toBe(
|
||||
"facts-unavailable",
|
||||
);
|
||||
const child = {
|
||||
...doc,
|
||||
frame: { ...doc.frame, frameId: "child", parentFrameId: "absent", ownerBackendNodeId: 2 },
|
||||
};
|
||||
expect((await discoverVisualCandidates(facts([child]))).issues[0].reason).toBe(
|
||||
"ownership-unresolved",
|
||||
);
|
||||
});
|
||||
|
||||
it("is deterministic, does not deduplicate or mutate inputs", async () => {
|
||||
const input = facts([
|
||||
await document([
|
||||
{ id: 2, parent: 1 },
|
||||
{ id: 3, parent: 1 },
|
||||
]),
|
||||
]);
|
||||
const freeze = (value: unknown): void => {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return;
|
||||
if (value instanceof Map) for (const item of value.values()) freeze(item);
|
||||
for (const item of Object.values(value)) freeze(item);
|
||||
Object.freeze(value);
|
||||
};
|
||||
freeze(input);
|
||||
const first = await discoverVisualCandidates(input);
|
||||
expect(await discoverVisualCandidates(input)).toEqual(first);
|
||||
expect(first.candidates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
1000, 10000, 100000,
|
||||
])("memoizes deep ancestry across %i nodes and checks cancellation", async (count) => {
|
||||
const doc = await document([{ id: 2, parent: 1 }]);
|
||||
const template = doc.index.nodes.get(2)!;
|
||||
let reads = 0;
|
||||
const input = [doc.index.nodes.get(0)!, doc.index.nodes.get(1)!];
|
||||
for (let id = 2; id < count + 2; id++) {
|
||||
const node = {
|
||||
...template,
|
||||
backendNodeId: id,
|
||||
tag: id < count / 2 ? "div" : "canvas",
|
||||
parentBackendNodeId: id < count / 2 ? id - 1 : count / 2 - 1,
|
||||
};
|
||||
Object.defineProperty(node, "parentBackendNodeId", {
|
||||
get: () => {
|
||||
reads++;
|
||||
return id < count / 2 ? id - 1 : count / 2 - 1;
|
||||
},
|
||||
});
|
||||
input.push(node);
|
||||
}
|
||||
const index = await buildDocumentIndex(input);
|
||||
reads = 0;
|
||||
const result = await discoverVisualCandidates(facts([{ ...doc, index }]));
|
||||
expect(result.candidates).toHaveLength(count / 2 + 2);
|
||||
expect(reads).toBeLessThan(count * 5);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(discoverVisualCandidates(facts([doc]), controller.signal)).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EMPTY_VISUAL_CONTEXT,
|
||||
extendVisualContext,
|
||||
projectVisualBox,
|
||||
resolveVisualRegion,
|
||||
type VisualAncestor,
|
||||
} from "../visual-region";
|
||||
|
||||
const styles = {
|
||||
position: "static",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
display: "block",
|
||||
transform: "none",
|
||||
zoom: "1",
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "visible",
|
||||
"clip-path": "none",
|
||||
"mask-image": "none",
|
||||
rotate: "none",
|
||||
scale: "none",
|
||||
perspective: "none",
|
||||
clip: "auto",
|
||||
contain: "none",
|
||||
"overflow-clip-margin": "0px",
|
||||
};
|
||||
const ancestor: VisualAncestor = {
|
||||
document: {
|
||||
attachmentId: "a",
|
||||
target: { tabId: 1 },
|
||||
frameId: "main",
|
||||
documentElementBackendNodeId: 1,
|
||||
},
|
||||
backendNodeId: 2,
|
||||
styles,
|
||||
clientBox: { x: 5, y: 5, width: 60, height: 30 },
|
||||
};
|
||||
const box = { x: 0, y: 0, width: 120, height: 40 };
|
||||
|
||||
describe("shared visual region rules", () => {
|
||||
it("accepts independently supplied normalized facts and preserves clipping policy", () => {
|
||||
const context = extendVisualContext(EMPTY_VISUAL_CONTEXT, {
|
||||
...ancestor,
|
||||
styles: { ...styles, "overflow-x": "hidden" },
|
||||
});
|
||||
expect(
|
||||
resolveVisualRegion({ borderBox: box, frameVisibleBox: box, visibility: "visible", context }),
|
||||
).toMatchObject({
|
||||
status: "available",
|
||||
crop: { x: 5, y: 0, width: 60, height: 40 },
|
||||
clips: { overflowX: "hidden", overflowY: "visible" },
|
||||
});
|
||||
const differentPolicy = extendVisualContext(EMPTY_VISUAL_CONTEXT, {
|
||||
...ancestor,
|
||||
styles: { ...styles, "overflow-x": "scroll" },
|
||||
});
|
||||
expect(differentPolicy.clip).toEqual(context.clip);
|
||||
expect(differentPolicy.clips).not.toEqual(context.clips);
|
||||
});
|
||||
|
||||
it("distinguishes fully clipped, missing client geometry and invalid rectangles", () => {
|
||||
const clipping = { ...ancestor, styles: { ...styles, "overflow-x": "hidden" } };
|
||||
const context = extendVisualContext(EMPTY_VISUAL_CONTEXT, {
|
||||
...clipping,
|
||||
clientBox: { x: 0, y: 0, width: 0, height: 20 },
|
||||
});
|
||||
expect(
|
||||
resolveVisualRegion({ borderBox: box, frameVisibleBox: box, visibility: "visible", context })
|
||||
.status,
|
||||
).toBe("empty");
|
||||
const unavailable = extendVisualContext(EMPTY_VISUAL_CONTEXT, { ...clipping, clientBox: null });
|
||||
expect(
|
||||
resolveVisualRegion({
|
||||
borderBox: box,
|
||||
frameVisibleBox: box,
|
||||
visibility: "visible",
|
||||
context: unavailable,
|
||||
}),
|
||||
).toEqual({ status: "unavailable", reason: "geometry-unavailable" });
|
||||
expect(
|
||||
resolveVisualRegion({
|
||||
borderBox: { ...box, x: NaN },
|
||||
frameVisibleBox: box,
|
||||
visibility: "visible",
|
||||
context: EMPTY_VISUAL_CONTEXT,
|
||||
}).status,
|
||||
).toBe("unavailable");
|
||||
expect(projectVisualBox({ x: 5, y: 5, width: 0, height: 20 }, [])).toEqual({
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 0,
|
||||
height: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it.each<Record<string, string>>([
|
||||
{ rotate: "45deg" },
|
||||
{ scale: "-1 1" },
|
||||
{ perspective: "100px" },
|
||||
{ clip: "rect(0px, 20px, 20px, 0px)" },
|
||||
{ contain: "paint" },
|
||||
{ "overflow-x": "clip", "overflow-clip-margin": "10px" },
|
||||
{ transform: "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0.01,0,0,0,1)" },
|
||||
])("refuses unsupported CSS evidence without changing DOM geometry: %o", (change) => {
|
||||
const context = extendVisualContext(EMPTY_VISUAL_CONTEXT, {
|
||||
...ancestor,
|
||||
styles: { ...styles, ...change },
|
||||
});
|
||||
expect(context.issue).toBe("geometry-unsupported");
|
||||
});
|
||||
|
||||
it("accepts axis-preserving matrix3d translation and independent positive scale", () => {
|
||||
for (const change of [
|
||||
{ transform: "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,10,20,0,1)" },
|
||||
{ scale: "1.25" },
|
||||
])
|
||||
expect(
|
||||
extendVisualContext(EMPTY_VISUAL_CONTEXT, { ...ancestor, styles: { ...styles, ...change } })
|
||||
.issue,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"matrix(1,0,0,1,0,0)",
|
||||
"matrix(1,0,0,1,10,20)",
|
||||
])("accepts unscaled overflow transform %s", (transform) => {
|
||||
const context = extendVisualContext(EMPTY_VISUAL_CONTEXT, {
|
||||
...ancestor,
|
||||
styles: {
|
||||
...styles,
|
||||
transform,
|
||||
position: "relative",
|
||||
"overflow-x": "hidden",
|
||||
"overflow-y": "hidden",
|
||||
},
|
||||
});
|
||||
expect(context.issue).toBeUndefined();
|
||||
const child = extendVisualContext(
|
||||
context,
|
||||
{
|
||||
...ancestor,
|
||||
styles: {
|
||||
...styles,
|
||||
position: "absolute",
|
||||
"overflow-x": "clip",
|
||||
"overflow-y": "clip",
|
||||
"overflow-clip-margin": "content-box",
|
||||
},
|
||||
},
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
resolveVisualRegion({
|
||||
borderBox: box,
|
||||
frameVisibleBox: box,
|
||||
visibility: "visible",
|
||||
context: child,
|
||||
}),
|
||||
).toMatchObject({ status: "available", crop: { x: 5, y: 5, width: 60, height: 30 } });
|
||||
});
|
||||
|
||||
it("supports positioned overflow containers but refuses uncertain out-of-flow clipping", () => {
|
||||
const overflow = { ...ancestor, styles: { ...styles, "overflow-x": "hidden" } };
|
||||
const staticParent = extendVisualContext(EMPTY_VISUAL_CONTEXT, overflow);
|
||||
expect(
|
||||
extendVisualContext(
|
||||
staticParent,
|
||||
{ ...ancestor, styles: { ...styles, position: "absolute" } },
|
||||
false,
|
||||
).issue,
|
||||
).toBe("geometry-unsupported");
|
||||
const positionedParent = extendVisualContext(EMPTY_VISUAL_CONTEXT, {
|
||||
...overflow,
|
||||
styles: { ...overflow.styles, position: "relative" },
|
||||
});
|
||||
const child = extendVisualContext(
|
||||
positionedParent,
|
||||
{ ...ancestor, styles: { ...styles, position: "absolute" } },
|
||||
false,
|
||||
);
|
||||
expect(child.issue).toBeUndefined();
|
||||
expect(child.clip).toEqual(positionedParent.clip);
|
||||
expect(
|
||||
extendVisualContext(
|
||||
positionedParent,
|
||||
{ ...ancestor, styles: { ...styles, position: "fixed" } },
|
||||
false,
|
||||
).issue,
|
||||
).toBe("geometry-unsupported");
|
||||
});
|
||||
});
|
||||
@@ -375,6 +375,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
facts.push({
|
||||
frame,
|
||||
identity: identities.get(document.frameId),
|
||||
...(doc?.geometry ? { geometry: doc.geometry } : {}),
|
||||
index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index,
|
||||
domNodes,
|
||||
axNodes,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Viewport } from "@browser-skill/vom";
|
||||
import type { CdpFrame, CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { isOverlayHostNode } from "@/lib/overlay-bridge";
|
||||
import type { FrameProjectionIssue } from "../geometry/coordinate-types";
|
||||
import type { GeometryProjection } from "../geometry";
|
||||
import type { FrameProjectionIssue, SnapshotCoordinates } from "../geometry/coordinate-types";
|
||||
import { createCaptureCheckpoint } from "./capture-abort";
|
||||
import type { CapturedNode } from "./capture-types";
|
||||
import type { FrameOwnedAxNode } from "./frame-document";
|
||||
@@ -10,21 +11,26 @@ import type { FrameOwnedAxNode } from "./frame-document";
|
||||
export interface SnapshotLayout {
|
||||
readonly boundsSpace: "snapshot-document-layout";
|
||||
bounds?: number[];
|
||||
/** [clientLeft, clientTop, clientWidth, clientHeight], untransformed local CSS units. */
|
||||
clientRect?: number[];
|
||||
styles: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface DecodedNode extends Omit<CapturedNode, "rect" | "localRect" | "rendered"> {
|
||||
nodeType?: number;
|
||||
parentMissing?: boolean;
|
||||
layout?: SnapshotLayout;
|
||||
}
|
||||
|
||||
export interface NodeFacts extends CapturedNode {
|
||||
nodeType?: number;
|
||||
parentMissing?: boolean;
|
||||
layout?: SnapshotLayout;
|
||||
}
|
||||
|
||||
export interface DocumentIndex<T extends DecodedNode = NodeFacts> {
|
||||
readonly nodes: ReadonlyMap<number, T>;
|
||||
readonly ancestryComplete: ReadonlyMap<number, boolean>;
|
||||
readonly excludedBackendNodeIds: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
@@ -41,6 +47,7 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
||||
const checkpoint = createCaptureCheckpoint(signal);
|
||||
const nodes = new Map<number, T>();
|
||||
const overlayByNode = new Map<number, boolean>();
|
||||
const ancestryComplete = new Map<number, boolean>();
|
||||
const excludedBackendNodeIds = new Set<number>();
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
if (i % 256 === 0) {
|
||||
@@ -61,6 +68,7 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
||||
const visiting = new Set<number>();
|
||||
let current: DecodedNode | undefined = node;
|
||||
let overlay = false;
|
||||
let complete = false;
|
||||
while (current && !overlayByNode.has(current.backendNodeId)) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
@@ -76,13 +84,16 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
||||
visiting.add(current.backendNodeId);
|
||||
path.push(current);
|
||||
if (current.parentBackendNodeId === null) {
|
||||
complete = !current.parentMissing && current.nodeType === 9;
|
||||
current = undefined;
|
||||
break;
|
||||
}
|
||||
current = nodes.get(current.parentBackendNodeId);
|
||||
}
|
||||
if (current && overlayByNode.has(current.backendNodeId))
|
||||
if (current && overlayByNode.has(current.backendNodeId)) {
|
||||
overlay = overlayByNode.get(current.backendNodeId)!;
|
||||
complete = ancestryComplete.get(current.backendNodeId) === true;
|
||||
}
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
@@ -91,10 +102,12 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
||||
const item = path[i];
|
||||
overlay = overlay || isOverlayHostNode(item.tag, Object.keys(item.attrs));
|
||||
overlayByNode.set(item.backendNodeId, overlay);
|
||||
complete = complete && !item.parentMissing;
|
||||
ancestryComplete.set(item.backendNodeId, complete);
|
||||
if (overlay) excludedBackendNodeIds.add(item.backendNodeId);
|
||||
}
|
||||
}
|
||||
return { nodes, excludedBackendNodeIds };
|
||||
return { nodes, ancestryComplete, excludedBackendNodeIds };
|
||||
}
|
||||
|
||||
export interface DocumentIdentity {
|
||||
@@ -127,7 +140,16 @@ export interface CaptureIssue {
|
||||
| "geometry-unavailable";
|
||||
}
|
||||
|
||||
/** Shared per document; never retain the live GeometryContext in published facts. */
|
||||
export interface DocumentGeometry {
|
||||
readonly projections: readonly GeometryProjection[];
|
||||
readonly coordinates: SnapshotCoordinates;
|
||||
/** Visual viewport scale (pinch), distinct from browser UI zoom. */
|
||||
readonly pageScale?: number;
|
||||
}
|
||||
|
||||
export interface DocumentFacts<T extends FrameOwnedAxNode> {
|
||||
readonly geometry?: DocumentGeometry;
|
||||
readonly frame: CdpFrame;
|
||||
readonly identity?: DocumentIdentity;
|
||||
readonly index: DocumentIndex;
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
isAbortError as isCaptureAbort,
|
||||
throwIfAborted as throwCaptureAborted,
|
||||
} from "./capture-abort";
|
||||
import { buildDocumentIndex, type CaptureIssue, type DocumentIndex, type NodeFacts } from "./facts";
|
||||
import {
|
||||
buildDocumentIndex,
|
||||
type CaptureIssue,
|
||||
type DocumentGeometry,
|
||||
type DocumentIndex,
|
||||
type NodeFacts,
|
||||
} from "./facts";
|
||||
import {
|
||||
decodeDocument,
|
||||
type SnapshotDocument,
|
||||
@@ -33,9 +39,11 @@ export interface FrameContext {
|
||||
targetProjection?: GeometryProjection | null;
|
||||
target: CdpTarget;
|
||||
coordinates: SnapshotCoordinates | null;
|
||||
pageScale?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedDocument {
|
||||
geometry?: DocumentGeometry;
|
||||
nodes: NodeFacts[];
|
||||
index: DocumentIndex;
|
||||
documentElementBackendNodeId?: number;
|
||||
@@ -99,6 +107,20 @@ export async function normalizeDocument(
|
||||
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||
),
|
||||
index,
|
||||
...(context.coordinates &&
|
||||
context.projection?.status === "available" &&
|
||||
context.targetProjection !== null
|
||||
? {
|
||||
geometry: {
|
||||
projections: [
|
||||
context.projection.projection.geometry,
|
||||
...(context.targetProjection ? [context.targetProjection] : []),
|
||||
],
|
||||
coordinates: context.coordinates,
|
||||
pageScale: context.pageScale,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
documentElementBackendNodeId: nodes.find(
|
||||
(node) =>
|
||||
node.nodeType === 1 &&
|
||||
@@ -313,6 +335,7 @@ export async function normalizeSnapshot(
|
||||
frameId: frame.frameId,
|
||||
ownerFrameBackendNodeId: frame.ownerBackendNodeId ?? null,
|
||||
projection: state,
|
||||
pageScale: metrics.cssVisualViewport?.scale ?? metrics.visualViewport?.scale,
|
||||
target,
|
||||
...(target.sessionId ? { targetProjection } : {}),
|
||||
coordinates,
|
||||
|
||||
@@ -8,6 +8,19 @@ export const REQUESTED_STYLES = [
|
||||
"cursor",
|
||||
"visibility",
|
||||
"opacity",
|
||||
"display",
|
||||
"overflow-x",
|
||||
"overflow-y",
|
||||
"transform",
|
||||
"zoom",
|
||||
"clip-path",
|
||||
"mask-image",
|
||||
"rotate",
|
||||
"scale",
|
||||
"perspective",
|
||||
"clip",
|
||||
"contain",
|
||||
"overflow-clip-margin",
|
||||
] as const;
|
||||
const STYLE_COL = Object.fromEntries(
|
||||
REQUESTED_STYLES.map((name, index) => [name, index]),
|
||||
@@ -49,6 +62,7 @@ export interface SnapshotDocument {
|
||||
nodeIndex?: number[];
|
||||
styles?: number[][];
|
||||
bounds?: number[][];
|
||||
clientRects?: number[][];
|
||||
paintOrders?: number[];
|
||||
};
|
||||
}
|
||||
@@ -177,6 +191,7 @@ export async function decodeDocument(
|
||||
: {
|
||||
boundsSpace: "snapshot-document-layout" as const,
|
||||
bounds: dl?.bounds?.[li],
|
||||
...(dl?.clientRects?.[li] ? { clientRect: dl.clientRects[li] } : {}),
|
||||
styles,
|
||||
};
|
||||
|
||||
@@ -200,6 +215,9 @@ export async function decodeDocument(
|
||||
backendNodeId,
|
||||
nodeType: dn.nodeType?.[n],
|
||||
parentBackendNodeId,
|
||||
...(dn.parentIndex?.[n] === undefined || (parentIdx >= 0 && parentBackendNodeId === null)
|
||||
? { parentMissing: true }
|
||||
: {}),
|
||||
|
||||
tag,
|
||||
attrs,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { type CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import type { ViewportRect } from "../geometry";
|
||||
import { createCaptureCheckpoint } from "./capture-abort";
|
||||
import type {
|
||||
CaptureIssue,
|
||||
DocumentFacts,
|
||||
DocumentIdentity,
|
||||
NodeFacts,
|
||||
ObservationFacts,
|
||||
} from "./facts";
|
||||
import type { FrameOwnedAxNode } from "./frame-document";
|
||||
import {
|
||||
EMPTY_VISUAL_CONTEXT,
|
||||
extendVisualContext,
|
||||
projectVisualBox,
|
||||
resolveVisualRegion,
|
||||
type VisualContext,
|
||||
type VisualIssueReason,
|
||||
type VisualRegionResult,
|
||||
visualProjectionIssue,
|
||||
} from "./visual-region";
|
||||
|
||||
export interface VisualCandidate {
|
||||
readonly document: DocumentIdentity;
|
||||
readonly backendNodeId: number;
|
||||
readonly parentBackendNodeId: number | null;
|
||||
readonly label?: string;
|
||||
readonly region: Extract<VisualRegionResult, { status: "available" }>;
|
||||
}
|
||||
|
||||
export interface VisualDiscoveryIssue {
|
||||
readonly target: CdpTarget;
|
||||
readonly frameId: string;
|
||||
readonly backendNodeId?: number;
|
||||
readonly reason: VisualIssueReason;
|
||||
}
|
||||
|
||||
export interface VisualDiscoveryResult {
|
||||
readonly candidates: readonly VisualCandidate[];
|
||||
readonly issues: readonly VisualDiscoveryIssue[];
|
||||
/** Preserve upstream failures, including those that do not invalidate Canvas geometry. */
|
||||
readonly captureIssues: readonly CaptureIssue[];
|
||||
readonly complete: boolean;
|
||||
}
|
||||
|
||||
type Document = DocumentFacts<FrameOwnedAxNode>;
|
||||
interface NodeContext {
|
||||
self: VisualContext;
|
||||
children: VisualContext;
|
||||
}
|
||||
|
||||
function viewportRect(
|
||||
rect: { x: number; y: number; w: number; h: number } | null | undefined,
|
||||
): ViewportRect | null {
|
||||
return rect ? { x: rect.x, y: rect.y, width: rect.w, height: rect.h } : null;
|
||||
}
|
||||
|
||||
/** Client offsets are unscaled local CSS units. Only ordinary, untransformed
|
||||
* overflow chains consume this adapter; other combinations are rejected by policy. */
|
||||
function clientBox(node: NodeFacts, document: Document): ViewportRect | null {
|
||||
const client = node.layout?.clientRect;
|
||||
const bounds = node.layout?.bounds;
|
||||
const border =
|
||||
node.localRect ??
|
||||
(bounds?.length === 4 && bounds.every(Number.isFinite) && document.geometry
|
||||
? {
|
||||
x:
|
||||
bounds[0] / document.geometry.coordinates.layoutUnitsPerCssPixel -
|
||||
document.geometry.coordinates.scrollCss.x,
|
||||
y:
|
||||
bounds[1] / document.geometry.coordinates.layoutUnitsPerCssPixel -
|
||||
document.geometry.coordinates.scrollCss.y,
|
||||
}
|
||||
: null);
|
||||
if (
|
||||
!client ||
|
||||
client.length !== 4 ||
|
||||
!client.every(Number.isFinite) ||
|
||||
client[2] < 0 ||
|
||||
client[3] < 0 ||
|
||||
!border ||
|
||||
!document.geometry
|
||||
)
|
||||
return null;
|
||||
return projectVisualBox(
|
||||
{ x: border.x + client[0], y: border.y + client[1], width: client[2], height: client[3] },
|
||||
document.geometry.projections,
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure Facts consumer: no CDP, ref registration, selection, or semantic keep/drop.
|
||||
* Both frame and node ancestry are memoized in this call, using iterative walks. */
|
||||
export async function discoverVisualCandidates(
|
||||
facts: ObservationFacts<FrameOwnedAxNode>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<VisualDiscoveryResult> {
|
||||
const checkpoint = createCaptureCheckpoint(signal);
|
||||
let work = 0;
|
||||
const candidates: VisualCandidate[] = [];
|
||||
const issues: VisualDiscoveryIssue[] = [];
|
||||
const documents = new Map(facts.documents.map((document) => [document.frame.frameId, document]));
|
||||
const contexts = new Map<string, Map<number, NodeContext>>();
|
||||
const roots = new Map<string, VisualContext>();
|
||||
|
||||
async function nodeContext(document: Document, id: number): Promise<NodeContext> {
|
||||
const cache = contexts.get(document.frame.frameId)!;
|
||||
const path: NodeFacts[] = [];
|
||||
let node = document.index.nodes.get(id);
|
||||
while (node && !cache.has(node.backendNodeId)) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (
|
||||
!document.index.ancestryComplete.get(node.backendNodeId) ||
|
||||
path.length >= document.index.nodes.size
|
||||
) {
|
||||
const incomplete = {
|
||||
...roots.get(document.frame.frameId)!,
|
||||
issue: roots.get(document.frame.frameId)!.issue ?? ("ancestry-incomplete" as const),
|
||||
};
|
||||
cache.set(node.backendNodeId, { self: incomplete, children: incomplete });
|
||||
break;
|
||||
}
|
||||
path.push(node);
|
||||
node =
|
||||
node.parentBackendNodeId === null
|
||||
? undefined
|
||||
: document.index.nodes.get(node.parentBackendNodeId);
|
||||
}
|
||||
let parent = node
|
||||
? cache.get(node.backendNodeId)!.children
|
||||
: roots.get(document.frame.frameId)!;
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
const current = path[i];
|
||||
let self = parent;
|
||||
let children = parent;
|
||||
if (document.index.excludedBackendNodeIds.has(current.backendNodeId)) {
|
||||
self = children = { ...parent, hidden: true };
|
||||
} else if (current.nodeType === 1) {
|
||||
if (!current.layout) {
|
||||
// An element without layout is not evidence that descendants are hidden
|
||||
// (e.g. a boxless ancestor). Keep the missing style evidence explicit.
|
||||
self = children = { ...parent, issue: parent.issue ?? "facts-unavailable" };
|
||||
} else if (document.identity) {
|
||||
const ancestor = {
|
||||
document: document.identity,
|
||||
backendNodeId: current.backendNodeId,
|
||||
styles: current.layout.styles,
|
||||
clientBox: clientBox(current, document),
|
||||
};
|
||||
children = extendVisualContext(
|
||||
parent,
|
||||
ancestor,
|
||||
current.tag !== "iframe" && current.tag !== "frame",
|
||||
);
|
||||
self = current.tag === "canvas" ? extendVisualContext(parent, ancestor, false) : children;
|
||||
}
|
||||
}
|
||||
const result = { self, children };
|
||||
cache.set(current.backendNodeId, result);
|
||||
parent = children;
|
||||
}
|
||||
return (
|
||||
cache.get(id) ?? {
|
||||
self: { ...parent, issue: "ancestry-incomplete" },
|
||||
children: { ...parent, issue: "ancestry-incomplete" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Process parent documents first, independent of snapshot/target completion order.
|
||||
for (const document of facts.documents) {
|
||||
const path: Document[] = [];
|
||||
const visiting = new Set<string>();
|
||||
let current: Document | undefined = document;
|
||||
while (current && !roots.has(current.frame.frameId)) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (visiting.has(current.frame.frameId)) {
|
||||
roots.set(current.frame.frameId, {
|
||||
...EMPTY_VISUAL_CONTEXT,
|
||||
issue: "ownership-unresolved",
|
||||
});
|
||||
contexts.set(current.frame.frameId, new Map());
|
||||
break;
|
||||
}
|
||||
visiting.add(current.frame.frameId);
|
||||
path.push(current);
|
||||
current = current.frame.parentFrameId
|
||||
? documents.get(current.frame.parentFrameId)
|
||||
: undefined;
|
||||
}
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
const doc = path[i];
|
||||
const { frame } = doc;
|
||||
let root: VisualContext = roots.get(frame.frameId) ?? EMPTY_VISUAL_CONTEXT;
|
||||
if (frame.parentFrameId) {
|
||||
const parent = documents.get(frame.parentFrameId);
|
||||
if (
|
||||
!parent ||
|
||||
!roots.has(parent.frame.frameId) ||
|
||||
frame.ownerBackendNodeId === undefined ||
|
||||
!parent.identity
|
||||
) {
|
||||
root = { ...root, issue: "ownership-unresolved" };
|
||||
} else {
|
||||
const owner = parent.index.nodes.get(frame.ownerBackendNodeId);
|
||||
const ownerState = await nodeContext(parent, frame.ownerBackendNodeId);
|
||||
root = {
|
||||
...ownerState.children,
|
||||
// CSS visibility inside a child document cannot override its hidden owner.
|
||||
hidden:
|
||||
ownerState.children.hidden ||
|
||||
owner?.layout?.styles.visibility === "hidden" ||
|
||||
owner?.layout?.styles.visibility === "collapse",
|
||||
// Frame scaling is already interpreted by the shared content-quad projection.
|
||||
transformed: false,
|
||||
localClip: false,
|
||||
unpositionedClip: false,
|
||||
};
|
||||
}
|
||||
} else if (frame.frameId !== facts.rootFrameId)
|
||||
root = { ...root, issue: "ownership-unresolved" };
|
||||
root = {
|
||||
...root,
|
||||
issue:
|
||||
root.issue ??
|
||||
(!doc.identity ? "identity-unverified" : visualProjectionIssue(doc.geometry)),
|
||||
};
|
||||
roots.set(frame.frameId, root);
|
||||
contexts.set(frame.frameId, new Map());
|
||||
}
|
||||
}
|
||||
|
||||
for (const document of facts.documents) {
|
||||
for (const node of document.index.nodes.values()) {
|
||||
if (work++ % 256 === 0) {
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
}
|
||||
if (node.tag !== "canvas" || document.index.excludedBackendNodeIds.has(node.backendNodeId))
|
||||
continue;
|
||||
// No layout/zero size is positive evidence of no screenshot area for this node.
|
||||
if (!node.layout) continue;
|
||||
const bounds = node.layout.bounds;
|
||||
if (
|
||||
bounds?.length === 4 &&
|
||||
bounds.every(Number.isFinite) &&
|
||||
(bounds[2] <= 0 || bounds[3] <= 0)
|
||||
)
|
||||
continue;
|
||||
const context = (await nodeContext(document, node.backendNodeId)).self;
|
||||
const local = viewportRect(node.localRect);
|
||||
const borderBox =
|
||||
local && document.geometry ? projectVisualBox(local, document.geometry.projections) : null;
|
||||
const region = resolveVisualRegion({
|
||||
borderBox,
|
||||
frameVisibleBox: viewportRect(node.rect),
|
||||
context,
|
||||
visibility: node.layout.styles.visibility,
|
||||
});
|
||||
if (region.status === "unavailable") {
|
||||
issues.push({
|
||||
target: document.frame.target,
|
||||
frameId: document.frame.frameId,
|
||||
backendNodeId: node.backendNodeId,
|
||||
reason: region.reason,
|
||||
});
|
||||
} else if (region.status === "available" && document.identity) {
|
||||
const label = node.attrs["aria-label"]?.trim() || node.attrs.title?.trim();
|
||||
candidates.push({
|
||||
document: document.identity,
|
||||
backendNodeId: node.backendNodeId,
|
||||
parentBackendNodeId: node.parentBackendNodeId,
|
||||
...(label ? { label } : {}),
|
||||
region,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// AX-only/missing documents have no Canvas nodes to carry the missing evidence.
|
||||
const reportedFrames = new Set(issues.map((issue) => issue.frameId));
|
||||
for (const document of facts.documents) {
|
||||
const reason = roots.get(document.frame.frameId)?.issue;
|
||||
if (
|
||||
reason &&
|
||||
!reportedFrames.has(document.frame.frameId) &&
|
||||
(!document.identity || !document.index.nodes.size)
|
||||
)
|
||||
issues.push({ target: document.frame.target, frameId: document.frame.frameId, reason });
|
||||
}
|
||||
const pending = checkpoint();
|
||||
if (pending) await pending;
|
||||
return {
|
||||
candidates,
|
||||
issues,
|
||||
captureIssues: facts.issues,
|
||||
complete: issues.length === 0 && facts.issues.length === 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
type GeometryProjection,
|
||||
type Polygon,
|
||||
projectPolygon,
|
||||
rectPolygon,
|
||||
regionBounds,
|
||||
type ViewportRect,
|
||||
} from "../geometry";
|
||||
import type { DocumentGeometry, DocumentIdentity } from "./facts";
|
||||
|
||||
export type VisualIssueReason =
|
||||
| "ancestry-incomplete"
|
||||
| "facts-unavailable"
|
||||
| "geometry-unavailable"
|
||||
| "geometry-unsupported"
|
||||
| "identity-unverified"
|
||||
| "ownership-unresolved";
|
||||
|
||||
/** Top viewport CSS coordinates. Unconstrained axes have infinite bounds. */
|
||||
export interface VisualClip {
|
||||
readonly left: number;
|
||||
readonly top: number;
|
||||
readonly right: number;
|
||||
readonly bottom: number;
|
||||
}
|
||||
|
||||
/** Shared persistent chain: one entry per clipping ancestor, not per Canvas. */
|
||||
export interface VisualClipSource {
|
||||
readonly document: DocumentIdentity;
|
||||
readonly backendNodeId: number;
|
||||
readonly x: boolean;
|
||||
readonly y: boolean;
|
||||
readonly overflowX: string;
|
||||
readonly overflowY: string;
|
||||
readonly box: ViewportRect;
|
||||
readonly parent?: VisualClipSource;
|
||||
}
|
||||
|
||||
export interface VisualContext {
|
||||
readonly clip: VisualClip;
|
||||
readonly clips?: VisualClipSource;
|
||||
readonly hidden: boolean;
|
||||
/** A local CSS scale/zoom makes snapshot client offsets ambiguous. */
|
||||
readonly transformed: boolean;
|
||||
readonly localClip?: boolean;
|
||||
/** Overflow between an absolute target and its nearest positioned ancestor. */
|
||||
readonly unpositionedClip?: boolean;
|
||||
readonly issue?: VisualIssueReason;
|
||||
}
|
||||
|
||||
export const EMPTY_VISUAL_CONTEXT: VisualContext = {
|
||||
clip: { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity },
|
||||
hidden: false,
|
||||
transformed: false,
|
||||
};
|
||||
|
||||
function axisRect(polygon: Polygon): boolean {
|
||||
if (polygon.length !== 4 || polygon.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))
|
||||
return false;
|
||||
const [a, b, c, d] = polygon;
|
||||
return a.y === b.y && b.x === c.x && c.y === d.y && d.x === a.x && b.x > a.x && d.y > a.y;
|
||||
}
|
||||
|
||||
/** Check evidence before projectRectToViewport can reduce a polygon to its bounds. */
|
||||
export function visualProjectionIssue(
|
||||
geometry: DocumentGeometry | undefined,
|
||||
): VisualIssueReason | undefined {
|
||||
if (!geometry || !geometry.projections.length || geometry.pageScale === undefined)
|
||||
return "geometry-unavailable";
|
||||
if (!Number.isFinite(geometry.pageScale) || geometry.pageScale <= 0)
|
||||
return "geometry-unavailable";
|
||||
if (geometry.pageScale !== 1) return "geometry-unsupported";
|
||||
const { layoutUnitsPerCssPixel, scrollCss } = geometry.coordinates;
|
||||
if (
|
||||
!Number.isFinite(layoutUnitsPerCssPixel) ||
|
||||
layoutUnitsPerCssPixel <= 0 ||
|
||||
![scrollCss.x, scrollCss.y].every(Number.isFinite)
|
||||
)
|
||||
return "geometry-unavailable";
|
||||
for (const projection of geometry.projections) {
|
||||
if (
|
||||
![projection.topViewport.width, projection.topViewport.height].every(
|
||||
(n) => Number.isFinite(n) && n > 0,
|
||||
)
|
||||
)
|
||||
return "geometry-unavailable";
|
||||
if (projection.sourceClips.some((clip) => !axisRect(clip))) return "geometry-unsupported";
|
||||
for (const edge of projection.edges) {
|
||||
if (
|
||||
![edge.sourceViewport.width, edge.sourceViewport.height].every(
|
||||
(n) => Number.isFinite(n) && n > 0,
|
||||
)
|
||||
)
|
||||
return "geometry-unavailable";
|
||||
if (!axisRect(edge.destinationQuad) || edge.destinationClips?.some((clip) => !axisRect(clip)))
|
||||
return "geometry-unsupported";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Uses the same projection math as DOM geometry, without clipping away box origins. */
|
||||
export function projectVisualBox(
|
||||
box: ViewportRect,
|
||||
projections: readonly GeometryProjection[],
|
||||
): ViewportRect | null {
|
||||
let polygon: Polygon = rectPolygon({ x: box.x, y: box.y, w: box.width, h: box.height });
|
||||
for (const projection of projections)
|
||||
for (const edge of projection.edges) polygon = projectPolygon(polygon, edge);
|
||||
// Preserve zero-size client boxes: they can prove a fully clipped axis.
|
||||
if (box.width === 0 || box.height === 0) {
|
||||
const [a, , c] = polygon;
|
||||
return { x: a.x, y: a.y, width: c.x - a.x, height: c.y - a.y };
|
||||
}
|
||||
return regionBounds([polygon]);
|
||||
}
|
||||
|
||||
function axisTransformScale(value: string): [number, number] | null {
|
||||
if (value === "none") return [1, 1];
|
||||
const match = /^matrix(3d)?\(([^)]+)\)$/.exec(value);
|
||||
if (!match) return null;
|
||||
const values = match[2].split(",").map((part) => Number(part.trim()));
|
||||
if (!values.every(Number.isFinite)) return null;
|
||||
if (match[1])
|
||||
return values.length === 16 &&
|
||||
values[0] > 0 &&
|
||||
values[5] > 0 &&
|
||||
values[10] === 1 &&
|
||||
values[15] === 1 &&
|
||||
[1, 2, 3, 4, 6, 7, 8, 9, 11, 14].every((index) => values[index] === 0)
|
||||
? [values[0], values[5]]
|
||||
: null;
|
||||
return values.length === 6 && values[0] > 0 && values[3] > 0 && values[1] === 0 && values[2] === 0
|
||||
? [values[0], values[3]]
|
||||
: null;
|
||||
}
|
||||
|
||||
export interface VisualAncestor {
|
||||
readonly document: DocumentIdentity;
|
||||
readonly backendNodeId: number;
|
||||
readonly styles: Readonly<Record<string, string>>;
|
||||
/** Already in top viewport CSS units, from a source-specific adapter. */
|
||||
readonly clientBox?: ViewportRect | null;
|
||||
}
|
||||
|
||||
/** One ancestor step, reusable by discovery and the future live local adapter. */
|
||||
export function extendVisualContext(
|
||||
parent: VisualContext,
|
||||
node: VisualAncestor,
|
||||
clipContents = true,
|
||||
): VisualContext {
|
||||
const styles = node.styles;
|
||||
const opacity = styles.opacity?.trim() ? Number(styles.opacity) : NaN;
|
||||
const zoom = styles.zoom === "normal" ? 1 : styles.zoom?.trim() ? Number(styles.zoom) : NaN;
|
||||
const scale =
|
||||
styles.scale === "none" ? [1] : (styles.scale ?? "").trim().split(/\s+/).map(Number);
|
||||
const scaleSupported =
|
||||
scale.length >= 1 &&
|
||||
scale.length <= 3 &&
|
||||
scale.every((n) => Number.isFinite(n) && n > 0) &&
|
||||
(scale.length < 3 || scale[2] === 1);
|
||||
const transformScale = axisTransformScale(styles.transform ?? "");
|
||||
// Identity/translation changes origins, which bounds already capture, but
|
||||
// leaves client offsets in the same units. Only scaling makes them ambiguous.
|
||||
const transformed =
|
||||
parent.transformed ||
|
||||
transformScale?.some((n) => n !== 1) === true ||
|
||||
zoom !== 1 ||
|
||||
scale.some((n) => n !== 1);
|
||||
const hidden = parent.hidden || opacity === 0 || styles.display === "none";
|
||||
let issue = parent.issue;
|
||||
if (
|
||||
!Number.isFinite(opacity) ||
|
||||
!Number.isFinite(zoom) ||
|
||||
zoom <= 0 ||
|
||||
!styles.display ||
|
||||
!styles.visibility
|
||||
)
|
||||
issue ??= "facts-unavailable";
|
||||
if (!styles.transform || !styles["clip-path"] || !styles["mask-image"])
|
||||
issue ??= "facts-unavailable";
|
||||
else if (!transformScale || styles["clip-path"] !== "none" || styles["mask-image"] !== "none")
|
||||
issue ??= "geometry-unsupported";
|
||||
|
||||
if (
|
||||
!["rotate", "scale", "perspective", "clip", "contain", "overflow-clip-margin"].every(
|
||||
(key) => styles[key],
|
||||
)
|
||||
)
|
||||
issue ??= "facts-unavailable";
|
||||
else if (
|
||||
!scaleSupported ||
|
||||
!["none", "0deg"].includes(styles.rotate) ||
|
||||
styles.perspective !== "none" ||
|
||||
styles.clip !== "auto" ||
|
||||
/(?:^|\s)(paint|strict|content)(?:$|\s)/.test(styles.contain)
|
||||
)
|
||||
issue ??= "geometry-unsupported";
|
||||
// Do not pretend every DOM ancestor clips out-of-flow descendants. Support
|
||||
// the ordinary positioned-container case; other containing-block cases stay explicit.
|
||||
if (
|
||||
(styles.position === "absolute" && parent.unpositionedClip) ||
|
||||
(styles.position === "fixed" && parent.localClip)
|
||||
)
|
||||
issue ??= "geometry-unsupported";
|
||||
const overflow = [styles["overflow-x"], styles["overflow-y"]];
|
||||
if (clipContents && overflow.includes("clip") && styles["overflow-clip-margin"] !== "0px")
|
||||
issue ??= "geometry-unsupported";
|
||||
if (
|
||||
overflow.some(
|
||||
(value) => !["visible", "hidden", "clip", "scroll", "auto", "overlay"].includes(value),
|
||||
)
|
||||
)
|
||||
issue ??= "facts-unavailable";
|
||||
const [x, y] = overflow.map((value) => value !== "visible");
|
||||
let clip = parent.clip;
|
||||
let clips = parent.clips;
|
||||
if (clipContents && (x || y)) {
|
||||
if (transformed) issue ??= "geometry-unsupported";
|
||||
const box = node.clientBox;
|
||||
if (
|
||||
!box ||
|
||||
![box.x, box.y, box.width, box.height].every(Number.isFinite) ||
|
||||
box.width < 0 ||
|
||||
box.height < 0
|
||||
)
|
||||
issue ??= "geometry-unavailable";
|
||||
else {
|
||||
clip = {
|
||||
left: x ? Math.max(clip.left, box.x) : clip.left,
|
||||
right: x ? Math.min(clip.right, box.x + box.width) : clip.right,
|
||||
top: y ? Math.max(clip.top, box.y) : clip.top,
|
||||
bottom: y ? Math.min(clip.bottom, box.y + box.height) : clip.bottom,
|
||||
};
|
||||
clips = {
|
||||
document: node.document,
|
||||
backendNodeId: node.backendNodeId,
|
||||
x,
|
||||
y,
|
||||
overflowX: styles["overflow-x"],
|
||||
overflowY: styles["overflow-y"],
|
||||
box,
|
||||
parent: clips,
|
||||
};
|
||||
}
|
||||
}
|
||||
const clipsHere = clipContents && (x || y);
|
||||
return {
|
||||
hidden,
|
||||
transformed,
|
||||
issue,
|
||||
clip,
|
||||
clips,
|
||||
localClip: parent.localClip || clipsHere,
|
||||
unpositionedClip:
|
||||
styles.position !== "static" ||
|
||||
styles.transform !== "none" ||
|
||||
/(?:^|\s)layout(?:$|\s)/.test(styles.contain ?? "")
|
||||
? false
|
||||
: parent.unpositionedClip || clipsHere,
|
||||
};
|
||||
}
|
||||
|
||||
export type VisualRegionResult =
|
||||
| { status: "available"; borderBox: ViewportRect; crop: ViewportRect; clips?: VisualClipSource }
|
||||
| { status: "empty" }
|
||||
| { status: "unavailable"; reason: VisualIssueReason };
|
||||
|
||||
/** Box and frameVisibleBox must come from the same box kind (Canvas border-box).
|
||||
* Frame/viewport clipping is supplied by the shared geometry adapter. */
|
||||
export function resolveVisualRegion(input: {
|
||||
borderBox: ViewportRect | null;
|
||||
frameVisibleBox: ViewportRect | null;
|
||||
context: VisualContext;
|
||||
visibility: string;
|
||||
}): VisualRegionResult {
|
||||
const { borderBox, frameVisibleBox, context, visibility } = input;
|
||||
if (context.hidden || visibility === "hidden" || visibility === "collapse")
|
||||
return { status: "empty" };
|
||||
if (context.issue) return { status: "unavailable", reason: context.issue };
|
||||
if (visibility !== "visible") return { status: "unavailable", reason: "facts-unavailable" };
|
||||
if (!borderBox) return { status: "unavailable", reason: "geometry-unavailable" };
|
||||
if (!frameVisibleBox) return { status: "empty" };
|
||||
if (
|
||||
![
|
||||
borderBox.x,
|
||||
borderBox.y,
|
||||
borderBox.width,
|
||||
borderBox.height,
|
||||
frameVisibleBox.x,
|
||||
frameVisibleBox.y,
|
||||
frameVisibleBox.width,
|
||||
frameVisibleBox.height,
|
||||
].every(Number.isFinite)
|
||||
)
|
||||
return { status: "unavailable", reason: "geometry-unavailable" };
|
||||
if (
|
||||
borderBox.width <= 0 ||
|
||||
borderBox.height <= 0 ||
|
||||
frameVisibleBox.width < 0 ||
|
||||
frameVisibleBox.height < 0 ||
|
||||
Object.values(context.clip).some(Number.isNaN)
|
||||
)
|
||||
return { status: "unavailable", reason: "geometry-unavailable" };
|
||||
const x = Math.max(frameVisibleBox.x, context.clip.left);
|
||||
const y = Math.max(frameVisibleBox.y, context.clip.top);
|
||||
const right = Math.min(frameVisibleBox.x + frameVisibleBox.width, context.clip.right);
|
||||
const bottom = Math.min(frameVisibleBox.y + frameVisibleBox.height, context.clip.bottom);
|
||||
return right > x && bottom > y
|
||||
? {
|
||||
status: "available",
|
||||
borderBox,
|
||||
crop: { x, y, width: right - x, height: bottom - y },
|
||||
clips: context.clips,
|
||||
}
|
||||
: { status: "empty" };
|
||||
}
|
||||
Reference in New Issue
Block a user