mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
refactor(vom): preserve complete canvas candidates with exact deduplication
This commit is contained in:
@@ -7,6 +7,7 @@ import type { CdpRunner } from "../../shared";
|
|||||||
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
||||||
import { buildSemanticGraph, buildSemanticVomScene } from "../semantic-graph";
|
import { buildSemanticGraph, buildSemanticVomScene } from "../semantic-graph";
|
||||||
import { REQUESTED_STYLES, type SnapshotReply, VISUAL_STYLES } from "../snapshot";
|
import { REQUESTED_STYLES, type SnapshotReply, VISUAL_STYLES } from "../snapshot";
|
||||||
|
import { deduplicateVisualCandidates } from "../visual-dedup";
|
||||||
import { discoverVisualCandidates } from "../visual-discovery";
|
import { discoverVisualCandidates } from "../visual-discovery";
|
||||||
|
|
||||||
function fixture(
|
function fixture(
|
||||||
@@ -1459,6 +1460,15 @@ describe("visual facts integration", () => {
|
|||||||
document: { frameId: "main", documentElementBackendNodeId: 1 },
|
document: { frameId: "main", documentElementBackendNodeId: 1 },
|
||||||
region: { crop: { x: 10, y: 20, width: 100, height: 40 } },
|
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].index.nodes.get(2)?.layout?.clientRect).toEqual([0, 0, 100, 40]);
|
||||||
expect(facts.documents[0].geometry?.pageScale).toBe(1);
|
expect(facts.documents[0].geometry?.pageScale).toBe(1);
|
||||||
const baseline = fixture({ frames: [{ frameId: "main", target: { tabId: 4 } }] });
|
const baseline = fixture({ frames: [{ frameId: "main", target: { tabId: 4 } }] });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<VisualDedupResult> {
|
||||||
|
const checkpoint = createCaptureCheckpoint(signal);
|
||||||
|
const keys = new Set<string>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user