mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
perf(observation): measure sibling iframe geometry with bounded concurrency
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge";
|
||||
import type { CdpRunner } from "../../shared";
|
||||
import { captureViewModel, collectOverlayExcludedBackendIds, probeHoverSurfaces } from "../capture";
|
||||
|
||||
// Minimal but format-accurate captureSnapshot reply: a body with one
|
||||
@@ -1262,3 +1263,147 @@ describe("captureViewModel", () => {
|
||||
expect(excluded).toEqual(new Set([200, 201, 202]));
|
||||
});
|
||||
});
|
||||
|
||||
// Six sibling frames and one nested frame exercise scheduling through the real capture entry.
|
||||
function siblingCaptureFixture(
|
||||
beforeReply: (method: string, params: Record<string, unknown>) => Promise<void> = async () => {},
|
||||
) {
|
||||
const document = (id: number, owners: number[], childIndexes: number[]) => ({
|
||||
frameId: `frame-${id}`,
|
||||
nodes: {
|
||||
parentIndex: [-1, ...owners.map(() => 0)],
|
||||
nodeName: [0, ...owners.map(() => 1)],
|
||||
backendNodeId: [1000 + id, ...owners],
|
||||
attributes: [[], ...owners.map(() => [])],
|
||||
contentDocumentIndex: { index: owners.map((_, i) => i + 1), value: childIndexes },
|
||||
},
|
||||
layout: {
|
||||
nodeIndex: [0, ...owners.map((_, i) => i + 1)],
|
||||
bounds: [[0, 0, 200, 100], ...owners.map(() => [0, 0, 200, 100])],
|
||||
},
|
||||
});
|
||||
const snapshot = {
|
||||
strings: ["body", "iframe"],
|
||||
documents: [
|
||||
document(0, [100, 101, 102, 103, 104, 105], [1, 2, 3, 4, 5, 6]),
|
||||
document(1, [200], [7]),
|
||||
...Array.from({ length: 6 }, (_, i) => document(i + 2, [], [])),
|
||||
],
|
||||
};
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const send = vi.fn(async (_tabId: number, method: string, params: object = {}) => {
|
||||
const args = params as Record<string, unknown>;
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
try {
|
||||
await beforeReply(method, args);
|
||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
||||
if (method === "DOM.getBoxModel")
|
||||
return { model: { content: [0, 0, 200, 0, 200, 100, 0, 100] } };
|
||||
if (method === "DOM.resolveNode") return { object: { objectId: String(args.backendNodeId) } };
|
||||
if (method === "Runtime.callFunctionOn")
|
||||
return { result: { value: { width: 200, height: 100 } } };
|
||||
return {};
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
});
|
||||
return {
|
||||
cdp: { send: send as CdpRunner["send"] },
|
||||
send,
|
||||
peak: () => peak,
|
||||
active: () => active,
|
||||
measured: () =>
|
||||
send.mock.calls
|
||||
.filter(([, method]) => method === "DOM.getBoxModel")
|
||||
.map(([, , params]) => (params as { backendNodeId: number }).backendNodeId),
|
||||
};
|
||||
}
|
||||
|
||||
describe("sibling frame measurement scheduling", () => {
|
||||
it("bounds concurrent reads, fills free slots and preserves depth-first output despite reordered replies", async () => {
|
||||
const pending = new Map<number, () => void>();
|
||||
const fixture = siblingCaptureFixture(async (method, params) => {
|
||||
if (method === "DOM.getBoxModel" && Number(params.backendNodeId) < 200)
|
||||
await new Promise<void>((resolve) => pending.set(Number(params.backendNodeId), resolve));
|
||||
});
|
||||
const capture = captureViewModel(fixture.cdp, 4);
|
||||
await vi.waitFor(() => expect(pending.size).toBe(4));
|
||||
expect(fixture.measured()).toEqual([100, 101, 102, 103]);
|
||||
pending.get(103)!();
|
||||
await vi.waitFor(() => expect(pending.has(104)).toBe(true));
|
||||
pending.get(104)!();
|
||||
await vi.waitFor(() => expect(pending.has(105)).toBe(true));
|
||||
expect(fixture.measured()).not.toContain(200);
|
||||
for (const id of [105, 102, 101, 100]) pending.get(id)!();
|
||||
const captured = await capture;
|
||||
expect(fixture.peak()).toBe(4);
|
||||
expect(fixture.active()).toBe(0);
|
||||
expect(fixture.measured()).toEqual([100, 101, 102, 103, 104, 105, 200]);
|
||||
expect([...captured.iframeNodes.keys()]).toEqual([100, 200, 101, 102, 103, 104, 105]);
|
||||
expect(captured.iframeNodes.get(200)?.[0].rect).toEqual({ x: 0, y: 0, w: 200, h: 100 });
|
||||
for (const method of [
|
||||
"DOM.getBoxModel",
|
||||
"DOM.resolveNode",
|
||||
"Runtime.callFunctionOn",
|
||||
"Runtime.releaseObject",
|
||||
])
|
||||
expect(fixture.send.mock.calls.filter(([, name]) => name === method)).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("does not measure descendants of a failed owner and retains sibling geometry and local nodes", async () => {
|
||||
const fixture = siblingCaptureFixture(async (method, params) => {
|
||||
if (method === "DOM.resolveNode" && params.backendNodeId === 100)
|
||||
throw new Error("owner replaced");
|
||||
});
|
||||
const captured = await captureViewModel(fixture.cdp, 4);
|
||||
expect(fixture.measured()).not.toContain(200);
|
||||
expect(captured.iframeNodes.get(200)?.[0]).toMatchObject({
|
||||
tag: "body",
|
||||
rect: null,
|
||||
localRect: { x: 0, y: 0, w: 200, h: 100 },
|
||||
});
|
||||
expect(captured.iframeNodes.get(101)?.[0].rect).toEqual({ x: 0, y: 0, w: 200, h: 100 });
|
||||
expect([...captured.iframeNodes.keys()]).toEqual([100, 200, 101, 102, 103, 104, 105]);
|
||||
});
|
||||
|
||||
it("stops scheduling on cancellation and waits for resolved objects to be released", async () => {
|
||||
const controller = new AbortController();
|
||||
const resolutions = new Map<string, () => void>();
|
||||
const releases = new Map<string, () => void>();
|
||||
const fixture = siblingCaptureFixture(async (method, params) => {
|
||||
if (method === "DOM.resolveNode")
|
||||
await new Promise<void>((resolve) =>
|
||||
resolutions.set(String(params.backendNodeId), resolve),
|
||||
);
|
||||
if (method === "Runtime.releaseObject")
|
||||
await new Promise<void>((resolve) => releases.set(String(params.objectId), resolve));
|
||||
});
|
||||
let settled = false;
|
||||
const capture = captureViewModel(fixture.cdp, 4, { signal: controller.signal });
|
||||
const rejected = expect(capture).rejects.toMatchObject({ name: "AbortError" });
|
||||
void capture.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => expect(resolutions.size).toBe(4));
|
||||
controller.abort();
|
||||
for (const resolve of resolutions.values()) resolve();
|
||||
await vi.waitFor(() => expect(releases.size).toBe(4));
|
||||
expect(settled).toBe(false);
|
||||
expect(fixture.measured()).toEqual([100, 101, 102, 103]);
|
||||
for (const release of releases.values()) release();
|
||||
await rejected;
|
||||
expect(fixture.active()).toBe(0);
|
||||
expect(fixture.send.mock.calls.some(([, method]) => method === "Runtime.callFunctionOn")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -976,6 +976,7 @@ async function parseChildFrameDocuments(
|
||||
parentDocIndex: number,
|
||||
parentContext: FrameContext,
|
||||
geometry: GeometryContext,
|
||||
signal?: AbortSignal,
|
||||
visited = new Set<number>(),
|
||||
): Promise<ParseFrameDocumentsResult> {
|
||||
const iframeNodes: CapturedIframeNodes = new Map();
|
||||
@@ -990,34 +991,57 @@ async function parseChildFrameDocuments(
|
||||
|
||||
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
|
||||
|
||||
for (const [nodeArrayIdx, childDocIndex] of cdi) {
|
||||
if (visited.has(childDocIndex)) continue;
|
||||
const children = [...cdi].flatMap(([nodeArrayIdx, childDocIndex]) => {
|
||||
const childDoc = documents[childDocIndex];
|
||||
if (!childDoc) continue;
|
||||
const iframeBackendId = parentBackendIds[nodeArrayIdx];
|
||||
if (iframeBackendId === undefined) continue;
|
||||
let projection: SnapshotProjection | null = null;
|
||||
const source = { target: parentContext.target, frameId: snapshotFrameId(childDoc, strings) };
|
||||
const parentProjection = parentContext.projection?.geometry;
|
||||
if (parentProjection) {
|
||||
// Each owner quad is target-relative; do not apply the parent's transform twice.
|
||||
const edge = parentProjection.edges[0];
|
||||
const clips = edge
|
||||
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
|
||||
: parentProjection.sourceClips;
|
||||
try {
|
||||
projection = await geometry.snapshotProjection(
|
||||
source,
|
||||
iframeBackendId,
|
||||
clips,
|
||||
parentProjection.topViewport,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
console.debug("[bsk capture] frame owner geometry unavailable", error);
|
||||
}
|
||||
}
|
||||
if (visited.has(childDocIndex) || !childDoc || iframeBackendId === undefined) return [];
|
||||
return [
|
||||
{
|
||||
childDocIndex,
|
||||
childDoc,
|
||||
iframeBackendId,
|
||||
source: { target: parentContext.target, frameId: snapshotFrameId(childDoc, strings) },
|
||||
projection: null as SnapshotProjection | null,
|
||||
},
|
||||
];
|
||||
});
|
||||
const parentProjection = parentContext.projection?.geometry;
|
||||
if (parentProjection) {
|
||||
// Measure only direct siblings. Recursion starts after these workers finish,
|
||||
// so descendants never hold or recursively acquire a measurement slot.
|
||||
const edge = parentProjection.edges[0];
|
||||
const clips = edge
|
||||
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
|
||||
: parentProjection.sourceClips;
|
||||
let cursor = 0;
|
||||
let aborted: unknown;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(4, children.length) }, async () => {
|
||||
while (cursor < children.length && !signal?.aborted && !aborted) {
|
||||
const child = children[cursor++];
|
||||
try {
|
||||
// Owner quads are target-relative; do not apply the parent transform twice.
|
||||
child.projection = await geometry.snapshotProjection(
|
||||
child.source,
|
||||
child.iframeBackendId,
|
||||
clips,
|
||||
parentProjection.topViewport,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) aborted = error;
|
||||
else console.debug("[bsk capture] frame owner geometry unavailable", error);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Join all active reads, including object cleanup, before propagating abort.
|
||||
if (aborted) throw aborted;
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
|
||||
// Completion order must not change document traversal or result insertion order.
|
||||
for (const { childDocIndex, childDoc, iframeBackendId, source, projection } of children) {
|
||||
throwIfAborted(signal);
|
||||
const childContext: FrameContext = {
|
||||
frameId: source.frameId,
|
||||
target: source.target,
|
||||
@@ -1042,6 +1066,7 @@ async function parseChildFrameDocuments(
|
||||
childDocIndex,
|
||||
childContext,
|
||||
geometry,
|
||||
signal,
|
||||
nextVisited,
|
||||
);
|
||||
for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) {
|
||||
@@ -1116,7 +1141,14 @@ export async function captureViewModel(
|
||||
const nodes = mainParsed.nodes;
|
||||
const excludedBackendNodeIds = new Set(mainParsed.excludedBackendNodeIds);
|
||||
|
||||
const frameParsed = await parseChildFrameDocuments(documents, strings, 0, topContext, geometry);
|
||||
const frameParsed = await parseChildFrameDocuments(
|
||||
documents,
|
||||
strings,
|
||||
0,
|
||||
topContext,
|
||||
geometry,
|
||||
options.signal,
|
||||
);
|
||||
const iframeNodes = frameParsed.iframeNodes;
|
||||
for (const id of frameParsed.excludedBackendNodeIds) {
|
||||
excludedBackendNodeIds.add(id);
|
||||
|
||||
Reference in New Issue
Block a user