mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4834d8b5fa | ||
|
|
b4006d5b85 | ||
|
|
2d1e5c6d9d | ||
|
|
282e6f5623 | ||
|
|
bd68c293b6 | ||
|
|
35a20582ad | ||
|
|
4e3365450a | ||
|
|
df5ed1a495 |
@@ -29,6 +29,24 @@ function fakeApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ChromiumCdp", () => {
|
describe("ChromiumCdp", () => {
|
||||||
|
it("changes attachment identity only across real debugger attachments", async () => {
|
||||||
|
const { api, onEvent, onDetach } = fakeApi();
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||||
|
await cdp.ensureAttached(4);
|
||||||
|
const first = cdp.getAttachmentId(4);
|
||||||
|
expect(first).toBeTruthy();
|
||||||
|
await cdp.ensureAttached(4);
|
||||||
|
onEvent.fire({ tabId: 4 }, "Target.attachedToTarget", { sessionId: "child" });
|
||||||
|
expect(cdp.getAttachmentId(4)).toBe(first);
|
||||||
|
onDetach.fire({ tabId: 4 }, "target_closed");
|
||||||
|
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||||
|
await cdp.ensureAttached(4);
|
||||||
|
expect(cdp.getAttachmentId(4)).not.toBe(first);
|
||||||
|
await cdp.detach(4);
|
||||||
|
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
||||||
const { api, onEvent } = fakeApi();
|
const { api, onEvent } = fakeApi();
|
||||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ async function settleBeforeDeadline(promises: Promise<void>[], deadline: number)
|
|||||||
export class ChromiumCdp {
|
export class ChromiumCdp {
|
||||||
private readonly api: CdpDebuggerApi;
|
private readonly api: CdpDebuggerApi;
|
||||||
private readonly attachedTabs = new Set<number>();
|
private readonly attachedTabs = new Set<number>();
|
||||||
|
private readonly attachmentIds = new Map<number, string>();
|
||||||
private readonly attachInFlight = new Map<number, Promise<void>>();
|
private readonly attachInFlight = new Map<number, Promise<void>>();
|
||||||
private readonly detachInFlight = new Map<number, Promise<void>>();
|
private readonly detachInFlight = new Map<number, Promise<void>>();
|
||||||
private readonly tabOwners = new Map<number, Set<string>>();
|
private readonly tabOwners = new Map<number, Set<string>>();
|
||||||
@@ -198,6 +199,11 @@ export class ChromiumCdp {
|
|||||||
this.bindFrameTargetHandler();
|
this.bindFrameTargetHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Identity of this actual debugger attachment, independent of frame topology. */
|
||||||
|
getAttachmentId(tabId: number): string | undefined {
|
||||||
|
return this.attachmentIds.get(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Attach to `tabId` if we haven't already in this driver. */
|
/** Attach to `tabId` if we haven't already in this driver. */
|
||||||
async ensureAttached(tabId: number): Promise<void> {
|
async ensureAttached(tabId: number): Promise<void> {
|
||||||
// Returning a tab clears the cache before Chrome finishes detaching.
|
// Returning a tab clears the cache before Chrome finishes detaching.
|
||||||
@@ -217,6 +223,7 @@ export class ChromiumCdp {
|
|||||||
await this.enableConsoleDomains(tabId);
|
await this.enableConsoleDomains(tabId);
|
||||||
await this.enableNetworkDomainBestEffort(tabId);
|
await this.enableNetworkDomainBestEffort(tabId);
|
||||||
this.attachedTabs.add(tabId);
|
this.attachedTabs.add(tabId);
|
||||||
|
this.attachmentIds.set(tabId, crypto.randomUUID());
|
||||||
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
||||||
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
||||||
});
|
});
|
||||||
@@ -444,6 +451,7 @@ export class ChromiumCdp {
|
|||||||
this.attachInFlight.delete(tabId);
|
this.attachInFlight.delete(tabId);
|
||||||
if (!this.attachedTabs.has(tabId)) return;
|
if (!this.attachedTabs.has(tabId)) return;
|
||||||
this.attachedTabs.delete(tabId);
|
this.attachedTabs.delete(tabId);
|
||||||
|
this.attachmentIds.delete(tabId);
|
||||||
this.clearDialogState(tabId);
|
this.clearDialogState(tabId);
|
||||||
this.clearConsoleState(tabId);
|
this.clearConsoleState(tabId);
|
||||||
this.clearNetworkState(tabId);
|
this.clearNetworkState(tabId);
|
||||||
@@ -502,6 +510,7 @@ export class ChromiumCdp {
|
|||||||
this.attachInFlight.clear();
|
this.attachInFlight.clear();
|
||||||
this.tabOwners.clear();
|
this.tabOwners.clear();
|
||||||
this.attachedTabs.clear();
|
this.attachedTabs.clear();
|
||||||
|
this.attachmentIds.clear();
|
||||||
this.dialogBuffers.clear();
|
this.dialogBuffers.clear();
|
||||||
this.dialogSequences.clear();
|
this.dialogSequences.clear();
|
||||||
this.consoleBuffers.clear();
|
this.consoleBuffers.clear();
|
||||||
@@ -848,6 +857,7 @@ export class ChromiumCdp {
|
|||||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||||
if (typeof source.tabId === "number") {
|
if (typeof source.tabId === "number") {
|
||||||
this.attachedTabs.delete(source.tabId);
|
this.attachedTabs.delete(source.tabId);
|
||||||
|
this.attachmentIds.delete(source.tabId);
|
||||||
this.attachInFlight.delete(source.tabId);
|
this.attachInFlight.delete(source.tabId);
|
||||||
this.tabOwners.delete(source.tabId);
|
this.tabOwners.delete(source.tabId);
|
||||||
this.clearDialogState(source.tabId);
|
this.clearDialogState(source.tabId);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,8 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { CdpFrame, CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
|
import type { CdpFrame, CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
import { GeometryContext } from "../geometry/frame-context";
|
|
||||||
import type { CdpRunner } from "../shared";
|
import type { CdpRunner } from "../shared";
|
||||||
import { captureViewModel } from "../vom/capture";
|
import { captureObservationFacts } from "../vom/capture-coordinator";
|
||||||
import { captureFrameData } from "../vom/frame-capture";
|
|
||||||
|
|
||||||
type Send = <T = Record<string, unknown>>(
|
type Send = <T = Record<string, unknown>>(
|
||||||
method: string,
|
method: string,
|
||||||
@@ -132,6 +130,7 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
|||||||
return send(method, params, sessionFor(target));
|
return send(method, params, sessionFor(target));
|
||||||
},
|
},
|
||||||
getFrameGraph: async () => graph,
|
getFrameGraph: async () => graph,
|
||||||
|
getAttachmentId: () => rootSession,
|
||||||
};
|
};
|
||||||
const oracles = new Map<string, Oracle>();
|
const oracles = new Map<string, Oracle>();
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
@@ -180,14 +179,12 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
|||||||
);
|
);
|
||||||
oracles.set(frame.frameId, reply.result.value);
|
oracles.set(frame.frameId, reply.result.value);
|
||||||
}
|
}
|
||||||
const geometry = new GeometryContext(cdp, 1, graph);
|
const facts = await captureObservationFacts(cdp, 1);
|
||||||
const captured = await captureViewModel(cdp, 1, { geometry });
|
expect(facts.issues).toEqual([]);
|
||||||
await captureFrameData(cdp, 1, captured, undefined, geometry);
|
|
||||||
expect(captured.frameGeometryIssues).toEqual([]);
|
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
const node = captured.frameNodes
|
const node = facts.documents
|
||||||
?.get(frame.frameId)
|
.find((doc) => doc.frame.frameId === frame.frameId)
|
||||||
?.find((node) => node.attrs.id === "probe");
|
?.domNodes.find((node) => node.attrs.id === "probe");
|
||||||
expect(node, `missing probe in ${names.get(frame.frameId) || "root"}`).toBeDefined();
|
expect(node, `missing probe in ${names.get(frame.frameId) || "root"}`).toBeDefined();
|
||||||
const local = oracles.get(frame.frameId)!.probe;
|
const local = oracles.get(frame.frameId)!.probe;
|
||||||
const top = { ...local };
|
const top = { ...local };
|
||||||
@@ -206,7 +203,7 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
|||||||
frame: names.get(frame.frameId),
|
frame: names.get(frame.frameId),
|
||||||
local,
|
local,
|
||||||
top,
|
top,
|
||||||
viewport: captured.viewport,
|
viewport: facts.viewport,
|
||||||
}),
|
}),
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
for (const key of ["x", "y", "w", "h"] as const) {
|
for (const key of ["x", "y", "w", "h"] as const) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CapturedSceneInput } from "./vom/facts";
|
||||||
// Observation handlers — `tool.snapshot`, `tool.get_html`, `tool.screenshot`,
|
// Observation handlers — `tool.snapshot`, `tool.get_html`, `tool.screenshot`,
|
||||||
// and semantic `tool.observe` (design §7). Each handler resolves the target
|
// and semantic `tool.observe` (design §7). Each handler resolves the target
|
||||||
// tab (defaulting to the Agent Window's active tab when omitted) and
|
// tab (defaulting to the Agent Window's active tab when omitted) and
|
||||||
@@ -35,7 +36,6 @@ import { attachDialogs, markDialogCursor } from "./dialogs";
|
|||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { resolveNodeGeometry } from "./frame-geometry";
|
import { resolveNodeGeometry } from "./frame-geometry";
|
||||||
import { screenshotPageRect } from "./geometry/coordinate-types";
|
import { screenshotPageRect } from "./geometry/coordinate-types";
|
||||||
import { cssViewport, GeometryContext } from "./geometry/frame-context";
|
|
||||||
import {
|
import {
|
||||||
type ChromeTabsApi,
|
type ChromeTabsApi,
|
||||||
enforceToolTargetScope,
|
enforceToolTargetScope,
|
||||||
@@ -49,15 +49,9 @@ import {
|
|||||||
type ToolEffect,
|
type ToolEffect,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||||
import {
|
import { type CapturedNode, type CapturedSurfaceProbe, probeHoverSurfaces } from "./vom/capture";
|
||||||
type CapturedNode,
|
import { captureObservationFacts, semanticCapture } from "./vom/capture-coordinator";
|
||||||
type CapturedSurfaceProbe,
|
import type { FrameDocument as CapturedFrameDocument } from "./vom/frame-document";
|
||||||
type CapturedViewModel,
|
|
||||||
captureViewModel,
|
|
||||||
collectOverlayExcludedBackendIds,
|
|
||||||
probeHoverSurfaces,
|
|
||||||
} from "./vom/capture";
|
|
||||||
import { type CapturedFrameDocument, captureFrameData } from "./vom/frame-capture";
|
|
||||||
import { withOverlayBypass } from "./vom/hover-perception";
|
import { withOverlayBypass } from "./vom/hover-perception";
|
||||||
import { probeTooltipNames } from "./vom/name-enrichment";
|
import { probeTooltipNames } from "./vom/name-enrichment";
|
||||||
import {
|
import {
|
||||||
@@ -531,7 +525,7 @@ function buildActiveScopeBlocks(nodes: VomNode[], signals: VomNodeDomSignals): A
|
|||||||
|
|
||||||
function buildConditionalSurfaces(
|
function buildConditionalSurfaces(
|
||||||
nodes: VomNode[],
|
nodes: VomNode[],
|
||||||
captured: CapturedViewModel,
|
captured: CapturedSceneInput,
|
||||||
probes: CapturedSurfaceProbe[],
|
probes: CapturedSurfaceProbe[],
|
||||||
): CondSurface[] {
|
): CondSurface[] {
|
||||||
if (probes.length === 0) return [];
|
if (probes.length === 0) return [];
|
||||||
@@ -661,163 +655,9 @@ export interface BuildVomSceneOptions {
|
|||||||
|
|
||||||
export type VomFrameDocument = CapturedFrameDocument<CdpAxNode>;
|
export type VomFrameDocument = CapturedFrameDocument<CdpAxNode>;
|
||||||
|
|
||||||
function legacyFrameDocuments(
|
|
||||||
axNodes: CdpAxNode[],
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
pageUrl?: string,
|
|
||||||
rootTarget: VomFrameDocument["target"] = { tabId: 0 },
|
|
||||||
): VomFrameDocument[] {
|
|
||||||
const rootFrameId = captured.rootFrameId ?? "root";
|
|
||||||
const documents: VomFrameDocument[] = [
|
|
||||||
{
|
|
||||||
frameId: rootFrameId,
|
|
||||||
contextScopeId: rootFrameId,
|
|
||||||
target: rootTarget,
|
|
||||||
...(pageUrl ? { url: pageUrl } : {}),
|
|
||||||
axNodes: [],
|
|
||||||
domNodes: captured.nodes.map((node) => ({ ...node, frameId: node.frameId ?? rootFrameId })),
|
|
||||||
excludedBackendNodeIds: captured.frameExcludedBackendNodeIds?.get(rootFrameId),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const pending = [...captured.iframeNodes.entries()];
|
|
||||||
let progress = true;
|
|
||||||
let nextSyntheticFrame = 1;
|
|
||||||
while (pending.length > 0 && progress) {
|
|
||||||
progress = false;
|
|
||||||
for (let index = pending.length - 1; index >= 0; index -= 1) {
|
|
||||||
const [ownerBackendNodeId, domNodes] = pending[index];
|
|
||||||
const parent = documents.find((document) =>
|
|
||||||
document.domNodes.some((node) => node.backendNodeId === ownerBackendNodeId),
|
|
||||||
);
|
|
||||||
if (!parent) continue;
|
|
||||||
const frameId =
|
|
||||||
domNodes.find((node) => node.frameId)?.frameId ?? `legacy-frame-${nextSyntheticFrame++}`;
|
|
||||||
documents.push({
|
|
||||||
frameId,
|
|
||||||
parentFrameId: parent.frameId,
|
|
||||||
ownerBackendNodeId,
|
|
||||||
contextScopeId: frameId,
|
|
||||||
target: parent.target,
|
|
||||||
axNodes: [],
|
|
||||||
domNodes: domNodes.map((node) => ({ ...node, frameId: node.frameId ?? frameId })),
|
|
||||||
excludedBackendNodeIds: captured.frameExcludedBackendNodeIds?.get(frameId),
|
|
||||||
});
|
|
||||||
pending.splice(index, 1);
|
|
||||||
progress = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentByFrameId = new Map(documents.map((document) => [document.frameId, document]));
|
|
||||||
const backendFrame = new Map<number, string>();
|
|
||||||
const childFrameByOwner = new Map<number, string>();
|
|
||||||
for (const document of documents) {
|
|
||||||
if (document.ownerBackendNodeId !== undefined) {
|
|
||||||
childFrameByOwner.set(document.ownerBackendNodeId, document.frameId);
|
|
||||||
}
|
|
||||||
for (const node of document.domNodes) backendFrame.set(node.backendNodeId, document.frameId);
|
|
||||||
}
|
|
||||||
const axById = new Map(axNodes.map((node) => [node.nodeId, node]));
|
|
||||||
const ownership = new Map<string, string>();
|
|
||||||
const resolving = new Set<string>();
|
|
||||||
const frameForAx = (node: CdpAxNode): string => {
|
|
||||||
const cached = ownership.get(node.nodeId);
|
|
||||||
if (cached) return cached;
|
|
||||||
let frameId: string | undefined;
|
|
||||||
if (node.frameId && documentByFrameId.has(node.frameId)) frameId = node.frameId;
|
|
||||||
if (!frameId && typeof node.backendDOMNodeId === "number") {
|
|
||||||
frameId = backendFrame.get(node.backendDOMNodeId);
|
|
||||||
}
|
|
||||||
if (!frameId && node.parentId && !resolving.has(node.nodeId)) {
|
|
||||||
const parent = axById.get(node.parentId);
|
|
||||||
if (parent) {
|
|
||||||
frameId =
|
|
||||||
typeof parent.backendDOMNodeId === "number"
|
|
||||||
? childFrameByOwner.get(parent.backendDOMNodeId)
|
|
||||||
: undefined;
|
|
||||||
if (!frameId) {
|
|
||||||
resolving.add(node.nodeId);
|
|
||||||
frameId = frameForAx(parent);
|
|
||||||
resolving.delete(node.nodeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
frameId ??= rootFrameId;
|
|
||||||
ownership.set(node.nodeId, frameId);
|
|
||||||
return frameId;
|
|
||||||
};
|
|
||||||
for (const node of axNodes) frameForAx(node);
|
|
||||||
for (const node of axNodes) {
|
|
||||||
const frameId = ownership.get(node.nodeId) ?? rootFrameId;
|
|
||||||
const document = documentByFrameId.get(frameId) ?? documents[0];
|
|
||||||
document.axNodes.push({
|
|
||||||
...node,
|
|
||||||
frameId,
|
|
||||||
...(node.parentId && ownership.get(node.parentId) === frameId
|
|
||||||
? { parentId: node.parentId }
|
|
||||||
: { parentId: undefined }),
|
|
||||||
...(node.childIds
|
|
||||||
? { childIds: node.childIds.filter((childId) => ownership.get(childId) === frameId) }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return documents;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withLegacyBackendIds(scene: VomScene): VomScene {
|
|
||||||
const used = new Set<number>();
|
|
||||||
const idMap = new Map<number, number>();
|
|
||||||
let nextVirtualId = -1;
|
|
||||||
for (const node of scene.nodes) {
|
|
||||||
const preferred = node.backendNodeId;
|
|
||||||
const id = preferred !== undefined && !used.has(preferred) ? preferred : nextVirtualId--;
|
|
||||||
used.add(id);
|
|
||||||
idMap.set(node.id, id);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...scene,
|
|
||||||
nodes: scene.nodes.map((node) => ({
|
|
||||||
...node,
|
|
||||||
id: idMap.get(node.id) as number,
|
|
||||||
parentId: node.parentId === null ? null : (idMap.get(node.parentId) ?? null),
|
|
||||||
...(node.domParentId !== undefined
|
|
||||||
? { domParentId: node.domParentId === null ? null : (idMap.get(node.domParentId) ?? null) }
|
|
||||||
: {}),
|
|
||||||
...(node.domAncestorIds
|
|
||||||
? { domAncestorIds: node.domAncestorIds.flatMap((id) => idMap.get(id) ?? []) }
|
|
||||||
: {}),
|
|
||||||
})),
|
|
||||||
...(scene.surfaces
|
|
||||||
? {
|
|
||||||
surfaces: scene.surfaces.map((surface) => ({
|
|
||||||
...surface,
|
|
||||||
triggerId: idMap.get(surface.triggerId) ?? surface.triggerId,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(scene.activeScopeBlocks
|
|
||||||
? {
|
|
||||||
activeScopeBlocks: scene.activeScopeBlocks.map((block) => ({
|
|
||||||
...block,
|
|
||||||
triggerId: idMap.get(block.triggerId) ?? block.triggerId,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildVomScene(
|
|
||||||
axNodes: CdpAxNode[],
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
options: BuildVomSceneOptions = {},
|
|
||||||
): VomScene {
|
|
||||||
return withLegacyBackendIds(
|
|
||||||
buildFrameVomScene(legacyFrameDocuments(axNodes, captured, options.pageUrl), captured, options),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFrameVomScene(
|
export function buildFrameVomScene(
|
||||||
documents: VomFrameDocument[],
|
documents: VomFrameDocument[],
|
||||||
captured: CapturedViewModel,
|
captured: CapturedSceneInput,
|
||||||
options: BuildVomSceneOptions = {},
|
options: BuildVomSceneOptions = {},
|
||||||
): VomScene {
|
): VomScene {
|
||||||
const scene = buildSemanticVomScene({
|
const scene = buildSemanticVomScene({
|
||||||
@@ -833,7 +673,7 @@ export function buildFrameVomScene(
|
|||||||
function attachCapturedSceneAnnotations(
|
function attachCapturedSceneAnnotations(
|
||||||
scene: VomScene,
|
scene: VomScene,
|
||||||
documents: VomFrameDocument[],
|
documents: VomFrameDocument[],
|
||||||
captured: CapturedViewModel,
|
captured: CapturedSceneInput,
|
||||||
surfaceProbes: CapturedSurfaceProbe[],
|
surfaceProbes: CapturedSurfaceProbe[],
|
||||||
): VomScene {
|
): VomScene {
|
||||||
const rootDocument = documents.find((document) => document.frameId === scene.rootFrameId);
|
const rootDocument = documents.find((document) => document.frameId === scene.rootFrameId);
|
||||||
@@ -983,45 +823,6 @@ export async function handleGetHtml(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyCapturedViewModel(viewport = { width: 0, height: 0 }): CapturedViewModel {
|
|
||||||
return { viewport, nodes: [], iframeNodes: new Map(), excludedBackendNodeIds: new Set() };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fallbackCapturedViewModel(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
geometry: GeometryContext,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<CapturedViewModel> {
|
|
||||||
throwIfAborted(signal, "observation");
|
|
||||||
let viewport = { width: 0, height: 0 };
|
|
||||||
try {
|
|
||||||
const metrics = await geometry.layoutMetrics({ tabId });
|
|
||||||
throwIfAborted(signal, "observation");
|
|
||||||
const source = cssViewport(metrics);
|
|
||||||
viewport = { width: source.width, height: source.height };
|
|
||||||
} catch (error) {
|
|
||||||
if (isAbortError(error)) throw error;
|
|
||||||
}
|
|
||||||
const excludedBackendNodeIds = await collectOverlayExcludedBackendIds(cdp, tabId, signal);
|
|
||||||
throwIfAborted(signal, "observation");
|
|
||||||
return { ...emptyCapturedViewModel(viewport), excludedBackendNodeIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureForVom(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
options: CaptureVomObservationOptions,
|
|
||||||
geometry: GeometryContext,
|
|
||||||
): Promise<CapturedViewModel> {
|
|
||||||
try {
|
|
||||||
return await captureViewModel(cdp, tabId, { signal: options.signal, geometry });
|
|
||||||
} catch (error) {
|
|
||||||
if (isAbortError(error)) throw error;
|
|
||||||
return fallbackCapturedViewModel(cdp, tabId, geometry, options.signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HoverProbeOutcome {
|
export interface HoverProbeOutcome {
|
||||||
/** Whether any active hover was dispatched during this observation. */
|
/** Whether any active hover was dispatched during this observation. */
|
||||||
performed: boolean;
|
performed: boolean;
|
||||||
@@ -1054,7 +855,7 @@ const NO_HOVER_PROBES: HoverProbeOutcome = {
|
|||||||
async function runHoverProbes(
|
async function runHoverProbes(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
captured: CapturedViewModel,
|
captured: CapturedSceneInput,
|
||||||
documents: VomFrameDocument[],
|
documents: VomFrameDocument[],
|
||||||
staticSemantics: ReturnType<typeof resolveSemanticGraph>,
|
staticSemantics: ReturnType<typeof resolveSemanticGraph>,
|
||||||
options: CaptureVomObservationOptions,
|
options: CaptureVomObservationOptions,
|
||||||
@@ -1093,21 +894,9 @@ export async function captureVomObservation(
|
|||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
await cdp.ensureAttachedToUrl?.(tabId, url);
|
await cdp.ensureAttachedToUrl?.(tabId, url);
|
||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
const geometry = new GeometryContext(cdp, tabId, undefined, options.signal);
|
const facts = await captureObservationFacts<CdpAxNode>(cdp, tabId, options.signal, url);
|
||||||
const captured = await captureForVom(cdp, tabId, options, geometry);
|
const { captured, documents: normalizedDocuments } = semanticCapture(facts);
|
||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
const documents = await captureFrameData<CdpAxNode>(
|
|
||||||
cdp,
|
|
||||||
tabId,
|
|
||||||
captured,
|
|
||||||
options.signal,
|
|
||||||
geometry,
|
|
||||||
);
|
|
||||||
throwIfAborted(options.signal, "observation");
|
|
||||||
const normalizedDocuments =
|
|
||||||
documents.length === 1 && captured.iframeNodes.size > 0
|
|
||||||
? legacyFrameDocuments(documents[0].axNodes, captured, url, documents[0].target)
|
|
||||||
: documents;
|
|
||||||
const semanticGraph = buildSemanticGraph({
|
const semanticGraph = buildSemanticGraph({
|
||||||
documents: normalizedDocuments,
|
documents: normalizedDocuments,
|
||||||
viewport: captured.viewport,
|
viewport: captured.viewport,
|
||||||
@@ -1137,19 +926,41 @@ export async function captureVomObservation(
|
|||||||
);
|
);
|
||||||
// Reserve space using the renderer's character-based token estimate. Like VOM
|
// Reserve space using the renderer's character-based token estimate. Like VOM
|
||||||
// headers, this integrity notice remains visible even under a tiny token budget.
|
// headers, this integrity notice remains visible even under a tiny token budget.
|
||||||
const geometryNotice = captured.frameGeometryIssues?.length
|
const notices: string[] = [];
|
||||||
? "@warning geometry incomplete: some page or frame content has no top-level coordinates.\n"
|
if (facts.issues.some((issue) => issue.stage === "geometry"))
|
||||||
: "";
|
notices.push(
|
||||||
|
"@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),
|
||||||
|
);
|
||||||
|
if (incompleteStages.length)
|
||||||
|
notices.push(
|
||||||
|
`@warning observation incomplete: some ${incompleteStages.join(", ")} data is unavailable or omitted.`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
facts.issues.some(
|
||||||
|
(issue) => issue.stage === "identity" && issue.reason !== "identity-unverified",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
notices.push(
|
||||||
|
"@warning observation incomplete: some documents were omitted because their identity changed or could not be revalidated.",
|
||||||
|
);
|
||||||
|
if (facts.issues.some((issue) => issue.reason === "identity-unverified"))
|
||||||
|
notices.push(
|
||||||
|
"@warning document identity unverified: some retained documents could not be checked for changes during capture.",
|
||||||
|
);
|
||||||
|
const captureNotice = notices.join("\n");
|
||||||
const rendered = renderVom(decoratedScene, {
|
const rendered = renderVom(decoratedScene, {
|
||||||
maxDepth: options.maxDepth,
|
maxDepth: options.maxDepth,
|
||||||
maxTokens:
|
maxTokens:
|
||||||
!geometryNotice || options.maxTokens === undefined
|
!captureNotice || options.maxTokens === undefined
|
||||||
? options.maxTokens
|
? options.maxTokens
|
||||||
: Math.max(0, options.maxTokens - Math.ceil(geometryNotice.length / 4)),
|
: Math.max(0, options.maxTokens - Math.ceil((captureNotice.length + 1) / 4)),
|
||||||
redactValues: options.redactValues,
|
redactValues: options.redactValues,
|
||||||
activeRegionPolicy: options.activeRegionPolicy,
|
activeRegionPolicy: options.activeRegionPolicy,
|
||||||
});
|
});
|
||||||
if (geometryNotice) rendered.text += `\n${geometryNotice.trimEnd()}`;
|
if (captureNotice) rendered.text += `\n${captureNotice}`;
|
||||||
throwIfAborted(options.signal, "observation");
|
throwIfAborted(options.signal, "observation");
|
||||||
return projectRecordSafeObservation({
|
return projectRecordSafeObservation({
|
||||||
rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root",
|
rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root",
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface CdpRunner {
|
|||||||
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
||||||
detach?(tabId: number): Promise<void>;
|
detach?(tabId: number): Promise<void>;
|
||||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||||
|
getAttachmentId?(tabId: number): string | undefined;
|
||||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||||
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge";
|
import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge";
|
||||||
import type { CdpRunner } from "../../shared";
|
import { collectOverlayExcludedBackendIds, probeHoverSurfaces } from "../capture";
|
||||||
import { captureViewModel, collectOverlayExcludedBackendIds, probeHoverSurfaces } from "../capture";
|
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
||||||
|
|
||||||
// Minimal but format-accurate captureSnapshot reply: a body with one
|
// Minimal but format-accurate captureSnapshot reply: a body with one
|
||||||
// fixed full-screen overlay div carrying a password input.
|
// fixed full-screen overlay div carrying a password input.
|
||||||
@@ -27,6 +27,7 @@ function fakeSnapshotReply() {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 2],
|
parentIndex: [-1, 0, 1, 2],
|
||||||
nodeType: [1, 1, 1, 1],
|
nodeType: [1, 1, 1, 1],
|
||||||
@@ -74,6 +75,7 @@ function hoverTriggerSnapshotReply() {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeType: [1, 1, 1],
|
nodeType: [1, 1, 1],
|
||||||
@@ -118,6 +120,7 @@ function twoHoverTriggerSnapshotReply() {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 1],
|
parentIndex: [-1, 0, 1, 1],
|
||||||
nodeType: [1, 1, 1, 1],
|
nodeType: [1, 1, 1, 1],
|
||||||
@@ -171,6 +174,7 @@ function nestedHoverTriggerSnapshotReply() {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 2, 3],
|
parentIndex: [-1, 0, 1, 2, 3],
|
||||||
nodeType: [1, 1, 1, 1, 1],
|
nodeType: [1, 1, 1, 1, 1],
|
||||||
@@ -248,13 +252,11 @@ function makeCdp(snapshot: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("captureViewModel", () => {
|
describe("snapshot capture and hover", () => {
|
||||||
it("parses nodes, attrs, rects, paint order and styles", async () => {
|
it("parses nodes, attrs, rects, paint order and styles", async () => {
|
||||||
const { nodes, viewport, iframeNodes } = await captureViewModel(
|
const { nodes, viewport } = semanticCapture(
|
||||||
makeCdp(fakeSnapshotReply()),
|
await captureObservationFacts(makeCdp(fakeSnapshotReply()), 4),
|
||||||
4,
|
).captured;
|
||||||
);
|
|
||||||
expect(iframeNodes.size).toBe(0);
|
|
||||||
expect(viewport).toEqual({ width: 1000, height: 800 });
|
expect(viewport).toEqual({ width: 1000, height: 800 });
|
||||||
const div = nodes.find((n) => n.backendNodeId === 12);
|
const div = nodes.find((n) => n.backendNodeId === 12);
|
||||||
expect(div).toMatchObject({ tag: "div", position: "fixed", parentBackendNodeId: 11 });
|
expect(div).toMatchObject({ tag: "div", position: "fixed", parentBackendNodeId: 11 });
|
||||||
@@ -266,35 +268,11 @@ describe("captureViewModel", () => {
|
|||||||
|
|
||||||
it("never hovers the page while capturing", async () => {
|
it("never hovers the page while capturing", async () => {
|
||||||
const cdp = makeCdp(fakeSnapshotReply());
|
const cdp = makeCdp(fakeSnapshotReply());
|
||||||
await captureViewModel(cdp, 4);
|
semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
|
|
||||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "Input.dispatchMouseEvent", expect.anything());
|
expect(cdp.send).not.toHaveBeenCalledWith(4, "Input.dispatchMouseEvent", expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("batch-enriches form controls without per-node object resolution", async () => {
|
|
||||||
const cdp = makeCdp(fakeSnapshotReply());
|
|
||||||
const result = await captureViewModel(cdp, 4);
|
|
||||||
const input = result.nodes.find((node) => node.backendNodeId === 13);
|
|
||||||
|
|
||||||
expect(input).toMatchObject({
|
|
||||||
formState: "filled",
|
|
||||||
formPlaceholder: "secret",
|
|
||||||
attrs: { type: "password" },
|
|
||||||
});
|
|
||||||
expect(input?.formValue).toBeUndefined();
|
|
||||||
expect(input?.formDefaultValue).toBeUndefined();
|
|
||||||
expect(cdp.send).toHaveBeenCalledWith(
|
|
||||||
4,
|
|
||||||
"Runtime.evaluate",
|
|
||||||
expect.objectContaining({
|
|
||||||
serializationOptions: expect.objectContaining({ serialization: "deep" }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(cdp.send).toHaveBeenCalledWith(4, "Runtime.releaseObjectGroup", expect.anything());
|
|
||||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything());
|
|
||||||
expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs hover surface probes when explicitly enabled", async () => {
|
it("runs hover surface probes when explicitly enabled", async () => {
|
||||||
let hoverStateCalls = 0;
|
let hoverStateCalls = 0;
|
||||||
const cdp = {
|
const cdp = {
|
||||||
@@ -336,7 +314,7 @@ describe("captureViewModel", () => {
|
|||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const captured = await captureViewModel(cdp, 4);
|
const captured = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes);
|
const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||||
|
|
||||||
expect(surfaceProbes).toEqual([
|
expect(surfaceProbes).toEqual([
|
||||||
@@ -398,7 +376,7 @@ describe("captureViewModel", () => {
|
|||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const captured = await captureViewModel(cdp, 4);
|
const captured = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes);
|
const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||||
|
|
||||||
expect(surfaceProbes).toEqual([
|
expect(surfaceProbes).toEqual([
|
||||||
@@ -445,7 +423,7 @@ describe("captureViewModel", () => {
|
|||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const captured = await captureViewModel(cdp, 4);
|
const captured = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
await probeHoverSurfaces(cdp, 4, captured.nodes);
|
await probeHoverSurfaces(cdp, 4, captured.nodes);
|
||||||
|
|
||||||
expect(hoverMoves).toBe(1);
|
expect(hoverMoves).toBe(1);
|
||||||
@@ -477,6 +455,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 0, 3, 4],
|
parentIndex: [-1, 0, 1, 0, 3, 4],
|
||||||
nodeName: [i("html"), i("body"), i("div"), i(OVERLAY_HOST_NAME), i("div"), i("button")],
|
nodeName: [i("html"), i("body"), i("div"), i(OVERLAY_HOST_NAME), i("div"), i("button")],
|
||||||
@@ -507,7 +486,7 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(makeCdp(snapshot), 4)).captured;
|
||||||
const ids = nodes.map((n) => n.backendNodeId);
|
const ids = nodes.map((n) => n.backendNodeId);
|
||||||
// Real page nodes survive.
|
// Real page nodes survive.
|
||||||
expect(ids).toContain(12);
|
expect(ids).toContain(12);
|
||||||
@@ -538,9 +517,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
scrollOffsetX: 0,
|
frameId: "root",
|
||||||
scrollOffsetY: 0,
|
|
||||||
frameId: "main",
|
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 0, 3, 4],
|
parentIndex: [-1, 0, 1, 0, 3, 4],
|
||||||
nodeName: [i("html"), i("body"), i("div"), i(OVERLAY_HOST_NAME), i("div"), i("button")],
|
nodeName: [i("html"), i("body"), i("div"), i(OVERLAY_HOST_NAME), i("div"), i("button")],
|
||||||
@@ -571,12 +548,10 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { excludedBackendNodeIds, frameExcludedBackendNodeIds } = await captureViewModel(
|
const { excludedBackendNodeIds } = semanticCapture(
|
||||||
makeCdp(snapshot),
|
await captureObservationFacts(makeCdp(snapshot), 4),
|
||||||
4,
|
).captured;
|
||||||
);
|
|
||||||
expect(excludedBackendNodeIds).toEqual(new Set([13, 14, 15]));
|
expect(excludedBackendNodeIds).toEqual(new Set([13, 14, 15]));
|
||||||
expect(frameExcludedBackendNodeIds?.get("main")).toEqual(excludedBackendNodeIds);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes overlay host matched by tag name only", async () => {
|
it("excludes overlay host matched by tag name only", async () => {
|
||||||
@@ -596,6 +571,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i(OVERLAY_HOST_NAME)],
|
nodeName: [i("html"), i("body"), i(OVERLAY_HOST_NAME)],
|
||||||
@@ -618,7 +594,9 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nodes, excludedBackendNodeIds } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes, excludedBackendNodeIds } = semanticCapture(
|
||||||
|
await captureObservationFacts(makeCdp(snapshot), 4),
|
||||||
|
).captured;
|
||||||
expect(nodes.map((n) => n.backendNodeId)).not.toContain(13);
|
expect(nodes.map((n) => n.backendNodeId)).not.toContain(13);
|
||||||
expect(excludedBackendNodeIds).toEqual(new Set([13]));
|
expect(excludedBackendNodeIds).toEqual(new Set([13]));
|
||||||
});
|
});
|
||||||
@@ -641,6 +619,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("div")],
|
nodeName: [i("html"), i("body"), i("div")],
|
||||||
@@ -663,7 +642,9 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nodes, excludedBackendNodeIds } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes, excludedBackendNodeIds } = semanticCapture(
|
||||||
|
await captureObservationFacts(makeCdp(snapshot), 4),
|
||||||
|
).captured;
|
||||||
expect(nodes.map((n) => n.backendNodeId)).not.toContain(13);
|
expect(nodes.map((n) => n.backendNodeId)).not.toContain(13);
|
||||||
expect(excludedBackendNodeIds).toEqual(new Set([13]));
|
expect(excludedBackendNodeIds).toEqual(new Set([13]));
|
||||||
});
|
});
|
||||||
@@ -686,6 +667,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("div")],
|
nodeName: [i("html"), i("body"), i("div")],
|
||||||
@@ -708,7 +690,7 @@ describe("captureViewModel", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(makeCdp(snapshot), 4)).captured;
|
||||||
expect(nodes.find((n) => n.backendNodeId === 12)?.cursor).toBe("pointer");
|
expect(nodes.find((n) => n.backendNodeId === 12)?.cursor).toBe("pointer");
|
||||||
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
|
expect(nodes.find((n) => n.backendNodeId === 11)?.cursor).toBe("auto");
|
||||||
});
|
});
|
||||||
@@ -720,6 +702,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1, 1, 1],
|
parentIndex: [-1, 0, 1, 1, 1],
|
||||||
nodeName: [i("html"), i("body"), i("button"), i("button"), i("button")],
|
nodeName: [i("html"), i("body"), i("button"), i("button"), i("button")],
|
||||||
@@ -746,7 +729,7 @@ describe("captureViewModel", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { nodes } = await captureViewModel(makeCdp(snapshot), 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(makeCdp(snapshot), 4)).captured;
|
||||||
|
|
||||||
expect(nodes.find((node) => node.backendNodeId === 12)?.rendered).toBe(true);
|
expect(nodes.find((node) => node.backendNodeId === 12)?.rendered).toBe(true);
|
||||||
expect(nodes.find((node) => node.backendNodeId === 13)?.rendered).toBe(false);
|
expect(nodes.find((node) => node.backendNodeId === 13)?.rendered).toBe(false);
|
||||||
@@ -768,51 +751,12 @@ describe("captureViewModel", () => {
|
|||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(cdp, 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
const div = nodes.find((n) => n.backendNodeId === 12);
|
const div = nodes.find((n) => n.backendNodeId === 12);
|
||||||
expect(div?.localRect?.y).toBe(-200);
|
expect(div?.localRect?.y).toBe(-200);
|
||||||
expect(div?.rect).toMatchObject({ y: 0, h: 600 });
|
expect(div?.rect).toMatchObject({ y: 0, h: 600 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes snapshot-owned scroll before using a stale CSS metrics fallback", async () => {
|
|
||||||
const snapshot = {
|
|
||||||
strings: ["html", "button"],
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
frameId: "root",
|
|
||||||
scrollOffsetX: 40,
|
|
||||||
scrollOffsetY: 200,
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0],
|
|
||||||
nodeName: [0, 1],
|
|
||||||
backendNodeId: [10, 11],
|
|
||||||
attributes: [[], []],
|
|
||||||
},
|
|
||||||
layout: { nodeIndex: [1], bounds: [[200, 800, 240, 80]] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tab: number, method: string) => {
|
|
||||||
if (method === "DOMSnapshot.captureSnapshot") return snapshot;
|
|
||||||
if (method === "Page.getLayoutMetrics")
|
|
||||||
return {
|
|
||||||
visualViewport: { clientWidth: 2000 },
|
|
||||||
cssVisualViewport: { clientWidth: 1000 },
|
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 999, pageY: 999 },
|
|
||||||
};
|
|
||||||
return {};
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
};
|
|
||||||
const captured = await captureViewModel(cdp, 4);
|
|
||||||
expect(captured.nodes.find((node) => node.backendNodeId === 11)).toMatchObject({
|
|
||||||
localRect: { x: 80, y: 300, w: 120, h: 40 },
|
|
||||||
rect: { x: 80, y: 300, w: 120, h: 40 },
|
|
||||||
rendered: true,
|
|
||||||
});
|
|
||||||
expect(captured.frameGeometryIssues).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes raw snapshot bounds before viewport clipping", async () => {
|
it("normalizes raw snapshot bounds before viewport clipping", async () => {
|
||||||
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
|
const S = ["html", "body", "div", "position", "fixed", "static", "pointer-events", "auto"];
|
||||||
const i = (s: string) => S.indexOf(s);
|
const i = (s: string) => S.indexOf(s);
|
||||||
@@ -820,6 +764,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("div")],
|
nodeName: [i("html"), i("body"), i("div")],
|
||||||
@@ -856,7 +801,7 @@ describe("captureViewModel", () => {
|
|||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(cdp, 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
expect(nodes.find((n) => n.backendNodeId === 12)?.rect).toEqual({
|
expect(nodes.find((n) => n.backendNodeId === 12)?.rect).toEqual({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -892,6 +837,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
scrollOffsetX: 0,
|
scrollOffsetX: 0,
|
||||||
scrollOffsetY: 0,
|
scrollOffsetY: 0,
|
||||||
nodes: {
|
nodes: {
|
||||||
@@ -932,7 +878,7 @@ describe("captureViewModel", () => {
|
|||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(cdp, 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
const btn = nodes.find((n) => n.backendNodeId === 12);
|
const btn = nodes.find((n) => n.backendNodeId === 12);
|
||||||
expect(btn?.textContent).toBe("登录");
|
expect(btn?.textContent).toBe("登录");
|
||||||
const link = nodes.find((n) => n.backendNodeId === 14);
|
const link = nodes.find((n) => n.backendNodeId === 14);
|
||||||
@@ -942,7 +888,7 @@ describe("captureViewModel", () => {
|
|||||||
expect(body?.textContent).toBeUndefined();
|
expect(body?.textContent).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses iframe sub-documents and returns iframeNodes keyed by iframe backendNodeId", async () => {
|
it("retains explicit iframe document ownership and projected nodes", async () => {
|
||||||
const S = [
|
const S = [
|
||||||
"html",
|
"html",
|
||||||
"body",
|
"body",
|
||||||
@@ -1030,30 +976,21 @@ describe("captureViewModel", () => {
|
|||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const {
|
const facts = await captureObservationFacts(cdp, 4);
|
||||||
nodes,
|
const root = facts.documents.find((doc) => doc.frame.frameId === "main-frame")!;
|
||||||
iframeNodes,
|
const child = facts.documents.find((doc) => doc.frame.frameId === "child-frame")!;
|
||||||
frameNodes,
|
expect(facts.documents).toHaveLength(2);
|
||||||
frameOwnerBackendNodeIds,
|
expect(root.domNodes.find((n) => n.backendNodeId === 13)?.tag).toBe("iframe");
|
||||||
frameParentIds,
|
const subNodes = child.domNodes;
|
||||||
rootFrameId,
|
|
||||||
} = await captureViewModel(cdp, 4);
|
|
||||||
// Main frame has 4 nodes; iframe is included
|
|
||||||
expect(nodes.find((n) => n.backendNodeId === 13)?.tag).toBe("iframe");
|
|
||||||
// iframeNodes keyed by the <iframe> element's backendNodeId=13
|
|
||||||
expect(iframeNodes.size).toBe(1);
|
|
||||||
expect(iframeNodes.has(13)).toBe(true);
|
|
||||||
const subNodes = iframeNodes.get(13)!;
|
|
||||||
const input = subNodes.find((n) => n.backendNodeId === 101);
|
const input = subNodes.find((n) => n.backendNodeId === 101);
|
||||||
expect(input?.tag).toBe("input");
|
expect(input?.tag).toBe("input");
|
||||||
expect(input?.attrs.type).toBe("text");
|
expect(input?.attrs.type).toBe("text");
|
||||||
expect(input?.ownerFrameBackendNodeId).toBe(13);
|
expect(input?.ownerFrameBackendNodeId).toBe(13);
|
||||||
expect(input?.localRect).toEqual({ x: 0, y: 0, w: 200, h: 40 });
|
expect(input?.localRect).toEqual({ x: 0, y: 0, w: 200, h: 40 });
|
||||||
expect(input?.rect).toEqual({ x: 100, y: 300, w: 200, h: 40 });
|
expect(input?.rect).toEqual({ x: 100, y: 300, w: 200, h: 40 });
|
||||||
expect(rootFrameId).toBe("main-frame");
|
expect(facts.rootFrameId).toBe("main-frame");
|
||||||
expect(frameNodes?.get("child-frame")).toBe(subNodes);
|
expect(child.frame.ownerBackendNodeId).toBe(13);
|
||||||
expect(frameOwnerBackendNodeIds?.get("child-frame")).toBe(13);
|
expect(child.frame.parentFrameId).toBe("main-frame");
|
||||||
expect(frameParentIds?.get("child-frame")).toBe("main-frame");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("projects iframe documents independently of owner snapshot geometry", async () => {
|
it("projects iframe documents independently of owner snapshot geometry", async () => {
|
||||||
@@ -1074,6 +1011,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
scrollOffsetX: 0,
|
scrollOffsetX: 0,
|
||||||
scrollOffsetY: 0,
|
scrollOffsetY: 0,
|
||||||
nodes: {
|
nodes: {
|
||||||
@@ -1091,6 +1029,7 @@ describe("captureViewModel", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
frameId: "child",
|
||||||
scrollOffsetX: 0,
|
scrollOffsetX: 0,
|
||||||
scrollOffsetY: 0,
|
scrollOffsetY: 0,
|
||||||
nodes: {
|
nodes: {
|
||||||
@@ -1129,8 +1068,10 @@ describe("captureViewModel", () => {
|
|||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { iframeNodes } = await captureViewModel(cdp, 4);
|
const { documents } = await captureObservationFacts(cdp, 4);
|
||||||
const input = iframeNodes.get(13)?.find((n) => n.backendNodeId === 101);
|
const input = documents
|
||||||
|
.find((doc) => doc.frame.frameId === "child")
|
||||||
|
?.domNodes?.find((n) => n.backendNodeId === 101);
|
||||||
|
|
||||||
expect(input?.localRect).toEqual({ x: 20, y: 20, w: 120, h: 60 });
|
expect(input?.localRect).toEqual({ x: 20, y: 20, w: 120, h: 60 });
|
||||||
expect(input?.rect).toEqual({ x: 120, y: 320, w: 120, h: 60 });
|
expect(input?.rect).toEqual({ x: 120, y: 320, w: 120, h: 60 });
|
||||||
@@ -1154,6 +1095,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("iframe")],
|
nodeName: [i("html"), i("body"), i("iframe")],
|
||||||
@@ -1177,6 +1119,7 @@ describe("captureViewModel", () => {
|
|||||||
{
|
{
|
||||||
scrollOffsetX: 0,
|
scrollOffsetX: 0,
|
||||||
scrollOffsetY: 0,
|
scrollOffsetY: 0,
|
||||||
|
frameId: "child",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("iframe")],
|
nodeName: [i("html"), i("body"), i("iframe")],
|
||||||
@@ -1200,6 +1143,7 @@ describe("captureViewModel", () => {
|
|||||||
{
|
{
|
||||||
scrollOffsetX: 0,
|
scrollOffsetX: 0,
|
||||||
scrollOffsetY: 0,
|
scrollOffsetY: 0,
|
||||||
|
frameId: "nested",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0],
|
parentIndex: [-1, 0],
|
||||||
nodeName: [i("body"), i("input")],
|
nodeName: [i("body"), i("input")],
|
||||||
@@ -1257,9 +1201,13 @@ describe("captureViewModel", () => {
|
|||||||
) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { iframeNodes } = await captureViewModel(cdp, 4);
|
const { documents } = await captureObservationFacts(cdp, 4);
|
||||||
const nestedIframe = iframeNodes.get(13)?.find((n) => n.backendNodeId === 23);
|
const nestedIframe = documents
|
||||||
const input = iframeNodes.get(23)?.find((n) => n.backendNodeId === 31);
|
.find((doc) => doc.frame.frameId === "child")
|
||||||
|
?.domNodes?.find((n) => n.backendNodeId === 23);
|
||||||
|
const input = documents
|
||||||
|
.find((doc) => doc.frame.frameId === "nested")
|
||||||
|
?.domNodes?.find((n) => n.backendNodeId === 31);
|
||||||
|
|
||||||
expect(nestedIframe?.localRect).toEqual({ x: 5, y: 6, w: 100, h: 80 });
|
expect(nestedIframe?.localRect).toEqual({ x: 5, y: 6, w: 100, h: 80 });
|
||||||
expect(nestedIframe?.rect).toEqual({ x: 15, y: 26, w: 100, h: 80 });
|
expect(nestedIframe?.rect).toEqual({ x: 15, y: 26, w: 100, h: 80 });
|
||||||
@@ -1275,6 +1223,7 @@ describe("captureViewModel", () => {
|
|||||||
strings: S,
|
strings: S,
|
||||||
documents: [
|
documents: [
|
||||||
{
|
{
|
||||||
|
frameId: "root",
|
||||||
nodes: {
|
nodes: {
|
||||||
parentIndex: [-1, 0, 1],
|
parentIndex: [-1, 0, 1],
|
||||||
nodeName: [i("html"), i("body"), i("div")],
|
nodeName: [i("html"), i("body"), i("div")],
|
||||||
@@ -1311,7 +1260,7 @@ describe("captureViewModel", () => {
|
|||||||
throw new Error(method);
|
throw new Error(method);
|
||||||
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
|
||||||
};
|
};
|
||||||
const { nodes } = await captureViewModel(cdp, 4);
|
const { nodes } = semanticCapture(await captureObservationFacts(cdp, 4)).captured;
|
||||||
expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 / 2 - 100);
|
expect(nodes.find((n) => n.backendNodeId === 12)?.rect?.y).toBe(400 / 2 - 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1337,200 +1286,3 @@ describe("captureViewModel", () => {
|
|||||||
expect(excluded).toEqual(new Set([200, 201, 202]));
|
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[]) => ({
|
|
||||||
scrollOffsetX: 0,
|
|
||||||
scrollOffsetY: 0,
|
|
||||||
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 {
|
|
||||||
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) } };
|
|
||||||
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("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) => {
|
|
||||||
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(captured.frameGeometryIssues).toEqual([]);
|
|
||||||
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(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",
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
|
|
||||||
import type { CdpRunner } from "../../shared";
|
|
||||||
import type { CapturedNode, CapturedViewModel } from "../capture";
|
|
||||||
import { captureFrameData } from "../frame-capture";
|
|
||||||
|
|
||||||
function ownerNode(backendNodeId: number, x: number): CapturedNode {
|
|
||||||
return {
|
|
||||||
backendNodeId,
|
|
||||||
parentBackendNodeId: 1,
|
|
||||||
frameId: "main",
|
|
||||||
ownerFrameBackendNodeId: null,
|
|
||||||
tag: "iframe",
|
|
||||||
attrs: {},
|
|
||||||
rect: { x, y: 100, w: 300, h: 200 },
|
|
||||||
localRect: { x, y: 100, w: 300, h: 200 },
|
|
||||||
paintOrder: 1,
|
|
||||||
position: "static",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function childSnapshot(frameId: string, backendNodeId: number) {
|
|
||||||
const strings = [frameId, "body", "button", "static", "auto", "pointer"];
|
|
||||||
return {
|
|
||||||
strings,
|
|
||||||
documents: [
|
|
||||||
{
|
|
||||||
scrollOffsetX: 0,
|
|
||||||
scrollOffsetY: 0,
|
|
||||||
frameId,
|
|
||||||
nodes: {
|
|
||||||
parentIndex: [-1, 0],
|
|
||||||
nodeName: [1, 2],
|
|
||||||
backendNodeId: [backendNodeId - 1, backendNodeId],
|
|
||||||
attributes: [[], []] as number[][],
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
nodeIndex: [0, 1],
|
|
||||||
styles: [
|
|
||||||
[3, 4, 4],
|
|
||||||
[3, 4, 5],
|
|
||||||
],
|
|
||||||
bounds: [
|
|
||||||
[0, 0, 300, 200],
|
|
||||||
[10, 20, 100, 40],
|
|
||||||
],
|
|
||||||
paintOrders: [0, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
visualViewport: { clientWidth: 1000 },
|
|
||||||
cssVisualViewport: { clientWidth: 1000 },
|
|
||||||
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);
|
|
||||||
const captured: CapturedViewModel = {
|
|
||||||
nodes: [leftOwner, rightOwner],
|
|
||||||
viewport: { width: 1000, height: 800 },
|
|
||||||
iframeNodes: new Map(),
|
|
||||||
frameNodes: new Map([["main", [leftOwner, rightOwner]]]),
|
|
||||||
frameOwnerBackendNodeIds: new Map(),
|
|
||||||
rootFrameId: "main",
|
|
||||||
excludedBackendNodeIds: new Set(),
|
|
||||||
};
|
|
||||||
const sendToTarget = vi.fn(async (target, method) => {
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
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") {
|
|
||||||
if (target.sessionId !== "left-session") return childSnapshot("right", 201);
|
|
||||||
const snapshot = childSnapshot("left", 101);
|
|
||||||
const marker = snapshot.strings.push(OVERLAY_HOST_MARKER_ATTR) - 1;
|
|
||||||
const nodes = snapshot.documents[0].nodes;
|
|
||||||
nodes.backendNodeId.push(201); // Same id as a real button in the other target.
|
|
||||||
nodes.parentIndex.push(0);
|
|
||||||
nodes.nodeName.push(2);
|
|
||||||
nodes.attributes.push([marker, -1]);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
|
|
||||||
throw new Error(`unexpected ${method}`);
|
|
||||||
});
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tabId, method, params) => {
|
|
||||||
if (method === "Accessibility.enable") return {};
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
|
|
||||||
if (method === "DOM.getBoxModel") {
|
|
||||||
const backendNodeId = (params as { backendNodeId?: number })?.backendNodeId;
|
|
||||||
const x = backendNodeId === 10 ? 50 : 500;
|
|
||||||
return { model: { content: [x, 100, x + 300, 100, x + 300, 300, x, 300] } };
|
|
||||||
}
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return {
|
|
||||||
visualViewport: { clientWidth: 1000 },
|
|
||||||
cssVisualViewport: { clientWidth: 1000 },
|
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected root ${method}`);
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
|
||||||
getFrameGraph: vi.fn(async () => ({
|
|
||||||
rootFrameId: "main",
|
|
||||||
frames: [
|
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
|
||||||
{
|
|
||||||
frameId: "left",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 10,
|
|
||||||
target: { tabId: 4, sessionId: "left-session" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
frameId: "right",
|
|
||||||
parentFrameId: "main",
|
|
||||||
ownerBackendNodeId: 20,
|
|
||||||
target: { tabId: 4, sessionId: "right-session" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const trees = await captureFrameData(cdp, 4, captured);
|
|
||||||
|
|
||||||
expect(trees.find((tree) => tree.frameId === "left")?.excludedBackendNodeIds).toEqual(
|
|
||||||
new Set([201]),
|
|
||||||
);
|
|
||||||
expect(trees.find((tree) => tree.frameId === "right")?.excludedBackendNodeIds?.size).toBe(0);
|
|
||||||
expect(trees.map((tree) => tree.frameId)).toEqual(["main", "left", "right"]);
|
|
||||||
expect(
|
|
||||||
captured.frameNodes?.get("left")?.find((node) => node.backendNodeId === 101)?.rect,
|
|
||||||
).toEqual({ x: 60, y: 120, w: 100, h: 40 });
|
|
||||||
expect(
|
|
||||||
captured.frameNodes?.get("right")?.find((node) => node.backendNodeId === 201)?.rect,
|
|
||||||
).toEqual({ x: 510, y: 120, w: 100, h: 40 });
|
|
||||||
expect(captured.frameOwnerBackendNodeIds).toEqual(
|
|
||||||
new Map([
|
|
||||||
["left", 10],
|
|
||||||
["right", 20],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(captured.frameParentIds).toEqual(
|
|
||||||
new Map([
|
|
||||||
["left", "main"],
|
|
||||||
["right", "main"],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps OOPIF semantics when viewport projection is unavailable", async () => {
|
|
||||||
const owner = { ...ownerNode(10, 50), rect: null, localRect: null };
|
|
||||||
const captured: CapturedViewModel = {
|
|
||||||
nodes: [owner],
|
|
||||||
viewport: { width: 1000, height: 800 },
|
|
||||||
iframeNodes: new Map(),
|
|
||||||
frameNodes: new Map([["main", [owner]]]),
|
|
||||||
rootFrameId: "main",
|
|
||||||
excludedBackendNodeIds: new Set(),
|
|
||||||
};
|
|
||||||
const cdp: CdpRunner = {
|
|
||||||
send: vi.fn(async (_tabId, method) => {
|
|
||||||
if (method === "Accessibility.enable") return {};
|
|
||||||
if (method === "Accessibility.getFullAXTree") return { nodes: [] };
|
|
||||||
if (method === "DOM.getBoxModel") throw new Error("owner geometry unavailable");
|
|
||||||
if (method === "Page.getLayoutMetrics") {
|
|
||||||
return {
|
|
||||||
visualViewport: { clientWidth: 1000 },
|
|
||||||
cssVisualViewport: { clientWidth: 1000 },
|
|
||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected root ${method}`);
|
|
||||||
}) as CdpRunner["send"],
|
|
||||||
sendToTarget: vi.fn(async (_target, method) => {
|
|
||||||
if (method === "Page.getLayoutMetrics") throw new Error("viewport unavailable");
|
|
||||||
if (method === "DOMSnapshot.enable" || method === "Accessibility.enable") return {};
|
|
||||||
if (method === "DOMSnapshot.captureSnapshot") return childSnapshot("child", 101);
|
|
||||||
if (method === "Accessibility.getFullAXTree") {
|
|
||||||
return {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
nodeId: "button",
|
|
||||||
backendDOMNodeId: 101,
|
|
||||||
role: { type: "role", value: "button" },
|
|
||||||
name: { type: "computedString", value: "Continue" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected ${method}`);
|
|
||||||
}) as unknown 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" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const documents = await captureFrameData(cdp, 4, captured);
|
|
||||||
const childDocument = documents.find((document) => document.frameId === "child");
|
|
||||||
|
|
||||||
expect(childDocument?.axNodes).toEqual([
|
|
||||||
expect.objectContaining({ backendDOMNodeId: 101, frameId: "child" }),
|
|
||||||
]);
|
|
||||||
expect(childDocument?.domNodes.find((node) => node.backendNodeId === 101)).toEqual(
|
|
||||||
expect.objectContaining({ rect: null, localRect: null, rendered: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
import type { CdpFrameGraph } from "@/browser-driver/frame-graph";
|
||||||
import type { CapturedNode, CapturedViewModel } from "../capture";
|
import type { CapturedNode } from "../capture";
|
||||||
import { buildFrameDocuments, type FrameAxBatch, type FrameOwnedAxNode } from "../frame-document";
|
import {
|
||||||
|
buildFrameDocuments,
|
||||||
|
type FrameAxBatch,
|
||||||
|
type FrameDomInput,
|
||||||
|
type FrameOwnedAxNode,
|
||||||
|
} from "../frame-document";
|
||||||
|
|
||||||
function domNode(frameId: string, backendNodeId: number): CapturedNode {
|
function domNode(frameId: string, backendNodeId: number): CapturedNode {
|
||||||
return {
|
return {
|
||||||
@@ -17,14 +22,11 @@ function domNode(frameId: string, backendNodeId: number): CapturedNode {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function captured(frameNodes: Map<string, CapturedNode[]>): CapturedViewModel {
|
function captured(frameNodes: Map<string, CapturedNode[]>): FrameDomInput {
|
||||||
return {
|
return {
|
||||||
nodes: frameNodes.get("main") ?? [],
|
nodes: frameNodes.get("main") ?? [],
|
||||||
viewport: { width: 800, height: 600 },
|
|
||||||
iframeNodes: new Map(),
|
|
||||||
frameNodes,
|
frameNodes,
|
||||||
rootFrameId: "main",
|
rootFrameId: "main",
|
||||||
excludedBackendNodeIds: new Set(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,10 +159,12 @@ describe("buildFrameDocuments", () => {
|
|||||||
["child", [domNode("child", 9)]],
|
["child", [domNode("child", 9)]],
|
||||||
]);
|
]);
|
||||||
const capture = captured(frameNodes);
|
const capture = captured(frameNodes);
|
||||||
capture.frameOwnerBackendNodeIds = new Map([["child", 7]]);
|
|
||||||
capture.frameParentIds = new Map([["child", "left"]]);
|
|
||||||
const frames = ["main", "left", "right", "child"].map((frameId) => ({
|
const frames = ["main", "left", "right", "child"].map((frameId) => ({
|
||||||
frame: { frameId, target: { tabId: 4 } },
|
frame: {
|
||||||
|
frameId,
|
||||||
|
target: { tabId: 4 },
|
||||||
|
...(frameId === "child" ? { parentFrameId: "left", ownerBackendNodeId: 7 } : {}),
|
||||||
|
},
|
||||||
nodes: [],
|
nodes: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -171,6 +175,23 @@ describe("buildFrameDocuments", () => {
|
|||||||
ownerBackendNodeId: 7,
|
ownerBackendNodeId: 7,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it("rejects explicit AX frame ownership in a different target", async () => {
|
||||||
|
const frames = [
|
||||||
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
|
{ frameId: "remote", target: { tabId: 4, sessionId: "remote" } },
|
||||||
|
];
|
||||||
|
const unresolved = vi.fn();
|
||||||
|
const documents = await buildFrameDocuments(
|
||||||
|
{ rootFrameId: "main", frames },
|
||||||
|
[{ frame: frames[0], nodes: [{ nodeId: "wrong", frameId: "remote" }] }],
|
||||||
|
captured(new Map()),
|
||||||
|
undefined,
|
||||||
|
unresolved,
|
||||||
|
);
|
||||||
|
expect(documents.every((doc) => doc.axNodes.length === 0)).toBe(true);
|
||||||
|
expect(unresolved).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves a reverse-ordered deep AX parent chain without recursion", async () => {
|
it("resolves a reverse-ordered deep AX parent chain without recursion", async () => {
|
||||||
const frame = { frameId: "main", target: { tabId: 4 } };
|
const frame = { frameId: "main", target: { tabId: 4 } };
|
||||||
const nodes: FrameOwnedAxNode[] = Array.from({ length: 10000 }, (_, i) => ({
|
const nodes: FrameOwnedAxNode[] = Array.from({ length: 10000 }, (_, i) => ({
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import {
|
||||||
|
type CdpFrame,
|
||||||
|
type CdpFrameGraph,
|
||||||
|
type CdpTarget,
|
||||||
|
cdpTargetKey,
|
||||||
|
} from "@/browser-driver/frame-graph";
|
||||||
|
import { cssViewport, GeometryContext } from "../geometry/frame-context";
|
||||||
|
import { type CdpRunner, cdpRunnerForTarget } from "../shared";
|
||||||
|
import { collectOverlayExcludedBackendIds } from "./capture";
|
||||||
|
import {
|
||||||
|
isAbortError as isCaptureAbort,
|
||||||
|
throwIfAborted as throwCaptureAborted,
|
||||||
|
} from "./capture-abort";
|
||||||
|
import { verifyDocumentIdentity } from "./document-identity";
|
||||||
|
import type { CapturedSceneInput, DocumentIdentity } from "./facts";
|
||||||
|
import { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts";
|
||||||
|
import { enrichFormControlStates } from "./form-capture";
|
||||||
|
import {
|
||||||
|
buildFrameDocuments,
|
||||||
|
type FrameAxBatch,
|
||||||
|
type FrameDocument,
|
||||||
|
type FrameOwnedAxNode,
|
||||||
|
} from "./frame-document";
|
||||||
|
import { type NormalizedFrameDocument, normalizeSnapshot } from "./normalize";
|
||||||
|
import { describeSnapshotFrames, REQUESTED_STYLES, type SnapshotReply } from "./snapshot";
|
||||||
|
|
||||||
|
interface TargetBatch<T extends FrameOwnedAxNode> {
|
||||||
|
target: CdpTarget;
|
||||||
|
frames: CdpFrame[];
|
||||||
|
snapshotAttachmentId?: string;
|
||||||
|
documents: NormalizedFrameDocument[];
|
||||||
|
ax: FrameAxBatch<T>[];
|
||||||
|
fallbackExcluded: Set<number>;
|
||||||
|
snapshotFrameIds?: Set<string>;
|
||||||
|
rootFrameId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fixed worker pool scoped to one capture; each worker issues at most one
|
||||||
|
* collection request at a time. Geometry has its own measurement-phase bound. */
|
||||||
|
async function collectTasks<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
collect: (item: T) => Promise<void>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
let cursor = 0;
|
||||||
|
let failed = false;
|
||||||
|
let failure: unknown;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(4, items.length) }, async () => {
|
||||||
|
try {
|
||||||
|
while (cursor < items.length && !failed && !signal?.aborted) {
|
||||||
|
const item = items[cursor++];
|
||||||
|
await collect(item);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!failed) failure = error;
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// A worker's rejection must not let the capture outlive its own task scope.
|
||||||
|
// In-flight reads and their finally blocks settle before cancellation/failure.
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (failed) throw failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
tabId: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
pageUrl?: string,
|
||||||
|
): Promise<ObservationFacts<T>> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
let graph: CdpFrameGraph | undefined;
|
||||||
|
try {
|
||||||
|
graph = await cdp.getFrameGraph?.(tabId);
|
||||||
|
} catch (error) {
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
}
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
const geometry = new GeometryContext(cdp, tabId, graph, signal);
|
||||||
|
const issues: CaptureIssue[] = [];
|
||||||
|
const graphFrames = new Map(graph?.frames.map((frame) => [frame.frameId, frame]) ?? []);
|
||||||
|
const groups = new Map<string, TargetBatch<T>>();
|
||||||
|
for (const frame of graph?.frames ?? []) {
|
||||||
|
const key = cdpTargetKey(frame.target);
|
||||||
|
let group = groups.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
target: frame.target,
|
||||||
|
frames: [],
|
||||||
|
documents: [],
|
||||||
|
ax: [],
|
||||||
|
fallbackExcluded: new Set(),
|
||||||
|
};
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
group.frames.push(
|
||||||
|
!frame.target.sessionId && frame.frameId === graph?.rootFrameId && !frame.url && pageUrl
|
||||||
|
? { ...frame, url: pageUrl }
|
||||||
|
: frame,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!groups.has(cdpTargetKey({ tabId })))
|
||||||
|
groups.set(cdpTargetKey({ tabId }), {
|
||||||
|
target: { tabId },
|
||||||
|
frames: [],
|
||||||
|
documents: [],
|
||||||
|
ax: [],
|
||||||
|
fallbackExcluded: new Set(),
|
||||||
|
});
|
||||||
|
const batches = [...groups.values()];
|
||||||
|
const unavailable = (batch: TargetBatch<T>, stage: CaptureIssue["stage"], frameId?: string) =>
|
||||||
|
issues.push({ target: batch.target, frameId, stage, reason: "capture-unavailable" });
|
||||||
|
let firstFailure: unknown;
|
||||||
|
const axReady = new Set<TargetBatch<T>>();
|
||||||
|
await collectTasks(
|
||||||
|
batches,
|
||||||
|
async (batch) => {
|
||||||
|
const scoped = cdpRunnerForTarget(cdp, batch.target);
|
||||||
|
try {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
await scoped.send(tabId, "DOMSnapshot.enable", {});
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
batch.snapshotAttachmentId = cdp.getAttachmentId?.(tabId);
|
||||||
|
const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
||||||
|
computedStyles: REQUESTED_STYLES,
|
||||||
|
includePaintOrder: true,
|
||||||
|
includeDOMRects: true,
|
||||||
|
});
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
const observed = describeSnapshotFrames(snapshot, batch.target, graphFrames, pageUrl);
|
||||||
|
batch.snapshotFrameIds = observed.ids;
|
||||||
|
batch.rootFrameId = observed.rootFrameId;
|
||||||
|
// Keep graph-only frames for AX fallback, never in preference to observed membership.
|
||||||
|
batch.frames = [
|
||||||
|
...observed.frames,
|
||||||
|
...batch.frames.filter((frame) => !observed.ids.has(frame.frameId)),
|
||||||
|
];
|
||||||
|
batch.documents = await normalizeSnapshot(
|
||||||
|
snapshot,
|
||||||
|
batch.target,
|
||||||
|
batch.frames.filter((frame) => observed.ids.has(frame.frameId)),
|
||||||
|
geometry,
|
||||||
|
issues,
|
||||||
|
signal,
|
||||||
|
observed.rootFrameId,
|
||||||
|
);
|
||||||
|
const formsAvailable = await enrichFormControlStates(
|
||||||
|
scoped,
|
||||||
|
tabId,
|
||||||
|
batch.documents.map((doc) => doc.nodes),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (!formsAvailable) unavailable(batch, "forms");
|
||||||
|
const present = new Set(batch.documents.map((doc) => doc.frame.frameId));
|
||||||
|
for (const frame of batch.frames)
|
||||||
|
if (!present.has(frame.frameId)) unavailable(batch, "dom", frame.frameId);
|
||||||
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
firstFailure ??= error;
|
||||||
|
unavailable(batch, "dom");
|
||||||
|
batch.fallbackExcluded = await collectOverlayExcludedBackendIds(scoped, tabId, signal);
|
||||||
|
}
|
||||||
|
if (!batch.frames.length && !graph)
|
||||||
|
batch.frames = [
|
||||||
|
{
|
||||||
|
frameId: "root",
|
||||||
|
target: batch.target,
|
||||||
|
...(!batch.target.sessionId && pageUrl ? { url: pageUrl } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
await scoped.send(tabId, "Accessibility.enable", {});
|
||||||
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
firstFailure ??= error;
|
||||||
|
unavailable(batch, "ax");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
axReady.add(batch);
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule AX by frame across all ready targets, rather than nesting pools.
|
||||||
|
// Keep result slots in source order even when CDP replies arrive out of order.
|
||||||
|
const axTasks: { batch: TargetBatch<T>; frame: CdpFrame; result?: FrameAxBatch<T> }[] =
|
||||||
|
batches.flatMap((batch) =>
|
||||||
|
axReady.has(batch) ? batch.frames.map((frame) => ({ batch, frame })) : [],
|
||||||
|
);
|
||||||
|
await collectTasks(
|
||||||
|
axTasks,
|
||||||
|
async (task) => {
|
||||||
|
const { batch, frame } = task;
|
||||||
|
try {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
const result = await cdpRunnerForTarget(cdp, batch.target).send<{ nodes?: T[] }>(
|
||||||
|
tabId,
|
||||||
|
"Accessibility.getFullAXTree",
|
||||||
|
frame.frameId === "root" ? {} : { frameId: frame.frameId },
|
||||||
|
);
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
task.result = { frame, nodes: result.nodes ?? [] };
|
||||||
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
firstFailure ??= error;
|
||||||
|
unavailable(batch, "ax", frame.frameId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
for (const task of axTasks) if (task.result) task.batch.ax.push(task.result);
|
||||||
|
|
||||||
|
let viewport = { width: 0, height: 0 };
|
||||||
|
try {
|
||||||
|
const measured = cssViewport(await geometry.layoutMetrics({ tabId }));
|
||||||
|
viewport = { width: measured.width, height: measured.height };
|
||||||
|
} catch (error) {
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A snapshot claim outranks an old graph hint. Conflicting actual claims
|
||||||
|
// cannot be resolved by completion order or by overwriting a frameId map.
|
||||||
|
const claims = new Map<string, string>();
|
||||||
|
const invalid = new Set<string>();
|
||||||
|
for (const batch of batches) {
|
||||||
|
const targetKey = cdpTargetKey(batch.target);
|
||||||
|
for (const id of batch.snapshotFrameIds ?? []) {
|
||||||
|
const previous = claims.get(id);
|
||||||
|
if (previous !== undefined && previous !== targetKey) invalid.add(id);
|
||||||
|
claims.set(id, targetKey);
|
||||||
|
}
|
||||||
|
const accepted = new Set(batch.frames.map((frame) => frame.frameId));
|
||||||
|
for (const id of batch.snapshotFrameIds ?? []) if (!accepted.has(id)) invalid.add(id);
|
||||||
|
}
|
||||||
|
const currentFrames = batches.flatMap((batch) =>
|
||||||
|
batch.frames.filter(
|
||||||
|
(frame) =>
|
||||||
|
!claims.has(frame.frameId) || claims.get(frame.frameId) === cdpTargetKey(batch.target),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rootBatch = batches.find((batch) => !batch.target.sessionId);
|
||||||
|
const rootFrameId =
|
||||||
|
rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root";
|
||||||
|
const ownershipInvalid = new Set(invalid);
|
||||||
|
const identities = new Map<string, DocumentIdentity>();
|
||||||
|
const selected = new Map(currentFrames.map((frame) => [frame.frameId, frame]));
|
||||||
|
const checks = batches.flatMap((batch) => {
|
||||||
|
const docs = new Map(batch.documents.map((doc) => [doc.frame.frameId, doc]));
|
||||||
|
return batch.frames
|
||||||
|
.filter(
|
||||||
|
(frame) =>
|
||||||
|
!invalid.has(frame.frameId) &&
|
||||||
|
cdpTargetKey(selected.get(frame.frameId)!.target) === cdpTargetKey(batch.target),
|
||||||
|
)
|
||||||
|
.map((frame) => ({ batch, frame, doc: docs.get(frame.frameId) }));
|
||||||
|
});
|
||||||
|
await collectTasks(
|
||||||
|
checks,
|
||||||
|
async ({ batch, frame, doc }) => {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (!batch.snapshotAttachmentId || doc?.documentElementBackendNodeId === undefined) {
|
||||||
|
issues.push({
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "identity",
|
||||||
|
reason: "identity-unverified",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const identity: DocumentIdentity = {
|
||||||
|
attachmentId: batch.snapshotAttachmentId,
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
documentElementBackendNodeId: doc.documentElementBackendNodeId,
|
||||||
|
};
|
||||||
|
const verified = await verifyDocumentIdentity(cdp, identity, signal);
|
||||||
|
const status = cdp.getAttachmentId?.(tabId) === identity.attachmentId ? verified : "changed";
|
||||||
|
if (status === "current") identities.set(frame.frameId, identity);
|
||||||
|
else {
|
||||||
|
invalid.add(frame.frameId);
|
||||||
|
issues.push({
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "identity",
|
||||||
|
reason: status === "changed" ? "document-changed" : "identity-unavailable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const children = new Map<string, string[]>();
|
||||||
|
for (const frame of currentFrames) {
|
||||||
|
if (!frame.parentFrameId) continue;
|
||||||
|
const siblings = children.get(frame.parentFrameId);
|
||||||
|
if (siblings) siblings.push(frame.frameId);
|
||||||
|
else children.set(frame.parentFrameId, [frame.frameId]);
|
||||||
|
}
|
||||||
|
const blocked = [...invalid];
|
||||||
|
for (let i = 0; i < blocked.length; i++) {
|
||||||
|
for (const child of children.get(blocked[i]) ?? []) {
|
||||||
|
if (!invalid.has(child)) {
|
||||||
|
invalid.add(child);
|
||||||
|
blocked.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const frame of currentFrames) {
|
||||||
|
if (ownershipInvalid.has(frame.frameId))
|
||||||
|
issues.push({
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "ownership",
|
||||||
|
reason: "frame-ownership-unresolved",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (invalid.has(rootFrameId))
|
||||||
|
throw new Error(
|
||||||
|
ownershipInvalid.has(rootFrameId)
|
||||||
|
? "observation root document ownership is ambiguous"
|
||||||
|
: "observation document identity changed or could not be verified; observe again",
|
||||||
|
);
|
||||||
|
const frames = currentFrames.filter((frame) => !invalid.has(frame.frameId));
|
||||||
|
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||||
|
const belongs = (frame: CdpFrame) => {
|
||||||
|
const current = frameById.get(frame.frameId);
|
||||||
|
return current && cdpTargetKey(current.target) === cdpTargetKey(frame.target);
|
||||||
|
};
|
||||||
|
const decoded = new Map(
|
||||||
|
batches
|
||||||
|
.flatMap((batch) => batch.documents)
|
||||||
|
.filter((doc) => belongs(doc.frame))
|
||||||
|
.map((doc) => [doc.frame.frameId, doc]),
|
||||||
|
);
|
||||||
|
const ax = batches.flatMap((batch) => batch.ax).filter((batch) => belongs(batch.frame));
|
||||||
|
const documents = await buildFrameDocuments(
|
||||||
|
{ rootFrameId, frames },
|
||||||
|
ax,
|
||||||
|
{
|
||||||
|
nodes: [],
|
||||||
|
rootFrameId,
|
||||||
|
frameNodes: new Map([...decoded].map(([id, doc]) => [id, doc.nodes])),
|
||||||
|
frameExcludedBackendNodeIds: new Map(
|
||||||
|
[...decoded].map(([id, doc]) => [id, doc.index.excludedBackendNodeIds]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
(frame) =>
|
||||||
|
issues.push({
|
||||||
|
target: frame.target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "ownership",
|
||||||
|
reason: "frame-ownership-unresolved",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const facts: ObservationFacts<T>["documents"][number][] = [];
|
||||||
|
for (const document of documents) {
|
||||||
|
const doc = decoded.get(document.frameId);
|
||||||
|
const index = doc?.index ?? (await buildDocumentIndex([], signal));
|
||||||
|
const fallback = groups.get(cdpTargetKey(document.target))?.fallbackExcluded;
|
||||||
|
const {
|
||||||
|
domNodes,
|
||||||
|
axNodes,
|
||||||
|
contextScopeId: _scope,
|
||||||
|
excludedBackendNodeIds: _excluded,
|
||||||
|
...frame
|
||||||
|
} = document;
|
||||||
|
facts.push({
|
||||||
|
frame,
|
||||||
|
identity: identities.get(document.frameId),
|
||||||
|
index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index,
|
||||||
|
domNodes,
|
||||||
|
axNodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (firstFailure && facts.every((doc) => !doc.domNodes.length && !doc.axNodes.length))
|
||||||
|
throw firstFailure;
|
||||||
|
return { rootFrameId, viewport, documents: facts, issues, startedAt, finishedAt: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing semantic consumers get a narrow view; no raw snapshot
|
||||||
|
* data can leak through recording's allowlisted output DTO. */
|
||||||
|
export function semanticCapture<T extends FrameOwnedAxNode>(
|
||||||
|
facts: ObservationFacts<T>,
|
||||||
|
): { captured: CapturedSceneInput; documents: FrameDocument<T>[] } {
|
||||||
|
const documents = facts.documents.map((doc) => ({
|
||||||
|
...doc.frame,
|
||||||
|
contextScopeId: doc.frame.frameId,
|
||||||
|
domNodes: doc.domNodes.filter(
|
||||||
|
(node) => !doc.index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||||
|
),
|
||||||
|
axNodes: doc.axNodes,
|
||||||
|
excludedBackendNodeIds: doc.index.excludedBackendNodeIds,
|
||||||
|
}));
|
||||||
|
const root = documents.find((doc) => doc.frameId === facts.rootFrameId);
|
||||||
|
return {
|
||||||
|
documents,
|
||||||
|
captured: {
|
||||||
|
nodes: root?.domNodes ?? [],
|
||||||
|
viewport: facts.viewport,
|
||||||
|
rootFrameId: facts.rootFrameId,
|
||||||
|
excludedBackendNodeIds: root?.excludedBackendNodeIds ?? new Set(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,67 +1,18 @@
|
|||||||
// Coordinate DOMSnapshot acquisition, frame geometry and hover probes.
|
// Browser-side hover probes and overlay exclusion fallback. Static collection
|
||||||
// Snapshot decoding and form enrichment live in their own modules.
|
// lives in capture-coordinator; snapshot decoding has no browser dependency.
|
||||||
|
|
||||||
import type { Viewport } from "@browser-skill/vom";
|
|
||||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
|
||||||
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy";
|
||||||
import { OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
import { OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge";
|
||||||
import {
|
|
||||||
type FrameProjectionIssue,
|
|
||||||
type FrameProjectionState,
|
|
||||||
snapshotCoordinates,
|
|
||||||
} from "../geometry/coordinate-types";
|
|
||||||
import {
|
|
||||||
GeometryContext,
|
|
||||||
type LayoutMetrics,
|
|
||||||
snapshotLayoutScale,
|
|
||||||
} from "../geometry/frame-context";
|
|
||||||
import type { CdpRunner } from "../shared";
|
import type { CdpRunner } from "../shared";
|
||||||
import { isAbortError, throwIfAborted } from "./capture-abort";
|
|
||||||
import type { CapturedNode } from "./capture-types";
|
import type { CapturedNode } from "./capture-types";
|
||||||
import { enrichFormControlStates } from "./form-capture";
|
import type { CapturedSurfaceProbe } from "./facts";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CapturedNode,
|
||||||
|
CapturedSurfaceProbe,
|
||||||
|
} from "./facts";
|
||||||
|
|
||||||
|
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||||
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
|
import { clearHover, ProbeBudget, waitForHover } from "./hover-perception";
|
||||||
import { type FrameContext, normalizeDocument } from "./normalize";
|
|
||||||
import {
|
|
||||||
REQUESTED_STYLES,
|
|
||||||
type SnapshotDocument,
|
|
||||||
type SnapshotReply,
|
|
||||||
snapshotFrameId,
|
|
||||||
sparseIndexMap,
|
|
||||||
} from "./snapshot";
|
|
||||||
|
|
||||||
export type { CapturedNode } from "./capture-types";
|
|
||||||
|
|
||||||
export type CapturedIframeNodes = Map<number, CapturedNode[]>;
|
|
||||||
|
|
||||||
export interface CapturedSurfaceProbe {
|
|
||||||
triggerBackendNodeId: number;
|
|
||||||
triggerPoint?: { x: number; y: number };
|
|
||||||
triggerAction: "hover" | "focus" | string;
|
|
||||||
subItems: string[];
|
|
||||||
confidence?: "high" | "medium" | "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CapturedViewModel {
|
|
||||||
nodes: CapturedNode[];
|
|
||||||
viewport: Viewport;
|
|
||||||
iframeNodes: CapturedIframeNodes;
|
|
||||||
frameNodes?: Map<string, CapturedNode[]>;
|
|
||||||
frameExcludedBackendNodeIds?: Map<string, ReadonlySet<number>>;
|
|
||||||
frameOwnerBackendNodeIds?: Map<string, number>;
|
|
||||||
/** 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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CaptureViewModelOptions {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
geometry?: GeometryContext;
|
|
||||||
target?: CdpTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RuntimeEvaluateReply {
|
interface RuntimeEvaluateReply {
|
||||||
result?: {
|
result?: {
|
||||||
@@ -492,295 +443,3 @@ export async function collectOverlayExcludedBackendIds(
|
|||||||
}
|
}
|
||||||
return excluded;
|
return excluded;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParseFrameDocumentsResult {
|
|
||||||
frameGeometryIssues: FrameProjectionIssue[];
|
|
||||||
iframeNodes: CapturedIframeNodes;
|
|
||||||
frameOwnerBackendNodeIds: Map<string, number>;
|
|
||||||
frameParentIds: Map<string, string>;
|
|
||||||
frameExcludedBackendNodeIds: Map<string, ReadonlySet<number>>;
|
|
||||||
excludedBackendNodeIds: Set<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseChildFrameDocuments(
|
|
||||||
documents: SnapshotDocument[],
|
|
||||||
strings: string[],
|
|
||||||
parentDocIndex: number,
|
|
||||||
parentContext: FrameContext,
|
|
||||||
geometry: GeometryContext,
|
|
||||||
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>();
|
|
||||||
const frameExcludedBackendNodeIds = new Map<string, ReadonlySet<number>>();
|
|
||||||
const excludedBackendNodeIds = new Set<number>();
|
|
||||||
const parentDoc = documents[parentDocIndex];
|
|
||||||
const cdi = sparseIndexMap(parentDoc?.nodes?.contentDocumentIndex);
|
|
||||||
if (cdi.size === 0) {
|
|
||||||
return {
|
|
||||||
iframeNodes,
|
|
||||||
frameOwnerBackendNodeIds,
|
|
||||||
frameParentIds,
|
|
||||||
frameExcludedBackendNodeIds,
|
|
||||||
excludedBackendNodeIds,
|
|
||||||
frameGeometryIssues,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? [];
|
|
||||||
|
|
||||||
const children = [...cdi].flatMap(([nodeArrayIdx, childDocIndex]) => {
|
|
||||||
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 coordinates = snapshotCoordinates(childDoc, parentContext.layoutUnitsPerCssPixel);
|
|
||||||
const parent = parentContext.projection;
|
|
||||||
const projection: FrameProjectionState =
|
|
||||||
parent.status === "available"
|
|
||||||
? coordinates
|
|
||||||
? { status: "unavailable", source, ownerBackendNodeId: iframeBackendId }
|
|
||||||
: { status: "unavailable", source, reason: "snapshot-coordinates-unavailable" }
|
|
||||||
: { status: "blocked", source, cause: parent.status === "blocked" ? parent.cause : parent };
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
childDocIndex,
|
|
||||||
childDoc,
|
|
||||||
iframeBackendId,
|
|
||||||
source,
|
|
||||||
coordinates,
|
|
||||||
projection: projection as FrameProjectionState,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
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.
|
|
||||||
const edge = parentProjection.edges[0];
|
|
||||||
const clips = edge
|
|
||||||
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
|
|
||||||
: parentProjection.sourceClips;
|
|
||||||
let cursor = 0;
|
|
||||||
let failure: unknown;
|
|
||||||
await Promise.all(
|
|
||||||
Array.from({ length: Math.min(4, children.length) }, async () => {
|
|
||||||
while (cursor < children.length && !signal?.aborted && !failure) {
|
|
||||||
const child = children[cursor++];
|
|
||||||
if (!child.coordinates) continue;
|
|
||||||
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) {
|
|
||||||
failure ??= error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// Join all active reads, including object cleanup, before propagating abort.
|
|
||||||
if (failure) throw failure;
|
|
||||||
}
|
|
||||||
throwIfAborted(signal);
|
|
||||||
|
|
||||||
// Completion order must not change document traversal or result insertion order.
|
|
||||||
for (const {
|
|
||||||
childDocIndex,
|
|
||||||
childDoc,
|
|
||||||
iframeBackendId,
|
|
||||||
source,
|
|
||||||
projection,
|
|
||||||
coordinates,
|
|
||||||
} of children) {
|
|
||||||
throwIfAborted(signal);
|
|
||||||
if (projection.status !== "available") frameGeometryIssues.push(projection);
|
|
||||||
const childContext: FrameContext = {
|
|
||||||
frameId: source.frameId,
|
|
||||||
target: source.target,
|
|
||||||
ownerFrameBackendNodeId: iframeBackendId,
|
|
||||||
projection,
|
|
||||||
coordinates,
|
|
||||||
layoutUnitsPerCssPixel: parentContext.layoutUnitsPerCssPixel,
|
|
||||||
};
|
|
||||||
const nextVisited = new Set(visited);
|
|
||||||
nextVisited.add(childDocIndex);
|
|
||||||
const parsed = await normalizeDocument(childDoc, strings, childContext, signal);
|
|
||||||
iframeNodes.set(iframeBackendId, parsed.nodes);
|
|
||||||
if (childContext.frameId) {
|
|
||||||
frameOwnerBackendNodeIds.set(childContext.frameId, iframeBackendId);
|
|
||||||
frameExcludedBackendNodeIds.set(childContext.frameId, parsed.index.excludedBackendNodeIds);
|
|
||||||
if (parentContext.frameId) frameParentIds.set(childContext.frameId, parentContext.frameId);
|
|
||||||
}
|
|
||||||
for (const id of parsed.index.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
|
|
||||||
|
|
||||||
const nested = await parseChildFrameDocuments(
|
|
||||||
documents,
|
|
||||||
strings,
|
|
||||||
childDocIndex,
|
|
||||||
childContext,
|
|
||||||
geometry,
|
|
||||||
signal,
|
|
||||||
nextVisited,
|
|
||||||
);
|
|
||||||
frameGeometryIssues.push(...nested.frameGeometryIssues);
|
|
||||||
for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) {
|
|
||||||
iframeNodes.set(nestedFrameId, nestedNodes);
|
|
||||||
}
|
|
||||||
for (const [frameId, ownerBackendNodeId] of nested.frameOwnerBackendNodeIds) {
|
|
||||||
frameOwnerBackendNodeIds.set(frameId, ownerBackendNodeId);
|
|
||||||
}
|
|
||||||
for (const [frameId, parentFrameId] of nested.frameParentIds) {
|
|
||||||
frameParentIds.set(frameId, parentFrameId);
|
|
||||||
}
|
|
||||||
for (const [frameId, excluded] of nested.frameExcludedBackendNodeIds)
|
|
||||||
frameExcludedBackendNodeIds.set(frameId, excluded);
|
|
||||||
for (const id of nested.excludedBackendNodeIds) excludedBackendNodeIds.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
iframeNodes,
|
|
||||||
frameOwnerBackendNodeIds,
|
|
||||||
frameParentIds,
|
|
||||||
frameExcludedBackendNodeIds,
|
|
||||||
excludedBackendNodeIds,
|
|
||||||
frameGeometryIssues,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureViewModel(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
options: CaptureViewModelOptions = {},
|
|
||||||
): Promise<CapturedViewModel> {
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
const target = options.target ?? { tabId };
|
|
||||||
const geometry = options.geometry ?? new GeometryContext(cdp, tabId, undefined, options.signal);
|
|
||||||
let metrics: LayoutMetrics = {};
|
|
||||||
try {
|
|
||||||
metrics = await geometry.layoutMetrics(target);
|
|
||||||
} catch (error) {
|
|
||||||
if (isAbortError(error)) throw error;
|
|
||||||
console.debug("[bsk capture] layout metrics unavailable", error);
|
|
||||||
}
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
const viewport: Viewport = {
|
|
||||||
width: metrics.cssLayoutViewport?.clientWidth ?? 0,
|
|
||||||
height: metrics.cssLayoutViewport?.clientHeight ?? 0,
|
|
||||||
};
|
|
||||||
const layoutUnitsPerCssPixel = snapshotLayoutScale(metrics);
|
|
||||||
|
|
||||||
await cdp.send(tabId, "DOMSnapshot.enable", {});
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
const snap = await cdp.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
|
||||||
computedStyles: REQUESTED_STYLES,
|
|
||||||
includePaintOrder: true,
|
|
||||||
includeDOMRects: true,
|
|
||||||
});
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
|
|
||||||
const strings = snap.strings ?? [];
|
|
||||||
const documents = snap.documents ?? [];
|
|
||||||
const doc0 = documents[0];
|
|
||||||
if (!doc0?.nodes?.backendNodeId) {
|
|
||||||
return {
|
|
||||||
nodes: [],
|
|
||||||
viewport,
|
|
||||||
iframeNodes: new Map(),
|
|
||||||
excludedBackendNodeIds: new Set(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = { target, frameId: snapshotFrameId(doc0, strings) };
|
|
||||||
const coordinates = snapshotCoordinates(doc0, layoutUnitsPerCssPixel, {
|
|
||||||
x: metrics.cssLayoutViewport?.pageX,
|
|
||||||
y: metrics.cssLayoutViewport?.pageY,
|
|
||||||
});
|
|
||||||
const topContext: FrameContext = {
|
|
||||||
frameId: snapshotFrameId(doc0, strings),
|
|
||||||
ownerFrameBackendNodeId: null,
|
|
||||||
target,
|
|
||||||
projection:
|
|
||||||
coordinates &&
|
|
||||||
[viewport.width, viewport.height].every((size) => Number.isFinite(size) && size > 0)
|
|
||||||
? {
|
|
||||||
status: "available",
|
|
||||||
projection: {
|
|
||||||
source,
|
|
||||||
geometry: { sourceClips: [], edges: [], topViewport: viewport },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { status: "unavailable", source, reason: "snapshot-coordinates-unavailable" },
|
|
||||||
coordinates,
|
|
||||||
layoutUnitsPerCssPixel,
|
|
||||||
};
|
|
||||||
const mainParsed = await normalizeDocument(doc0, strings, topContext, options.signal);
|
|
||||||
const nodes = mainParsed.nodes;
|
|
||||||
const excludedBackendNodeIds = new Set(mainParsed.index.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,
|
|
||||||
0,
|
|
||||||
topContext,
|
|
||||||
geometry,
|
|
||||||
options.signal,
|
|
||||||
);
|
|
||||||
const iframeNodes = frameParsed.iframeNodes;
|
|
||||||
if (topContext.frameId)
|
|
||||||
frameParsed.frameExcludedBackendNodeIds.set(
|
|
||||||
topContext.frameId,
|
|
||||||
mainParsed.index.excludedBackendNodeIds,
|
|
||||||
);
|
|
||||||
for (const id of frameParsed.excludedBackendNodeIds) {
|
|
||||||
excludedBackendNodeIds.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()], options.signal);
|
|
||||||
throwIfAborted(options.signal);
|
|
||||||
|
|
||||||
const frameNodes = new Map<string, CapturedNode[]>();
|
|
||||||
if (topContext.frameId) frameNodes.set(topContext.frameId, nodes);
|
|
||||||
for (const iframeFrameNodes of iframeNodes.values()) {
|
|
||||||
const frameId = iframeFrameNodes.find((node) => node.frameId)?.frameId;
|
|
||||||
if (frameId) frameNodes.set(frameId, iframeFrameNodes);
|
|
||||||
}
|
|
||||||
for (const [frameId, ownerBackendNodeId] of frameParsed.frameOwnerBackendNodeIds) {
|
|
||||||
if (!frameNodes.has(frameId)) {
|
|
||||||
frameNodes.set(frameId, iframeNodes.get(ownerBackendNodeId) ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
viewport,
|
|
||||||
iframeNodes,
|
|
||||||
frameNodes,
|
|
||||||
frameGeometryIssues: [
|
|
||||||
...(topContext.projection.status === "available" ? [] : [topContext.projection]),
|
|
||||||
...frameParsed.frameGeometryIssues,
|
|
||||||
],
|
|
||||||
frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds,
|
|
||||||
frameExcludedBackendNodeIds: frameParsed.frameExcludedBackendNodeIds,
|
|
||||||
frameParentIds: frameParsed.frameParentIds,
|
|
||||||
...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}),
|
|
||||||
excludedBackendNodeIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { CdpRunner } from "../shared";
|
||||||
|
import { sendToCdpTarget } from "../shared";
|
||||||
|
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||||
|
import type { DocumentIdentity } from "./facts";
|
||||||
|
|
||||||
|
/** Compare the snapshot root with the current root in that exact frame.
|
||||||
|
* Deep serialization supplies the backend ID without a separate describeNode. */
|
||||||
|
export async function verifyDocumentIdentity(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
identity: DocumentIdentity,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<"current" | "changed" | "unavailable"> {
|
||||||
|
const attached = () => cdp.getAttachmentId?.(identity.target.tabId) === identity.attachmentId;
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (!attached()) return "changed";
|
||||||
|
const objectGroup = `bsk-document-identity-${crypto.randomUUID()}`;
|
||||||
|
const send = <T>(method: string, params: object) => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
return sendToCdpTarget<T>(cdp, identity.target, method, params);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", {
|
||||||
|
frameId: identity.frameId,
|
||||||
|
worldName: "bsk-document-identity",
|
||||||
|
});
|
||||||
|
const reply = await send<{
|
||||||
|
result?: { deepSerializedValue?: { type: string; value?: { backendNodeId?: number } } };
|
||||||
|
}>("Runtime.evaluate", {
|
||||||
|
expression: "document.documentElement",
|
||||||
|
contextId: world.executionContextId,
|
||||||
|
objectGroup,
|
||||||
|
serializationOptions: {
|
||||||
|
serialization: "deep",
|
||||||
|
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!attached()) return "changed";
|
||||||
|
const root = reply.result?.deepSerializedValue;
|
||||||
|
if (root?.type === "null") return "changed";
|
||||||
|
const id = root?.type === "node" ? root.value?.backendNodeId : undefined;
|
||||||
|
if (id === undefined) return "unavailable";
|
||||||
|
return id === identity.documentElementBackendNodeId ? "current" : "changed";
|
||||||
|
} catch (error) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (isAbortError(error)) throw error;
|
||||||
|
return attached() ? "unavailable" : "changed";
|
||||||
|
} finally {
|
||||||
|
await sendToCdpTarget(cdp, identity.target, "Runtime.releaseObjectGroup", {
|
||||||
|
objectGroup,
|
||||||
|
}).catch(() => {});
|
||||||
|
throwIfAborted(signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import type { Viewport } from "@browser-skill/vom";
|
||||||
|
import type { CdpFrame, CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
import { isOverlayHostNode } from "@/lib/overlay-bridge";
|
import { isOverlayHostNode } from "@/lib/overlay-bridge";
|
||||||
|
import type { FrameProjectionIssue } from "../geometry/coordinate-types";
|
||||||
import { createCaptureCheckpoint } from "./capture-abort";
|
import { createCaptureCheckpoint } from "./capture-abort";
|
||||||
import type { CapturedNode } from "./capture-types";
|
import type { CapturedNode } from "./capture-types";
|
||||||
|
import type { FrameOwnedAxNode } from "./frame-document";
|
||||||
|
|
||||||
/** Bounds retain their raw snapshot document layout units until normalization. */
|
/** Bounds retain their raw snapshot document layout units until normalization. */
|
||||||
export interface SnapshotLayout {
|
export interface SnapshotLayout {
|
||||||
@@ -92,3 +96,60 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
|||||||
}
|
}
|
||||||
return { nodes, excludedBackendNodeIds };
|
return { nodes, excludedBackendNodeIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocumentIdentity {
|
||||||
|
attachmentId: string;
|
||||||
|
target: CdpTarget;
|
||||||
|
frameId: string;
|
||||||
|
documentElementBackendNodeId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow input shared by the existing semantic scene/hover consumers. */
|
||||||
|
export interface CapturedSceneInput {
|
||||||
|
nodes: CapturedNode[];
|
||||||
|
viewport: Viewport;
|
||||||
|
rootFrameId?: string;
|
||||||
|
excludedBackendNodeIds: ReadonlySet<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureIssue {
|
||||||
|
/** Owner failure or inherited blockage; absent for other geometry failures. */
|
||||||
|
projectionIssue?: FrameProjectionIssue;
|
||||||
|
target: CdpTarget;
|
||||||
|
frameId?: string;
|
||||||
|
stage: "dom" | "ax" | "identity" | "ownership" | "geometry" | "forms";
|
||||||
|
reason:
|
||||||
|
| "document-changed"
|
||||||
|
| "identity-unavailable"
|
||||||
|
| "identity-unverified"
|
||||||
|
| "capture-unavailable"
|
||||||
|
| "frame-ownership-unresolved"
|
||||||
|
| "geometry-unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentFacts<T extends FrameOwnedAxNode> {
|
||||||
|
readonly frame: CdpFrame;
|
||||||
|
readonly identity?: DocumentIdentity;
|
||||||
|
readonly index: DocumentIndex;
|
||||||
|
readonly domNodes: CapturedNode[];
|
||||||
|
readonly axNodes: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationFacts<T extends FrameOwnedAxNode> {
|
||||||
|
readonly rootFrameId: string;
|
||||||
|
readonly viewport: Viewport;
|
||||||
|
readonly documents: readonly DocumentFacts<T>[];
|
||||||
|
readonly issues: readonly CaptureIssue[];
|
||||||
|
readonly startedAt: number;
|
||||||
|
readonly finishedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapturedSurfaceProbe {
|
||||||
|
triggerBackendNodeId: number;
|
||||||
|
triggerPoint?: { x: number; y: number };
|
||||||
|
triggerAction: "hover" | "focus" | string;
|
||||||
|
subItems: string[];
|
||||||
|
confidence?: "high" | "medium" | "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CapturedNode } from "./capture-types";
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import type { CdpFrame, CdpFrameGraph } from "@/browser-driver/frame-graph";
|
|
||||||
import { type GeometryProjection, projectRectToViewport } from "../geometry";
|
|
||||||
import { GeometryContext } from "../geometry/frame-context";
|
|
||||||
import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared";
|
|
||||||
import { type CapturedNode, type CapturedViewModel, captureViewModel } from "./capture";
|
|
||||||
import {
|
|
||||||
buildFrameDocuments,
|
|
||||||
type FrameAxBatch,
|
|
||||||
type FrameDocument,
|
|
||||||
type FrameOwnedAxNode,
|
|
||||||
} from "./frame-document";
|
|
||||||
|
|
||||||
export type FrameAxNode = FrameOwnedAxNode;
|
|
||||||
export type CapturedFrameDocument<T extends FrameAxNode> = FrameDocument<T>;
|
|
||||||
|
|
||||||
function throwIfAborted(signal?: AbortSignal): void {
|
|
||||||
if (signal?.aborted) throw new DOMException("observation aborted", "AbortError");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbortError(err: unknown): boolean {
|
|
||||||
return err instanceof Error && err.name === "AbortError";
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformFrameNodes(
|
|
||||||
nodes: CapturedNode[],
|
|
||||||
projection: GeometryProjection | null,
|
|
||||||
): CapturedNode[] {
|
|
||||||
return nodes.map((node) => ({
|
|
||||||
...node,
|
|
||||||
rect: projection
|
|
||||||
? (() => {
|
|
||||||
const bounds = projectRectToViewport(node.rect, projection);
|
|
||||||
return bounds ? { x: bounds.x, y: bounds.y, w: bounds.width, h: bounds.height } : null;
|
|
||||||
})()
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureMissingFrameDocuments(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
graph: CdpFrameGraph,
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
geometry: GeometryContext,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!captured.frameNodes) captured.frameNodes = new Map();
|
|
||||||
if (!captured.frameOwnerBackendNodeIds) captured.frameOwnerBackendNodeIds = new Map();
|
|
||||||
if (!captured.frameParentIds) captured.frameParentIds = new Map();
|
|
||||||
|
|
||||||
for (const frame of graph.frames) {
|
|
||||||
if (!frame.target.sessionId || captured.frameNodes.has(frame.frameId)) continue;
|
|
||||||
try {
|
|
||||||
const child = await captureViewModel(cdpRunnerForTarget(cdp, frame.target), tabId, {
|
|
||||||
signal,
|
|
||||||
geometry,
|
|
||||||
target: frame.target,
|
|
||||||
});
|
|
||||||
if (child.frameExcludedBackendNodeIds?.size) {
|
|
||||||
captured.frameExcludedBackendNodeIds ??= new Map();
|
|
||||||
for (const [id, excluded] of child.frameExcludedBackendNodeIds)
|
|
||||||
captured.frameExcludedBackendNodeIds.set(id, excluded);
|
|
||||||
}
|
|
||||||
if (child.frameGeometryIssues?.length) {
|
|
||||||
(captured.frameGeometryIssues ??= []).push(...child.frameGeometryIssues);
|
|
||||||
}
|
|
||||||
let projection: GeometryProjection | null = null;
|
|
||||||
try {
|
|
||||||
projection = await geometry.targetProjection(frame.frameId);
|
|
||||||
} catch (err) {
|
|
||||||
if (isAbortError(err)) throw err;
|
|
||||||
console.debug("[bsk observation] child frame geometry projection failed", {
|
|
||||||
frameId: frame.frameId,
|
|
||||||
err,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const rootFrameId = child.rootFrameId ?? frame.frameId;
|
|
||||||
const childFrames = child.frameNodes ?? new Map<string, CapturedNode[]>();
|
|
||||||
if (!childFrames.has(rootFrameId)) childFrames.set(rootFrameId, child.nodes);
|
|
||||||
if (rootFrameId !== frame.frameId && !childFrames.has(frame.frameId)) {
|
|
||||||
childFrames.set(
|
|
||||||
frame.frameId,
|
|
||||||
child.nodes.map((node) => ({ ...node, frameId: frame.frameId })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [childFrameId, nodes] of childFrames) {
|
|
||||||
captured.frameNodes.set(childFrameId, transformFrameNodes(nodes, projection));
|
|
||||||
}
|
|
||||||
if (frame.ownerBackendNodeId !== undefined) {
|
|
||||||
captured.iframeNodes.set(
|
|
||||||
frame.ownerBackendNodeId,
|
|
||||||
captured.frameNodes.get(frame.frameId) ?? [],
|
|
||||||
);
|
|
||||||
captured.frameOwnerBackendNodeIds.set(frame.frameId, frame.ownerBackendNodeId);
|
|
||||||
}
|
|
||||||
if (frame.parentFrameId) captured.frameParentIds.set(frame.frameId, frame.parentFrameId);
|
|
||||||
for (const [childFrameId, ownerBackendNodeId] of child.frameOwnerBackendNodeIds ?? []) {
|
|
||||||
captured.frameOwnerBackendNodeIds.set(childFrameId, ownerBackendNodeId);
|
|
||||||
}
|
|
||||||
for (const [childFrameId, parentFrameId] of child.frameParentIds ?? []) {
|
|
||||||
captured.frameParentIds.set(childFrameId, parentFrameId);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isAbortError(err)) throw err;
|
|
||||||
console.debug("[bsk observation] child frame DOM capture failed", {
|
|
||||||
frameId: frame.frameId,
|
|
||||||
err,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureAxTrees<T extends FrameAxNode>(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
discoveredFrames: CdpFrame[],
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<FrameAxBatch<T>[]> {
|
|
||||||
let frames = [...discoveredFrames];
|
|
||||||
const capturedFrameIds = [...(captured.frameNodes?.keys() ?? [])];
|
|
||||||
if (frames.length === 0) {
|
|
||||||
const rootFrameId = captured.rootFrameId ?? capturedFrameIds[0] ?? "root";
|
|
||||||
frames = [{ frameId: rootFrameId, target: { tabId } }];
|
|
||||||
}
|
|
||||||
for (const frameId of capturedFrameIds) {
|
|
||||||
if (!frames.some((frame) => frame.frameId === frameId)) {
|
|
||||||
frames.push({ frameId, target: { tabId } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let firstFailure: unknown;
|
|
||||||
const trees = await Promise.all(
|
|
||||||
frames.map(async (frame): Promise<FrameAxBatch<T>> => {
|
|
||||||
try {
|
|
||||||
throwIfAborted(signal);
|
|
||||||
await sendToCdpTarget(cdp, frame.target, "Accessibility.enable", {});
|
|
||||||
const result = await sendToCdpTarget<{ nodes?: T[] }>(
|
|
||||||
cdp,
|
|
||||||
frame.target,
|
|
||||||
"Accessibility.getFullAXTree",
|
|
||||||
frame.frameId === "root" ? {} : { frameId: frame.frameId },
|
|
||||||
);
|
|
||||||
throwIfAborted(signal);
|
|
||||||
return {
|
|
||||||
frame,
|
|
||||||
nodes: result.nodes ?? [],
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
if (isAbortError(err)) throw err;
|
|
||||||
firstFailure ??= err;
|
|
||||||
console.debug("[bsk observation] frame AX capture failed", {
|
|
||||||
frameId: frame.frameId,
|
|
||||||
err,
|
|
||||||
});
|
|
||||||
return { frame, nodes: [] };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const capturedNodeCount = [...captured.iframeNodes.values()].reduce(
|
|
||||||
(count, nodes) => count + nodes.length,
|
|
||||||
captured.nodes.length,
|
|
||||||
);
|
|
||||||
if (firstFailure && capturedNodeCount === 0 && trees.every((tree) => tree.nodes.length === 0)) {
|
|
||||||
throw firstFailure;
|
|
||||||
}
|
|
||||||
return trees;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureFrameData<T extends FrameAxNode>(
|
|
||||||
cdp: CdpRunner,
|
|
||||||
tabId: number,
|
|
||||||
captured: CapturedViewModel,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
geometry = new GeometryContext(cdp, tabId, undefined, signal),
|
|
||||||
): Promise<CapturedFrameDocument<T>[]> {
|
|
||||||
const graph = await geometry.graph();
|
|
||||||
const frames = graph?.frames ?? [];
|
|
||||||
if (graph) await captureMissingFrameDocuments(cdp, tabId, graph, captured, geometry, signal);
|
|
||||||
throwIfAborted(signal);
|
|
||||||
const batches = await captureAxTrees<T>(cdp, tabId, frames, captured, signal);
|
|
||||||
return buildFrameDocuments(graph, batches, captured, signal);
|
|
||||||
}
|
|
||||||
@@ -32,8 +32,6 @@ export interface FrameDomInput {
|
|||||||
nodes: CapturedNode[];
|
nodes: CapturedNode[];
|
||||||
rootFrameId?: string;
|
rootFrameId?: string;
|
||||||
frameNodes?: ReadonlyMap<string, CapturedNode[]>;
|
frameNodes?: ReadonlyMap<string, CapturedNode[]>;
|
||||||
frameOwnerBackendNodeIds?: ReadonlyMap<string, number>;
|
|
||||||
frameParentIds?: ReadonlyMap<string, string>;
|
|
||||||
frameExcludedBackendNodeIds?: ReadonlyMap<string, ReadonlySet<number>>;
|
frameExcludedBackendNodeIds?: ReadonlyMap<string, ReadonlySet<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,27 +57,13 @@ function targetBackendKey(target: CdpTarget, backendNodeId: number): string {
|
|||||||
function frameList<T extends FrameOwnedAxNode>(
|
function frameList<T extends FrameOwnedAxNode>(
|
||||||
graph: CdpFrameGraph | null,
|
graph: CdpFrameGraph | null,
|
||||||
batches: FrameAxBatch<T>[],
|
batches: FrameAxBatch<T>[],
|
||||||
captured: FrameDomInput,
|
|
||||||
): CdpFrame[] {
|
): CdpFrame[] {
|
||||||
const frames = new Map<string, CdpFrame>();
|
const frames = new Map<string, CdpFrame>();
|
||||||
for (const frame of graph?.frames ?? []) frames.set(frame.frameId, frame);
|
for (const frame of graph?.frames ?? []) frames.set(frame.frameId, frame);
|
||||||
for (const batch of batches) {
|
for (const batch of batches) {
|
||||||
if (!frames.has(batch.frame.frameId)) frames.set(batch.frame.frameId, batch.frame);
|
if (!frames.has(batch.frame.frameId)) frames.set(batch.frame.frameId, batch.frame);
|
||||||
}
|
}
|
||||||
for (const frameId of captured.frameNodes?.keys() ?? []) {
|
return [...frames.values()];
|
||||||
if (!frames.has(frameId))
|
|
||||||
frames.set(frameId, { frameId, target: { tabId: batches[0]?.frame.target.tabId ?? 0 } });
|
|
||||||
}
|
|
||||||
return [...frames.values()].map((frame) => {
|
|
||||||
const ownerBackendNodeId =
|
|
||||||
frame.ownerBackendNodeId ?? captured.frameOwnerBackendNodeIds?.get(frame.frameId);
|
|
||||||
const parentFrameId = frame.parentFrameId ?? captured.frameParentIds?.get(frame.frameId);
|
|
||||||
return {
|
|
||||||
...frame,
|
|
||||||
...(ownerBackendNodeId !== undefined ? { ownerBackendNodeId } : {}),
|
|
||||||
...(parentFrameId ? { parentFrameId } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
||||||
@@ -87,10 +71,11 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
batches: FrameAxBatch<T>[],
|
batches: FrameAxBatch<T>[],
|
||||||
captured: FrameDomInput,
|
captured: FrameDomInput,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
unresolved?: (frame: CdpFrame) => void,
|
||||||
): Promise<FrameDocument<T>[]> {
|
): Promise<FrameDocument<T>[]> {
|
||||||
const checkpoint = createCaptureCheckpoint(signal);
|
const checkpoint = createCaptureCheckpoint(signal);
|
||||||
let work = 0;
|
let work = 0;
|
||||||
const frames = frameList(graph, batches, captured);
|
const frames = frameList(graph, batches);
|
||||||
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||||
const rootFrameId = graph?.rootFrameId ?? captured.rootFrameId ?? frames[0]?.frameId;
|
const rootFrameId = graph?.rootFrameId ?? captured.rootFrameId ?? frames[0]?.frameId;
|
||||||
const domNodesForFrame = (frameId: string): CapturedNode[] =>
|
const domNodesForFrame = (frameId: string): CapturedNode[] =>
|
||||||
@@ -116,6 +101,7 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
}
|
}
|
||||||
nodeById.set(node.nodeId, node);
|
nodeById.set(node.nodeId, node);
|
||||||
}
|
}
|
||||||
|
let reported = false;
|
||||||
const ownershipByNodeId = new Map<string, Ownership>();
|
const ownershipByNodeId = new Map<string, Ownership>();
|
||||||
for (const node of batch.nodes) {
|
for (const node of batch.nodes) {
|
||||||
const path: T[] = [];
|
const path: T[] = [];
|
||||||
@@ -134,8 +120,18 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
}
|
}
|
||||||
if (visiting.has(current.nodeId)) break;
|
if (visiting.has(current.nodeId)) break;
|
||||||
visiting.add(current.nodeId);
|
visiting.add(current.nodeId);
|
||||||
if (current.frameId && frameById.has(current.frameId)) {
|
if (current.frameId) {
|
||||||
ownership = { frameId: current.frameId, strength: 4 };
|
const frame = frameById.get(current.frameId);
|
||||||
|
// An explicit contradiction must not fall through to a guessed owner.
|
||||||
|
if (!frame || cdpTargetKey(frame.target) !== cdpTargetKey(batch.frame.target)) {
|
||||||
|
if (!reported) {
|
||||||
|
unresolved?.(batch.frame);
|
||||||
|
reported = true;
|
||||||
|
}
|
||||||
|
ownership = { frameId: "", strength: 0 };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ownership = { frameId: frame.frameId, strength: 4 };
|
||||||
ownershipByNodeId.set(current.nodeId, ownership);
|
ownershipByNodeId.set(current.nodeId, ownership);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -160,6 +156,7 @@ export async function buildFrameDocuments<T extends FrameOwnedAxNode>(
|
|||||||
ownershipByNodeId.set(path[i].nodeId, ownership);
|
ownershipByNodeId.set(path[i].nodeId, ownership);
|
||||||
}
|
}
|
||||||
if (!ownershipByNodeId.has(node.nodeId)) ownershipByNodeId.set(node.nodeId, ownership);
|
if (!ownershipByNodeId.has(node.nodeId)) ownershipByNodeId.set(node.nodeId, ownership);
|
||||||
|
if (!ownership.frameId) continue;
|
||||||
const key = targetNodeKey(batch.frame.target, node.nodeId);
|
const key = targetNodeKey(batch.frame.target, node.nodeId);
|
||||||
const existing = candidates.get(key);
|
const existing = candidates.get(key);
|
||||||
if (!existing || ownership.strength > existing.ownership.strength) {
|
if (!existing || ownership.strength > existing.ownership.strength) {
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
import type { Viewport } from "@browser-skill/vom";
|
||||||
|
import { type CdpFrame, type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||||
|
import { type GeometryProjection, projectRectToViewport } from "../geometry";
|
||||||
import {
|
import {
|
||||||
type FrameProjectionState,
|
type FrameProjectionState,
|
||||||
projectSnapshotRect,
|
projectSnapshotRect,
|
||||||
type SnapshotCoordinates,
|
type SnapshotCoordinates,
|
||||||
|
snapshotCoordinates,
|
||||||
snapshotViewportRect,
|
snapshotViewportRect,
|
||||||
} from "../geometry/coordinate-types";
|
} from "../geometry/coordinate-types";
|
||||||
import { createCaptureCheckpoint } from "./capture-abort";
|
import {
|
||||||
import { buildDocumentIndex, type DocumentIndex, type NodeFacts } from "./facts";
|
GeometryContext,
|
||||||
import { decodeDocument, type SnapshotDocument } from "./snapshot";
|
type LayoutMetrics,
|
||||||
|
snapshotLayoutScale,
|
||||||
|
} from "../geometry/frame-context";
|
||||||
|
import {
|
||||||
|
createCaptureCheckpoint,
|
||||||
|
isAbortError as isCaptureAbort,
|
||||||
|
throwIfAborted as throwCaptureAborted,
|
||||||
|
} from "./capture-abort";
|
||||||
|
import { buildDocumentIndex, type CaptureIssue, type DocumentIndex, type NodeFacts } from "./facts";
|
||||||
|
import {
|
||||||
|
decodeDocument,
|
||||||
|
type SnapshotDocument,
|
||||||
|
type SnapshotReply,
|
||||||
|
snapshotFrameId,
|
||||||
|
} from "./snapshot";
|
||||||
|
|
||||||
export interface FrameContext {
|
export interface FrameContext {
|
||||||
frameId?: string;
|
frameId?: string;
|
||||||
ownerFrameBackendNodeId: number | null;
|
ownerFrameBackendNodeId: number | null;
|
||||||
projection: FrameProjectionState;
|
projection: FrameProjectionState | null;
|
||||||
|
targetProjection?: GeometryProjection | null;
|
||||||
target: CdpTarget;
|
target: CdpTarget;
|
||||||
coordinates: SnapshotCoordinates | null;
|
coordinates: SnapshotCoordinates | null;
|
||||||
layoutUnitsPerCssPixel: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedDocument {
|
export interface NormalizedDocument {
|
||||||
nodes: NodeFacts[];
|
nodes: NodeFacts[];
|
||||||
index: DocumentIndex;
|
index: DocumentIndex;
|
||||||
|
documentElementBackendNodeId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
|
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
|
||||||
@@ -46,10 +64,17 @@ export async function normalizeDocument(
|
|||||||
context.coordinates,
|
context.coordinates,
|
||||||
);
|
);
|
||||||
const local = input?.rect;
|
const local = input?.rect;
|
||||||
const rect =
|
let rect =
|
||||||
input && context.projection.status === "available"
|
input && context.projection?.status === "available"
|
||||||
? projectSnapshotRect(input, context.projection.projection)
|
? projectSnapshotRect(input, context.projection.projection)
|
||||||
: null;
|
: null;
|
||||||
|
if (rect && context.targetProjection !== undefined)
|
||||||
|
rect = context.targetProjection
|
||||||
|
? projectRectToViewport(
|
||||||
|
{ x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
||||||
|
context.targetProjection,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const visibility = node.layout?.styles.visibility || "visible";
|
const visibility = node.layout?.styles.visibility || "visible";
|
||||||
const opacity = node.layout?.styles.opacity || "1";
|
const opacity = node.layout?.styles.opacity || "1";
|
||||||
nodes.push({
|
nodes.push({
|
||||||
@@ -74,5 +99,227 @@ export async function normalizeDocument(
|
|||||||
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||||
),
|
),
|
||||||
index,
|
index,
|
||||||
|
documentElementBackendNodeId: nodes.find(
|
||||||
|
(node) =>
|
||||||
|
node.nodeType === 1 &&
|
||||||
|
node.parentBackendNodeId !== null &&
|
||||||
|
index.nodes.get(node.parentBackendNodeId)?.nodeType === 9,
|
||||||
|
)?.backendNodeId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NormalizedFrameDocument extends NormalizedDocument {
|
||||||
|
frame: CdpFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One target's snapshot. Source ownership is checked before interpreting any
|
||||||
|
* coordinates. Snapshot and live quad coordinates are deliberately not conflated. */
|
||||||
|
export async function normalizeSnapshot(
|
||||||
|
snapshot: SnapshotReply,
|
||||||
|
target: CdpTarget,
|
||||||
|
frames: readonly CdpFrame[],
|
||||||
|
geometry: GeometryContext,
|
||||||
|
issues: CaptureIssue[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
rootFrameId?: string,
|
||||||
|
): Promise<NormalizedFrameDocument[]> {
|
||||||
|
const strings = snapshot.strings ?? [];
|
||||||
|
const raw = snapshot.documents ?? [];
|
||||||
|
const ownerIds = new Set<number>();
|
||||||
|
for (const document of raw) {
|
||||||
|
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 frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||||
|
const sources = new Map<string, SnapshotDocument>();
|
||||||
|
for (const doc of raw) {
|
||||||
|
const id = snapshotFrameId(doc, strings);
|
||||||
|
if (!id || !frameById.has(id) || sources.has(id)) {
|
||||||
|
issues.push({
|
||||||
|
target,
|
||||||
|
frameId: id,
|
||||||
|
stage: "ownership",
|
||||||
|
reason: "frame-ownership-unresolved",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sources.set(id, doc);
|
||||||
|
}
|
||||||
|
let metrics: LayoutMetrics = {};
|
||||||
|
try {
|
||||||
|
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<string, CdpFrame[]>();
|
||||||
|
const pending: CdpFrame[] = [];
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (!frame.parentFrameId || !frameById.has(frame.parentFrameId)) pending.push(frame);
|
||||||
|
else {
|
||||||
|
const siblings = children.get(frame.parentFrameId);
|
||||||
|
if (siblings) siblings.push(frame);
|
||||||
|
else children.set(frame.parentFrameId, [frame]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const projections = new Map<string, FrameProjectionState | null>();
|
||||||
|
const result: NormalizedFrameDocument[] = [];
|
||||||
|
let targetProjection: GeometryProjection | null = null;
|
||||||
|
const targetRoot = rootFrameId ? frameById.get(rootFrameId) : undefined;
|
||||||
|
if (target.sessionId && targetRoot) {
|
||||||
|
try {
|
||||||
|
const known = await geometry.frame(targetRoot.frameId);
|
||||||
|
if (known && cdpTargetKey(known.target) === cdpTargetKey(target))
|
||||||
|
targetProjection = await geometry.targetProjection(targetRoot.frameId);
|
||||||
|
} catch (error) {
|
||||||
|
if (isCaptureAbort(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const checkpoint = createCaptureCheckpoint(signal);
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const remaining = frames[Symbol.iterator]();
|
||||||
|
for (let cursor = 0; visited.size < frames.length; cursor++) {
|
||||||
|
if (cursor >= pending.length) {
|
||||||
|
let next = remaining.next();
|
||||||
|
while (!next.done && visited.has(next.value.frameId)) next = remaining.next();
|
||||||
|
if (next.done) break;
|
||||||
|
pending.push(next.value);
|
||||||
|
}
|
||||||
|
if (cursor % 256 === 0) {
|
||||||
|
const pending = checkpoint();
|
||||||
|
if (pending) await pending;
|
||||||
|
}
|
||||||
|
const frame = pending[cursor];
|
||||||
|
if (visited.has(frame.frameId)) continue;
|
||||||
|
visited.add(frame.frameId);
|
||||||
|
for (const child of children.get(frame.frameId) ?? []) pending.push(child);
|
||||||
|
const doc = sources.get(frame.frameId);
|
||||||
|
if (!doc) continue;
|
||||||
|
const source = { target, frameId: frame.frameId };
|
||||||
|
const coordinates = snapshotCoordinates(
|
||||||
|
doc,
|
||||||
|
layoutUnitsPerCssPixel,
|
||||||
|
frame.frameId === rootFrameId
|
||||||
|
? { 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({
|
||||||
|
target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
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))
|
||||||
|
issues.push({
|
||||||
|
target,
|
||||||
|
frameId: frame.frameId,
|
||||||
|
stage: "geometry",
|
||||||
|
reason: "geometry-unavailable",
|
||||||
|
...(state && state.status !== "available" ? { projectionIssue: state } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only direct siblings share this pool. Keep normalization in its existing
|
||||||
|
// breadth-first order, independent of owner read completion order.
|
||||||
|
const siblings = (children.get(frame.frameId) ?? []).filter((child) =>
|
||||||
|
sources.has(child.frameId),
|
||||||
|
);
|
||||||
|
const edge = projection?.geometry.edges[0];
|
||||||
|
const clips = edge
|
||||||
|
? [edge.destinationQuad, ...(edge.destinationClips ?? [])]
|
||||||
|
: (projection?.geometry.sourceClips ?? []);
|
||||||
|
let next = 0;
|
||||||
|
let failure: unknown;
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(4, siblings.length) }, async () => {
|
||||||
|
while (next < siblings.length && !signal?.aborted && !failure) {
|
||||||
|
const child = siblings[next++];
|
||||||
|
const childSource = { target, frameId: child.frameId };
|
||||||
|
if (!projection) {
|
||||||
|
projections.set(
|
||||||
|
child.frameId,
|
||||||
|
state && state.status !== "available"
|
||||||
|
? {
|
||||||
|
status: "blocked",
|
||||||
|
source: childSource,
|
||||||
|
cause: state.status === "blocked" ? state.cause : state,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (child.ownerBackendNodeId === undefined) {
|
||||||
|
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,
|
||||||
|
await geometry.snapshotProjection(
|
||||||
|
childSource,
|
||||||
|
child.ownerBackendNodeId,
|
||||||
|
clips,
|
||||||
|
viewport,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failure ??= error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Join active reads and their cleanup before returning cancellation.
|
||||||
|
if (failure) throw failure;
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
const normalized = await normalizeDocument(
|
||||||
|
doc,
|
||||||
|
strings,
|
||||||
|
{
|
||||||
|
frameId: frame.frameId,
|
||||||
|
ownerFrameBackendNodeId: frame.ownerBackendNodeId ?? null,
|
||||||
|
projection: state,
|
||||||
|
target,
|
||||||
|
...(target.sessionId ? { targetProjection } : {}),
|
||||||
|
coordinates,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
result.push({ ...normalized, frame });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type CdpFrame, type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||||
import { createCaptureCheckpoint } from "./capture-abort";
|
import { createCaptureCheckpoint } from "./capture-abort";
|
||||||
import type { CapturedNode } from "./capture-types";
|
import type { CapturedNode } from "./capture-types";
|
||||||
import type { DecodedDocument, DecodedNode } from "./facts";
|
import type { DecodedDocument, DecodedNode } from "./facts";
|
||||||
@@ -23,6 +24,7 @@ interface RareBooleanData {
|
|||||||
|
|
||||||
export interface SnapshotDocument {
|
export interface SnapshotDocument {
|
||||||
frameId?: string | number;
|
frameId?: string | number;
|
||||||
|
documentURL?: number;
|
||||||
scrollOffsetX?: number;
|
scrollOffsetX?: number;
|
||||||
scrollOffsetY?: number;
|
scrollOffsetY?: number;
|
||||||
nodes?: {
|
nodes?: {
|
||||||
@@ -222,3 +224,68 @@ export async function decodeDocument(
|
|||||||
}
|
}
|
||||||
return { nodes };
|
return { nodes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot membership and same-target edges are current response evidence.
|
||||||
|
* The earlier graph only supplies compatible metadata and cross-target edges. */
|
||||||
|
export function describeSnapshotFrames(
|
||||||
|
snapshot: SnapshotReply,
|
||||||
|
target: CdpTarget,
|
||||||
|
hintById: ReadonlyMap<string, CdpFrame>,
|
||||||
|
pageUrl?: string,
|
||||||
|
): { frames: CdpFrame[]; ids: Set<string>; rootFrameId?: string } {
|
||||||
|
const raw = snapshot.documents ?? [];
|
||||||
|
const strings = snapshot.strings ?? [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const invalid = new Set<string>();
|
||||||
|
const frames = raw.map((doc) => {
|
||||||
|
const frameId = snapshotFrameId(doc, strings);
|
||||||
|
if (!frameId) return undefined;
|
||||||
|
if (ids.has(frameId)) invalid.add(frameId);
|
||||||
|
ids.add(frameId);
|
||||||
|
const hint = hintById.get(frameId);
|
||||||
|
const compatible =
|
||||||
|
hint && cdpTargetKey(hint.target) === cdpTargetKey(target) ? hint : undefined;
|
||||||
|
const url =
|
||||||
|
(doc.documentURL === undefined ? undefined : strings[doc.documentURL]) || compatible?.url;
|
||||||
|
return { frameId, target, ...(url ? { url } : {}) } as CdpFrame;
|
||||||
|
});
|
||||||
|
const root = frames[0];
|
||||||
|
if (root) {
|
||||||
|
const hint = hintById.get(root.frameId);
|
||||||
|
const parent = hint?.parentFrameId ? hintById.get(hint.parentFrameId) : undefined;
|
||||||
|
if (
|
||||||
|
target.sessionId &&
|
||||||
|
hint &&
|
||||||
|
cdpTargetKey(hint.target) === cdpTargetKey(target) &&
|
||||||
|
parent &&
|
||||||
|
cdpTargetKey(parent.target) !== cdpTargetKey(target)
|
||||||
|
) {
|
||||||
|
root.parentFrameId = parent.frameId;
|
||||||
|
root.ownerBackendNodeId = hint.ownerBackendNodeId;
|
||||||
|
}
|
||||||
|
if (!target.sessionId && !root.url && pageUrl) root.url = pageUrl;
|
||||||
|
}
|
||||||
|
const owned = new Set<string>();
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const edges = raw[i].nodes?.contentDocumentIndex;
|
||||||
|
const parent = frames[i];
|
||||||
|
if (!parent || !edges) continue;
|
||||||
|
for (let e = 0; e < edges.index.length; e++) {
|
||||||
|
const child = frames[edges.value[e]];
|
||||||
|
const owner = raw[i].nodes?.backendNodeId?.[edges.index[e]];
|
||||||
|
if (!child || owner === undefined) continue;
|
||||||
|
if (child === root || child === parent || owned.has(child.frameId)) {
|
||||||
|
invalid.add(child.frameId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
owned.add(child.frameId);
|
||||||
|
child.parentFrameId = parent.frameId;
|
||||||
|
child.ownerBackendNodeId = owner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
frames: frames.filter((frame): frame is CdpFrame => !!frame && !invalid.has(frame.frameId)),
|
||||||
|
ids,
|
||||||
|
rootFrameId: root?.frameId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user