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 0dde3b9..c0aea5e 100644 --- a/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts +++ b/apps/extension/src/tools/vom/__tests__/capture-coordinator.test.ts @@ -7,6 +7,7 @@ import type { CdpRunner } from "../../shared"; import { captureObservationFacts, semanticCapture } from "../capture-coordinator"; import { buildSemanticGraph, buildSemanticVomScene } from "../semantic-graph"; import { REQUESTED_STYLES, type SnapshotReply, VISUAL_STYLES } from "../snapshot"; +import { deduplicateVisualCandidates } from "../visual-dedup"; import { discoverVisualCandidates } from "../visual-discovery"; function fixture( @@ -1459,6 +1460,15 @@ describe("visual facts integration", () => { document: { frameId: "main", documentElementBackendNodeId: 1 }, region: { crop: { x: 10, y: 20, width: 100, height: 40 } }, }); + const deduplicated = await deduplicateVisualCandidates(result); + expect(deduplicated.candidates).toEqual(result.candidates); + expect(deduplicated.candidates[0]).toBe(result.candidates[0]); + expect(deduplicated).toMatchObject({ + candidateCount: 1, + deduplicatedCount: 0, + dedupDegraded: false, + }); + expect(logs).toHaveLength(before); expect(facts.documents[0].index.nodes.get(2)?.layout?.clientRect).toEqual([0, 0, 100, 40]); expect(facts.documents[0].geometry?.pageScale).toBe(1); const baseline = fixture({ frames: [{ frameId: "main", target: { tabId: 4 } }] }); diff --git a/apps/extension/src/tools/vom/__tests__/visual-dedup.test.ts b/apps/extension/src/tools/vom/__tests__/visual-dedup.test.ts new file mode 100644 index 0000000..2337d99 --- /dev/null +++ b/apps/extension/src/tools/vom/__tests__/visual-dedup.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { deduplicateVisualCandidates, MAX_VISUAL_DEDUP_KEYS } from "../visual-dedup"; +import type { VisualCandidate, VisualDiscoveryResult } from "../visual-discovery"; + +function candidate(id: number, width = 100, parent = 1): VisualCandidate { + const crop = { x: 0, y: 0, width, height: 100 }; + return { + document: { + attachmentId: "a", + target: { tabId: 1 }, + frameId: "main", + documentElementBackendNodeId: 1, + }, + backendNodeId: id, + parentBackendNodeId: parent, + region: { status: "available", borderBox: crop, crop }, + }; +} +function discovery(candidates: VisualCandidate[]): VisualDiscoveryResult { + return { candidates, complete: true, issues: [], captureIssues: [] }; +} + +describe("visual candidate deduplication", () => { + it("preserves small, unnamed and labelled candidates and their original evidence", async () => { + const small = { ...candidate(3, 1), label: ' A "label"\nšŸ˜€' }; + const large = candidate(2, 1000); + const input = discovery([small, large]); + Object.freeze(input.candidates); + Object.freeze(small); + Object.freeze(large); + for (let i = 0; i < 2; i++) { + const result = await deduplicateVisualCandidates(input); + expect(result.candidates).toEqual([small, large]); + expect(result.candidates[0]).toBe(small); + expect(result.candidates[1]).toBe(large); + expect(result).toMatchObject({ + candidateCount: 2, + deduplicatedCount: 0, + dedupDegraded: false, + }); + } + }); + + it("only deduplicates exact crops within the same full DOM identity and direct parent", async () => { + const base = candidate(20); + const variants = [ + candidate(30, 100, 2), + { ...candidate(40), parentBackendNodeId: null }, + ...["x", "y", "width", "height"].map((axis) => ({ + ...base, + region: { + ...base.region, + crop: { + ...base.region.crop, + [axis]: base.region.crop[axis as keyof typeof base.region.crop] + 0.001, + }, + }, + })), + ...[ + { frameId: "child" }, + { target: { tabId: 2 } }, + { target: { tabId: 1, sessionId: "remote" } }, + { documentElementBackendNodeId: 9 }, + { attachmentId: "b" }, + ].map((identity) => ({ ...base, document: { ...base.document, ...identity } })), + ]; + const duplicate = { ...candidate(10), label: "Not a new target" }; + const result = await deduplicateVisualCandidates(discovery([base, ...variants, duplicate])); + expect(result.candidates).toEqual([base, ...variants]); + expect(result.candidates[0]).toBe(base); + expect(result.deduplicatedCount).toBe(1); + }); + + it.each([ + 50_001, 100_000, + ])("keeps all %s distinct candidates including the last tiny Canvas", async (count) => { + const input = Array.from({ length: count }, (_, i) => candidate(i + 1, 100, i + 1)); + input[count - 1] = candidate(count, 1, count); + // A remembered key still deduplicates; an unremembered key passes through. + input.push({ ...input[0] }, { ...input[count - 1] }); + const result = await deduplicateVisualCandidates(discovery(input)); + expect(result.candidates).toEqual([...input.slice(0, count), input[count + 1]]); + expect(result.candidates[count - 1]).toBe(input[count - 1]); + expect(result).toMatchObject({ + candidateCount: count + 2, + deduplicatedCount: 1, + dedupDegraded: true, + }); + }); + + it("does not report degradation when every key fits", async () => { + const input = Array.from({ length: MAX_VISUAL_DEDUP_KEYS }, (_, i) => candidate(i, 1, i)); + input.push({ ...input[0] }); + const result = await deduplicateVisualCandidates(discovery(input)); + expect(result.candidates).toHaveLength(MAX_VISUAL_DEDUP_KEYS); + expect(result.dedupDegraded).toBe(false); + expect(result.deduplicatedCount).toBe(1); + }); + + it("preserves upstream unknowns without counting issues as Canvas nodes", async () => { + const input: VisualDiscoveryResult = { + ...discovery([candidate(2)]), + complete: false, + issues: [{ reason: "geometry-unavailable", target: { tabId: 1 }, frameId: "missing" }], + captureIssues: [{ stage: "ax", reason: "capture-unavailable", target: { tabId: 1 } }], + }; + const result = await deduplicateVisualCandidates(input); + expect(result.complete).toBe(false); + expect(result.candidateCount).toBe(1); + expect(result.issues).toBe(input.issues); + expect(result.captureIssues).toBe(input.captureIssues); + const absent = await deduplicateVisualCandidates({ + ...input, + candidates: [], + issues: [{ reason: "visual-facts-not-collected" }], + }); + expect(absent).toMatchObject({ + complete: false, + candidateCount: 0, + issues: [{ reason: "visual-facts-not-collected" }], + }); + expect(await deduplicateVisualCandidates(discovery([]))).toMatchObject({ + complete: true, + candidates: [], + candidateCount: 0, + dedupDegraded: false, + }); + }); + + it("drains cancellation through the shared checkpoint", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + deduplicateVisualCandidates(discovery([]), controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + const during = new AbortController(); + const input = discovery(Array.from({ length: 100_000 }, (_, i) => candidate(i, 100, i))); + const timer = setTimeout(() => during.abort(), 0); + try { + await expect(deduplicateVisualCandidates(input, during.signal)).rejects.toMatchObject({ + name: "AbortError", + }); + } finally { + clearTimeout(timer); + } + }); +}); diff --git a/apps/extension/src/tools/vom/visual-dedup.ts b/apps/extension/src/tools/vom/visual-dedup.ts new file mode 100644 index 0000000..149030e --- /dev/null +++ b/apps/extension/src/tools/vom/visual-dedup.ts @@ -0,0 +1,64 @@ +import { createCaptureCheckpoint } from "./capture-abort"; +import type { VisualCandidate, VisualDiscoveryResult } from "./visual-discovery"; + +export const MAX_VISUAL_DEDUP_KEYS = 50_000; + +export interface VisualDedupResult extends VisualDiscoveryResult { + /** Known valid candidates in this capture, not the total Canvas count on the page. */ + readonly candidateCount: number; + /** Candidates actually removed by exact coverage deduplication. */ + readonly deduplicatedCount: number; + readonly dedupDegraded: boolean; +} + +/** Keep the first real anchor for each exact DOM/parent/crop key, in encounter order. + * Once the key set is full, unknown keys pass through: capacity never drops candidates. */ +export async function deduplicateVisualCandidates( + discovery: VisualDiscoveryResult, + signal?: AbortSignal, +): Promise { + const checkpoint = createCaptureCheckpoint(signal); + const keys = new Set(); + const candidates: VisualCandidate[] = []; + let deduplicatedCount = 0; + let dedupDegraded = false; + for (let i = 0; i < discovery.candidates.length; i++) { + if (i % 256 === 0) { + const pending = checkpoint(); + if (pending) await pending; + } + const candidate = discovery.candidates[i]; + const { + document, + region: { crop }, + } = candidate; + const key = JSON.stringify([ + document.attachmentId, + document.target.tabId, + document.target.sessionId ?? null, + document.frameId, + document.documentElementBackendNodeId, + candidate.parentBackendNodeId, + crop.x, + crop.y, + crop.width, + crop.height, + ]); + if (keys.has(key)) { + deduplicatedCount++; + continue; + } + if (keys.size < MAX_VISUAL_DEDUP_KEYS) keys.add(key); + else dedupDegraded = true; + candidates.push(candidate); + } + const pending = checkpoint(); + if (pending) await pending; + return { + ...discovery, + candidates, + candidateCount: discovery.candidates.length, + deduplicatedCount, + dedupDegraded, + }; +}