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:
@@ -1340,6 +1340,7 @@ describe("sibling frame measurement scheduling", () => {
|
||||
expect(fixture.measured()).not.toContain(200);
|
||||
for (const id of [105, 102, 101, 100]) pending.get(id)!();
|
||||
const captured = await capture;
|
||||
expect(captured.frameGeometryIssues).toEqual([]);
|
||||
expect(fixture.peak()).toBe(4);
|
||||
expect(fixture.active()).toBe(0);
|
||||
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");
|
||||
});
|
||||
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(captured.iframeNodes.get(200)?.[0]).toMatchObject({
|
||||
tag: "body",
|
||||
|
||||
@@ -50,6 +50,68 @@ function childSnapshot(frameId: string, backendNodeId: number) {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const leftOwner = ownerNode(10, 50);
|
||||
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 { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||
import {
|
||||
type FrameProjectionIssue,
|
||||
type FrameProjectionState,
|
||||
projectSnapshotRect,
|
||||
type SnapshotProjection,
|
||||
snapshotViewportRect,
|
||||
} from "../geometry/coordinate-types";
|
||||
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. */
|
||||
frameParentIds?: Map<string, 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. */
|
||||
excludedBackendNodeIds: Set<number>;
|
||||
}
|
||||
@@ -351,7 +354,7 @@ interface ParseDocumentResult {
|
||||
interface FrameContext {
|
||||
frameId?: string;
|
||||
ownerFrameBackendNodeId: number | null;
|
||||
projection: SnapshotProjection | null;
|
||||
projection: FrameProjectionState;
|
||||
target: CdpTarget;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
@@ -896,7 +899,9 @@ function parseDocumentNodes(
|
||||
localRect = { x, y, w, h };
|
||||
}
|
||||
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;
|
||||
}
|
||||
paintOrder = dl?.paintOrders?.[li] ?? 0;
|
||||
@@ -964,6 +969,7 @@ function parseDocumentNodes(
|
||||
}
|
||||
|
||||
interface ParseFrameDocumentsResult {
|
||||
frameGeometryIssues: FrameProjectionIssue[];
|
||||
iframeNodes: CapturedIframeNodes;
|
||||
frameOwnerBackendNodeIds: Map<string, number>;
|
||||
frameParentIds: Map<string, string>;
|
||||
@@ -979,6 +985,7 @@ async function parseChildFrameDocuments(
|
||||
signal?: AbortSignal,
|
||||
visited = new Set<number>(),
|
||||
): Promise<ParseFrameDocumentsResult> {
|
||||
const frameGeometryIssues: FrameProjectionIssue[] = [];
|
||||
const iframeNodes: CapturedIframeNodes = new Map();
|
||||
const frameOwnerBackendNodeIds = new Map<string, number>();
|
||||
const frameParentIds = new Map<string, string>();
|
||||
@@ -986,7 +993,13 @@ async function parseChildFrameDocuments(
|
||||
const parentDoc = documents[parentDocIndex];
|
||||
const cdi = sparseIndexMap(parentDoc?.nodes?.contentDocumentIndex);
|
||||
if (cdi.size === 0) {
|
||||
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
|
||||
return {
|
||||
iframeNodes,
|
||||
frameOwnerBackendNodeIds,
|
||||
frameParentIds,
|
||||
excludedBackendNodeIds,
|
||||
frameGeometryIssues,
|
||||
};
|
||||
}
|
||||
|
||||
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
|
||||
@@ -995,17 +1008,26 @@ async function parseChildFrameDocuments(
|
||||
const childDoc = documents[childDocIndex];
|
||||
const iframeBackendId = parentBackendIds[nodeArrayIdx];
|
||||
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 [
|
||||
{
|
||||
childDocIndex,
|
||||
childDoc,
|
||||
iframeBackendId,
|
||||
source: { target: parentContext.target, frameId: snapshotFrameId(childDoc, strings) },
|
||||
projection: null as SnapshotProjection | null,
|
||||
source,
|
||||
projection: projection as FrameProjectionState,
|
||||
},
|
||||
];
|
||||
});
|
||||
const parentProjection = parentContext.projection?.geometry;
|
||||
const parentProjection =
|
||||
parentContext.projection.status === "available"
|
||||
? parentContext.projection.projection.geometry
|
||||
: null;
|
||||
if (parentProjection) {
|
||||
// Measure only direct siblings. Recursion starts after these workers finish,
|
||||
// so descendants never hold or recursively acquire a measurement slot.
|
||||
@@ -1014,10 +1036,10 @@ async function parseChildFrameDocuments(
|
||||
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
|
||||
: parentProjection.sourceClips;
|
||||
let cursor = 0;
|
||||
let aborted: unknown;
|
||||
let failure: unknown;
|
||||
await Promise.all(
|
||||
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++];
|
||||
try {
|
||||
// Owner quads are target-relative; do not apply the parent transform twice.
|
||||
@@ -1028,20 +1050,20 @@ async function parseChildFrameDocuments(
|
||||
parentProjection.topViewport,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) aborted = error;
|
||||
else console.debug("[bsk capture] frame owner geometry unavailable", error);
|
||||
failure ??= error;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Join all active reads, including object cleanup, before propagating abort.
|
||||
if (aborted) throw aborted;
|
||||
if (failure) throw failure;
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
// Completion order must not change document traversal or result insertion order.
|
||||
for (const { childDocIndex, childDoc, iframeBackendId, source, projection } of children) {
|
||||
throwIfAborted(signal);
|
||||
if (projection.status !== "available") frameGeometryIssues.push(projection);
|
||||
const childContext: FrameContext = {
|
||||
frameId: source.frameId,
|
||||
target: source.target,
|
||||
@@ -1069,6 +1091,7 @@ async function parseChildFrameDocuments(
|
||||
signal,
|
||||
nextVisited,
|
||||
);
|
||||
frameGeometryIssues.push(...nested.frameGeometryIssues);
|
||||
for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) {
|
||||
iframeNodes.set(nestedFrameId, nestedNodes);
|
||||
}
|
||||
@@ -1081,7 +1104,13 @@ async function parseChildFrameDocuments(
|
||||
for (const id of nested.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
|
||||
}
|
||||
|
||||
return { iframeNodes, frameOwnerBackendNodeIds, frameParentIds, excludedBackendNodeIds };
|
||||
return {
|
||||
iframeNodes,
|
||||
frameOwnerBackendNodeIds,
|
||||
frameParentIds,
|
||||
excludedBackendNodeIds,
|
||||
frameGeometryIssues,
|
||||
};
|
||||
}
|
||||
|
||||
export async function captureViewModel(
|
||||
@@ -1131,8 +1160,11 @@ export async function captureViewModel(
|
||||
ownerFrameBackendNodeId: null,
|
||||
target,
|
||||
projection: {
|
||||
source: { target, frameId: snapshotFrameId(doc0, strings) },
|
||||
geometry: { sourceClips: [], edges: [], topViewport: viewport },
|
||||
status: "available",
|
||||
projection: {
|
||||
source: { target, frameId: snapshotFrameId(doc0, strings) },
|
||||
geometry: { sourceClips: [], edges: [], topViewport: viewport },
|
||||
},
|
||||
},
|
||||
scrollX,
|
||||
scrollY,
|
||||
@@ -1174,6 +1206,7 @@ export async function captureViewModel(
|
||||
viewport,
|
||||
iframeNodes,
|
||||
frameNodes,
|
||||
frameGeometryIssues: frameParsed.frameGeometryIssues,
|
||||
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
|
||||
frameParentIds: frameParsed.frameParentIds,
|
||||
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
|
||||
|
||||
@@ -56,6 +56,9 @@ async function captureMissingFrameDocuments(
|
||||
geometry,
|
||||
target: frame.target,
|
||||
});
|
||||
if (child.frameGeometryIssues?.length) {
|
||||
(captured.frameGeometryIssues ??= []).push(...child.frameGeometryIssues);
|
||||
}
|
||||
let projection: GeometryProjection | null = null;
|
||||
try {
|
||||
projection = await geometry.targetProjection(frame.frameId);
|
||||
|
||||
Reference in New Issue
Block a user