From 282e6f5623d027c8b1fe164875fbc20dd3570250 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 9 Sep 2026 16:05:42 +0800 Subject: [PATCH] fix(observation): carry snapshot units through capture coordination --- .../src/tools/__tests__/observation.test.ts | 26 +++++- .../snapshot-coordinates.browser.test.ts | 18 ++--- apps/extension/src/tools/observation.ts | 2 +- .../vom/__tests__/capture-coordinator.test.ts | 81 ++++++++++++++++--- .../src/tools/vom/__tests__/capture.test.ts | 38 +++++++-- apps/extension/src/tools/vom/normalize.ts | 60 ++++++++++---- 6 files changed, 180 insertions(+), 45 deletions(-) diff --git a/apps/extension/src/tools/__tests__/observation.test.ts b/apps/extension/src/tools/__tests__/observation.test.ts index b529754..07547ca 100644 --- a/apps/extension/src/tools/__tests__/observation.test.ts +++ b/apps/extension/src/tools/__tests__/observation.test.ts @@ -2690,6 +2690,8 @@ describe("handleSnapshot", () => { strings, documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "root", nodes: { parentIndex: [-1, 0], @@ -2786,6 +2788,8 @@ describe("handleSnapshot", () => { strings, documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "root", nodes: { parentIndex: [-1, 0], @@ -2885,6 +2889,8 @@ describe("handleSnapshot", () => { strings, documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "root", nodes: { parentIndex: [-1, 0], @@ -3154,6 +3160,8 @@ describe("handleSnapshot", () => { strings: S, documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "root", nodes: { parentIndex: [-1, 0, 1, 2], @@ -3212,6 +3220,8 @@ describe("handleSnapshot", () => { strings: S, documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "root", nodes: { parentIndex: [-1, 0, 1, 1, 1, 1], @@ -3371,7 +3381,11 @@ describe("handleSnapshot", () => { }); const sendToTarget = vi.fn(async (_target, method: string) => { if (method === "Page.getLayoutMetrics" && ownerGeometry !== "unavailable") - return { cssLayoutViewport: { clientWidth: 400, clientHeight: 300 } }; + return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 400, clientHeight: 300 }, + }; if (method === "Accessibility.enable" || method === "DOMSnapshot.enable") return {}; if (method === "DOMSnapshot.captureSnapshot") return { ...snapshot, documents: [snapshot.documents[1]] }; @@ -3414,6 +3428,8 @@ describe("handleSnapshot", () => { strings: ["body", "a", "href", "//outside.test/docs", "https://page.test/start"], documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "main", ...(hasSnapshotUrl ? { documentURL: 4 } : {}), nodes: { @@ -3535,6 +3551,8 @@ describe("handleSnapshot", () => { strings, documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "root", nodes: { parentIndex: [-1, 0, 1, 1, 3], @@ -3685,7 +3703,7 @@ describe("handleSnapshot", () => { 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 }, + localRect: null, }); } if (maxTokens === 100) expect(Math.ceil(result.text.length / 4)).toBeLessThanOrEqual(maxTokens); @@ -3774,7 +3792,7 @@ describe("handleSnapshot", () => { expect(result.matchNodes.find((node) => node.backendNodeId === 22)).toMatchObject({ frameId: "child", tag: "button", - localRect: { x: 20, y: 30, w: 120, h: 40 }, + localRect: null, }); expect(result.refs.find((ref) => ref.backendNodeId === 22)).toMatchObject({ frameId: "child", @@ -4118,6 +4136,8 @@ describe("handleSnapshot", () => { strings: S, documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "root", nodes: { parentIndex: [-1, 0, 1, 0, 3, 4, 5], diff --git a/apps/extension/src/tools/__tests__/snapshot-coordinates.browser.test.ts b/apps/extension/src/tools/__tests__/snapshot-coordinates.browser.test.ts index 5ef89d9..1c1e190 100644 --- a/apps/extension/src/tools/__tests__/snapshot-coordinates.browser.test.ts +++ b/apps/extension/src/tools/__tests__/snapshot-coordinates.browser.test.ts @@ -1,10 +1,8 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; import type { CdpFrame, CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph"; -import { GeometryContext } from "../geometry/frame-context"; import type { CdpRunner } from "../shared"; -import { captureViewModel } from "../vom/capture"; -import { captureFrameData } from "../vom/frame-capture"; +import { captureObservationFacts } from "../vom/capture-coordinator"; type Send = >( method: string, @@ -180,14 +178,12 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c ); oracles.set(frame.frameId, reply.result.value); } - const geometry = new GeometryContext(cdp, 1, graph); - const captured = await captureViewModel(cdp, 1, { geometry }); - await captureFrameData(cdp, 1, captured, undefined, geometry); - expect(captured.frameGeometryIssues).toEqual([]); + const facts = await captureObservationFacts(cdp, 1); + expect(facts.issues).toEqual([]); for (const frame of frames) { - const node = captured.frameNodes - ?.get(frame.frameId) - ?.find((node) => node.attrs.id === "probe"); + const node = facts.documents + .find((doc) => doc.frame.frameId === frame.frameId) + ?.domNodes.find((node) => node.attrs.id === "probe"); expect(node, `missing probe in ${names.get(frame.frameId) || "root"}`).toBeDefined(); const local = oracles.get(frame.frameId)!.probe; const top = { ...local }; @@ -206,7 +202,7 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c frame: names.get(frame.frameId), local, top, - viewport: captured.viewport, + viewport: facts.viewport, }), ).not.toBeNull(); for (const key of ["x", "y", "w", "h"] as const) { diff --git a/apps/extension/src/tools/observation.ts b/apps/extension/src/tools/observation.ts index fcef7f2..230b267 100644 --- a/apps/extension/src/tools/observation.ts +++ b/apps/extension/src/tools/observation.ts @@ -929,7 +929,7 @@ export async function captureVomObservation( const notices: string[] = []; if (facts.issues.some((issue) => issue.stage === "geometry")) notices.push( - "@warning iframe geometry incomplete: some frame content has no top-level coordinates.", + "@warning geometry incomplete: some page or frame content has no top-level coordinates.", ); const incompleteStages = ["dom", "ax", "forms", "ownership"].filter((stage) => facts.issues.some((issue) => issue.stage === stage), diff --git a/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts b/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts index febd07d..a4aad0b 100644 --- a/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts +++ b/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts @@ -5,7 +5,7 @@ import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge"; import type { CdpRunner } from "../../shared"; import { captureObservationFacts, semanticCapture } from "../capture-coordinator"; import { buildSemanticGraph } from "../semantic-graph/build"; -import { REQUESTED_STYLES } from "../snapshot"; +import { REQUESTED_STYLES, type SnapshotReply } from "../snapshot"; function fixture( options: { frames?: CdpFrame[]; fail?: string; omitDocument?: string; overlay?: string } = {}, @@ -43,7 +43,11 @@ function fixture( throw new Error("fixture failure"); let result: unknown = {}; if (method === "Page.getLayoutMetrics") - result = { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } }; + result = { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 }, + }; if (method === "DOMSnapshot.captureSnapshot") { expect((params as { computedStyles: unknown }).computedStyles).toEqual(REQUESTED_STYLES); result = { @@ -74,6 +78,8 @@ function fixture( const children = localFrames.filter((f) => f.parentFrameId === frame.frameId); return { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: frame.frameId, nodes: { backendNodeId: [ @@ -140,6 +146,35 @@ function fixture( } describe("captureObservationFacts", () => { + it("normalizes snapshot-owned root scroll instead of mixing in stale CSS scroll", async () => { + const { cdp } = fixture({ frames: [{ frameId: "main", target: { tabId: 4 } }] }); + const original = cdp.sendToTarget!; + cdp.sendToTarget = async (target, method, params) => { + if (method === "Page.getLayoutMetrics") + return { + visualViewport: { clientWidth: 2000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 999, pageY: 999 }, + } as never; + const reply = await original(target, method, params); + if (method === "DOMSnapshot.captureSnapshot") { + const doc = (reply as SnapshotReply).documents![0]; + doc.scrollOffsetX = 40; + doc.scrollOffsetY = 200; + doc.layout!.bounds![1] = [200, 800, 240, 80]; + } + return reply as never; + }; + cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params); + const facts = await captureObservationFacts(cdp, 4); + expect(facts.documents[0].domNodes.find((node) => node.backendNodeId === 2)).toMatchObject({ + localRect: { x: 80, y: 300, w: 120, h: 40 }, + rect: { x: 80, y: 300, w: 120, h: 40 }, + rendered: true, + }); + expect(facts.issues).toEqual([]); + }); + it("collects each target once and scopes equal backend IDs", async () => { const { cdp, logs } = fixture(); const facts = await captureObservationFacts(cdp, 4); @@ -329,6 +364,8 @@ function childSnapshot(frameId: string, backendNodeId: number) { strings, documents: [ { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId, nodes: { parentIndex: [-1, 0], @@ -357,7 +394,11 @@ describe("OOPIF capture", () => { it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => { const sendToTarget = vi.fn(async (target, method) => { if (method === "Page.getLayoutMetrics") { - return { cssLayoutViewport: { clientWidth: 300, clientHeight: 200, pageX: 0, pageY: 0 } }; + return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 300, clientHeight: 200, pageX: 0, pageY: 0 }, + }; } if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {}; if (method === "DOMSnapshot.captureSnapshot") { @@ -379,7 +420,11 @@ describe("OOPIF capture", () => { return { model: { content: [x, 100, x + 300, 100, x + 300, 300, x, 300] } }; } if (method === "Page.getLayoutMetrics") { - return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } }; + return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 }, + }; } throw new Error(`unexpected root ${method}`); }) as CdpRunner["send"], @@ -457,7 +502,11 @@ describe("OOPIF capture", () => { }; const reply = async (_target: unknown, method: string) => { if (method === "Page.getLayoutMetrics") - return { cssLayoutViewport: { clientWidth: 300, clientHeight: 200 } }; + return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 300, clientHeight: 200 }, + }; if (method === "DOMSnapshot.captureSnapshot") return typeof _target === "number" ? childSnapshot("main", 11) : snapshot; if (method === "DOM.getBoxModel") @@ -515,7 +564,11 @@ describe("OOPIF capture", () => { if (method === "Accessibility.getFullAXTree") return { nodes: [] }; if (method === "DOM.getBoxModel") throw new Error("owner geometry unavailable"); if (method === "Page.getLayoutMetrics") { - return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } }; + return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 }, + }; } throw new Error(`unexpected root ${method}`); }) as CdpRunner["send"], @@ -558,7 +611,7 @@ describe("OOPIF capture", () => { expect.objectContaining({ backendDOMNodeId: 101, frameId: "child" }), ]); expect(childDocument?.domNodes.find((node) => node.backendNodeId === 101)).toEqual( - expect.objectContaining({ rect: null, localRect: { x: 10, y: 20, w: 100, h: 40 } }), + expect.objectContaining({ rect: null, localRect: null, rendered: true }), ); }); }); @@ -568,6 +621,8 @@ function siblingCaptureFixture( beforeReply: (method: string, params: Record) => Promise = async () => {}, ) { const document = (id: number, owners: number[], childIndexes: number[]) => ({ + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: `frame-${id}`, nodes: { parentIndex: [-1, ...owners.map(() => 0)], @@ -599,7 +654,11 @@ function siblingCaptureFixture( await beforeReply(method, args); if (method === "DOMSnapshot.captureSnapshot") return snapshot; if (method === "Page.getLayoutMetrics") - return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } }; + return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + 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) } }; @@ -803,7 +862,11 @@ describe("snapshot document provenance", () => { (method === "DOMSnapshot.captureSnapshot" ? { ...f.snapshot, documents: [f.snapshot.documents[1]] } : method === "Page.getLayoutMetrics" - ? { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } } + ? { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 200, clientHeight: 100 }, + } : {}) as T, }; const facts = await captureObservationFacts(cdp, 4); diff --git a/apps/extension/src/tools/vom/__tests__/capture.test.ts b/apps/extension/src/tools/vom/__tests__/capture.test.ts index 96813a0..503dd21 100644 --- a/apps/extension/src/tools/vom/__tests__/capture.test.ts +++ b/apps/extension/src/tools/vom/__tests__/capture.test.ts @@ -215,7 +215,11 @@ function makeCdp(snapshot: unknown) { if (method === "DOMSnapshot.enable") return {}; if (method === "DOMSnapshot.captureSnapshot") return snapshot; if (method === "Page.getLayoutMetrics") { - return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } }; + return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, + cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, + }; } if (method === "Runtime.evaluate") { return { @@ -277,6 +281,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return hoverTriggerSnapshotReply(); if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, }; } @@ -340,6 +346,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return twoHoverTriggerSnapshotReply(); if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, }; } @@ -388,6 +396,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return nestedHoverTriggerSnapshotReply(); if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, }; } @@ -733,6 +743,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return fakeSnapshotReply(); if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 200 }, }; } @@ -745,7 +757,7 @@ describe("snapshot capture and hover", () => { expect(div?.rect).toMatchObject({ y: 0, h: 600 }); }); - it("keeps CSS snapshot bounds independent of legacy metrics ratio", async () => { + it("normalizes raw snapshot bounds before viewport clipping", async () => { const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"]; const i = (s: string) => S.indexOf(s); const snapshot = { @@ -780,6 +792,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return snapshot; if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 2000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, layoutViewport: { clientWidth: 2000, clientHeight: 1600 }, }; @@ -797,8 +811,8 @@ describe("snapshot capture and hover", () => { expect(nodes.find((n) => n.backendNodeId === 12)?.localRect).toEqual({ x: 0, y: 0, - w: 2000, - h: 1600, + w: 1000, + h: 800, }); }); @@ -856,6 +870,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return snapshot; if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, }; } @@ -946,6 +962,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return snapshot; if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, }; } @@ -1035,6 +1053,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return snapshot; if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, }; } @@ -1097,6 +1117,8 @@ describe("snapshot capture and hover", () => { }, }, { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "child", nodes: { parentIndex: [-1, 0, 1], @@ -1119,6 +1141,8 @@ describe("snapshot capture and hover", () => { }, }, { + scrollOffsetX: 0, + scrollOffsetY: 0, frameId: "nested", nodes: { parentIndex: [-1, 0], @@ -1146,6 +1170,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return snapshot; if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 1000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, }; } @@ -1225,6 +1251,8 @@ describe("snapshot capture and hover", () => { if (method === "DOMSnapshot.captureSnapshot") return snapshot; if (method === "Page.getLayoutMetrics") { return { + visualViewport: { clientWidth: 2000 }, + cssVisualViewport: { clientWidth: 1000 }, cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 100 }, layoutViewport: { clientWidth: 2000, clientHeight: 1600 }, }; @@ -1233,7 +1261,7 @@ describe("snapshot capture and hover", () => { }) as unknown as (tabId: number, method: string, params?: object) => Promise, }; const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured; - expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 - 100); + expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 / 2 - 100); }); it("collectOverlayExcludedBackendIds walks the pierced overlay host subtree", async () => { diff --git a/apps/extension/src/tools/vom/normalize.ts b/apps/extension/src/tools/vom/normalize.ts index 9f37a9d..445c5f5 100644 --- a/apps/extension/src/tools/vom/normalize.ts +++ b/apps/extension/src/tools/vom/normalize.ts @@ -5,9 +5,14 @@ import { type FrameProjectionState, projectSnapshotRect, type SnapshotCoordinates, + snapshotCoordinates, snapshotViewportRect, } from "../geometry/coordinate-types"; -import { cssViewport, GeometryContext } from "../geometry/frame-context"; +import { + GeometryContext, + type LayoutMetrics, + snapshotLayoutScale, +} from "../geometry/frame-context"; import { createCaptureCheckpoint, isAbortError as isCaptureAbort, @@ -28,7 +33,6 @@ export interface FrameContext { targetProjection?: GeometryProjection | null; target: CdpTarget; coordinates: SnapshotCoordinates | null; - layoutUnitsPerCssPixel: number | null; } export interface NormalizedDocument { @@ -137,17 +141,20 @@ export async function normalizeSnapshot( } sources.set(id, doc); } - let viewport: Viewport = { width: 0, height: 0 }; - let scrollX = 0, - scrollY = 0; + let metrics: LayoutMetrics = {}; try { - const measured = cssViewport(await geometry.layoutMetrics(target)); - viewport = { width: measured.width, height: measured.height }; - scrollX = measured.scrollX; - scrollY = measured.scrollY; + metrics = await geometry.layoutMetrics(target); } catch (error) { if (isCaptureAbort(error)) throw error; } + const viewport: Viewport = { + width: metrics.cssLayoutViewport?.clientWidth ?? 0, + height: metrics.cssLayoutViewport?.clientHeight ?? 0, + }; + const layoutUnitsPerCssPixel = snapshotLayoutScale(metrics); + const viewportAvailable = [viewport.width, viewport.height].every( + (size) => Number.isFinite(size) && size > 0, + ); const children = new Map(); const pending: CdpFrame[] = []; @@ -193,12 +200,24 @@ export async function normalizeSnapshot( const doc = sources.get(frame.frameId); if (!doc) continue; const source = { target, frameId: frame.frameId }; - const state: FrameProjectionState | null = + const coordinates = snapshotCoordinates( + doc, + layoutUnitsPerCssPixel, frame.frameId === rootFrameId - ? { - status: "available", - projection: { source, geometry: { sourceClips: [], edges: [], topViewport: viewport } }, - } + ? { x: metrics.cssLayoutViewport?.pageX, y: metrics.cssLayoutViewport?.pageY } + : undefined, + ); + let state: FrameProjectionState | null = + frame.frameId === rootFrameId + ? viewportAvailable && coordinates + ? { + status: "available", + projection: { + source, + geometry: { sourceClips: [], edges: [], topViewport: viewport }, + }, + } + : { status: "unavailable", source, reason: "snapshot-coordinates-unavailable" } : (projections.get(frame.frameId) ?? null); if (!state && frame.frameId !== rootFrameId) issues.push({ @@ -207,6 +226,8 @@ export async function normalizeSnapshot( stage: "ownership", reason: "frame-ownership-unresolved", }); + if (!coordinates && (!state || state.status === "available")) + state = { status: "unavailable", source, reason: "snapshot-coordinates-unavailable" }; projections.set(frame.frameId, state); const projection = state?.status === "available" ? state.projection : null; if (!projection || (target.sessionId && !targetProjection)) @@ -251,6 +272,14 @@ export async function normalizeSnapshot( projections.set(child.frameId, null); continue; } + if (!snapshotCoordinates(sources.get(child.frameId)!, layoutUnitsPerCssPixel)) { + projections.set(child.frameId, { + status: "unavailable", + source: childSource, + reason: "snapshot-coordinates-unavailable", + }); + continue; + } try { projections.set( child.frameId, @@ -279,8 +308,7 @@ export async function normalizeSnapshot( projection: state, target, ...(target.sessionId ? { targetProjection } : {}), - scrollX: doc.scrollOffsetX ?? (frame.frameId === rootFrameId ? scrollX : 0), - scrollY: doc.scrollOffsetY ?? (frame.frameId === rootFrameId ? scrollY : 0), + coordinates, }, signal, );