diff --git a/apps/extension/src/tools/__tests__/geometry-context.test.ts b/apps/extension/src/tools/__tests__/geometry-context.test.ts index addd227..3b295f0 100644 --- a/apps/extension/src/tools/__tests__/geometry-context.test.ts +++ b/apps/extension/src/tools/__tests__/geometry-context.test.ts @@ -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((resolve) => { + cleanup = resolve; + }); + cdp.send = async (tabId, method, params) => { + if (method === "Runtime.evaluate") return {} as never; + if (method === "Runtime.releaseObjectGroup") { + cleanup(); + await new Promise((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], diff --git a/apps/extension/src/tools/geometry/frame-context.ts b/apps/extension/src/tools/geometry/frame-context.ts index be9d54b..6669f83 100644 --- a/apps/extension/src/tools/geometry/frame-context.ts +++ b/apps/extension/src/tools/geometry/frame-context.ts @@ -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>(); private readonly projections = new Map>(); private readonly snapshotOwners = new Map>(); + private readonly sizeBatches = new Map Promise>>(); private active = 0; private readonly waiting: Array<() => void> = []; private graphPromise?: Promise; @@ -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): void { + const key = cdpTargetKey(target); + if (owners.size < 2 || this.sizeBatches.has(key)) return; + let pending: Promise> | 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(); + }); + 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", diff --git a/apps/extension/src/tools/geometry/snapshot-owner-sizes.ts b/apps/extension/src/tools/geometry/snapshot-owner-sizes.ts new file mode 100644 index 0000000..0de2c55 --- /dev/null +++ b/apps/extension/src/tools/geometry/snapshot-owner-sizes.ts @@ -0,0 +1,74 @@ +import type { Size } from "../geometry"; + +interface SerializedValue { + type: string; + value?: unknown; +} + +type Send = (method: string, params: object, cleanup?: boolean) => Promise; + +/** 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, +): Promise> { + const sizes = new Map(); + 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; +} diff --git a/apps/extension/src/tools/vom/__tests__/capture.test.ts b/apps/extension/src/tools/vom/__tests__/capture.test.ts index 6d95b68..25d76c4 100644 --- a/apps/extension/src/tools/vom/__tests__/capture.test.ts +++ b/apps/extension/src/tools/vom/__tests__/capture.test.ts @@ -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 void>(); const fixture = siblingCaptureFixture(async (method, params) => { diff --git a/apps/extension/src/tools/vom/capture.ts b/apps/extension/src/tools/vom/capture.ts index 4dddd85..91702d3 100644 --- a/apps/extension/src/tools/vom/capture.ts +++ b/apps/extension/src/tools/vom/capture.ts @@ -1173,6 +1173,14 @@ export async function captureViewModel( const nodes = mainParsed.nodes; const excludedBackendNodeIds = new Set(mainParsed.excludedBackendNodeIds); + const ownerIds = new Set(); + 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,