mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(vom): surface incomplete iframe geometry in observations
This commit is contained in:
@@ -60,15 +60,16 @@ describe("measurement geometry context", () => {
|
|||||||
width: 1000,
|
width: 1000,
|
||||||
height: 1000,
|
height: 1000,
|
||||||
});
|
});
|
||||||
|
if (projection.status !== "available") throw new Error("expected available projection");
|
||||||
const input = snapshotViewportRect([17, 23, 120, 40], source, { x: 0, y: 0 });
|
const input = snapshotViewportRect([17, 23, 120, 40], source, { x: 0, y: 0 });
|
||||||
expect(projectSnapshotRect(input!, projection!)).toEqual({
|
expect(projectSnapshotRect(input!, projection.projection)).toEqual({
|
||||||
x: 73.75,
|
x: 73.75,
|
||||||
y: 641.25,
|
y: 641.25,
|
||||||
width: 150,
|
width: 150,
|
||||||
height: 50,
|
height: 50,
|
||||||
});
|
});
|
||||||
const scrolled = snapshotViewportRect([17, 23, 120, 40], source, { x: 0, y: 10 });
|
const scrolled = snapshotViewportRect([17, 23, 120, 40], source, { x: 0, y: 10 });
|
||||||
expect(projectSnapshotRect(scrolled!, projection!)?.y).toBe(628.75);
|
expect(projectSnapshotRect(scrolled!, projection.projection)?.y).toBe(628.75);
|
||||||
await context.snapshotProjection(source, 10, [], { width: 1000, height: 1000 });
|
await context.snapshotProjection(source, 10, [], { width: 1000, height: 1000 });
|
||||||
expect(cdp.calls.mock.calls.filter(([, method]) => method === "DOM.resolveNode")).toHaveLength(
|
expect(cdp.calls.mock.calls.filter(([, method]) => method === "DOM.resolveNode")).toHaveLength(
|
||||||
1,
|
1,
|
||||||
@@ -118,7 +119,11 @@ describe("measurement geometry context", () => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toEqual({
|
||||||
|
status: "unavailable",
|
||||||
|
source: { target, frameId: "child" },
|
||||||
|
ownerBackendNodeId: 10,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps ancestor clips in target coordinates instead of applying nested offsets twice", async () => {
|
it("keeps ancestor clips in target coordinates instead of applying nested offsets twice", async () => {
|
||||||
@@ -129,12 +134,13 @@ describe("measurement geometry context", () => {
|
|||||||
[rectPolygon({ x: 60, y: 620, w: 100, h: 100 })],
|
[rectPolygon({ x: 60, y: 620, w: 100, h: 100 })],
|
||||||
{ width: 1000, height: 1000 },
|
{ width: 1000, height: 1000 },
|
||||||
);
|
);
|
||||||
|
if (projection.status !== "available") throw new Error("expected available projection");
|
||||||
const input = snapshotViewportRect(
|
const input = snapshotViewportRect(
|
||||||
[0, 0, 300, 200],
|
[0, 0, 300, 200],
|
||||||
{ target, frameId: "nested" },
|
{ target, frameId: "nested" },
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
);
|
);
|
||||||
expect(projectSnapshotRect(input!, projection!)).toEqual({
|
expect(projectSnapshotRect(input!, projection.projection)).toEqual({
|
||||||
x: 60,
|
x: 60,
|
||||||
y: 620,
|
y: 620,
|
||||||
width: 100,
|
width: 100,
|
||||||
|
|||||||
@@ -3147,7 +3147,9 @@ describe("handleSnapshot", () => {
|
|||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeFrameAwareDeps() {
|
function makeFrameAwareDeps(
|
||||||
|
ownerGeometry: "unavailable" | "available" | "clipped" = "unavailable",
|
||||||
|
) {
|
||||||
const strings = ["html", "body", "iframe", "button", "title", "Remote", "static", "auto"];
|
const strings = ["html", "body", "iframe", "button", "title", "Remote", "static", "auto"];
|
||||||
const i = (value: string) => strings.indexOf(value);
|
const i = (value: string) => strings.indexOf(value);
|
||||||
const styles = [i("static"), i("auto"), i("auto")];
|
const styles = [i("static"), i("auto"), i("auto")];
|
||||||
@@ -3227,6 +3229,16 @@ describe("handleSnapshot", () => {
|
|||||||
];
|
];
|
||||||
const send = vi.fn(async (_tabId: number, method: string) => {
|
const send = vi.fn(async (_tabId: number, method: string) => {
|
||||||
if (method === "Page.getLayoutMetrics") return VP_METRICS;
|
if (method === "Page.getLayoutMetrics") return VP_METRICS;
|
||||||
|
if (ownerGeometry !== "unavailable") {
|
||||||
|
if (method === "DOM.getBoxModel") {
|
||||||
|
const x = ownerGeometry === "clipped" ? 10000 : 100;
|
||||||
|
return { model: { content: [x, 100, x + 400, 100, x + 400, 400, x, 400] } };
|
||||||
|
}
|
||||||
|
if (method === "DOM.resolveNode") return { object: { objectId: "owner" } };
|
||||||
|
if (method === "Runtime.callFunctionOn")
|
||||||
|
return { result: { value: { width: 400, height: 300 } } };
|
||||||
|
if (method === "Runtime.releaseObject") return {};
|
||||||
|
}
|
||||||
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
|
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
|
||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: mainAx };
|
if (method === "Accessibility.getFullAXTree") return { nodes: mainAx };
|
||||||
@@ -3467,6 +3479,43 @@ describe("handleSnapshot", () => {
|
|||||||
expect(result.text).toContain("•••");
|
expect(result.text).toContain("•••");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
undefined,
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
])("reports unavailable iframe geometry within the observation budget %s", async (maxTokens) => {
|
||||||
|
const result = await captureVomObservation(makeFrameAwareDeps().cdp, 4, "https://example.com", {
|
||||||
|
maxTokens,
|
||||||
|
});
|
||||||
|
expect(result.text.match(/@warning/g)).toHaveLength(1);
|
||||||
|
expect(result.text).toContain("iframe geometry incomplete");
|
||||||
|
expect(result.text.startsWith("@vom 1\n")).toBe(true);
|
||||||
|
if (maxTokens === undefined) {
|
||||||
|
expect(result.truncated).toBe(false);
|
||||||
|
expect(result.text).toContain("Frame action");
|
||||||
|
expect(result.matchNodes.find((node) => node.backendNodeId === 22)).toMatchObject({
|
||||||
|
rect: null,
|
||||||
|
localRect: { x: 20, y: 30, w: 120, h: 40 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (maxTokens === 100) expect(Math.ceil(result.text.length / 4)).toBeLessThanOrEqual(maxTokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"available",
|
||||||
|
"clipped",
|
||||||
|
] as const)("does not report successful %s geometry as an observation failure", async (ownerGeometry) => {
|
||||||
|
const result = await captureVomObservation(
|
||||||
|
makeFrameAwareDeps(ownerGeometry).cdp,
|
||||||
|
4,
|
||||||
|
"https://example.com",
|
||||||
|
);
|
||||||
|
expect(result.text).not.toContain("@warning");
|
||||||
|
const node = result.matchNodes.find((item) => item.backendNodeId === 22);
|
||||||
|
if (ownerGeometry === "clipped") expect(node?.rect).toBeNull();
|
||||||
|
else expect(node?.rect).toEqual({ x: 120, y: 130, w: 120, h: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps frames and rendered refs on the same frame identity", async () => {
|
it("keeps frames and rendered refs on the same frame identity", async () => {
|
||||||
const deps = makeFrameAwareDeps();
|
const deps = makeFrameAwareDeps();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ export interface SnapshotProjection {
|
|||||||
geometry: GeometryProjection;
|
geometry: GeometryProjection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A failed owner read is different from a successfully projected, clipped-out rect. */
|
||||||
|
export interface UnavailableFrameProjection {
|
||||||
|
status: "unavailable";
|
||||||
|
source: CoordinateOwner;
|
||||||
|
ownerBackendNodeId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SnapshotProjectionResult =
|
||||||
|
| { status: "available"; projection: SnapshotProjection }
|
||||||
|
| UnavailableFrameProjection;
|
||||||
|
|
||||||
|
/** Descendants inherit the failed boundary without attempting another owner read. */
|
||||||
|
export type FrameProjectionIssue =
|
||||||
|
| UnavailableFrameProjection
|
||||||
|
| { status: "blocked"; source: CoordinateOwner; cause: UnavailableFrameProjection };
|
||||||
|
|
||||||
|
export type FrameProjectionState = SnapshotProjectionResult | FrameProjectionIssue;
|
||||||
|
|
||||||
/** DOMSnapshot bounds are document-relative CSS pixels, independent of raster DPR. */
|
/** DOMSnapshot bounds are document-relative CSS pixels, independent of raster DPR. */
|
||||||
export function snapshotViewportRect(
|
export function snapshotViewportRect(
|
||||||
bounds: number[],
|
bounds: number[],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type Size,
|
type Size,
|
||||||
} from "../geometry";
|
} from "../geometry";
|
||||||
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
||||||
import type { CoordinateOwner, CssViewport, SnapshotProjection } from "./coordinate-types";
|
import type { CoordinateOwner, CssViewport, SnapshotProjectionResult } from "./coordinate-types";
|
||||||
|
|
||||||
export interface LayoutMetrics {
|
export interface LayoutMetrics {
|
||||||
cssVisualViewport?: { zoom?: number };
|
cssVisualViewport?: { zoom?: number };
|
||||||
@@ -149,30 +149,41 @@ export class GeometryContext {
|
|||||||
ownerBackendNodeId: number,
|
ownerBackendNodeId: number,
|
||||||
ancestorClips: Polygon[],
|
ancestorClips: Polygon[],
|
||||||
viewport: Size,
|
viewport: Size,
|
||||||
): Promise<SnapshotProjection | null> {
|
): Promise<SnapshotProjectionResult> {
|
||||||
const key = `${cdpTargetKey(source.target)}:${ownerBackendNodeId}`;
|
const key = `${cdpTargetKey(source.target)}:${ownerBackendNodeId}`;
|
||||||
let promise = this.snapshotOwners.get(key);
|
let promise = this.snapshotOwners.get(key);
|
||||||
if (!promise) {
|
if (!promise) {
|
||||||
promise = this.snapshotOwner(source.target, ownerBackendNodeId);
|
promise = this.snapshotOwner(source.target, ownerBackendNodeId);
|
||||||
this.snapshotOwners.set(key, promise);
|
this.snapshotOwners.set(key, promise);
|
||||||
}
|
}
|
||||||
const owner = await promise;
|
let owner: { quad: Quad; size: Size } | null;
|
||||||
|
try {
|
||||||
|
owner = await promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "name" in error && error.name === "AbortError")
|
||||||
|
throw error;
|
||||||
|
console.debug("[bsk geometry] frame owner unavailable", error);
|
||||||
|
owner = null;
|
||||||
|
}
|
||||||
return owner
|
return owner
|
||||||
? {
|
? {
|
||||||
source,
|
status: "available",
|
||||||
geometry: {
|
projection: {
|
||||||
sourceClips: [],
|
source,
|
||||||
edges: [
|
geometry: {
|
||||||
{
|
sourceClips: [],
|
||||||
sourceViewport: owner.size,
|
edges: [
|
||||||
destinationQuad: owner.quad,
|
{
|
||||||
destinationClips: ancestorClips,
|
sourceViewport: owner.size,
|
||||||
},
|
destinationQuad: owner.quad,
|
||||||
],
|
destinationClips: ancestorClips,
|
||||||
topViewport: viewport,
|
},
|
||||||
|
],
|
||||||
|
topViewport: viewport,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null;
|
: { status: "unavailable", source, ownerBackendNodeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async snapshotOwner(
|
private async snapshotOwner(
|
||||||
|
|||||||
@@ -1133,12 +1133,21 @@ export async function captureVomObservation(
|
|||||||
captured,
|
captured,
|
||||||
hoverProbes.surfaceProbes,
|
hoverProbes.surfaceProbes,
|
||||||
);
|
);
|
||||||
|
// Reserve space using the renderer's character-based token estimate. Like VOM
|
||||||
|
// headers, this integrity notice remains visible even under a tiny token budget.
|
||||||
|
const geometryNotice = captured.frameGeometryIssues?.length
|
||||||
|
? "@warning iframe geometry incomplete: some frame content has no top-level coordinates.\n"
|
||||||
|
: "";
|
||||||
const rendered = renderVom(decoratedScene, {
|
const rendered = renderVom(decoratedScene, {
|
||||||
maxDepth: options.maxDepth,
|
maxDepth: options.maxDepth,
|
||||||
maxTokens: options.maxTokens,
|
maxTokens:
|
||||||
|
!geometryNotice || options.maxTokens === undefined
|
||||||
|
? options.maxTokens
|
||||||
|
: Math.max(0, options.maxTokens - Math.ceil(geometryNotice.length / 4)),
|
||||||
redactValues: options.redactValues,
|
redactValues: options.redactValues,
|
||||||
activeRegionPolicy: options.activeRegionPolicy,
|
activeRegionPolicy: options.activeRegionPolicy,
|
||||||
});
|
});
|
||||||
|
if (geometryNotice) rendered.text += `\n${geometryNotice.trimEnd()}`;
|
||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
return projectRecordSafeObservation({
|
return projectRecordSafeObservation({
|
||||||
rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root",
|
rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root",
|
||||||
|
|||||||
@@ -1340,6 +1340,7 @@ describe("sibling frame measurement scheduling", () => {
|
|||||||
expect(fixture.measured()).not.toContain(200);
|
expect(fixture.measured()).not.toContain(200);
|
||||||
for (const id of [105, 102, 101, 100]) pending.get(id)!();
|
for (const id of [105, 102, 101, 100]) pending.get(id)!();
|
||||||
const captured = await capture;
|
const captured = await capture;
|
||||||
|
expect(captured.frameGeometryIssues).toEqual([]);
|
||||||
expect(fixture.peak()).toBe(4);
|
expect(fixture.peak()).toBe(4);
|
||||||
expect(fixture.active()).toBe(0);
|
expect(fixture.active()).toBe(0);
|
||||||
expect(fixture.measured()).toEqual([100, 101, 102, 103, 104, 105, 200]);
|
expect(fixture.measured()).toEqual([100, 101, 102, 103, 104, 105, 200]);
|
||||||
@@ -1360,6 +1361,18 @@ describe("sibling frame measurement scheduling", () => {
|
|||||||
throw new Error("owner replaced");
|
throw new Error("owner replaced");
|
||||||
});
|
});
|
||||||
const captured = await captureViewModel(fixture.cdp, 4);
|
const captured = await captureViewModel(fixture.cdp, 4);
|
||||||
|
expect(captured.frameGeometryIssues).toEqual([
|
||||||
|
{
|
||||||
|
status: "unavailable",
|
||||||
|
source: { target: { tabId: 4 }, frameId: "frame-1" },
|
||||||
|
ownerBackendNodeId: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "blocked",
|
||||||
|
source: { target: { tabId: 4 }, frameId: "frame-7" },
|
||||||
|
cause: captured.frameGeometryIssues?.[0],
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(fixture.measured()).not.toContain(200);
|
expect(fixture.measured()).not.toContain(200);
|
||||||
expect(captured.iframeNodes.get(200)?.[0]).toMatchObject({
|
expect(captured.iframeNodes.get(200)?.[0]).toMatchObject({
|
||||||
tag: "body",
|
tag: "body",
|
||||||
|
|||||||
@@ -50,6 +50,68 @@ function childSnapshot(frameId: string, backendNodeId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("captureFrameData", () => {
|
describe("captureFrameData", () => {
|
||||||
|
it("retains nested owner failures when merging a captured OOPIF", async () => {
|
||||||
|
const owner = ownerNode(10, 50);
|
||||||
|
const captured: CapturedViewModel = {
|
||||||
|
nodes: [owner],
|
||||||
|
viewport: { width: 1000, height: 800 },
|
||||||
|
iframeNodes: new Map(),
|
||||||
|
frameNodes: new Map([["main", [owner]]]),
|
||||||
|
rootFrameId: "main",
|
||||||
|
excludedBackendNodeIds: new Set(),
|
||||||
|
};
|
||||||
|
const child = childSnapshot("child", 101);
|
||||||
|
const nested = childSnapshot("nested", 201);
|
||||||
|
const document = child.documents[0];
|
||||||
|
const snapshot = {
|
||||||
|
strings: child.strings,
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
...document,
|
||||||
|
nodes: { ...document.nodes, contentDocumentIndex: { index: [1], value: [1] } },
|
||||||
|
},
|
||||||
|
nested.documents[0],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const reply = async (_target: unknown, method: string) => {
|
||||||
|
if (method === "Page.getLayoutMetrics")
|
||||||
|
return { cssLayoutViewport: { clientWidth: 300, clientHeight: 200 } };
|
||||||
|
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||||
|
if (method === "DOM.getBoxModel")
|
||||||
|
return { model: { content: [50, 100, 350, 100, 350, 300, 50, 300] } };
|
||||||
|
if (method === "DOM.resolveNode") throw new Error("nested owner replaced");
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
const cdp: CdpRunner = {
|
||||||
|
send: vi.fn(reply) as CdpRunner["send"],
|
||||||
|
sendToTarget: vi.fn(reply) as NonNullable<CdpRunner["sendToTarget"]>,
|
||||||
|
getFrameGraph: vi.fn(async () => ({
|
||||||
|
rootFrameId: "main",
|
||||||
|
frames: [
|
||||||
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
|
{
|
||||||
|
frameId: "child",
|
||||||
|
parentFrameId: "main",
|
||||||
|
ownerBackendNodeId: 10,
|
||||||
|
target: { tabId: 4, sessionId: "child-session" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await captureFrameData(cdp, 4, captured);
|
||||||
|
expect(captured.frameGeometryIssues).toEqual([
|
||||||
|
{
|
||||||
|
status: "unavailable",
|
||||||
|
source: { target: { tabId: 4, sessionId: "child-session" }, frameId: "nested" },
|
||||||
|
ownerBackendNodeId: 101,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(captured.frameNodes?.get("nested")?.[1]).toMatchObject({
|
||||||
|
rect: null,
|
||||||
|
localRect: { x: 10, y: 20, w: 100, h: 40 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
|
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
|
||||||
const leftOwner = ownerNode(10, 50);
|
const leftOwner = ownerNode(10, 50);
|
||||||
const rightOwner = ownerNode(20, 500);
|
const rightOwner = ownerNode(20, 500);
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import type { CdpTarget } from "@/browser-driver/frame-graph";
|
|||||||
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
||||||
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||||
import {
|
import {
|
||||||
|
type FrameProjectionIssue,
|
||||||
|
type FrameProjectionState,
|
||||||
projectSnapshotRect,
|
projectSnapshotRect,
|
||||||
type SnapshotProjection,
|
|
||||||
snapshotViewportRect,
|
snapshotViewportRect,
|
||||||
} from "../geometry/coordinate-types";
|
} from "../geometry/coordinate-types";
|
||||||
import { cssViewport, GeometryContext, type LayoutMetrics } from "../geometry/frame-context";
|
import { cssViewport, GeometryContext, type LayoutMetrics } from "../geometry/frame-context";
|
||||||
@@ -77,6 +78,8 @@ export interface CapturedViewModel {
|
|||||||
/** Explicit DOMSnapshot frame ancestry; never inferred from backend node ids. */
|
/** Explicit DOMSnapshot frame ancestry; never inferred from backend node ids. */
|
||||||
frameParentIds?: Map<string, string>;
|
frameParentIds?: Map<string, string>;
|
||||||
rootFrameId?: string;
|
rootFrameId?: string;
|
||||||
|
/** Failed boundaries and descendants blocked by them; clipping is not a failure. */
|
||||||
|
frameGeometryIssues?: FrameProjectionIssue[];
|
||||||
/** Backend node ids belonging to the agent overlay host + its shadow subtree. */
|
/** Backend node ids belonging to the agent overlay host + its shadow subtree. */
|
||||||
excludedBackendNodeIds: Set<number>;
|
excludedBackendNodeIds: Set<number>;
|
||||||
}
|
}
|
||||||
@@ -351,7 +354,7 @@ interface ParseDocumentResult {
|
|||||||
interface FrameContext {
|
interface FrameContext {
|
||||||
frameId?: string;
|
frameId?: string;
|
||||||
ownerFrameBackendNodeId: number | null;
|
ownerFrameBackendNodeId: number | null;
|
||||||
projection: SnapshotProjection | null;
|
projection: FrameProjectionState;
|
||||||
target: CdpTarget;
|
target: CdpTarget;
|
||||||
scrollX: number;
|
scrollX: number;
|
||||||
scrollY: number;
|
scrollY: number;
|
||||||
@@ -896,7 +899,9 @@ function parseDocumentNodes(
|
|||||||
localRect = { x, y, w, h };
|
localRect = { x, y, w, h };
|
||||||
}
|
}
|
||||||
const bounds =
|
const bounds =
|
||||||
input && context.projection ? projectSnapshotRect(input, context.projection) : null;
|
input && context.projection.status === "available"
|
||||||
|
? projectSnapshotRect(input, context.projection.projection)
|
||||||
|
: null;
|
||||||
rect = bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
rect = bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
||||||
}
|
}
|
||||||
paintOrder = dl?.paintOrders?.[li] ?? 0;
|
paintOrder = dl?.paintOrders?.[li] ?? 0;
|
||||||
@@ -964,6 +969,7 @@ function parseDocumentNodes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ParseFrameDocumentsResult {
|
interface ParseFrameDocumentsResult {
|
||||||
|
frameGeometryIssues: FrameProjectionIssue[];
|
||||||
iframeNodes: CapturedIframeNodes;
|
iframeNodes: CapturedIframeNodes;
|
||||||
frameOwnerBackendNodeIds: Map<string, number>;
|
frameOwnerBackendNodeIds: Map<string, number>;
|
||||||
frameParentIds: Map<string, string>;
|
frameParentIds: Map<string, string>;
|
||||||
@@ -979,6 +985,7 @@ async function parseChildFrameDocuments(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
visited = new Set<number>(),
|
visited = new Set<number>(),
|
||||||
): Promise<ParseFrameDocumentsResult> {
|
): Promise<ParseFrameDocumentsResult> {
|
||||||
|
const frameGeometryIssues: FrameProjectionIssue[] = [];
|
||||||
const iframeNodes: CapturedIframeNodes = new Map();
|
const iframeNodes: CapturedIframeNodes = new Map();
|
||||||
const frameOwnerBackendNodeIds = new Map<string, number>();
|
const frameOwnerBackendNodeIds = new Map<string, number>();
|
||||||
const frameParentIds = new Map<string, string>();
|
const frameParentIds = new Map<string, string>();
|
||||||
@@ -986,7 +993,13 @@ async function parseChildFrameDocuments(
|
|||||||
const parentDoc = documents[parentDocIndex];
|
const parentDoc = documents[parentDocIndex];
|
||||||
const cdi = sparseIndexMap(parentDoc?.nodes?.contentDocumentIndex);
|
const cdi = sparseIndexMap(parentDoc?.nodes?.contentDocumentIndex);
|
||||||
if (cdi.size === 0) {
|
if (cdi.size === 0) {
|
||||||
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
|
return {
|
||||||
|
iframeNodes,
|
||||||
|
frameOwnerBackendNodeIds,
|
||||||
|
frameParentIds,
|
||||||
|
excludedBackendNodeIds,
|
||||||
|
frameGeometryIssues,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
|
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
|
||||||
@@ -995,17 +1008,26 @@ async function parseChildFrameDocuments(
|
|||||||
const childDoc = documents[childDocIndex];
|
const childDoc = documents[childDocIndex];
|
||||||
const iframeBackendId = parentBackendIds[nodeArrayIdx];
|
const iframeBackendId = parentBackendIds[nodeArrayIdx];
|
||||||
if (visited.has(childDocIndex) || !childDoc || iframeBackendId === undefined) return [];
|
if (visited.has(childDocIndex) || !childDoc || iframeBackendId === undefined) return [];
|
||||||
|
const source = { target: parentContext.target, frameId: snapshotFrameId(childDoc, strings) };
|
||||||
|
const parent = parentContext.projection;
|
||||||
|
const projection: FrameProjectionState =
|
||||||
|
parent.status === "available"
|
||||||
|
? { status: "unavailable", source, ownerBackendNodeId: iframeBackendId }
|
||||||
|
: { status: "blocked", source, cause: parent.status === "blocked" ? parent.cause : parent };
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
childDocIndex,
|
childDocIndex,
|
||||||
childDoc,
|
childDoc,
|
||||||
iframeBackendId,
|
iframeBackendId,
|
||||||
source: { target: parentContext.target, frameId: snapshotFrameId(childDoc, strings) },
|
source,
|
||||||
projection: null as SnapshotProjection | null,
|
projection: projection as FrameProjectionState,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
const parentProjection = parentContext.projection?.geometry;
|
const parentProjection =
|
||||||
|
parentContext.projection.status === "available"
|
||||||
|
? parentContext.projection.projection.geometry
|
||||||
|
: null;
|
||||||
if (parentProjection) {
|
if (parentProjection) {
|
||||||
// Measure only direct siblings. Recursion starts after these workers finish,
|
// Measure only direct siblings. Recursion starts after these workers finish,
|
||||||
// so descendants never hold or recursively acquire a measurement slot.
|
// so descendants never hold or recursively acquire a measurement slot.
|
||||||
@@ -1014,10 +1036,10 @@ async function parseChildFrameDocuments(
|
|||||||
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
|
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
|
||||||
: parentProjection.sourceClips;
|
: parentProjection.sourceClips;
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let aborted: unknown;
|
let failure: unknown;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: Math.min(4, children.length) }, async () => {
|
Array.from({ length: Math.min(4, children.length) }, async () => {
|
||||||
while (cursor < children.length && !signal?.aborted && !aborted) {
|
while (cursor < children.length && !signal?.aborted && !failure) {
|
||||||
const child = children[cursor++];
|
const child = children[cursor++];
|
||||||
try {
|
try {
|
||||||
// Owner quads are target-relative; do not apply the parent transform twice.
|
// Owner quads are target-relative; do not apply the parent transform twice.
|
||||||
@@ -1028,20 +1050,20 @@ async function parseChildFrameDocuments(
|
|||||||
parentProjection.topViewport,
|
parentProjection.topViewport,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) aborted = error;
|
failure ??= error;
|
||||||
else console.debug("[bsk capture] frame owner geometry unavailable", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Join all active reads, including object cleanup, before propagating abort.
|
// Join all active reads, including object cleanup, before propagating abort.
|
||||||
if (aborted) throw aborted;
|
if (failure) throw failure;
|
||||||
}
|
}
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
|
||||||
// Completion order must not change document traversal or result insertion order.
|
// Completion order must not change document traversal or result insertion order.
|
||||||
for (const { childDocIndex, childDoc, iframeBackendId, source, projection } of children) {
|
for (const { childDocIndex, childDoc, iframeBackendId, source, projection } of children) {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
if (projection.status !== "available") frameGeometryIssues.push(projection);
|
||||||
const childContext: FrameContext = {
|
const childContext: FrameContext = {
|
||||||
frameId: source.frameId,
|
frameId: source.frameId,
|
||||||
target: source.target,
|
target: source.target,
|
||||||
@@ -1069,6 +1091,7 @@ async function parseChildFrameDocuments(
|
|||||||
signal,
|
signal,
|
||||||
nextVisited,
|
nextVisited,
|
||||||
);
|
);
|
||||||
|
frameGeometryIssues.push(...nested.frameGeometryIssues);
|
||||||
for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) {
|
for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) {
|
||||||
iframeNodes.set(nestedFrameId, nestedNodes);
|
iframeNodes.set(nestedFrameId, nestedNodes);
|
||||||
}
|
}
|
||||||
@@ -1081,7 +1104,13 @@ async function parseChildFrameDocuments(
|
|||||||
for (const id of nested.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
|
for (const id of nested.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
|
return {
|
||||||
|
iframeNodes,
|
||||||
|
frameOwnerBackendNodeIds,
|
||||||
|
frameParentIds,
|
||||||
|
excludedBackendNodeIds,
|
||||||
|
frameGeometryIssues,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function captureViewModel(
|
export async function captureViewModel(
|
||||||
@@ -1131,8 +1160,11 @@ export async function captureViewModel(
|
|||||||
ownerFrameBackendNodeId: null,
|
ownerFrameBackendNodeId: null,
|
||||||
target,
|
target,
|
||||||
projection: {
|
projection: {
|
||||||
source: { target, frameId: snapshotFrameId(doc0, strings) },
|
status: "available",
|
||||||
geometry: { sourceClips: [], edges: [], topViewport: viewport },
|
projection: {
|
||||||
|
source: { target, frameId: snapshotFrameId(doc0, strings) },
|
||||||
|
geometry: { sourceClips: [], edges: [], topViewport: viewport },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
scrollX,
|
scrollX,
|
||||||
scrollY,
|
scrollY,
|
||||||
@@ -1174,6 +1206,7 @@ export async function captureViewModel(
|
|||||||
viewport,
|
viewport,
|
||||||
iframeNodes,
|
iframeNodes,
|
||||||
frameNodes,
|
frameNodes,
|
||||||
|
frameGeometryIssues: frameParsed.frameGeometryIssues,
|
||||||
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
|
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
|
||||||
frameParentIds: frameParsed.frameParentIds,
|
frameParentIds: frameParsed.frameParentIds,
|
||||||
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
|
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ async function captureMissingFrameDocuments(
|
|||||||
geometry,
|
geometry,
|
||||||
target: frame.target,
|
target: frame.target,
|
||||||
});
|
});
|
||||||
|
if (child.frameGeometryIssues?.length) {
|
||||||
|
(captured.frameGeometryIssues ??= []).push(...child.frameGeometryIssues);
|
||||||
|
}
|
||||||
let projection: GeometryProjection | null = null;
|
let projection: GeometryProjection | null = null;
|
||||||
try {
|
try {
|
||||||
projection = await geometry.targetProjection(frame.frameId);
|
projection = await geometry.targetProjection(frame.frameId);
|
||||||
|
|||||||
Reference in New Issue
Block a user