diff --git a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts index 41b6719..fccd4e2 100644 --- a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts +++ b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts @@ -29,24 +29,6 @@ function fakeApi() { } 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 () => { const { api, onEvent } = fakeApi(); (api.sendCommand as ReturnType).mockImplementation( diff --git a/apps/extension/src/browser-driver/__tests__/frame-graph.test.ts b/apps/extension/src/browser-driver/__tests__/frame-graph.test.ts index 3838d2b..b9d4b40 100644 --- a/apps/extension/src/browser-driver/__tests__/frame-graph.test.ts +++ b/apps/extension/src/browser-driver/__tests__/frame-graph.test.ts @@ -2,24 +2,6 @@ import { describe, expect, it } from "vitest"; import { buildFrameGraph, type CdpFrameTreeNode, type CdpFrameTreeSource } from "../frame-graph"; describe("buildFrameGraph", () => { - it("preserves the owning target's loader identity", () => { - const graph = buildFrameGraph([ - { - target: { tabId: 4 }, - tree: { - frame: { id: "root", loaderId: "root-loader" }, - childFrames: [{ frame: { id: "child", loaderId: "stale-placeholder" } }], - }, - }, - { - target: { tabId: 4, sessionId: "child-session" }, - tree: { frame: { id: "child", loaderId: "child-loader" } }, - }, - ]); - expect(graph?.frames.find((frame) => frame.frameId === "root")?.loaderId).toBe("root-loader"); - expect(graph?.frames.find((frame) => frame.frameId === "child")?.loaderId).toBe("child-loader"); - }); - it("keeps sibling frames distinct and routes nested OOPIFs to their child sessions", () => { const graph = buildFrameGraph([ { diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index ddbf5ee..8af2abc 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -164,7 +164,6 @@ async function settleBeforeDeadline(promises: Promise[], deadline: number) export class ChromiumCdp { private readonly api: CdpDebuggerApi; private readonly attachedTabs = new Set(); - private readonly attachmentIds = new Map(); private readonly attachInFlight = new Map>(); private readonly detachInFlight = new Map>(); private readonly tabOwners = new Map>(); @@ -199,11 +198,6 @@ export class ChromiumCdp { 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. */ async ensureAttached(tabId: number): Promise { // Returning a tab clears the cache before Chrome finishes detaching. @@ -223,7 +217,6 @@ export class ChromiumCdp { await this.enableConsoleDomains(tabId); await this.enableNetworkDomainBestEffort(tabId); this.attachedTabs.add(tabId); - this.attachmentIds.set(tabId, crypto.randomUUID()); await this.enableFrameDiscovery({ tabId }).catch((err) => { console.debug("[bsk cdp] frame discovery unavailable", { tabId, err }); }); @@ -451,7 +444,6 @@ export class ChromiumCdp { this.attachInFlight.delete(tabId); if (!this.attachedTabs.has(tabId)) return; this.attachedTabs.delete(tabId); - this.attachmentIds.delete(tabId); this.clearDialogState(tabId); this.clearConsoleState(tabId); this.clearNetworkState(tabId); @@ -510,7 +502,6 @@ export class ChromiumCdp { this.attachInFlight.clear(); this.tabOwners.clear(); this.attachedTabs.clear(); - this.attachmentIds.clear(); this.dialogBuffers.clear(); this.dialogSequences.clear(); this.consoleBuffers.clear(); @@ -857,7 +848,6 @@ export class ChromiumCdp { const listener = (source: chrome.debugger.Debuggee, _reason: string) => { if (typeof source.tabId === "number") { this.attachedTabs.delete(source.tabId); - this.attachmentIds.delete(source.tabId); this.attachInFlight.delete(source.tabId); this.tabOwners.delete(source.tabId); this.clearDialogState(source.tabId); diff --git a/apps/extension/src/browser-driver/frame-graph.ts b/apps/extension/src/browser-driver/frame-graph.ts index 2367a5f..1dc2ead 100644 --- a/apps/extension/src/browser-driver/frame-graph.ts +++ b/apps/extension/src/browser-driver/frame-graph.ts @@ -8,7 +8,6 @@ export interface CdpFrame { parentFrameId?: string; ownerBackendNodeId?: number; url?: string; - loaderId?: string; target: CdpTarget; } @@ -22,7 +21,6 @@ export interface CdpFrameTreeNode { id: string; parentId?: string; url?: string; - loaderId?: string; }; childFrames?: CdpFrameTreeNode[]; } @@ -50,7 +48,6 @@ function mergeFrame( frameId: node.frame.id, ...(parentFrameId ? { parentFrameId } : {}), ...(url ? { url } : {}), - ...(node.frame.loaderId ? { loaderId: node.frame.loaderId } : {}), target, }); } diff --git a/apps/extension/src/tools/__tests__/observation.test.ts b/apps/extension/src/tools/__tests__/observation.test.ts index 3e5e935..b529754 100644 --- a/apps/extension/src/tools/__tests__/observation.test.ts +++ b/apps/extension/src/tools/__tests__/observation.test.ts @@ -3691,6 +3691,58 @@ describe("handleSnapshot", () => { if (maxTokens === 100) expect(Math.ceil(result.text.length / 4)).toBeLessThanOrEqual(maxTokens); }); + it.each([ + ["DOMSnapshot.captureSnapshot", "dom"], + ["Accessibility.getFullAXTree", "ax"], + ])("reports partial capture when child %s fails", async (failedMethod, stage) => { + const { cdp } = makeFrameAwareDeps("available"); + const send = cdp.sendToTarget; + cdp.sendToTarget = async (target, method, params) => { + if (method === failedMethod) throw new Error("unavailable child data"); + return send(target, method, params); + }; + for (const maxTokens of [undefined, 100, 0]) { + const result = await captureVomObservation(cdp, 4, "https://example.com", { maxTokens }); + expect(result.text).toContain(`@warning observation incomplete: some ${stage} data`); + expect(result.text.match(/@warning/g)).toHaveLength(1); + expect(result.text.startsWith("@vom 1\n")).toBe(true); + if (maxTokens === undefined) { + expect(result.frames.map((frame) => frame.frameId)).toEqual(["main", "child"]); + expect(result.truncated).toBe(false); + } + if (maxTokens === 100) + expect(Math.ceil(result.text.length / 4)).toBeLessThanOrEqual(maxTokens); + } + }); + + it("reports omitted child ownership without exposing capture internals", async () => { + const { cdp } = makeFrameAwareDeps("available"); + const send = cdp.send; + cdp.send = async (tabId, method, params) => { + const result = await send(tabId, method, params); + if (method === "DOMSnapshot.captureSnapshot") { + const child = await cdp.sendToTarget({ tabId, sessionId: "child-session" }, method, params); + (result as { documents: unknown[] }).documents.push( + ...(child as { documents: unknown[] }).documents, + ); + } + return result as never; + }; + const result = await captureVomObservation(cdp, 4, "https://example.com"); + expect(result.frames.map((frame) => frame.frameId)).toEqual(["main"]); + expect(result.text).toContain("observation incomplete: some ownership data"); + expect(result.text).not.toContain("Frame action"); + expect(result).not.toHaveProperty("issues"); + }); + + it("reports unavailable form state while retaining the observed document", async () => { + const { cdp } = makeOverlayDeps([], loginSnapshotReply(), VP_METRICS); + const result = await captureVomObservation(cdp, 4, "https://example.com"); + expect(result.text).toContain("observation incomplete: some forms data"); + expect(result.frames).toHaveLength(1); + expect(result.text).not.toContain("unexpected CDP method"); + }); + it.each([ "available", "clipped", diff --git a/apps/extension/src/tools/observation.ts b/apps/extension/src/tools/observation.ts index 00e6c42..fcef7f2 100644 --- a/apps/extension/src/tools/observation.ts +++ b/apps/extension/src/tools/observation.ts @@ -926,19 +926,29 @@ export async function captureVomObservation( ); // Reserve space using the renderer's character-based token estimate. Like VOM // headers, this integrity notice remains visible even under a tiny token budget. - const geometryNotice = facts.issues.some((issue) => issue.stage === "geometry") - ? "@warning iframe geometry incomplete: some frame content has no top-level coordinates.\n" - : ""; + const notices: string[] = []; + if (facts.issues.some((issue) => issue.stage === "geometry")) + notices.push( + "@warning iframe geometry incomplete: some frame content has no top-level coordinates.", + ); + 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.`, + ); + const captureNotice = notices.join("\n"); const rendered = renderVom(decoratedScene, { maxDepth: options.maxDepth, maxTokens: - !geometryNotice || options.maxTokens === undefined + !captureNotice || options.maxTokens === undefined ? 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, activeRegionPolicy: options.activeRegionPolicy, }); - if (geometryNotice) rendered.text += `\n${geometryNotice.trimEnd()}`; + if (captureNotice) rendered.text += `\n${captureNotice}`; throwIfAborted(options.signal, "observation"); return projectRecordSafeObservation({ rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root", diff --git a/apps/extension/src/tools/shared.ts b/apps/extension/src/tools/shared.ts index 7ae9fda..82aed79 100644 --- a/apps/extension/src/tools/shared.ts +++ b/apps/extension/src/tools/shared.ts @@ -55,7 +55,6 @@ export interface CdpRunner { sendToTarget?(target: CdpTarget, method: string, params?: object): Promise; detach?(tabId: number): Promise; getFrameGraph?(tabId: number): Promise; - getAttachmentId?(tabId: number): string | undefined; ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise; trackSessionTab?(sessionId: string, tabId: number): void; releaseSessionTab?(sessionId: string, tabId: number): Promise; diff --git a/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts b/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts index 2a8471c..11cab0b 100644 --- a/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts +++ b/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts @@ -8,51 +8,33 @@ import { buildSemanticGraph } from "../semantic-graph/build"; import { REQUESTED_STYLES } from "../snapshot"; function fixture( - options: { - frames?: CdpFrame[]; - after?: Record< - string, - { loader?: string; element?: number; missing?: boolean; unreadable?: boolean } - >; - fail?: string; - missingIdentity?: boolean; - omitDocument?: string; - attachmentChanged?: boolean; - overlay?: string; - } = {}, + options: { frames?: CdpFrame[]; fail?: string; omitDocument?: string; overlay?: string } = {}, ) { const frames = options.frames ?? [ - { frameId: "main", loaderId: "main-loader", target: { tabId: 4 } }, + { frameId: "main", target: { tabId: 4 } }, { frameId: "same", parentFrameId: "main", ownerBackendNodeId: 3, - loaderId: "same-loader", target: { tabId: 4 }, }, { frameId: "nested", parentFrameId: "same", ownerBackendNodeId: 13, - loaderId: "nested-loader", target: { tabId: 4 }, }, { frameId: "remote", parentFrameId: "main", ownerBackendNodeId: 4, - loaderId: "remote-loader", target: { tabId: 4, sessionId: "remote" }, }, ]; const elements = new Map( frames.map((frame, i) => [frame.frameId, frame.target.sessionId ? 1 : i * 10 + 1]), ); - const contexts = new Map(frames.map((frame, i) => [i + 1, frame.frameId])); - const reads = new Map(); const logs: Array<{ target: CdpTarget; method: string; params: Record }> = []; - let snapshots = 0; - const targetCount = new Set(frames.map((frame) => cdpTargetKey(frame.target))).size; const send = vi.fn( async (target: CdpTarget, method: string, params: object = {}): Promise => { logs.push({ target, method, params: params as Record }); @@ -60,55 +42,9 @@ function fixture( if (options.fail === `${target.sessionId ?? "main"}:${method}`) throw new Error("fixture failure"); let result: unknown = {}; - if (method === "Page.createIsolatedWorld") - result = { - executionContextId: frames.findIndex((frame) => frame.frameId === args.frameId) + 1, - }; - if (method === "Runtime.evaluate") - result = { result: { objectId: contexts.get(Number(args.contextId)) } }; - if (method === "DOM.describeNode") { - const id = String(args.objectId); - const count = reads.get(id) ?? 0; - reads.set(id, count + 1); - result = - options.missingIdentity || (count > 0 && options.after?.[id]?.unreadable) - ? {} - : { - node: { - backendNodeId: - count > 0 - ? (options.after?.[id]?.element ?? elements.get(id)) - : elements.get(id), - }, - }; - } - if (method === "Page.getFrameTree") { - const own = frames.filter( - (frame) => - cdpTargetKey(frame.target) === cdpTargetKey(target) && - !options.after?.[frame.frameId]?.missing, - ); - const trees = new Map( - own.map((frame) => [ - frame.frameId, - { - frame: { - id: frame.frameId, - loaderId: options.after?.[frame.frameId]?.loader ?? frame.loaderId, - }, - childFrames: [] as unknown[], - }, - ]), - ); - for (const frame of own) - if (frame.parentFrameId && trees.has(frame.parentFrameId)) - trees.get(frame.parentFrameId)!.childFrames.push(trees.get(frame.frameId)); - result = { frameTree: trees.get(own[0]?.frameId) }; - } if (method === "Page.getLayoutMetrics") result = { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } }; if (method === "DOMSnapshot.captureSnapshot") { - snapshots++; expect((params as { computedStyles: unknown }).computedStyles).toEqual(REQUESTED_STYLES); result = { strings: [ @@ -195,8 +131,6 @@ function fixture( }, ); const cdp: CdpRunner = { - getAttachmentId: () => - options.attachmentChanged && snapshots === targetCount ? "new-attachment" : "attachment", getFrameGraph: async () => ({ rootFrameId: frames[0].frameId, frames }), send: (tabId, method, params) => (send as NonNullable)({ tabId }, method, params), @@ -212,9 +146,6 @@ describe("captureObservationFacts", () => { expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(2); expect(logs.filter((call) => call.method === "Accessibility.enable")).toHaveLength(2); expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(4); - expect(facts.documents.every((doc) => doc.identity)).toBe(true); - expect(logs.filter((call) => call.method === "DOM.describeNode")).toHaveLength(8); - expect(logs.filter((call) => call.method === "Runtime.releaseObject")).toHaveLength(8); const main = facts.documents.find((doc) => doc.frame.frameId === "main")!; const remote = facts.documents.find((doc) => doc.frame.frameId === "remote")!; expect(main.index.nodes.get(2)).not.toBe(remote.index.nodes.get(2)); @@ -225,113 +156,6 @@ describe("captureObservationFacts", () => { expect(facts.finishedAt).toBeGreaterThanOrEqual(facts.startedAt); }); - it.each([ - { loader: "new-loader" }, - { element: 99 }, - { missing: true }, - ])("isolates changed child identity %j and dependent descendants", async (change) => { - const { cdp } = fixture({ after: { same: change } }); - const facts = await captureObservationFacts(cdp, 4); - expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "remote"]); - expect(facts.issues).toContainEqual( - expect.objectContaining({ frameId: "same", reason: "document-changed" }), - ); - }); - - it.each([ - { after: { main: { element: 99 } } }, - { attachmentChanged: true }, - ])("rejects a root replacement or reattachment %j", async (options) => { - await expect(captureObservationFacts(fixture(options).cdp, 4)).rejects.toThrow( - "document identity", - ); - }); - - it.each([ - "main", - "same", - ])("does not publish a previously identified %s document after identity reads fail", async (frameId) => { - const { cdp } = fixture({ after: { [frameId]: { unreadable: true } } }); - if (frameId === "main") - await expect(captureObservationFacts(cdp, 4)).rejects.toThrow("document identity"); - else { - const facts = await captureObservationFacts(cdp, 4); - expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "remote"]); - expect(facts.issues).toContainEqual( - expect.objectContaining({ frameId, reason: "identity-unverified" }), - ); - } - }); - - it("keeps newly discovered snapshot documents unverified without reading a synthetic before identity", async () => { - const { cdp, logs } = fixture(); - const originalGraph = await cdp.getFrameGraph!(4); - cdp.getFrameGraph = async () => ({ - ...originalGraph, - frames: originalGraph.frames.slice(0, 1), - }); - const facts = await captureObservationFacts(cdp, 4); - expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "same", "nested"]); - expect(facts.documents[0].identity).toBeDefined(); - for (const doc of facts.documents.slice(1)) { - expect(doc.identity).toBeUndefined(); - expect(facts.issues).toContainEqual( - expect.objectContaining({ frameId: doc.frame.frameId, reason: "identity-unverified" }), - ); - } - expect( - logs.filter((call) => call.method === "DOM.describeNode").map((call) => call.params.objectId), - ).toEqual(["main", "main"]); - }); - - it.each([ - "frame-id", - "snapshot-element", - ])("rejects root replacement evidenced by %s even if the later graph is stale", async (mode) => { - const { cdp } = fixture(); - const original = cdp.sendToTarget!; - cdp.sendToTarget = async (target, method, params) => { - const result = await original(target, method, params); - if (method === "DOMSnapshot.captureSnapshot" && !target.sessionId) { - const snapshot = result as { - documents: { frameId: string; nodes: { backendNodeId: number[] } }[]; - }; - if (mode === "frame-id") snapshot.documents[0].frameId = "replacement"; - else snapshot.documents[0].nodes.backendNodeId[2] = 999; - } - return result as never; - }; - cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params); - await expect(captureObservationFacts(cdp, 4)).rejects.toThrow("document identity"); - }); - - it("starts after-identity reads only after every target finishes and propagates cancellation during identity cleanup", async () => { - const { cdp, logs } = fixture(); - const controller = new AbortController(); - const original = cdp.sendToTarget!; - let identityCleanup = 0; - cdp.sendToTarget = async (target, method, params) => { - if (method === "Page.getFrameTree") { - expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength( - 4, - ); - } - const result = await original(target, method, params); - if ( - method === "Runtime.releaseObject" && - (params as { objectId?: string })?.objectId === "remote" - ) { - if (++identityCleanup === 2) controller.abort(); - } - return result as never; - }; - cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params); - await expect(captureObservationFacts(cdp, 4, controller.signal)).rejects.toMatchObject({ - name: "AbortError", - }); - expect(identityCleanup).toBe(2); - }); - it("does not retry failed or missing documents and retains valid AX-only semantics", async () => { const { cdp, logs } = fixture({ fail: "remote:DOMSnapshot.captureSnapshot", @@ -350,12 +174,6 @@ describe("captureObservationFacts", () => { ); }); - it("keeps missing identity explicit without manufacturing a verified document", async () => { - const facts = await captureObservationFacts(fixture({ missingIdentity: true }).cdp, 4); - expect(facts.documents.every((doc) => !doc.identity)).toBe(true); - expect(facts.issues.filter((issue) => issue.reason === "identity-unverified")).toHaveLength(4); - }); - it("excludes overlay AX in its own document without excluding equal IDs elsewhere", async () => { const facts = await captureObservationFacts(fixture({ overlay: "remote" }).cdp, 4); const { documents } = semanticCapture(facts); diff --git a/apps/extension/src/tools/vom/capture-coordinator.ts b/apps/extension/src/tools/vom/capture-coordinator.ts index a4c1f9a..bafb1a1 100644 --- a/apps/extension/src/tools/vom/capture-coordinator.ts +++ b/apps/extension/src/tools/vom/capture-coordinator.ts @@ -1,20 +1,17 @@ import { - buildFrameGraph, type CdpFrame, type CdpFrameGraph, - type CdpFrameTreeNode, type CdpTarget, cdpTargetKey, } from "@/browser-driver/frame-graph"; import { cssViewport, GeometryContext } from "../geometry/frame-context"; -import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared"; +import { type CdpRunner, cdpRunnerForTarget } from "../shared"; import { collectOverlayExcludedBackendIds } from "./capture"; import { isAbortError as isCaptureAbort, throwIfAborted as throwCaptureAborted, } from "./capture-abort"; -import { readDocumentIdentity, sameDocument } from "./document-identity"; -import type { CapturedSceneInput, DocumentIdentity } from "./facts"; +import type { CapturedSceneInput } from "./facts"; import { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts"; import { enrichFormControlStates } from "./form-capture"; import { @@ -29,7 +26,6 @@ import { describeSnapshotFrames, REQUESTED_STYLES, type SnapshotReply } from "./ interface TargetBatch { target: CdpTarget; frames: CdpFrame[]; - before: Map; documents: NormalizedFrameDocument[]; ax: FrameAxBatch[]; fallbackExcluded: Set; @@ -92,7 +88,6 @@ export async function captureObservationFacts( group = { target: frame.target, frames: [], - before: new Map(), documents: [], ax: [], fallbackExcluded: new Set(), @@ -109,7 +104,6 @@ export async function captureObservationFacts( groups.set(cdpTargetKey({ tabId }), { target: { tabId }, frames: [], - before: new Map(), documents: [], ax: [], fallbackExcluded: new Set(), @@ -122,10 +116,6 @@ export async function captureObservationFacts( batches, async (batch) => { const scoped = cdpRunnerForTarget(cdp, batch.target); - for (const frame of batch.frames) { - const identity = await readDocumentIdentity(cdp, frame, signal); - if (identity) batch.before.set(frame.frameId, identity); - } try { throwCaptureAborted(signal); await scoped.send(tabId, "DOMSnapshot.enable", {}); @@ -241,91 +231,6 @@ export async function captureObservationFacts( 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(); - const rootBefore = graph && rootBatch?.before.get(graph.rootFrameId); - if (rootBefore && rootFrameId !== rootBefore.frameId) { - invalid.add(rootFrameId); - issues.push({ - target: rootBefore.target, - frameId: rootFrameId, - stage: "identity", - reason: "document-changed", - }); - } - const selected = new Map(currentFrames.map((frame) => [frame.frameId, frame])); - await collectTargets( - batches, - async (batch) => { - const frames = batch.frames.filter( - (frame) => - !invalid.has(frame.frameId) && - cdpTargetKey(selected.get(frame.frameId)!.target) === cdpTargetKey(batch.target), - ); - if (!frames.length) return; - let fresh: Map | undefined; - if (frames.some((frame) => batch.before.has(frame.frameId))) { - try { - throwCaptureAborted(signal); - const reply = await sendToCdpTarget<{ frameTree?: CdpFrameTreeNode }>( - cdp, - batch.target, - "Page.getFrameTree", - {}, - ); - const tree = reply.frameTree - ? buildFrameGraph([{ target: batch.target, tree: reply.frameTree }]) - : null; - if (tree) fresh = new Map(tree.frames.map((frame) => [frame.frameId, frame])); - } catch (error) { - throwCaptureAborted(signal); - if (isCaptureAbort(error)) throw error; - } - } - const docs = new Map(batch.documents.map((doc) => [doc.frame.frameId, doc])); - for (const frame of frames) { - throwCaptureAborted(signal); - const before = batch.before.get(frame.frameId); - const current = fresh?.get(frame.frameId); - const after = - before && current ? await readDocumentIdentity(cdp, current, signal) : undefined; - const doc = docs.get(frame.frameId); - const snapshotMismatch = - before && - doc?.documentElementBackendNodeId !== undefined && - before.documentElementBackendNodeId !== doc.documentElementBackendNodeId; - const changed = - before && - (snapshotMismatch || - (fresh && !current) || - (after && !sameDocument(before, after)) || - cdp.getAttachmentId?.(tabId) !== before.attachmentId); - if (changed || (before && !after)) { - invalid.add(frame.frameId); - issues.push({ - target: frame.target, - frameId: frame.frameId, - stage: "identity", - reason: changed ? "document-changed" : "identity-unverified", - }); - } else if ( - before && - after && - (!doc || doc.documentElementBackendNodeId === before.documentElementBackendNodeId) - ) { - identities.set(frame.frameId, after); - } else { - issues.push({ - target: frame.target, - frameId: frame.frameId, - stage: "identity", - reason: "identity-unverified", - }); - } - } - }, - signal, - ); const children = new Map(); for (const frame of currentFrames) { if (!frame.parentFrameId) continue; @@ -343,7 +248,7 @@ export async function captureObservationFacts( } } for (const frame of currentFrames) { - if (ownershipInvalid.has(frame.frameId)) + if (invalid.has(frame.frameId)) issues.push({ target: frame.target, frameId: frame.frameId, @@ -351,12 +256,7 @@ export async function captureObservationFacts( 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", - ); + if (invalid.has(rootFrameId)) throw new Error("observation root document ownership is ambiguous"); const frames = currentFrames.filter((frame) => !invalid.has(frame.frameId)); const frameById = new Map(frames.map((frame) => [frame.frameId, frame])); const belongs = (frame: CdpFrame) => { @@ -404,7 +304,6 @@ export async function captureObservationFacts( } = document; facts.push({ frame, - identity: identities.get(document.frameId), index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index, domNodes, axNodes, diff --git a/apps/extension/src/tools/vom/document-identity.ts b/apps/extension/src/tools/vom/document-identity.ts deleted file mode 100644 index 90b6391..0000000 --- a/apps/extension/src/tools/vom/document-identity.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { CdpFrame } from "@/browser-driver/frame-graph"; -import type { CdpRunner } from "../shared"; -import { sendToCdpTarget } from "../shared"; -import { - isAbortError as isCaptureAbort, - throwIfAborted as throwCaptureAborted, -} from "./capture-abort"; -import type { DocumentIdentity } from "./facts"; - -/** Fresh, frame-scoped identity read. An isolated world supplies the owning - * document without querying each node or installing a page observer. */ -export async function readDocumentIdentity( - cdp: CdpRunner, - frame: CdpFrame, - signal?: AbortSignal, -): Promise { - const attachmentId = cdp.getAttachmentId?.(frame.target.tabId); - if (!attachmentId || !frame.loaderId) return undefined; - let objectId: string | undefined; - const send = (method: string, params: object) => { - throwCaptureAborted(signal); - return sendToCdpTarget(cdp, frame.target, method, params); - }; - try { - const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", { - frameId: frame.frameId, - worldName: "bsk-document-identity", - }); - const element = await send<{ result?: { objectId?: string } }>("Runtime.evaluate", { - expression: "document.documentElement", - contextId: world.executionContextId, - }); - objectId = element.result?.objectId; - if (!objectId) return undefined; - const reply = await send<{ node?: { backendNodeId?: number } }>("DOM.describeNode", { - objectId, - depth: 0, - }); - const backendNodeId = reply.node?.backendNodeId; - if (!backendNodeId || cdp.getAttachmentId?.(frame.target.tabId) !== attachmentId) - return undefined; - return { - attachmentId, - target: frame.target, - frameId: frame.frameId, - loaderId: frame.loaderId, - documentElementBackendNodeId: backendNodeId, - }; - } catch (error) { - throwCaptureAborted(signal); - if (isCaptureAbort(error)) throw error; - return undefined; - } finally { - if (objectId) - await sendToCdpTarget(cdp, frame.target, "Runtime.releaseObject", { objectId }).catch( - () => {}, - ); - throwCaptureAborted(signal); - } -} - -export function sameDocument(a: DocumentIdentity, b: DocumentIdentity): boolean { - return ( - a.attachmentId === b.attachmentId && - a.target.tabId === b.target.tabId && - a.target.sessionId === b.target.sessionId && - a.frameId === b.frameId && - a.loaderId === b.loaderId && - a.documentElementBackendNodeId === b.documentElementBackendNodeId - ); -} diff --git a/apps/extension/src/tools/vom/facts.ts b/apps/extension/src/tools/vom/facts.ts index cf4fe48..c6cb1ca 100644 --- a/apps/extension/src/tools/vom/facts.ts +++ b/apps/extension/src/tools/vom/facts.ts @@ -97,14 +97,6 @@ export async function buildDocumentIndex( return { nodes, excludedBackendNodeIds }; } -export interface DocumentIdentity { - attachmentId: string; - target: CdpTarget; - frameId: string; - loaderId: string; - documentElementBackendNodeId: number; -} - /** Narrow input shared by the existing semantic scene/hover consumers. */ export interface CapturedSceneInput { nodes: CapturedNode[]; @@ -118,18 +110,12 @@ export interface CaptureIssue { projectionIssue?: FrameProjectionIssue; target: CdpTarget; frameId?: string; - stage: "dom" | "ax" | "identity" | "ownership" | "geometry" | "forms"; - reason: - | "document-changed" - | "identity-unverified" - | "capture-unavailable" - | "frame-ownership-unresolved" - | "geometry-unavailable"; + stage: "dom" | "ax" | "ownership" | "geometry" | "forms"; + reason: "capture-unavailable" | "frame-ownership-unresolved" | "geometry-unavailable"; } export interface DocumentFacts { readonly frame: CdpFrame; - readonly identity?: DocumentIdentity; readonly index: DocumentIndex; readonly domNodes: CapturedNode[]; readonly axNodes: T[]; diff --git a/apps/extension/src/tools/vom/normalize.ts b/apps/extension/src/tools/vom/normalize.ts index fb9e29a..c229425 100644 --- a/apps/extension/src/tools/vom/normalize.ts +++ b/apps/extension/src/tools/vom/normalize.ts @@ -34,7 +34,6 @@ export interface FrameContext { export interface NormalizedDocument { nodes: NodeFacts[]; index: DocumentIndex; - documentElementBackendNodeId?: number; } /** Interpret one document using a supplied projection. No live reads or frame scheduling. */ @@ -95,12 +94,6 @@ export async function normalizeDocument( (node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId), ), index, - documentElementBackendNodeId: nodes.find( - (node) => - node.nodeType === 1 && - node.parentBackendNodeId !== null && - index.nodes.get(node.parentBackendNodeId)?.nodeType === 9, - )?.backendNodeId, }; }