mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
perf(geometry): batch iframe owner size measurements
This commit is contained in:
@@ -79,6 +79,96 @@ describe("measurement geometry context", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("falls back only for missing or invalid batch entries and does not reuse sizes across contexts", async () => {
|
||||
const cdp = driver();
|
||||
const original = cdp.send;
|
||||
const calls: string[] = [];
|
||||
cdp.send = async (tabId, method, params) => {
|
||||
calls.push(method);
|
||||
if (method === "Runtime.evaluate")
|
||||
return {
|
||||
result: {
|
||||
deepSerializedValue: {
|
||||
type: "array",
|
||||
value: [
|
||||
{
|
||||
type: "array",
|
||||
value: [
|
||||
{ type: "node", value: { backendNodeId: 10 } },
|
||||
{ type: "string", value: '{"width":300,"height":200}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "array",
|
||||
value: [
|
||||
{ type: "node", value: { backendNodeId: 11 } },
|
||||
{ type: "string", value: '{"width":0,"height":200}' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
if (method === "Runtime.releaseObjectGroup") return {} as never;
|
||||
return original(tabId, method, params);
|
||||
};
|
||||
const context = new GeometryContext(cdp, 4);
|
||||
context.registerSnapshotOwners(target, new Set([10, 11]));
|
||||
const values = await Promise.all(
|
||||
[10, 11].map((id) =>
|
||||
context.snapshotProjection({ target, frameId: String(id) }, id, [], {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(values.every((value) => value.status === "available")).toBe(true);
|
||||
expect(calls.filter((method) => method === "DOM.resolveNode")).toHaveLength(1);
|
||||
expect(calls.filter((method) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||
await new GeometryContext(cdp, 4).snapshotProjection({ target, frameId: "child" }, 10, [], {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
});
|
||||
expect(calls.filter((method) => method === "DOM.resolveNode")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("waits for batch object cleanup and propagates cancellation", async () => {
|
||||
const cdp = driver();
|
||||
const original = cdp.send;
|
||||
const controller = new AbortController();
|
||||
let release!: () => void;
|
||||
let cleanup!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
cleanup = resolve;
|
||||
});
|
||||
cdp.send = async (tabId, method, params) => {
|
||||
if (method === "Runtime.evaluate") return {} as never;
|
||||
if (method === "Runtime.releaseObjectGroup") {
|
||||
cleanup();
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return {} as never;
|
||||
}
|
||||
return original(tabId, method, params);
|
||||
};
|
||||
const context = new GeometryContext(cdp, 4, undefined, controller.signal);
|
||||
context.registerSnapshotOwners(target, new Set([10, 11]));
|
||||
let settled = false;
|
||||
const operation = context
|
||||
.snapshotProjection({ target, frameId: "child" }, 10, [], { width: 1000, height: 1000 })
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
const rejected = expect(operation).rejects.toMatchObject({ name: "AbortError" });
|
||||
await started;
|
||||
controller.abort();
|
||||
expect(settled).toBe(false);
|
||||
release();
|
||||
await rejected;
|
||||
expect(cdp.calls.mock.calls.some(([, method]) => method === "DOM.resolveNode")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects owner mismatches and invalid numeric bounds", () => {
|
||||
const input = snapshotViewportRect(
|
||||
[30, 50, 120, 40],
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
||||
import type { CoordinateOwner, CssViewport, SnapshotProjectionResult } from "./coordinate-types";
|
||||
|
||||
import { readSnapshotOwnerSizes } from "./snapshot-owner-sizes";
|
||||
|
||||
export interface LayoutMetrics {
|
||||
cssVisualViewport?: { zoom?: number };
|
||||
visualViewport?: { zoom?: number };
|
||||
@@ -45,6 +47,7 @@ export class GeometryContext {
|
||||
private readonly owners = new Map<string, Promise<Quad | null>>();
|
||||
private readonly projections = new Map<string, Promise<GeometryProjection | null>>();
|
||||
private readonly snapshotOwners = new Map<string, Promise<{ quad: Quad; size: Size } | null>>();
|
||||
private readonly sizeBatches = new Map<string, () => Promise<Map<number, Size>>>();
|
||||
private active = 0;
|
||||
private readonly waiting: Array<() => void> = [];
|
||||
private graphPromise?: Promise<CdpFrameGraph | null>;
|
||||
@@ -186,12 +189,34 @@ export class GeometryContext {
|
||||
: { status: "unavailable", source, ownerBackendNodeId };
|
||||
}
|
||||
|
||||
/** Register one target's snapshot owners; the batch starts only when a
|
||||
* projection actually needs a size. Single owners keep the cheaper old path. */
|
||||
registerSnapshotOwners(target: CdpTarget, owners: ReadonlySet<number>): void {
|
||||
const key = cdpTargetKey(target);
|
||||
if (owners.size < 2 || this.sizeBatches.has(key)) return;
|
||||
let pending: Promise<Map<number, Size>> | undefined;
|
||||
this.sizeBatches.set(key, () => {
|
||||
pending ??= readSnapshotOwnerSizes(
|
||||
(method, params, cleanup) => this.request(target, method, params, cleanup),
|
||||
owners,
|
||||
).catch((error) => {
|
||||
if (error && typeof error === "object" && "name" in error && error.name === "AbortError")
|
||||
throw error;
|
||||
return new Map<number, Size>();
|
||||
});
|
||||
return pending;
|
||||
});
|
||||
}
|
||||
|
||||
private async snapshotOwner(
|
||||
target: CdpTarget,
|
||||
backendNodeId: number,
|
||||
): Promise<{ quad: Quad; size: Size } | null> {
|
||||
const quad = await this.ownerContent(target, backendNodeId);
|
||||
if (!quad) return null;
|
||||
const batchSize = (await this.sizeBatches.get(cdpTargetKey(target))?.())?.get(backendNodeId);
|
||||
if (this.signal?.aborted) throw new DOMException("geometry aborted", "AbortError");
|
||||
if (batchSize) return { quad, size: batchSize };
|
||||
const resolved = await this.request<{ object?: { objectId?: string } }>(
|
||||
target,
|
||||
"DOM.resolveNode",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Size } from "../geometry";
|
||||
|
||||
interface SerializedValue {
|
||||
type: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
type Send = <T>(method: string, params: object, cleanup?: boolean) => Promise<T>;
|
||||
|
||||
/** Batch only accessible frame owners. Missing/shadow/cross-origin owners keep
|
||||
* the existing node-scoped read; no page-wide style columns are requested. */
|
||||
export async function readSnapshotOwnerSizes(
|
||||
send: Send,
|
||||
owners: ReadonlySet<number>,
|
||||
): Promise<Map<number, Size>> {
|
||||
const sizes = new Map<number, Size>();
|
||||
const objectGroup = `bsk-frame-sizes-${crypto.randomUUID()}`;
|
||||
try {
|
||||
const reply = await send<{ result?: { deepSerializedValue?: SerializedValue } }>(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: `(() => {
|
||||
const result = [];
|
||||
const documents = [document];
|
||||
for (let i = 0; i < documents.length && result.length < ${owners.size}; i++) {
|
||||
for (const owner of documents[i].querySelectorAll('iframe,frame')) {
|
||||
if (result.length >= ${owners.size}) break;
|
||||
if (!owner.isConnected) continue;
|
||||
const style = getComputedStyle(owner);
|
||||
result.push([owner, JSON.stringify({
|
||||
width: owner.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight),
|
||||
height: owner.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||
})]);
|
||||
try { if (owner.contentDocument) documents.push(owner.contentDocument); } catch {}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})()`,
|
||||
objectGroup,
|
||||
serializationOptions: {
|
||||
serialization: "deep",
|
||||
maxDepth: 3,
|
||||
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = reply.result?.deepSerializedValue;
|
||||
if (result?.type !== "array" || !Array.isArray(result.value)) return sizes;
|
||||
for (const entry of result.value as SerializedValue[]) {
|
||||
if (entry.type !== "array" || !Array.isArray(entry.value)) continue;
|
||||
const [node, data] = entry.value as SerializedValue[];
|
||||
if (node?.type !== "node" || data?.type !== "string" || typeof data.value !== "string")
|
||||
continue;
|
||||
const id = (node.value as { backendNodeId?: number } | undefined)?.backendNodeId;
|
||||
if (id === undefined || !owners.has(id)) continue;
|
||||
try {
|
||||
const size = JSON.parse(data.value) as Size;
|
||||
if (
|
||||
size &&
|
||||
Number.isFinite(size.width) &&
|
||||
Number.isFinite(size.height) &&
|
||||
size.width > 0 &&
|
||||
size.height > 0
|
||||
)
|
||||
sizes.set(id, size);
|
||||
} catch {
|
||||
/* An invalid entry uses the existing individual read. */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await send("Runtime.releaseObjectGroup", { objectGroup }, true).catch(() => {});
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
@@ -1324,6 +1324,40 @@ function siblingCaptureFixture(
|
||||
}
|
||||
|
||||
describe("sibling frame measurement scheduling", () => {
|
||||
it("batches same-target owner sizes without changing projected results", async () => {
|
||||
const fixture = siblingCaptureFixture();
|
||||
const original = fixture.cdp.send;
|
||||
const methods: string[] = [];
|
||||
fixture.cdp.send = async (tabId, method, params) => {
|
||||
methods.push(method);
|
||||
if (method === "Runtime.evaluate")
|
||||
return {
|
||||
result: {
|
||||
deepSerializedValue: {
|
||||
type: "array",
|
||||
value: [100, 101, 102, 103, 104, 105, 200].map((backendNodeId) => ({
|
||||
type: "array",
|
||||
value: [
|
||||
{ type: "node", value: { backendNodeId } },
|
||||
{ type: "string", value: JSON.stringify({ width: 200, height: 100 }) },
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return original(tabId, method, params);
|
||||
};
|
||||
const captured = await captureViewModel(fixture.cdp, 4);
|
||||
expect(captured.frameGeometryIssues).toEqual([]);
|
||||
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 });
|
||||
expect(methods.filter((method) => method === "DOM.getBoxModel")).toHaveLength(7);
|
||||
expect(methods.filter((method) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||
expect(methods.filter((method) => method === "Runtime.releaseObjectGroup")).toHaveLength(1);
|
||||
expect(methods).not.toContain("DOM.resolveNode");
|
||||
expect(methods).not.toContain("Runtime.callFunctionOn");
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -1173,6 +1173,14 @@ export async function captureViewModel(
|
||||
const nodes = mainParsed.nodes;
|
||||
const excludedBackendNodeIds = new Set(mainParsed.excludedBackendNodeIds);
|
||||
|
||||
const ownerIds = new Set<number>();
|
||||
for (const document of documents) {
|
||||
for (const index of document.nodes?.contentDocumentIndex?.index ?? []) {
|
||||
const id = document.nodes?.backendNodeId?.[index];
|
||||
if (id !== undefined) ownerIds.add(id);
|
||||
}
|
||||
}
|
||||
geometry.registerSnapshotOwners(target, ownerIds);
|
||||
const frameParsed = await parseChildFrameDocuments(
|
||||
documents,
|
||||
strings,
|
||||
|
||||
Reference in New Issue
Block a user