diff --git a/apps/extension/test-fixtures/visual-observation/README.md b/apps/extension/test-fixtures/visual-observation/README.md new file mode 100644 index 0000000..641f745 --- /dev/null +++ b/apps/extension/test-fixtures/visual-observation/README.md @@ -0,0 +1,119 @@ +# Visual observation design fixtures + +These are synthetic, site-independent inputs for the visual observation architecture in +[the design](../../../../docs/design/visual-observation.md). They contain no original page +data, account identifiers, external fonts or production selectors. They are not a production +discovery implementation. + +Use [the acceptance checklist](../../../../docs/design/visual-observation-acceptance.md) +for each PR's steps and success criteria. No separate report is required. +Historical probe success does not certify future production modules. + +## Browser contract probe + +Requires Node.js with built-in WebSocket support (Node 22+) and an installed Chrome. No extra +packages, user profile, extension, or login are required. + +From the repository root: + +```sh +node apps/extension/test-fixtures/visual-observation/probe.mjs /tmp/bsk-visual-evidence +``` + +On platforms other than macOS, set `CHROME_PATH` to the installed Chrome executable. The probe +starts its own headless Chrome with an ephemeral profile and a loopback fixture server. It +removes the profile and stops Chrome/server on completion or failure. `report.json`, raw CDP +replies and PNGs are written to the explicitly supplied directory; do not commit them. + +The probe verifies CSS exposure versus visibility, snapshot units, DPR raster dimensions, +CSS zoom, frame content origins, child scrolling, nested same-target geometry, a confirmed +OOPIF target, and navigation/document.open identity behavior. It records page-scale emulation +separately; that is **not** browser UI zoom. It does not certify production VOM behavior, arbitrary +occlusion, exact clip-path geometry, or all browser versions. + +## Minimal end-to-end reproduction + +```sh +python3 -m http.server 4177 --bind 127.0.0.1 \ + --directory apps/extension/test-fixtures/visual-observation +bsk session start --width 1440 --height 1100 +bsk navigate http://127.0.0.1:4177/table.html --session +bsk observe --session +bsk screenshot --session --out /tmp/bsk-table.png +bsk session stop +``` + +Always stop the session and the local server. Use a foreground Agent Window and confirm the +painted grid is visible before interpreting the observation. `load`, AX toolbar appearance, +and background screenshots alone do not prove a canvas has drawn. + +`table.html` embeds `grid.html` through `localhost` from a `127.0.0.1` parent. The grid has DOM +toolbar controls and one unlabeled Canvas with three synthetic columns and six painted rows. + +Main baseline: `Page action`, `Insert record`, and `Filter` are referenceable; the painted +`Canvas column A/B/C` and `Canvas row 1..6` have no textual observation representation or visual +ref, despite an untruncated result. This is a **known gap**, not a golden expectation that future +implementations must preserve. + +Future acceptance: retain the DOM controls and add a screenshot-only visual ref for the visible +grid, with correct frame projection. Do not invent AX cell text. Validate the corresponding +screenshot, then move/remove/resize and re-observe according to the freshness contract. + +## Discovery policy cases + +At a 1440×1100 window and without scrolling: + +| Canvas ID | Expected candidate in the new design | Reason | +|---|---|---| +| plain | yes | No name required | +| named | yes | Name does not establish complete pixel semantics | +| aria-hidden | yes | Accessibility exclusion is not visual exclusion | +| inert | yes | Inertness is not visual hiding | +| pointer-none | yes | Screenshot observation does not require pointer interaction | +| visibility-override | yes | Child restores computed visibility | +| opacity-parent | no | Ancestor makes subtree transparent | +| display-none | no | No layout box | +| visibility-hidden | no | Computed visibility suppresses this element | +| zero | no | Empty geometry | +| hidden-overridden | yes | CSS display overrides normal hidden presentation | +| partial-clip | yes, 60×30 crop | Supported overflow intersection | +| full-clip | no | Fully outside ancestor clip | +| axis-clip | yes, x clipped only | overflow-x:clip and overflow-y:visible remain independent | +| border-clip | yes, clipped to client box | Ancestor border box is not its scrollport | +| border-canvas | yes, border-box region | Observation and live screenshot resolver must use the same box kind | +| fallback | yes | AX fallback is not full pixel coverage | +| stack-a / stack-b | one output entry after selection | Same document/parent/exact crop; not proof of a logical object | + +`checkVisibility()` is used in the probe only for CSS suppression evidence; it does not establish +viewport intersection, ancestor overflow clipping, or absence of occlusion. `frame.html` supplies +known local coordinates, bordered owners, nested frames and scrollable content for independent +geometry verification. The fixture IDs must never enter production discovery code. + +## Renderer operation counts + +After installing the repository's normal dependencies: + +```sh +node apps/extension/test-fixtures/visual-observation/measure-render.mjs /tmp/bsk-render-evidence +``` + +This instruments a temporary copy of the current renderer, runs the installed Vitest, and writes +the source hash and loop counters to `render-counts.json`. It checks instrumentation sites and +fails explicitly when source structure changes; update the diagnostic deliberately then. It never +modifies production sources and never asserts that today's quadratic costs must persist. + +The wide fixture contains N headings followed by N equally named buttons in a common context. +The deep fixture contains 2,000/4,000 transparent wrappers. Counters cover render-stack visits, +ordered DOM context candidates, and same-container sibling visits. They are not a complete CPU +profile or an assertion about the complexity of all VOM stages. + +Use these existing-renderer counts only as a before/after regression baseline. Improving the +historical algorithm is not a prerequisite for Canvas work. New Canvas stages need their own +workload models and measurements; do not copy this renderer's algorithms or use these counts +as evidence that visual discovery, clipping, selection, or screenshot execution is efficient. + +## Review boundary + +Commit only these synthetic fixtures, diagnostic tools, and reviewed design/evidence summaries. +Keep local browser profiles, original internal-site screenshots/HTML/observations, generated +captures, temporary source instrumentation and build outputs outside the changeset. diff --git a/apps/extension/test-fixtures/visual-observation/frame.html b/apps/extension/test-fixtures/visual-observation/frame.html new file mode 100644 index 0000000..0f97b50 --- /dev/null +++ b/apps/extension/test-fixtures/visual-observation/frame.html @@ -0,0 +1,25 @@ + + + + Frame geometry contract + + + + diff --git a/apps/extension/test-fixtures/visual-observation/grid.html b/apps/extension/test-fixtures/visual-observation/grid.html new file mode 100644 index 0000000..8d77428 --- /dev/null +++ b/apps/extension/test-fixtures/visual-observation/grid.html @@ -0,0 +1,26 @@ + + + + Canvas grid + + + + + diff --git a/apps/extension/test-fixtures/visual-observation/index.html b/apps/extension/test-fixtures/visual-observation/index.html new file mode 100644 index 0000000..fbdfa54 --- /dev/null +++ b/apps/extension/test-fixtures/visual-observation/index.html @@ -0,0 +1,56 @@ + + + + Visual observation contract fixture + +

Canvas contract

+ +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
Accessible fallback is not pixel content
+
+
+
+
+
+ + + + diff --git a/apps/extension/test-fixtures/visual-observation/measure-render.mjs b/apps/extension/test-fixtures/visual-observation/measure-render.mjs new file mode 100644 index 0000000..a50060f --- /dev/null +++ b/apps/extension/test-fixtures/visual-observation/measure-render.mjs @@ -0,0 +1,86 @@ +// Diagnostic, not a golden test requiring the current quadratic cost to persist. +// Instrument a temporary copy; never modify the production renderer. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const output = process.argv[2]; +if (!output || !path.isAbsolute(output)) + throw new Error("Pass an absolute evidence output directory"); +const require = createRequire(path.join(root, "apps/extension/package.json")); +const vitestDirectory = path.dirname(require.resolve("vitest/package.json")); +const temporary = await mkdtemp(path.join(os.tmpdir(), "bsk-render-measure-")); +await mkdir(output, { recursive: true }); +try { + const source = await readFile(path.join(root, "packages/vom/src/render.ts"), "utf8"); + let instrumented = source; + for (const [needle, field] of [ + ["const { node, depth } = stack.pop() as { node: VomNode; depth: number };", "renderVisits"], + ["for (const candidate of orderedCandidates) {", "contextCandidates"], + ["for (const sibling of siblings) {", "siblingVisits"], + ]) { + assert.equal(instrumented.split(needle).length, 2, `Update measurement site: ${field}`); + instrumented = instrumented.replace(needle, `${needle} designCounts.${field}++;`); + } + instrumented = `export const designCounts = {renderVisits:0,contextCandidates:0,siblingVisits:0};\n${instrumented}`; + await writeFile(path.join(temporary, "render.ts"), instrumented); + for (const file of ["layers.ts", "types.ts"]) + await copyFile(path.join(root, "packages/vom/src", file), path.join(temporary, file)); + const reportPath = path.join(output, "render-counts.json"); + await writeFile( + path.join(temporary, "measure.test.ts"), + ` +import {writeFileSync} from 'node:fs'; +import {test,expect} from ${JSON.stringify(path.join(vitestDirectory, "dist/index.js"))}; +import {renderVom,designCounts} from './render'; +test('measure operation counts',()=>{ + const rows=[]; + const make=(id,extra={})=>({id,parentId:1,backendNodeId:id,tag:'div',role:'generic',rect:{x:0,y:0,w:100,h:20},paintOrder:0,position:'static',pointerEvents:'auto',contextScopeId:'root',...extra}); + const run=(shape,n,nodes)=>{ + Object.assign(designCounts,{renderVisits:0,contextCandidates:0,siblingVisits:0}); + const result=renderVom({viewport:{width:1440,height:1000},nodes},{maxTokens:Infinity}); + rows.push({shape,n,counts:{...designCounts},refs:result.refs.length}); + }; + for(const n of [100,200,400]){ + const nodes=[make(1,{parentId:null,role:'RootWebArea',name:'Doc'})]; + for(let i=0;i new Promise((resolve) => setTimeout(resolve, ms)); +const styles = [ + "position", + "pointer-events", + "cursor", + "visibility", + "opacity", + "overflow-x", + "overflow-y", + "display", +]; +const checks = []; +function check(name, condition) { + checks.push({ name, passed: Boolean(condition) }); + assert.ok(condition, name); +} + +class Cdp { + nextId = 0; + pending = new Map(); + events = []; + counts = {}; + async connect(url) { + this.socket = new WebSocket(url); + this.socket.addEventListener("message", ({ data }) => { + const reply = JSON.parse(data); + if (!reply.id) { + this.events.push(reply); + return; + } + const task = this.pending.get(reply.id); + if (!task) return; + this.pending.delete(reply.id); + clearTimeout(task.timer); + if (reply.error) task.reject(new Error(JSON.stringify(reply.error))); + else task.resolve(reply.result); + }); + await new Promise((resolve, reject) => { + this.socket.addEventListener("open", resolve, { once: true }); + this.socket.addEventListener("error", reject, { once: true }); + }); + } + send(method, params = {}, sessionId) { + const id = ++this.nextId; + this.counts[method] = (this.counts[method] ?? 0) + 1; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP timeout: ${method}`)); + }, 10000); + this.pending.set(id, { resolve, reject, timer }); + this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + }); + } + close() { + for (const task of this.pending.values()) { + clearTimeout(task.timer); + task.reject(new Error("CDP closed")); + } + this.pending.clear(); + this.socket?.close(); + } +} + +function decodedCanvases(snapshot) { + return snapshot.documents.flatMap((document) => { + const layoutByNode = new Map(document.layout.nodeIndex.map((node, index) => [node, index])); + return document.nodes.nodeName.flatMap((name, index) => { + if (snapshot.strings[name] !== "CANVAS") return []; + const attrs = document.nodes.attributes[index]; + const attributes = {}; + for (let i = 0; i < attrs.length; i += 2) + attributes[snapshot.strings[attrs[i]]] = snapshot.strings[attrs[i + 1]]; + const layoutIndex = layoutByNode.get(index) ?? -1; + return [ + { + id: attributes.id, + backendNodeId: document.nodes.backendNodeId[index], + frameId: snapshot.strings[document.frameId], + attributes, + bounds: layoutIndex < 0 ? null : document.layout.bounds[layoutIndex], + styles: + layoutIndex < 0 + ? null + : Object.fromEntries( + styles.map((style, i) => [ + style, + snapshot.strings[document.layout.styles[layoutIndex][i]], + ]), + ), + }, + ]; + }); + }); +} + +await mkdir(output, { recursive: true }); +const profile = await mkdtemp(path.join(os.tmpdir(), "bsk-visual-probe-")); +const server = http.createServer(async (request, response) => { + const name = new URL(request.url, "http://fixture").pathname; + if (!["/index.html", "/frame.html"].includes(name)) { + response.writeHead(404).end(); + return; + } + try { + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.end(await readFile(path.join(fixtureDirectory, name.slice(1)))); + } catch { + response.writeHead(500).end(); + } +}); +const cdp = new Cdp(); +let chrome; +let report = {}; +try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = server.address().port; + chrome = spawn( + chromePath, + [ + "--headless=new", + `--user-data-dir=${profile}`, + "--remote-debugging-port=0", + "--no-first-run", + "--no-default-browser-check", + "--site-per-process", + "--window-size=1440,1100", + "about:blank", + ], + { stdio: "ignore" }, + ); + chrome.on("error", (error) => { + report.launchError = error.message; + }); + let activePort; + for (let attempt = 0; attempt < 100; attempt++) { + try { + activePort = (await readFile(path.join(profile, "DevToolsActivePort"), "utf8")) + .trim() + .split("\n"); + break; + } catch { + await pause(100); + } + } + if (!activePort) + throw new Error(report.launchError ?? "Chrome did not expose DevToolsActivePort"); + await cdp.connect(`ws://127.0.0.1:${activePort[0]}${activePort[1]}`); + report.browser = await cdp.send("Browser.getVersion"); + const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); + const { sessionId: root } = await cdp.send("Target.attachToTarget", { targetId, flatten: true }); + const send = (method, params = {}) => cdp.send(method, params, root); + const evaluate = async (expression, session = root, contextId) => { + const result = await cdp.send( + "Runtime.evaluate", + { expression, returnByValue: true, awaitPromise: true, ...(contextId ? { contextId } : {}) }, + session, + ); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + await send("Page.enable"); + await send("Runtime.enable"); + async function navigate(url) { + await send("Page.navigate", { url }); + for (let i = 0; i < 100; i++) { + if (await evaluate("document.readyState === 'complete' && window.fixtureReady === true")) + return; + await pause(50); + } + throw new Error("Fixture did not become ready"); + } + const url = `http://127.0.0.1:${port}/index.html`; + await navigate(`${url}?noFrames=1`); + async function capture(label) { + const metrics = await send("Page.getLayoutMetrics"); + const snapshot = await send("DOMSnapshot.captureSnapshot", { + computedStyles: styles, + includePaintOrder: true, + includeDOMRects: true, + }); + const ax = await send("Accessibility.getFullAXTree"); + const runtime = await evaluate(`Array.from(document.querySelectorAll('canvas'), el => { + const r=el.getBoundingClientRect(), s=getComputedStyle(el); + return {id:el.id,rect:{x:r.x,y:r.y,w:r.width,h:r.height},visibility:s.visibility,display:s.display, + checkVisibility:el.checkVisibility({opacityProperty:true,visibilityProperty:true}), + dpr:devicePixelRatio,scrollX,scrollY}; })`); + const result = { metrics, snapshot, ax, runtime, canvases: decodedCanvases(snapshot) }; + await writeFile(path.join(output, `${label}.json`), JSON.stringify(result, null, 2)); + return result; + } + const baseline = await capture("root-baseline"); + const rootImage = await send("Page.captureScreenshot", { format: "png" }); + await writeFile(path.join(output, "root.png"), Buffer.from(rootImage.data, "base64")); + const runtime = Object.fromEntries(baseline.runtime.map((node) => [node.id, node])); + for (const id of [ + "plain", + "named", + "aria-hidden", + "inert", + "pointer-none", + "visibility-override", + "hidden-overridden", + "fallback", + ]) + check(`CSS visible: ${id}`, runtime[id].checkVisibility); + for (const id of ["opacity-parent", "display-none", "visibility-hidden"]) + check(`CSS suppressed: ${id}`, !runtime[id].checkVisibility); + check("painted text is absent from AX", !JSON.stringify(baseline.ax).includes("PIXEL TABLE 42")); + report.css = baseline.runtime; + const bordered = baseline.canvases.find((node) => node.id === "border-canvas"); + report.canvasBox = await send("DOM.getBoxModel", { backendNodeId: bordered.backendNodeId }); + check( + "Canvas snapshot uses border box", + bordered.bounds[2] === 130 && + report.canvasBox.model.border[2] - report.canvasBox.model.border[0] === 130 && + report.canvasBox.model.content[2] - report.canvasBox.model.content[0] === 120, + ); + const rootDocument = baseline.snapshot.documents[0]; + const ownerNode = rootDocument.nodes.attributes.findIndex((attrs) => + attrs.some( + (value, index) => index % 2 === 1 && baseline.snapshot.strings[value] === "border-clip-owner", + ), + ); + const ownerLayout = rootDocument.layout.nodeIndex.indexOf(ownerNode); + report.clipClientBox = { + snapshot: rootDocument.layout.clientRects[ownerLayout], + runtime: await evaluate( + "(() => {const e=document.getElementById('border-clip-owner');return [e.clientLeft,e.clientTop,e.clientWidth,e.clientHeight]})()", + ), + }; + check( + "snapshot client rect contains local client offsets and size", + JSON.stringify(report.clipClientBox.snapshot) === + JSON.stringify(report.clipClientBox.runtime) && + JSON.stringify(report.clipClientBox.runtime) === "[5,5,68,38]", + ); + report.clips = await evaluate(`['partial-clip','full-clip','axis-clip'].map(id=>{ + const el=document.getElementById(id),p=el.parentElement,s=getComputedStyle(p); + const a=el.getBoundingClientRect(),b=p.getBoundingClientRect(); + return {id,overflowX:s.overflowX,overflowY:s.overflowY, + child:{x:a.x,y:a.y,w:a.width,h:a.height},parent:{x:b.x,y:b.y,w:b.width,h:b.height}}; + })`); + const [partial, full, axis] = report.clips; + check( + "partial overflow crop dimensions", + partial.parent.w === 60 && + partial.parent.h === 30 && + partial.child.x === partial.parent.x && + partial.child.y === partial.parent.y, + ); + check( + "fully clipped candidate lies beyond ancestor", + full.child.x >= full.parent.x + full.parent.w, + ); + check( + "single-axis clip remains independent in computed style", + axis.overflowX === "clip" && axis.overflowY === "visible" && axis.child.h > axis.parent.h, + ); + const samples = []; + for (let i = 0; i < 12; i++) + samples.push( + await evaluate( + "JSON.stringify(document.querySelector('#plain').getBoundingClientRect().toJSON())", + ), + ); + check("static rect stable over 12 reads", new Set(samples).size === 1); + await evaluate("document.querySelector('#plain').style.transform='translateX(0.5px)'"); + report.halfPixelMovement = await evaluate( + "document.querySelector('#plain').getBoundingClientRect().x", + ); + await evaluate("document.querySelector('#plain').style.transform=''"); + report.raster = []; + for (const dpr of [1, 1.5, 2]) { + await send("Emulation.setDeviceMetricsOverride", { + width: 1000, + height: 900, + deviceScaleFactor: dpr, + mobile: false, + }); + const state = await capture(`dpr-${dpr}`); + const image = await send("Page.captureScreenshot", { + format: "png", + clip: { x: 20, y: 100, width: 120, height: 40, scale: 1 }, + }); + const bytes = Buffer.from(image.data, "base64"); + const dimensions = { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; + report.raster.push({ + dpr, + dimensions, + metrics: state.metrics, + plain: state.canvases.find((node) => node.id === "plain"), + }); + check( + `screenshot raster DPR ${dpr}`, + dimensions.width === 120 * dpr && dimensions.height === 40 * dpr, + ); + } + await send("Emulation.clearDeviceMetricsOverride"); + await evaluate("document.body.style.zoom='1.25'"); + const cssZoom = await capture("css-zoom-1.25"); + check( + "CSS zoom is reflected in snapshot geometry", + cssZoom.canvases.find((n) => n.id === "plain").bounds[2] === 150, + ); + report.cssZoom = { + runtime: cssZoom.runtime[0], + snapshot: cssZoom.canvases.find((n) => n.id === "plain"), + }; + await evaluate("document.body.style.zoom=''"); + await send("Emulation.setPageScaleFactor", { pageScaleFactor: 1.5 }); + report.pageScale = (await capture("page-scale-1.5")).metrics; + await send("Emulation.setPageScaleFactor", { pageScaleFactor: 1 }); + await evaluate("scrollTo(0,100)"); + const scrolled = await capture("scroll-100"); + check( + "snapshot document bounds stable under scroll", + JSON.stringify(scrolled.canvases.find((n) => n.id === "plain").bounds) === + JSON.stringify(baseline.canvases.find((n) => n.id === "plain").bounds), + ); + check( + "runtime viewport y follows scroll", + scrolled.runtime[0].rect.y === baseline.runtime[0].rect.y - 100, + ); + await navigate(url); + await pause(200); + const targets = await cdp.send("Target.getTargets"); + const iframeTarget = targets.targetInfos.find( + (target) => target.type === "iframe" && target.url.startsWith(`http://localhost:${port}/`), + ); + check("cross-site frame is an actual OOPIF target", iframeTarget); + const { sessionId: child } = await cdp.send("Target.attachToTarget", { + targetId: iframeTarget.targetId, + flatten: true, + }); + report.frames = {}; + for (const [name, session] of [ + ["root", root], + ["oopif", child], + ]) { + const tree = await cdp.send("Page.getFrameTree", {}, session); + const snapshot = await cdp.send( + "DOMSnapshot.captureSnapshot", + { computedStyles: styles, includeDOMRects: true }, + session, + ); + const frameRows = []; + const stack = [tree.frameTree]; + while (stack.length) { + const node = stack.pop(); + const world = await cdp.send( + "Page.createIsolatedWorld", + { frameId: node.frame.id, worldName: "bsk-design-probe" }, + session, + ); + const local = await evaluate( + "({url:location.pathname,innerWidth,innerHeight,scrollX,scrollY,canvas:document.querySelector('canvas').getBoundingClientRect().toJSON()})", + session, + world.executionContextId, + ); + let owner; + if (node.frame.parentId) { + // All owners in this fixture live in the root target; child content can be OOPIF. + const address = await send("DOM.getFrameOwner", { frameId: node.frame.id }); + owner = await send("DOM.getBoxModel", { backendNodeId: address.backendNodeId }); + } + frameRows.push({ frame: node.frame, local, owner }); + stack.push(...(node.childFrames ?? [])); + } + report.frames[name] = { tree, snapshot, canvases: decodedCanvases(snapshot), frameRows }; + } + check( + "same-target snapshot includes nested documents", + report.frames.root.snapshot.documents.length === 3, + ); + check("oopif snapshot isolated from root", report.frames.oopif.snapshot.documents.length === 1); + const sameRow = report.frames.root.frameRows.find( + (row) => row.owner && row.local.innerWidth === 300, + ); + const nestedRow = report.frames.root.frameRows.find( + (row) => row.owner && row.local.innerWidth === 180, + ); + check( + "same frame content origin includes border", + sameRow.owner.model.content[0] === 50 && sameRow.owner.model.content[1] === 610, + ); + check( + "nested owner quad is already target-root relative", + nestedRow.owner.model.content[0] === 87 && nestedRow.owner.model.content[1] === 707, + ); + const crossRow = report.frames.oopif.frameRows[0]; + check( + "OOPIF owner content origin", + crossRow.owner.model.content[0] === 410 && crossRow.owner.model.content[1] === 610, + ); + const sameFrame = sameRow.frame.id; + const world = await send("Page.createIsolatedWorld", { + frameId: sameFrame, + worldName: "bsk-design-probe", + }); + await evaluate("scrollTo(0,10)", root, world.executionContextId); + const frameScroll = await send("DOMSnapshot.captureSnapshot", { + computedStyles: styles, + includeDOMRects: true, + }); + report.frameScroll = { + documents: frameScroll.documents.map((d) => ({ + frameId: frameScroll.strings[d.frameId], + scrollOffsetY: d.scrollOffsetY, + })), + canvases: decodedCanvases(frameScroll), + local: await evaluate( + "document.querySelector('canvas').getBoundingClientRect().toJSON()", + root, + world.executionContextId, + ), + }; + check("frame scroll is local", report.frameScroll.local.y === 13); + check( + "frame snapshot stays document-relative after child scroll", + report.frameScroll.canvases.find((n) => n.frameId === sameFrame).bounds[1] === 23, + ); + await evaluate("scrollTo(0,0)", root, world.executionContextId); + await evaluate( + "document.querySelector('#same').style.transformOrigin='0 0';document.querySelector('#same').style.transform='scale(1.25)'", + ); + const sameOwner = await send("DOM.getFrameOwner", { frameId: sameFrame }); + report.transformedOwner = await send("DOM.getBoxModel", { + backendNodeId: sameOwner.backendNodeId, + }); + check( + "scaled frame owner content width", + report.transformedOwner.model.content[2] - report.transformedOwner.model.content[0] === 375, + ); + await evaluate("document.querySelector('#same').style.transform=''"); + const screenshot = await send("Page.captureScreenshot", { format: "png" }); + await writeFile(path.join(output, "frames.png"), Buffer.from(screenshot.data, "base64")); + const before = (await send("Page.getFrameTree")).frameTree.frame; + const oldDocument = (await send("DOM.getDocument")).root.backendNodeId; + await evaluate("history.replaceState(null,'','#same-document')"); + const sameDocument = (await send("Page.getFrameTree")).frameTree.frame; + const sameDocumentBackend = (await send("DOM.getDocument")).root.backendNodeId; + await navigate(`${url}?noFrames=1&navigation=2`); + const after = (await send("Page.getFrameTree")).frameTree.frame; + const newDocument = (await send("DOM.getDocument")).root.backendNodeId; + report.identity = { before, sameDocument, after, oldDocument, sameDocumentBackend, newDocument }; + check("same-document navigation preserves loader", before.loaderId === sameDocument.loaderId); + check("cross-document navigation changes loader", before.loaderId !== after.loaderId); + check("frame id survives document replacement", before.id === after.id); + check( + "document backend identity changes on navigation", + oldDocument !== newDocument && oldDocument === sameDocumentBackend, + ); + const documentTree = await send("DOM.getDocument"); + report.identity.htmlBeforeOpen = documentTree.root.children.find( + (node) => node.nodeType === 1, + ).backendNodeId; + const anchorNode = await send("DOM.querySelector", { + nodeId: documentTree.root.nodeId, + selector: "canvas", + }); + const anchor = await send("DOM.describeNode", { nodeId: anchorNode.nodeId }); + await evaluate( + "document.open(); document.write('Replacement'); document.close()", + ); + report.identity.documentOpen = (await send("Page.getFrameTree")).frameTree.frame; + const afterOpenTree = await send("DOM.getDocument"); + report.identity.documentOpenBackend = afterOpenTree.root.backendNodeId; + report.identity.htmlAfterOpen = afterOpenTree.root.children.find( + (node) => node.nodeType === 1, + ).backendNodeId; + check( + "document.open replaces documentElement identity", + report.identity.htmlBeforeOpen !== report.identity.htmlAfterOpen, + ); + try { + const object = await send("DOM.resolveNode", { backendNodeId: anchor.node.backendNodeId }); + const connected = await send("Runtime.callFunctionOn", { + objectId: object.object.objectId, + functionDeclaration: "function(){return this.isConnected}", + returnByValue: true, + }); + report.identity.oldAnchorConnected = connected.result.value; + } catch { + report.identity.oldAnchorConnected = false; + } + check( + "document.open invalidates old anchor even if document identifiers survive", + report.identity.oldAnchorConnected === false, + ); + report.counts = cdp.counts; + report.checks = checks; + process.stdout.write( + `${JSON.stringify({ browser: report.browser.product, checks: checks.length, output })}\n`, + ); +} catch (error) { + report.error = error.stack; + report.checks = checks; + process.exitCode = 1; + process.stderr.write(`${error.stack}\n`); +} finally { + await writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2)); + cdp.close(); + if (chrome && chrome.exitCode === null) { + const exited = once(chrome, "exit"); + chrome.kill("SIGTERM"); + await Promise.race([exited, pause(2000)]); + if (chrome.exitCode === null) { + chrome.kill("SIGKILL"); + await exited; + } + } + await new Promise((resolve) => server.close(resolve)); + await rm(profile, { recursive: true, force: true }); +} diff --git a/apps/extension/test-fixtures/visual-observation/table.html b/apps/extension/test-fixtures/visual-observation/table.html new file mode 100644 index 0000000..8e0437b --- /dev/null +++ b/apps/extension/test-fixtures/visual-observation/table.html @@ -0,0 +1,15 @@ + + + + Embedded Canvas table reproduction + +

Embedded table

+ + + + diff --git a/docs/design/visual-observation-acceptance.md b/docs/design/visual-observation-acceptance.md new file mode 100644 index 0000000..fb30a6a --- /dev/null +++ b/docs/design/visual-observation-acceptance.md @@ -0,0 +1,184 @@ +# Visual observation:验收清单 + +新 agent 先读 [架构与 PR 划分](visual-observation.md),再按本清单验收当前 PR;无需聊天记录,也无需另写报告、逐项编号或填写模板。历史结果见 [PR 0 证据](visual-observation-evidence.md),不能代替本次执行。 + +## 怎么开始 + +- 查看分支、HEAD 和工作区改动,确认当前做哪个 PR。PR 0 基于 main `3c5f838`;后续模块对照其前置基线,最终回归对照 main。注意未跟踪文件也属于改动。 +- 当前仅有设计、合成 fixture 和诊断脚本;未来模块尚未实现。只验收当前阶段及受影响的已有功能,不要求提前完成后续 PR。 +- 测试放在模块附近,调用实际生产代码。未接线模块可用人工输入和 CDP stub;实现 PR 写明运行命令即可,不强制新建指定目录、命名或诊断 API。 +- 浏览器验收前核实 CLI 版本,并重新 build、确认扩展 reload。PATH 中的 bsk 曾经比仓库版本旧,不能只凭版本号认定运行了目标代码。 +- 不 commit/push。截图、原始页面内容和一次性实验放临时目录;只把长期需要的设计、fixture 和测试留在仓库。 + +## 现有验证入口 + +从仓库根目录运行,命令成功退出且相关断言通过才算成功: + +```sh +pnpm vom:test +pnpm --filter @browser-skill/extension test src/tools/__tests__/frame-geometry.test.ts src/tools/__tests__/element-geometry.test.ts src/tools/__tests__/observation.test.ts src/tools/__tests__/snapshot-ref.test.ts src/tools/__tests__/interaction.test.ts src/tools/vom src/session-manager/__tests__/ref-store.test.ts +pnpm exec biome check apps/extension/test-fixtures/visual-observation +VISUAL_EVIDENCE="$(mktemp -d /tmp/bsk-visual-acceptance.XXXXXX)" +node apps/extension/test-fixtures/visual-observation/probe.mjs "$VISUAL_EVIDENCE/browser-contract" +node apps/extension/test-fixtures/visual-observation/measure-render.mjs "$VISUAL_EVIDENCE/performance" +``` + +probe 需要 Node 22+ 和 Chrome,配置见 [fixture 说明](../../apps/extension/test-fixtures/visual-observation/README.md)。它验证浏览器事实,不代表后续生产模块已经通过。measure-render 用于旧 renderer 对照,不要求修复历史复杂度。 + +实现 PR 还需运行所属模块的新测试、`pnpm --filter @browser-skill/extension compile`、`pnpm ext:test` 和 `pnpm ext:build`。仅修改文档时检查链接和内容即可,不必重跑浏览器。已有失败应说明,不在范围外顺手修复。 + +## 每个 PR 怎么算完成 + +### PR 0:设计与复现 + +只有文档、合成 fixture 和诊断变动;架构、边界及后续 PR 的完成条件明确。现有测试通过;probe 的 checks 非空且全部通过(本版 39 项);measure-render 产生五组 wide/deep 结果。 + +按下方浏览器步骤,在 main 重现“表格已绘制,但 observe 没有视觉入口”。合成页面和原始 iWiki 分别确认。原始页面未加载、无权限或缺入口时说明阻塞,不能算已复现。入库内容不含内部页面地址、标识或截图。 + +### PR 1:几何与身份 + +数值误差:纯函数输入允许 1e-6 CSS px;真实浏览器每条边允许 0.25 CSS px。坐标类型不能混传;动态 frame 归属需运行时检查。 + +| 怎么测 | 什么算成功 | +|---|---| +| top document bounds=(30,50,120,40),document scroll=(10,20),viewport=800×600 | top border region=(20,30,120,40);不受 backing store 尺寸影响 | +| 使用 frame.html:local Canvas=(17,23,120,40),same owner content origin=(50,610);nested origin=(87,707);OOPIF origin=(410,610) | top origins 分别=(67,633)、(104,730)、(427,633);不能重复给 nested 加父偏移;owner border 不能代替 content | +| child scrollY=10;same frame owner origin 不变 | Canvas top y=623,local y=13;snapshot document y=23;scroll 只扣一次 | +| same iframe 以自身左上角为原点 scale(1.25),原 border origin=(40,600),border=10 | owner content origin=(52.5,612.5),Canvas top=(73.75,641.25),尺寸150×50 | +| Canvas content 120×40,border 每边5;overflow owner CSS width60/height30/border5/padding4 | Canvas region 宽130高50,live 验证同 box;owner clientRect=(5,5,68,38),不能使用78×48 border范围作为clip | +| DPR=1/1.5/2,CSS Canvas120×40;UI zoom与CSS zoom分别处理 | DPR不直接除掉 CSS rect;raster 预测与实际图片分别记录;不以layout/css宽度比作为万能DPR | +| 分别构造缺失 owner、失效 frame、旋转、透视、mask、clip-path、活动pinch、变换的overflow祖先 | 前两项 geometry-unavailable,后六类 geometry-unsupported;无伪造精确 region ref、无按border猜偏移 | +| 同一操作并发请求同一frame 20次,再开启第二次操作 | 第一次每个metrics来源、owner quad、frame投影最多构建/请求一次;第二次重新获取;异常promise不泄漏到其他操作 | + +### PR 2:采集与共享事实 + +| 怎么测 | 什么算成功 | +|---|---| +| target A/B 各含 backendNodeId=7,frame/document不同 | 两个lookup结果独立;AX join、overlay排除和父子查找不串target | +| 4个frame分属2个target,其中一个target有3份document;均成功采集 | 每target恰好一次DOMSnapshot;每frame恰好一次AX批次;失败重试不混入成功请求数;无按frame重复snapshot | +| 采集中模拟frame detach或loader/documentElement更换 | 丢弃受影响document的混合事实并标记原因;其他完整document可保留;不得把新AX接到旧DOM | +| 输入10,000深链、10,000兄弟、孤儿及A→B→A环 | 无栈溢出/无限循环;孤儿/环有明确不完整状态;不生成完整祖先证据;context推导每有效节点至多一次 | +| opacity0祖先;visibility:hidden祖先+visible子;aria-hidden/inert祖先 | 分别得到视觉透明、子可见、语义/交互状态独立;不折叠为同一个excluded | +| 一个frame采集失败,其他frame有语义节点;全部成功但无Canvas | 前者partial且保留其他语义;后者empty而非unavailable;错误原因可追踪到frame及阶段 | +| 注入CDP调用记录和hover探针;纯计算中途取消 | 所有静态DOM/AX捕获先于hover输入;取消后下一工作块不继续处理,未执行的输入/截图为0;取消不变成普通空结果 | + +### PR 3:视觉发现 + +| 怎么测 | 什么算成功 | +|---|---| +| index.html?noFrames=1 的plain、named、aria-hidden、inert、pointer-none、visibility-override、hidden-overridden、fallback | 八个均为候选;无label不影响发现,有AX/fallback不抑制候选 | +| opacity-parent、display-none、visibility-hidden、zero、full-clip | 五个均无候选,原因分别能追踪到视觉/几何规则;不能把aria-hidden列为原因 | +| partial-clip、axis-clip、border-clip、border-canvas | crop尺寸分别60×30、60×40、64×34、130×50;边界来自client box/同种Canvas box | +| 同frame中删除语义parent,再保持DOM/几何不变 | 候选仍存在、地址不变;语义context可变为null;frame owner本身无法解析时不是该用例 | +| 连续两次输入同一只读Facts;对Facts启用深冻结 | 候选内容一致,无输入mutation,无CDP调用;去重前stack-a/b仍是两个候选 | +| 普通候选部分越出viewport;完全越界;overlay-owned Canvas;unsupported clip | 分别交集区域、无候选、排除、明确unsupported状态;不把未知状态伪装成empty | + +### PR 4:选择与预算 + +预算按每个输出行的 `ceil(line.length/4)` 求和(UTF-16 length),包括头部和截断提示。H 为完整协议头成本,V 为固定行 `[visual] Canvas regions present; use screenshot` 的成本。 + +| 怎么测 | 什么算成功 | +|---|---| +| 同context:无名1000×800候选A,有名10×10候选B,K=1 | 选A;名称不能压倒区域面积 | +| 同document/parent且crop完全相同的A/B;另一个parent的C;crop右边相差0.01的D;相邻tile E | A/B仅输出一个代表;C/D/E独立保留;不输出layers=N作为业务解释 | +| 面积相同的候选随机打乱20次,地址/位置保持不变 | 排序相同;tie-break顺序固定为frameId、top y、left x、backendNodeId(同一观察内);去重代表取该顺序最小者;字符串按代码单元字典序、数值按数值升序 | +| 50,001个不同key,最后一个为最大Canvas,K=1 | 最后最大者仍被选中;dedup key峰值≤50,000;标记dedup-degraded;未因容量耗尽停扫描 | +| 100,000候选、K=1/32/512;另测K=0 | 选中数量≤K、heap峰值≤K、exact-key之外的模糊pair比较为0;K=0不产生ref候选;结果与独立全排序小规模oracle一致 | +| 带blocking layer的纯输入,候选context在L1、背景、未知各一个 | L1优先,背景不标为物理不可见,未知不伪装L1;同层再按面积及固定 tie-break 排序;不修改Facts | +| 预算H-1 | 新observe规划返回insufficient-budget,工具映射invalid_params并给minimum=H;无新文本/ref提交;snapshot旧路径不改变 | +| 有候选,预算H、H+V-1、H+V | 前两个输出合法协议头、无悬空ref、truncated=true;H+V能保留固定视觉提示;全部账本≤预算 | +| 没有显式maxTokens,候选足够多 | 视觉附加部分cost≤2048;最终视觉条目≤512;不是对整个旧语义输出强加2048 | +| 标签含200个Unicode code point、引号、换行、代理对emoji | 标签先截至160 code point再JSON escape,按最终行核算;无断开的代理对,无换行注入额外条目;写不下的行不产生ref | +| finite预算取H+V、64、128、256、2048(小于H时拒绝),混合长短条目 | 每次总账本≤budget;输出决定可重复;已知omitted才给精确数,候选/去重组/上游未知数量不混加 | + +### PR 5:类型化 ref + +| 怎么测 | 什么算成功 | +|---|---| +| 注册DOM和visual-region两个判别目标 | kind决定操作资格;类型测试不能构造visual+interact矛盾状态;不额外接受调用者capability数组 | +| 同名ref置于两个session/tab;输入@e1与e1 | canonical形式可解析;不同session/tab不串目标;未知目标为既有not_found类错误 | +| 对visual ref调用click/fill/hover/select/get_html/人工帮助DOM定位入口 | 全部按主设计拒绝;CDP输入、DOM HTML读取、DOM高亮等该工具副作用次数为0;不能偷偷降为anchor操作 | +| loader变化、target重attach、documentElement替换;另测history.replaceState不改身份 | 前三者拒绝旧目标;最后不单凭URL变化拒绝;实际anchor仍需连接 | +| document.open保留loader/document backend但替换html和Canvas | 旧ref失效;只比较loader的实现必须被此测试击败 | +| 第二次observe重新绑定e1;第一次ref字符串再次输入 | 明确验证“最新观察映射”而非声称能识别旧字符串;新映射仅来自成功提交;旧DOM工具回归通过 | + +### PR 6:截图执行 + +首次 clipScale 为 `min(1, 2048/(max(w,h)*r), sqrt(4000000/(w*h*r*r)))`;r 来自实际 metrics 适配,最终仍检查真实 PNG 尺寸。 + +| 怎么测 | 什么算成功 | +|---|---| +| visual ref解析成功并截图;DOM ref另测 | visual路径scrollIntoView次数0;DOM路径保持BASE策略;visual实时box种类与观察一致 | +| 同anchor四边分别移动0.249、0.25、0.251 CSS px;分别改尺寸/裁剪策略 | 前两种仅数值误差可接受,0.251拒绝;document/裁剪策略变化不受容差豁免;不靠old∩live返回小片段 | +| 保持身份/几何,重绘Canvas颜色;另移除Canvas | 重绘允许取得当前图像;移除拒绝;不承诺像素快照或把颜色改变误报为stale | +| css4096×2048,rasterEstimate2;css4000×4000,rasterEstimate1 | 首次clipScale分别0.25、0.5(不放大,取edge/area/1最小值);实际成功尺寸每边≤2048、面积≤4,000,000 | +| mock第一次图片3000×3000,第二次2000×2000;再测两次均3000×3000 | 第一组重试一次并成功,第二组恰好两次后pixel-budget-exceeded;不能无限重试或返回超限图 | +| 空data、非法PNG头、0尺寸、负/非有限几何 | 明确失败,不能猜测width/height;此项不要求将PNG头检查宣称为完整解码校验 | +| capture前取消;overlay抑制后CDP失败;采集时出现dialog | 取消前不再capture;成功/失败/取消均恢复overlay;dialog机制保持既有契约;无额外输入 | + +### PR 7:observe 接线 + +| 怎么测 | 什么算成功 | +|---|---| +| 固定SemanticScene,无视觉候选;普通预算,与BASE replay同一输入 | 语义文本、ref相对顺序、role/name一致;不因为新通道重跑semantic pipeline | +| 同SemanticScene加两个视觉候选,大预算 | 原DOM ref顺序不变,视觉ref在其后;每个输出ref均可在registry找到同目标,registry无未输出新ref | +| DOM文本多到耗尽除预留外的预算 | 固定视觉提示可见(预算允许时);truncated=true;不是DOM stopped后无条件跳过视觉提示 | +| 视觉capture unavailable但语义成功 | 语义结果保留,观察状态能区分empty/unavailable/truncated;debug reason准确且无原始DOM泄漏 | +| snapshot、recording调用、hover probe on/off与BASE对比 | snapshot不新增visual;recording不隐式启用发现;probe默认off,on时仍在静态采集后;相应既有测试通过 | +| blocking layer混合DOM/visual、重复候选、不同预算 | 输出层策略与选择模块的活跃层策略一致;头、摘要、条目、occlusion/omitted行均纳入账本;无悬空ref | + +### PR 8:整体验收 + +复跑上述模块测试、下方性能对照和浏览器场景,确认原始 iWiki 的缺口已解决。检查没有站点特判、重复的几何/预算权威实现或遗留的替代路径;没有顺带扩展到 OCR、逻辑画布聚合或旧算法重写。不能只凭合成页面通过宣布原始问题解决。 + +## 性能怎么验 + +在相关模块实现时检查,不拖到最后统一补。旧路径和新增 Canvas 成本分开看: + +- **旧路径**:同输入对照 main/前置基线,已有 render/context/sibling 工作量、语义构建次数和 DOM 操作请求不增加;保留 #165 的回归。不要求消除旧算法的历史二次项。 +- **无 Canvas**:允许必要检测与共享几何成本,但不构建视觉专用索引、不发视觉专用 CDP 请求、不重复跑语义链路。 +- **新增树处理**:deep/wide 节点数取 1k、10k、100k;每节点 context 至多推导一次,每条边每个索引 pass 至多访问一次,pass 数不随规模增加。无递归溢出、每候选完整祖先遍历或全祖先数组复制。 +- **选择**:候选数取 1k、10k、100k,K 取 1、32、512;堆峰值不超过 K,去重 key 不超过 50,000,无模糊两两比较。比较计数符合具体算法推导的上界;小规模结果与独立全排序对照一致,容量耗尽后仍能选中最后的大图。 +- **请求与取消**:每 target 一次 snapshot、每 frame 一次 AX 批次;单次操作内几何请求共享,不跨操作复用 live cache。几何最大并发 4,新增计算每块最多 256 项并实际让出事件循环,取消后不再启动下一块。 +- **接线后耗时**:同机器、浏览器和固定内容,分别测无 Canvas 页与合成表格页。各预热 3 次,再基线/新版本交替各 20 次,比较中位数和 p95;旧阶段与新增成本分开。旧阶段中位数增加超过 max(基线 10%, 2ms) 时复测,两轮均超过且可归因于改动则未通过;噪声无法排除则说明尚未确认。操作数仍须满足前述条件,不能用耗时阈值代替它们。 + +计数可以由测试插桩取得,不必为验收增加生产接口。性能结果只需说明旧路径是否回退、新增路径的规模与成本,不需要另填表格。 + +## 浏览器怎么验 + +### 合成表格(PR 0 复现;PR 7/8 验证修复) + +1. 核实 `bsk --version`、`bsk browsers`、`bsk status`;运行 `pnpm ext:build`,确认用户已 reload `apps/extension/dist/chrome-mv3`。 +2. 单独启动服务器:`python3 -m http.server 4177 --bind 127.0.0.1 --directory apps/extension/test-fixtures/visual-observation`。 +3. `bsk session start --width 1440 --height 1100`,使用返回的 session id 打开下列页面。尖括号替换为实际值,每条命令传同一 session。 + +```sh +bsk navigate http://127.0.0.1:4177/table.html --session --json +bsk observe --session --json +bsk screenshot --session --out <临时目录>/table.png --json +``` + +4. 把窗口放前台,实际看到三列六行后再采集 observe。load 完成、AX 工具栏出现或背景占位截图都不能证明表格已经绘制。 +5. main 应有 Page action、Insert record、Filter 三个 DOM 控件,truncated=false,但无表格视觉入口。这是 PR 0 的缺口复现。 +6. 新实现应保留三个控件,并增加 visual ref。用本次返回的 ref 执行 `bsk screenshot --ref --session --out <临时目录>/crop.png`,实际查看图片:包含 Canvas column A/B/C 和六行,裁剪对应 780×360 CSS px 的表格区域,不能错到工具栏或 iframe 边框。PNG 满足每边 2048、总像素 4,000,000 的上限。无需生成单元格语义。 + +### 几何与动态场景 + +PR 1 验证实际生产几何模块;PR 6 验证截图执行;PR 8 复验完整链路。未接 observe 时可注入人工 facts/ref,但必须调用实际模块,不能以独立 probe 代替实现测试。 + +- 必测缩放集合:DPR 1/1.5/2 × top/frame;真实 UI zoom 100%/90% × top/frame;CSS zoom 1.25 的 top Canvas;frame scale 1.25 的 same/nested/OOPIF。按 PR 1 的坐标预期核对。OOPIF 需确认实际 iframe target,跨 origin 本身不够。 +- UI zoom 要通过浏览器菜单或原生快捷键确认,不能以 CSS zoom 或 pinch emulation 替代。旋转、mask、clip-path、活动 pinch、变换 overflow 按不支持策略降级,不输出猜测的精确 ref。 +- 使用 `index.html?noFrames=1` 的 `#plain`,每个实验从 reload 后的新 ref 开始:平移 1px、宽度改为 121px → 旧 ref 截图要求重新观察;移除节点或 document.open → 旧目标失效;仅重绘颜色 → 截图成功且是新颜色。不要在重新 observe 后拿重用的 eN 字符串声称验证了旧 ref。 +- visual ref 的 click/fill/hover/select/get_html/DOM 高亮拒绝测试只在合成页或自动测试运行,确认拒绝前没有派发输入、读 HTML 或高亮副作用。 + +### 原始 iWiki(PR 0 与 PR 8) + +入口与就绪说明在本机临时文件 `/private/tmp/bsk-visual-design/private-reproduction.md`。文件丢失或登录失效时向用户取得入口/权限,说明这一项未完成;继续独立的合成验证。 + +保持只读。前台实际看到表格后采集 observe 和截图:PR 0 确认缺口;PR 8 确认外围 DOM 控件仍在、新 visual ref 能截到实际表格。不要编辑单元格,不要求原页面永久维持历史的 63 个 ref。私有内容不入库。 + +结束后停止自己的 session、服务器及临时 Chrome,恢复修改过的 zoom/emulation,不关闭其他任务资源。 + +## 做完怎么回复 + +直接告诉用户:验了哪个 PR、跑了什么、是否符合上述成功条件;若有失败或未做的检查,说明具体原因。必要时附关键测试输出或截图路径。无需单独报告文件、hash 清单、状态统计或全量日志归档;没运行、被跳过或受阻的项目不要写成通过。 diff --git a/docs/design/visual-observation-evidence.md b/docs/design/visual-observation-evidence.md new file mode 100644 index 0000000..3b72ec0 --- /dev/null +++ b/docs/design/visual-observation-evidence.md @@ -0,0 +1,235 @@ +# Visual observation:PR 0 验证结果与架构决策 + +日期:2026-09-08。生产基线:main `3c5f838`。分支:`codex/visual-observation-design`。 + +本次只新增设计、合成 fixture 和诊断工具。没有生产算法变更,没有 commit/push。 +总体设计及后续 PR 依赖见 [架构与实施计划](visual-observation.md)。 +本文是历史证据;新会话请按 [验收清单](visual-observation-acceptance.md) 执行相关检查并说明结果,不继承本文的通过结论。 + +## 1. 环境与证据等级 + +| 环境 | 实际版本/说明 | +|---|---| +| PATH 中的 bsk | 0.1.7,未用于有效页面基线 | +| 仓库已有 target/debug/bsk | 0.2.0;`update --check` 报告发布最新版也是 0.2.0,没有安装更新 | +| daemon / connected extension | 0.2.0,protocol 1.1 | +| 页面基线 Chrome | 151.0.0.0,由 bsk browsers 报告 | +| 生产扩展构建 | `pnpm ext:build`,用户 reload 当前 main 基线构建后采集 | +| 独立协议实验 Chrome | 152.0.7977.76,临时无登录 profile;与用户浏览器不是同一个运行实例 | +| 基线测试 | VOM 47 tests / 2 files;相关扩展 217 tests / 14 files;全部通过 | + +页面结果是实际 bsk + reload 扩展的结果。协议实验是独立 Chrome 的真实 CDP 响应。 +临时 replay 是将合成响应送入 main adapter 的诊断,不代表另一条真实浏览器完整运行。 +不同证据不混用,版本号本身不证明构建 commit。 + +所有内部页面原文、地址、iframe 标识、截图和用户信息仅保存在本机临时目录,不进入仓库。 +本文仅保存匿名化事实,仓库 fixture 完全独立构造。 + +## 2. 原始问题已确认:表格加载完成后仍有观察缺口 + +有效就绪条件:前台可见页面已经绘制出网格、列标题和六行表格。不能使用以下替代条件: + +- `document.readyState === complete`。 +- AX 已有嵌入应用的工具栏。 +- 页面出现 iframe。 +- 背景截图仍为站点加载 logo。 + +本次初始后台窗口恰好出现“AX 已有工具栏,图像仍为加载占位”的情况。这些样本已排除。 +在前台绘制完成后重新取得匹配的截图和 observe,结果为: + +| 事实 | 结果 | +|---|---| +| 实际截图 | 可见列标题、网格与六行表格 | +| main observe | 63 个 ref,含外围和嵌入应用工具栏 | +| truncated | false | +| 表格列内容 | 没有相应文本表示 | +| visual ref | 没有 | +| 直接打开已观察到的嵌入应用 | 发现一个无 aria-label 的 Canvas,CSS 尺寸 1450×835、backing store 2900×1670,DPR 2 | + +直接打开嵌入应用用于验证其渲染介质;其宽度变化是独立 tab 布局,不能拿这组坐标当父页面中的 crop。 +父页面的截图/VOM 对照与直接嵌入页的 DOM 证据分别记录。 + +结论:主要能力缺口是视觉内容没有观察入口;不属于这次有效样本的文本预算耗尽。 +新架构不能承诺从 Canvas 自动恢复单元格语义;正确结果是保留工具栏,并提供可截图的区域入口。 + +## 3. 合成最小复现 + +`table.html` → 跨站点 `grid.html`:一个 iframe、DOM 工具栏、一个无名 Canvas。 +Canvas 绘制三列、六行,所有文字与原页面无关。 + +main 实际输出的关键部分: + +```text +@view 1440x923 +@e1 button "Page action" +Iframe "Table application" + navigation + @e2 button "Insert record" + @e3 button "Filter" +``` + +`ref_count=3`,`truncated=false`。截图已人工确认包含 `Canvas column A/B/C` 和六行。 +这些像素文字既没有文本表示,也没有 visual ref。该最小复现已覆盖原场景的关键结构, +无需站点权限或生产选择器。 + +## 4. CSS 与语义/交互分离 + +真实浏览器实验确认: + +- aria-hidden 祖先、inert 祖先、pointer-events:none 不会使 Canvas 自动视觉消失。 +- `visibility:hidden` 祖先下的 `visibility:visible` 子 Canvas 可以恢复可见。 +- `hidden` 元素被 `display:block` 覆盖时仍可绘制。 +- opacity:0 祖先会使子树透明;不能仅检查 Canvas 自己的 opacity。 +- display:none 无 layout bounds;visibility:hidden 可以仍有 bounds。 +- AX fallback 文本不包含绘制的 `PIXEL TABLE 42`,不能用 fallback 存在作为发现去重条件。 + +决定:事实层分别保存 computed visibility、透明继承、语义暴露和交互属性。 +视觉发现不读取 semantic keep/drop 结果;祖先索引只能传播符合属性语义的状态。 + +`checkVisibility()` 的实验作用仅限 CSS suppression,不用它证明 overflow、viewport 或遮挡结果。 +常规 overflow 由独立几何规则负责;任意遮挡不进入首版精确能力范围。 + +## 5. 单位、frame 投影与截图 + +### 5.1 DPR 与截图像素是不同契约 + +独立 Chrome 上对相同 120×40 CSS clip 的结果: + +| Emulated DPR | DOMSnapshot Canvas w×h | Screenshot w×h | layoutViewport/cssLayoutViewport 宽度比 | +|---|---|---|---| +| 1 | 120×40 | 120×40 | 1 | +| 1.5 | 120×40 | 180×60 | 1 | +| 2 | 120×40 | 240×80 | 1 | + +因此该比值不能用作通用截图 DPR。原始 DOMSnapshot 单位与截图 raster scale 必须分别适配, +输出 PNG 尺寸是最终预算事实。不能把本组 DPR emulation 结果泛化为所有浏览器缩放组合。 + +### 5.2 三种缩放分别记账 + +- CSS `zoom:1.25`:120px 宽 Canvas 的布局 bounds 为 150px,运行时几何同步变化。 +- Emulation.setPageScaleFactor(1.5):记录为 page-scale/pinch 类证据,不充当 UI zoom。 +- 用户 Chrome 原生 UI 缩放到 90%:界面明确显示 90%,DPR 从 2 变为约 1.8,innerWidth 从 1440 变为 1600,visualViewport.scale 仍为 1;Canvas CSS 宽约 120.0000076,截图仍是 2880px 宽。main VOM 视口为 1600×1025。已恢复 100%。 + +现有 bsk 的 Meta+- CDP 输入未改变浏览器缩放,所以该无效果尝试不算缩放验证; +有效样本来自原生浏览器 UI 操作。真实 UI zoom 仅验证了上述点,不能宣称完整 zoom 矩阵已通过。 + +决定:使用 CSS 视口空间作为发现/截图区域公共契约;精确单位 adapter 的实现还必须在 PR 1 +跑计划内组合回归,不用一个比例同时解决所有协议字段。 + +### 5.3 Frame 空间与 main 已知偏移 + +fixture local Canvas 原点为 (17,23)。 + +| 场景 | 实测 owner content 原点(target root) | 正确 top Canvas 原点 | main parser replay | +|---|---|---|---| +| 同 target frame,10px border | (50,610) | (67,633) | (57,623) | +| 上述 frame 内 nested,另有 7px border | (87,707) | (104,730) | (87,713) | +| OOPIF,10px border | (410,610) | (427,633) | 本表不混入非完整 replay 的 OOPIF 结果 | + +根 target 的 DOMSnapshot 含三份 document(top/same/nested);OOPIF 在单独 target 中只返回自己的 document。 +probe 通过实际 `TargetInfo.type=iframe` 验证 OOPIF,而非仅根据 origin 推断进程隔离。 + +子 frame scrollY=10 后,runtime local y 从 23 变为 13,snapshot document y 仍为 23。 +owner scale(1.25) 后 content quad 宽度变为 375,child innerWidth 仍为 300。 + +决定:frame-local 原始矩形必须先减该 document 的滚动,再经过一次权威投影;同 target 的 +owner quad 已是 target-root 坐标,不能重复逐层加偏移。禁止 border-box 替代 content origin。 + +Box 种类也必须进入契约:带 5px border 的 120px Canvas,其 snapshot/border quad 宽 130, +content quad 宽 120。视觉入口采用 border-box,实时验证同种 box;iframe 投影则采用 content quad。 +带 border/padding 的 overflow owner,snapshot.clientRects 对应 `[clientLeft,clientTop,clientWidth,clientHeight]`, +本 fixture 为 `[5,5,68,38]`。裁剪边界应从 client box 派生,不能直接拿 ancestor border bounds。 +单轴 `overflow-x:clip;overflow-y:visible` 的 computed style 保持分轴,必须分轴传播。 + +CSS zoom 1.25 后,上述 owner 的 bounds 变为 `[745,455.625,97,59.5]`,clientRects 仍为 +`[5,5,68,38]`,offsetRects 为 `[596,365,78,48]`。直接按 border-width 比例推导 client box +会混入不同空间与整数取整。首版将有 CSS zoom/DOM transform 的 overflow 祖先组合明确降级, +不承诺精确 crop;普通未变换 overflow 及 iframe content-quad 投影保留支持。 + +## 6. Document 与 ref 时效 + +真实 CDP 实验: + +| 操作 | frameId | loaderId | document backend | anchor | +|---|---|---|---|---| +| history.replaceState | 不变 | 不变 | 不变 | 仍属于当前树 | +| 跨 document 导航 | 不变 | 变化 | 变化 | 旧目标不可继续视为当前内容 | +| document.open/write/close | 不变 | 不变 | 本次实验不变 | 旧 Canvas 失去连接,documentElement 被替换 | + +决定:身份取 target attachment epoch + frameId + loaderId + documentElement backend identity; +工具执行时仍检查 anchor.isConnected 及其所属 documentElement。不能仅凭 loaderId 判断有效。 +DOMSnapshot 可提供根元素 backend identity,frame graph 需要保留 loaderId;当前 main graph +没有该字段,这是后续数据契约的明确修改点。 + +12 次静态几何采样完全相同;0.5px 移动可被检测;真实 90% 缩放引入约 1e-5 CSS px 的 +数值误差。首版采用最大边误差 0.25 CSS px 作为保守工程阈值,超出要求重新 observe。 +这不是大规模统计得到的通用稳定性保证;改变阈值需更新其测试及行为文档。 + +既有 eN 被下一次观察重用后,单靠旧字符串不能检测它来自上一代。内部 generation 不消除 +该协议限制。本次不改变协议,只验证当前存储目标的身份/几何。Canvas 可以在身份与几何 +不变时重绘;截图始终表达执行时图像,不承诺观察时像素快照。 + +## 7. 既有路径回归基线:用于防止负优化 + +诊断复制当前 renderer 到临时目录,在三个明确循环点加计数;生产文件未改变。 +复现命令见 [fixture README](../../apps/extension/test-fixtures/visual-observation/README.md)。 + +| 输入 | Render stack visits | DOM context candidates | Same-container sibling visits | +|---|---:|---:|---:| +| 100 headings + 100 View buttons | 201 | 10,000 | 15,050 | +| 200 headings + 200 View buttons | 401 | 40,000 | 60,100 | +| 400 headings + 400 View buttons | 801 | 160,000 | 240,200 | +| 2,000 transparent wrappers + control | 2,001 | 0 | 0 | +| 4,000 transparent wrappers + control | 4,001 | 0 | 0 | + +显式栈访问线性并通过深树输入;本组宽树上下文计数呈二次增长。这里记录的是现有路径的 +特征,供后续同输入比较是否发生回退,不是本次要求修复的问题,也不是 Canvas 新算法的模板。 +计数不是完整 CPU profile,更不能替代 Canvas 发现、裁剪、选择和截图各自的性能测试。 + +处理边界: + +1. 既有语义、DOM 操作和截图路径对照 main 不发生性能回退;不把历史算法优化列为本次前置依赖。 +2. Canvas 新路径按自己的规模和访问模式论证算法;发现、裁剪、去重、选择和截图分别验收。 +3. 精确 crop key 去重的选择来自首版的覆盖等价契约和新增成本分析,与 #165 采用什么算法无关。 +4. PR 7/8 分别报告既有路径回归、无 Canvas 页面的额外成本、有 Canvas 页面的新增成本。 + 新功能有合理的额外成本不等于旧算法负优化;重复执行旧工作则属于需要消除的回退。 + +## 8. 锁定首版策略与退化 + +| 决策 | 首版契约 | 理由/验收 | +|---|---|---| +| 去重 | 同 document + 同直接 parent + 四条 crop 边完全相等 | 无模糊聚类;误差导致重复比误合并更可控 | +| 索引容量 | 50,000 keys;耗尽后其余候选继续未去重选择 | 内存有界,不因早期小图耗尽而停止后续发现 | +| K | min(预算推导容量, 512) | 所有行仍逐条核算可变成本 | +| 默认视觉预算 | 未给总 maxTokens 时 2048 estimated tokens | 有显式 maxTokens 时全部纳入同一预算 | +| 摘要预留 | 固定提示行按实际估算器成本预留 | 不用不可验证的固定 token 常量表示某段文本 | +| 几何时效 | 最大边差 > 0.25 CSS px 即重新观察 | 身份和裁剪策略变化独立拒绝 | +| 图像 | max edge 2048、max pixels 4,000,000、最多两次 capture | 成功必须以实际尺寸满足后置条件 | +| 外部几何并发 | 4 | 单操作缓存;操作结束释放 | +| 新增纯计算取消 | 每 256 项形成工作块并让出事件循环 | 不仅是轮询无法及时更新的 signal | + +这些数值是可审查的首版资源政策,不是声称已证明用户体验最优的测量结果。 +参数可以在对应模块 PR 中经新的证据调整,但必须一并更新契约及测试。 + +进一步收敛:首版精确区域支持轴向矩形、普通 overflow 链、正向轴对齐平移/缩放与 frame。 +旋转、透视、clip-path、mask、活动 pinch-zoom 进入明确的 geometry-unsupported 降级, +不给看似精确的 region ref,提示现有视口截图途径。不会把包围矩形偷换成精确区域。 + +## 9. 已执行的检查与下一阶段门槛 + +已执行: + +- main VOM tests:47 passed。 +- main 相关 extension tests:217 passed,含 frame/element geometry、observation、interaction、ref-store、VOM。 +- `pnpm ext:build` 成功,用户 reload 后采集有效页面基线。 +- 独立 Chrome browser contract probe:39 个断言通过;可复现命令见 fixture README。 +- main 合成 capture replay、显式栈/上下文操作计数。 +- 实际 Chrome UI 90% zoom 样本,恢复 100%。 +- 原始表格与合成嵌入式表格均人工查看截图,并与未截断 observe 对照。 + +PR 0 交付的是已明确边界的设计和基线,不是未来功能已经通过:新的 discovery、typed ref、 +freshness resolver、pixel planner 和 renderer 接线尚未实现。后续每个 PR 必须验证自己的 +生产实现,不能把本次浏览器事实验证替代实现验收。 + +人工证据不设置默认 CI 对内网站点的访问依赖。合成 probe 可按需执行;页面原文不进仓库。 +后续 PR 的职责和验收保持在主设计中,本文负责记录证据、决策及没有作出的保证。 diff --git a/docs/design/visual-observation.md b/docs/design/visual-observation.md new file mode 100644 index 0000000..753b23f --- /dev/null +++ b/docs/design/visual-observation.md @@ -0,0 +1,675 @@ +# BrowserSkill 视觉观察:架构设计、边界约束与 PR 实施计划 + +> 日期:2026-09-08 +> 状态:PR 0 设计验证稿;基线、浏览器实验和参数决策见 [验证结果](visual-observation-evidence.md)。生产实现尚未开始。执行步骤和成功条件见 [验收清单](visual-observation-acceptance.md),无需另写报告。 +> 实施基线:从实施时确认的 main 新建分支;本次分析使用本地 main `3c5f838`。 +> 参考材料:当前 `fix/vom-canvas-observation` 分支、PR #165、此前的 visual-surface 架构蓝图。旧实现和旧测试是证据来源,不是不可改变的规格。 +> 本次范围:设计规格及可复现的合成 fixture/诊断工具;不修改生产代码,不继承旧分支提交。 + +## 1. 设计结论 + +本次工程的目标是: + +**为 VOM 建立独立的视觉观察通道,共享可靠的采集与几何基础,通过明确的区域身份提供截图能力,并使处理成本随输入规模可预测增长。** + +工作方式是先确定整体架构、行为契约、模块依赖、复杂度和验收矩阵,再分 PR 实现。分步实现不等于分步思考架构。任何实现阶段需要改变已定契约时,必须先更新设计及相关验收项,不得用局部特殊分支掩盖契约冲突。 + +本次不是一次目录整理,也不是通用视觉理解平台重写。设计完整覆盖当前能力的生命周期,但不为尚无需求和证据的未来能力预建实现。 + +### 1.1 必须成立的五个结果 + +1. observe 能表达“这里存在需要看图理解的 Canvas 内容”,不依赖该元素是否有可用 AX 语义。 +2. 视觉入口的 document、frame、target、节点及坐标归属明确。 +3. 视觉 ref 与 DOM 操作 ref 具有明确且不可矛盾的工具契约。 +4. 截图解析当前目标并验证时效,返回浏览器最终合成的当前图像及真实尺寸。 +5. 采集、索引、发现、选择、输出和截图均有复杂度或资源上限,并从首个实现 PR 开始验证。 + +### 1.2 不能用来证明成功的指标 + +- 代码文件拆得更多。 +- 旧分支全部测试通过,但没有重新审核测试中的行为假设。 +- 能识别某个特定 嵌入式 Canvas 表格 页面,却依赖站点 class、URL 或布局常量。 +- 单个简单页面运行很快,但没有最坏输入分析。 +- 文本出现 surface 标记,但其截图对应关系、时效和工具限制没有验证。 + +## 2. 问题本质与现状 + +### 2.1 main 的基础能力 + +当前 main 的主要路径为: + +```text +DOMSnapshot + 各 frame 的 AX + → FrameDocument + → semantic graph:构建、解析、结构整理 + → VomScene + → VOM 文本及 DOM ref +``` + +已经存在 frame document、语义图、几何模块、ref store、截图路径和显式栈渲染器。应复用这些职责,不在旁边建立第二套长期运行的采集、frame traversal 或语义树。 + +缺口是:元素的 DOM/AX 描述不等于 Canvas 内部的文字、图形和布局。补 role、名称或普通可点击 ref,并不能建立完整的视觉观察能力。 + +### 2.2 必须分别解决的问题 + +| 问题 | 要回答的具体问题 | 不属于该问题的内容 | +|---|---|---| +| 视觉发现 | 哪些 Canvas 构成可观察候选? | 它是否可点击、输出多少条 | +| 空间定位 | 候选在顶层视口的区域是什么? | 它是否重要、属于哪个业务画布 | +| 输出选择 | 有限预算下展示哪些入口? | 改写几何事实、推断业务实体 | +| ref 身份 | 入口绑定什么目标、允许哪些工具使用? | 像素缩放、内容识别 | +| 时效验证 | 当前目标是否仍符合此次观察契约? | 保证 Canvas 像素未发生重绘 | +| 图像获取 | 如何获取当前合成区域并限制成本? | 重建 Canvas 内部对象 | + +### 2.3 当前分支值得保留与需要重审的部分 + +值得保留:语义/视觉双通道、视觉附录、单节点截图锚点、frame 几何共享、像素预算、相关失败场景和 fixture。 + +需要重审: + +- 将 `aria-hidden`、`inert` 与视觉隐藏合并排除。语义暴露、交互资格、视觉存在是不同维度。 +- 将旧区域与实时区域的交集当作时效验证。区域重合不能证明目标仍对应原观察。 +- 将几何重合和共同父节点解释成逻辑画布身份。 +- 将名称有无作为重要性的绝对优先条件,可能让大量有标签的小图排在无标签主画布之前。 +- 视觉附录依赖 DOM 渲染后的剩余预算,可能使视觉通道完全消失。 +- 在每个候选上重复祖先遍历,或假设引入空间索引便自动消除了最坏二次复杂度。 + +PR #165 优化了既有渲染及上下文查询路径。本次将它作为性能回归保护基线,不作为 Canvas 算法模板,也不据此要求重做原有算法。Canvas 的发现、裁剪、选择和截图具有不同的数据规模与访问模式,应分别设计和验证适合自己的算法。 + +PR 0 已在用户指定页面确认:表格实际绘制后,main 输出外围工具栏而没有表格列内容及视觉入口,且未截断。直接打开嵌入应用确认存在无标签 Canvas。内部页面资料仅留临时目录;入库使用匿名化结论和独立合成 fixture。 + +## 3. 范围收敛 + +### 3.1 本次纳入 + +- Canvas 元素锚定的视觉观察入口。 +- 顶层、同 target 子 frame、OOPIF 及嵌套 frame 的身份和坐标归属。 +- 已明确支持的视口及 overflow 裁剪。 +- 无标签、存在 AX、不可交互但有视觉内容等候选情况。 +- 有界输出选择及保守的重复截图入口去重。 +- 视觉 ref 的工具契约和 document/几何时效检查。 +- 截图区域解析、像素预算、真实输出尺寸验证和有界重试。 +- 深层 DOM、密集小图、重叠 Canvas 和多 frame 的性能约束。 + +### 3.2 本次不纳入 + +- OCR、图像语义识别、Canvas 内部对象树。 +- 画布内部点选、拖拽或内部对象 ref。 +- 地图 tile 拼接、跨节点逻辑画布聚合、多 anchor 区域。 +- 面向所有 SVG、视频、CSS 背景等介质的通用发现插件系统。 +- 任意页面遮挡、透明度、clip-path 和 mask 的精确像素级重建。 +- 改写所有现有语义算法、表单、hover probe 或 recording 协议。 +- 新的 ref 字符串协议、跨 observe 持久对象 ID、分页接口。 + +边界外的能力可以提出独立设计,不能通过放宽当前去重阈值或给单节点 target 塞可选字段实现。 + +## 4. 总体架构 + +```mermaid +flowchart TD + Browser[浏览器 / CDP] --> Capture[CaptureCoordinator] + Capture --> Batch[RawCaptureBatch] + Batch --> Geometry[GeometryContext] + Batch --> Normalize[Normalize + DocumentIndex] + Geometry --> Normalize + Normalize --> Facts[ObservationFacts] + Facts --> Semantic[现有 SemanticPipeline] + Facts --> Discover[VisualDiscovery] + Discover --> Candidates[VisualCandidates] + Semantic --> SemanticScene[SemanticScene] + Candidates --> Select[VisualSelection] + SemanticScene --> Context[输出上下文 / 活跃层策略] + Context --> Select + SemanticScene --> Renderer[ObservationRenderer + BudgetPlan] + Select --> Renderer + Renderer --> Output[文本 + Typed Ref Descriptors] + Output --> Registry[RefRegistry] + Registry --> Resolve[当前目标及区域解析] + Browser --> Resolve + Resolve --> Pixel[PixelPlanner] + Pixel --> Shot[CaptureExecutor] + Browser --> Shot +``` + +### 4.1 架构原则 + +1. 共享事实,分开解释。视觉存在不复用语义节点的保留/排除结论。 +2. 语义关系是视觉区域的可选上下文,不是视觉身份的根。 +3. 几何只有一套解释和投影实现,观察与工具执行使用不同生命周期的上下文。 +4. renderer 只处理数据;浏览器调用止于采集或工具执行边界。 +5. 预算选择只影响输出,不回写采集事实。 +6. ref 的 kind 决定工具适用性,不能由调用者传入另一份可矛盾能力数组。 +7. 模块失败必须可归因;不能将“没有候选”与“采集失败”混为一谈。 +8. 所有资源限制都具有可解释的退化行为,不只是一句“超过上限停止”。 + +## 5. 模块划分与实现位置 + +以下为目标职责和建议位置,不要求第一个 PR 搬动全部文件。迁移文件与修改算法尽量分开;模块完成切换后删除被替代路径。 + +```text +apps/extension/src/tools/ + observation.ts # 工具入口与结果接线,逐步减去内部实现 + observation/ + capture-coordinator.ts # CDP 编排及批次生命周期 + capture-types.ts # 原始批次、归属、阶段状态 + normalize.ts # 原始节点 → 归一化事实 + document-index.ts # 按 document 建立共享索引 + facts.ts # 下游可读事实契约 + geometry/ + coordinate-types.ts + frame-context.ts # frame/target 投影权威入口 + primitives.ts # 复用/迁入现有数学实现 + vom/ + capture.ts # 保留或拆出的 DOMSnapshot adapter + frame-document.ts # 复用现有 frame 归属逻辑 + semantic-graph/ # 保留语义职责 + visual/ + types.ts + discovery.ts + selection.ts + coverage-dedup.ts + refs/ + target.ts + resolve.ts + screenshot/ + resolve-region.ts + pixel-plan.ts + capture.ts + +apps/extension/src/session-manager/ + ref-store.ts # session 生命周期与注册存储 + +packages/vom/src/ + types.ts # 纯场景及渲染契约 + render.ts # 保留显式栈语义遍历 + render-budget.ts # 唯一输出预算核算 + visual-appendix.ts # 纯数据视觉条目输出 +``` + +不同时保留旧 `geometry.ts` 与新 primitives 的两套数学实现。上述目录可按现有工程规范调整,但职责与依赖不得随意调整。 + +### 5.1 CaptureCoordinator + +输入:tab/target、已验证的 URL、取消信号、采集选项。 +输出:带来源、时间范围、document 身份和阶段状态的 RawCaptureBatch。 + +- 按 target 分组采集;DOMSnapshot 每 target 一次成功采集,重试单独计数且有界。 +- AX 按协议支持的 frame/target 组织,数量有预算。 +- 原始采集完成后才能运行会改变页面状态的 hover probe,保留已有行为顺序。 +- 明确记录无法采集的 frame,不能把失败 frame 的数据借用其他 target 补齐。 +- 不判断 Canvas 重要性,不投放 ref。 + +批次是来源和生命周期边界,不是浏览器原子事务。`Readonly` 或冻结对象不能证明各 CDP 响应来自同一瞬间。遇到可检测的导航/document 更换时应中止或有界重采集,不能拼接不同 document 的数据。 + +### 5.2 GeometryContext + +输入:该次操作的 frame graph、原始 metrics、按需取得的 owner quad。 +输出:frame-local/target-local 到 top-viewport 的投影、裁剪及当前节点区域解析。 + +- 坐标类型同时携带空间及所属 frame/target;仅标记 `frame-local` 不足以防止错 frame 使用。 +- 原始 DOMSnapshot 单位、CSS 视口单位、截图像素单位分别适配。 +- Canvas 视觉入口采用元素 border-box 的可截图区域;实时解析明确请求同一种 box,不能沿用默认 content-quad 路径再将边框差异误判为移动。iframe 子视口投影仍采用 owner content quad,两者职责不同。 +- 普通 overflow 裁剪使用 ancestor client/scrollport box,不使用祖先 border bounds。adapter 保留 includeDOMRects 的 client offsets/size 及其单位;存在缩放时先转换到相同坐标空间,禁止直接相加不同单位。证据不足时明确降级,不猜测 border/padding 常量。 +- 一个操作内复用 metrics、owner quad 和 projection promise;不跨 observe 或 tool execution 复用 live geometry。 +- 合成变换可以预计算;裁剪边界复杂度须真实计入,不能靠 promise cache 就宣称全部计算线性。 +- 不允许通过 iframe border box 加减常量替代有证据支持的 content geometry。 + +### 5.3 Normalize / ObservationFacts / DocumentIndex + +输入:原始批次及几何上下文。 +输出:按 document 隔离的只读事实和有界共享索引。 + +- Raw node 不能含语义不明的 top rect;normalized node 的派生字段由 normalization 创建。 +- 节点 key 包含 document/target 所属信息,不能仅用 backendNodeId 跨 target 建表。 +- 建立 node lookup、parent/children 等基础索引;派生索引必须有明确消费者和复杂度。 +- 用显式栈和 visiting/visited 状态处理任意深度及异常环。 +- orphan 保留“祖先信息不完整”状态;可以在已知范围内生成保守候选,但不能伪装成完整裁剪证据。 +- 视觉样式、AX 暴露、交互资格分开保存,禁止万能 `excluded` 字段承载全部规则。 +- 不给每个节点复制完整祖先数组或完整裁剪链;必要时共享结构或按需解析,计入内存预算。 + +### 5.4 SemanticPipeline + +消费归一化事实,输出现有语义场景。通过窄 adapter 接入新事实契约;迁移完成后删除不再需要的 adapter。 + +- 不因为视觉发现需求改变 semantic keep/drop、名称恢复或可交互推断规则。 +- 不把视觉候选塞回语义树遍历。 +- PR #165 的显式栈、上下文索引及行为回归保留。 +- 偶然发现的既有语义性能问题只作范围外记录,不构成本次工作项或前置依赖。本次只要求不使原有路径发生性能回退。 + +### 5.5 VisualDiscovery + +输入:ObservationFacts。 +输出:候选及发现状态,不含最终 ref,不依赖 token 预算。 + +候选表达一个 Canvas 节点锚定的截图区域。必须具有可解析地址、支持范围内的几何证据和非空候选区域。 + +- 无 label 不排除;有 AX 内容也不能证明像素内容已被完整描述。 +- `aria-hidden`、`inert`、`pointer-events:none` 不单独构成视觉排除原因。 +- 零尺寸、无渲染区域、已证实的完全透明/隐藏、支持范围内的完全裁剪可排除。 +- `visibility` 等继承与覆盖行为必须按实际 CSS 语义处理,不能把祖先 hidden 一律不可逆传播。 +- `hidden` 等属性不应替代最终渲染事实;存在特殊状态时按实验确认的规则处理。 +- agent 自身 overlay 排除应按所属 document/target 传播。 +- 常规轴向 overflow 累积在一致的坐标空间计算;变换后不能把 top-space 包围矩形当作精确 local-axis clip。 +- 不重建任意遮挡。几何区域是截图候选范围,不保证范围内每个像素均来自该 Canvas。 + +### 5.6 VisualSelection / CoverageDedup + +输入:候选、输出上下文及预算规划所需参数。 +输出:选中候选、明确的遗漏/退化状态。 + +- 默认以区域覆盖量等可解释几何依据排序;label 是命名事实,不作为压倒面积的优先条件。 +- 活跃层优先级属于选择策略;被降低优先级不等于物理不可见。 +- 排序键预计算;同层按可见面积降序,再按 frameId、top y、left x、backendNodeId 升序决定顺序。字符串按代码单元字典序、数值按数值比较;同一精确去重组取该顺序最小者。不把跨批次 backend id 稳定性作为保证。 +- 去重只减少重复截图入口,不产生新的 logical surface。 +- 首版仅在同 document、同直接 DOM parent、四条裁剪边数值完全相同的候选之间去重,使用精确 key 的 Map;不使用近似 IoU、尺寸 band 或空间邻居比较。亚像素不同的范围保留独立入口。 +- 首版不输出无法证明的 `layers=N` 业务解释。去重成员只是覆盖证据,不是内部对象模型。 +- 精确去重索引达到 50,000 个 key 后,剩余候选以未去重形式继续进入有界选择,记录去重退化;不得直接丢掉剩余区域。索引已知 key 仍可命中去重。 +- 即使没有去重,top-K 选择和总输出成本也必须有界。 + +### 5.7 ObservationRenderer / BudgetPlan + +输入:SemanticScene、视觉选择结果和输出预算。 +输出:文本及与已提交行严格对应的 typed ref descriptors。 + +- 唯一模块核算总文本预算;摘要、头部、条目和截断提示全部计入。新 observe 的统一账本为每个实际输出行分别 `ceil(line.length / 4)` 后求和,length 使用 JavaScript UTF-16 单位;不混用整段核算与逐行核算。 +- 保留语义显式栈和 DOM ref 相对顺序;视觉条目在语义阶段之后提交。 +- 有视觉候选时,规划阶段预留简短视觉提示;其余视觉条目使用明确分配的剩余预算。 +- 极小预算不保证语义与视觉同时完整,必须产生确定的截断结果。 +- 写入行与提交 ref 在逻辑上原子:未写入的条目不能留下可解析 ref。 +- 使用既有 token 估算方法时,预算表示“估算 token 上限”,不能宣称精确模型 tokenizer 上限。 +- 新 observe 预算规划若发现显式预算不足以容纳最小协议头,返回 insufficient-budget,由工具层映射为既有 invalid_params,附最低估算成本;不生成半截协议头或新 ref。snapshot 的旧预算语义不在本次顺带改变。这是 observe 极小预算的明确兼容性例外。逐行统一账本也可能改变临界预算的截断位置;足够预算下无 Canvas 的语义文本及顺序保持不变。 +- 首版固定视觉提示为 `[visual] Canvas regions present; use screenshot`,成本由同一估算器计算。有候选但条目不足时仍尝试保留提示;连提示也容不下则只返回合法的截断语义输出,truncated=true。未知计数不伪装成精确遗漏数。 +- 输出标签最多 160 个 Unicode code point,再经 JSON escaping 与整行成本核算;排序不依赖标签长度。原始采集输入量 B 仍单独计入,不因输出裁剪便宣称采集内存有界。 +- `snapshot` 继续使用既定静态语义输出;新视觉入口首先仅接入 `observe`。不要顺带改变 recording/其他调用方默认选项。 + +### 5.8 RefRegistry / Resolve + +保存 session/tab 隔离的目标描述。DOM 与视觉目标通过判别联合区分;kind 为权威,外部展示能力可从 kind 派生。 + +```ts +// 概念契约,具体类型名与仓库适配时确定。 +type RefTarget = + | { kind: "dom"; address: NodeAddress } + | { + kind: "visual-region"; + anchor: NodeAddress; + observed: ObservedGeometry; + observationId: ObservationId; + }; + +// NodeAddress 必须能表示 tab、CDP target、frame、document 身份及 backend node。 +// 坐标值同时保留所属空间及 owner,不使用无归属 Rect 穿越模块边界。 +``` + +工具适用性: + +| 消费入口 | DOM ref | Visual ref | +|---|---|---| +| screenshot | 维持既有 DOM 截图策略 | 使用视觉区域策略 | +| click/fill/hover/select 等 | 维持既有检查 | 拒绝,且拒绝发生在派发输入前 | +| get_html | 维持既有行为 | 拒绝;不隐式降级为 anchor 的 HTML | +| 人工帮助定位 | 维持既有行为 | 若当前入口必须解释为 DOM 目标则拒绝;未来支持视觉区域需另立契约 | + +所有 ref 消费者都必须通过类型化 resolver,不能依赖调用者记得传可选 capability 参数。 + +现有 `eN` 重用存在明确限制:如果新的 observe 已将 `e1` 重新绑定,单凭客户端传来的旧字符串 `e1` 无法判断它来自上一代。内部 generation 不会自动解决该问题。观察身份采用 target attach epoch + frameId + loaderId + documentElement backend identity,并在执行时验证 anchor 仍连接到匹配的 documentElement;loaderId 单独不足以覆盖 document.open。 + +本次保留当前“最新观察映射”的外部语义;只承诺能检测当前已存条目的 document/节点/几何失效,不承诺识别所有跨代旧字符串。彻底解决需独立协议设计。 + +### 5.9 ScreenshotExecution + +分成三部分: + +1. `resolve-region`:按目标种类解析当前身份与几何、验证 document 和区域变化。 +2. `pixel-plan`:纯数值函数,输出 clip scale 等计划。 +3. `capture`:overlay suppression、CDP 截图、真实尺寸读取、有界重试、取消及 dialog 集成。 + +- Visual ref 不自动滚动,不通过旧、新区域交集掩盖位移。 +- 首版对超过既定容差的位移、尺寸或裁剪变化返回“需要重新观察”;容差在 PR 0 由测量误差证据锁定。 +- 截图返回执行时浏览器最终合成图,不是观察时像素快照。 +- document 身份验证不能替代几何验证;几何相同也不能证明 Canvas 内容未重绘。 +- 像素规划采用明确 metrics 适配值;真实 PNG 尺寸是输出预算后置条件的依据。 +- 尺寸头校验不宣称完整 PNG 内容验证;缺失/不合法数据与预算超限分开归因。 +- 成功返回必须满足像素上限;有界重试仍失败则返回失败,不能猜测尺寸或无界重拍。 + +## 6. 算法与资源预算 + +### 6.0 两类独立的性能责任 + +**既有路径:防止回退。** 在相同输入、选项和环境下对照 main,检查原有语义处理、DOM ref、普通交互与截图的工作量、内存和实际耗时。不得因接入 Canvas 而重复运行语义解析、重建已有索引、增加原有节点的上下文查询,或撤销 #165 的优化。本次不要求把既有算法统一改成某个复杂度。 + +**新增路径:独立设计。** 围绕 Canvas 数量、frame 数量、裁剪复杂度、输出预算及截图像素量分析新成本;不照搬旧 renderer 的算法,也不以“旧算法使用了索引/显式栈”代替新问题的算法论证。下文复杂度与资源要求约束新增或本次实际改动的职责。 + +性能验收分三组,分别报告,不能互相替代: + +| 验收组 | 对照方式 | 必须说明的结果 | +|---|---|---| +| 既有语义/操作路径 | 同一输入在 main 与新分支运行,隔离新增视觉工作 | 既有阶段未引入额外遍历、重复请求或可归因的性能回退 | +| 无 Canvas 页面 | 实际完整 observe 对照 | 仅保留必要的候选检测开销;确认无候选后不建视觉索引、不执行视觉几何查询或截图 | +| 有 Canvas 页面 | 单独测量视觉各阶段,再测完整 observe | 新成本与 C/F/P/K/像素等规模相符,资源有界;不把合理的新功能成本误记为旧算法退化 | + +实际耗时需要多次同环境对照并结合操作数判断,不能仅凭一个容易波动的耗时值下结论。新增字段采集或共享模块调整的额外开销也必须归因并报告,不能藏在“原有成本”中。 + +### 6.1 规模定义 + +| 符号 | 含义 | +|---|---| +| B | 原始协议数据及字符串总量 | +| N | 采集 DOM 节点数 | +| A | AX 节点数 | +| F / T | frame 数 / CDP target 数 | +| C | 视觉候选数 | +| K | 最大选中输出条目数 | +| Q | 去重实际候选比较次数 | +| P | 几何计算量,如顶点处理、裁剪操作总数 | +| H | 语义上下文查询实际处理的数据总量 | +| L | 最终输出文本长度 | + +不能忽略 B、P、H 后笼统宣称整个管线 `O(N)`。原始数据解码至少与输入量相关,复杂裁剪和语义上下文的总成本需要显式计算。 + +### 6.2 模块性能契约 + +| 阶段 | 复杂度/上限目标 | 禁止的实现 | +|---|---|---| +| 原始解码 | 与 B 线性相关 | 为每个节点重新解码共享列或字符串 | +| 文档索引 | `O(N + A + F)`,不含协议字节解码 | 每个候选全文扫描 | +| 可合成祖先状态 | `O(N)` 建立,候选常数次查询 | 每 Canvas 完整祖先 walk | +| Frame 几何 | 变换/证据按 frame 复用,额外成本计入 P | 每 node 查询 metrics/quad | +| 发现 | 索引后 `O(C)`,额外几何成本计入 P | 发现函数内重新查 CDP | +| top-K | `O(C log(K+1) + K log(K+1))` | 无需要地重复全量排序 | +| 去重 | 精确 key Map,摊销 O(C),索引至多 50,000 个 key;首版 Q=0 | 引入模糊匹配和同 bucket 两两比较 | +| 新增视觉渲染与既有渲染接线 | 新增工作按选中条目及 L 核算;既有 H 对照 main 不回退 | 为视觉条目重新执行语义遍历或扫描全部节点 | +| 截图 | 调用和重试次数有界,成功图像像素数有界 | 无限重试、猜测输出尺寸 | + +K 从预算允许的最小条目成本推导,并受异常安全上限约束;实际每行仍核算成本。面积优先 top-K 不应被描述为严格求解“可变长度条目预算最优装包”,本次不实现此类优化问题。 + +### 6.3 首版资源参数及锁定规则 + +以下是 PR 0 锁定的首版策略参数。它们是有明确退化语义的工程预算,不是性能最优值的统计结论;调整须更新对应契约测试: + +- Visual 截图最大边:2048 px;最大面积:4,000,000 pixels。 +- 单次视觉截图最多 2 次 capture 尝试。 +- Frame 几何外部请求最大并发:4。 +- K 异常安全上限 512;正常数量由预算与条目成本决定。 +- 几何变化阈值:top-viewport CSS px 的四条边最大差值超过 0.25 即要求重新 observe;document、anchor、裁剪策略改变不受该容差豁免。 +- 没有显式 maxTokens 时,视觉通道估算 token 预算 2048;有显式预算时,优先为固定摘要行按当前估算器计算实际预留量,条目用剩余预算,全部计入同一总预算。 +- 精确覆盖去重索引最多 50,000 个 key;耗尽后继续未去重 top-K,标记 dedup-degraded;不得停止扫描剩余候选。 +- 新增纯计算阶段每 256 个节点/候选形成可取消工作块;需要实际让出事件循环,而不是只读一个尚无机会更新的 AbortSignal。 + +每个限额均须有单位、适用阶段、计数口径和耗尽行为。数字集中在所属模块策略配置中,不散落为多个含义相近的常量。若 PR 0 未锁定参数及退化契约,对应实现 PR 不得以随意常量合入。 + +### 6.4 性能测试方法 + +- 节点访问、祖先推导、候选比较、CDP 请求数、最大并发及中间集合大小均做可断言计数。 +- 对新增树处理使用深树/宽树测试,证明无调用栈溢出或重复全量扫描;既有 renderer 的对应输入用于 main 前后回归对照,不要求本次消除其历史复杂度。 +- 规模翻倍时检查操作数增长是否符合分析,避免只看一次墙钟时间。 +- 精确去重索引至少测试:全部重合、亚像素差异、同 crop 不同 parent、全部互不相交、容量耗尽;未来若引入空间索引,必须另增退化测试。 +- 大量有名小图与无名主画布并存,验证排序不会因标签策略丢失主要区域。 +- 保留 #165 回归;为新增模块增加性能契约,而不是声称旧测试覆盖整个链路。 +- 真实耗时基准用于观察额外成本和实际收益,需记录环境;不使用耗时阈值代替算法验证。按验收清单的性能步骤 预热 3 次、交替采样 20 次,分别报告旧阶段和新增成本;旧阶段中位数增加超过 max(基线 10%, 2ms) 时独立复测,两轮均超过且可归因于改动则 FAIL,无法排除噪声则 BLOCKED。 + +## 7. 失败、降级与一致性 + +内部状态至少区分: + +```text +capture-unavailable +document-changed +frame-ownership-unresolved +geometry-unavailable +geometry-unsupported +outside-supported-clip +discovery-partial +selection-truncated +dedup-degraded +target-detached +geometry-changed +image-invalid +pixel-budget-exceeded +``` + +这些是内部分类,不要求逐项扩展外部 RPC code。外部沿用既有错误分类并给出准确 message/必要 detail;不可把所有时效或几何失败叫权限问题。 + +原则: + +- 视觉分支失败不应使已获得的语义结果无故丢失。 +- 没有候选、没有预算、没有采集成功必须可区分。 +- exact omitted count 只在可计算时输出;原始候选数与去重后的区域数不能相加冒充精确数量。 +- 捕获批次不保证页面静止;可检测的身份变化必须处理,不可检测的像素重绘不作虚假保证。 +- 取消在阶段边界和有界工作块之间检查,不能在大型纯计算循环结束后才响应。 + +## 8. 兼容性与影响面约束 + +1. 从 main 新建实现分支;不将当前分支整体 cherry-pick 作为迁移方式。 +2. 没有视觉候选的页面,语义文本、DOM ref 相对顺序和操作行为维持原有契约。 + 例外为上述 observe 显式预算小于最小协议头的 invalid_params 行为及统一账本导致的临界预算截断位置变化;足够预算不改变旧语义。 +3. 有视觉候选的 observe 允许新增视觉文本,并按新预算规划改变截断位置;这是明确行为变化。 +4. `snapshot`、recording、hover probe 默认行为不随视觉通道接入而隐式改变。 +5. 修正已证明错误的坐标允许改变输出位置,不以错误 golden 为兼容目标。 +6. 几何共享改动必须覆盖已有 DOM 点击/截图路径回归;不以 Canvas 测试代替。 +7. CLI/协议外形优先保持;确需新增外部字段、错误子码或参数时,必须在对应 PR 明确列出全部消费者。 +8. 不进行无关格式化、目录大搬迁、依赖升级或语义启发式修改。 +9. 不长期保留两套 capture decoder、metrics adapter、frame resolver、token accounting 或 pixel planner。 +10. 不把站点特征、fixture 标签、CSS class 或 URL 放入生产决策。 + +## 9. 设计验证清单与关闭方式 + +| 待验证项 | 证据 | 必须形成的决定 | +|---|---|---| +| 嵌入式 Canvas 表格 原始失败 | main 的观察输出、对应截图及结构证据 | 失败归属、最小复现和成功标准 | +| DOMSnapshot / metrics 单位 | DPR、zoom、scroll、frame 矩阵 | 原始单位适配和转换入口(实验结论见证据文档) | +| frame / target 坐标 | 同 target、OOPIF、nested、带边框/变换样例 | content quad 来源、坐标归属及支持范围 | +| document 身份 | 导航、同 frame document 替换、target 重建 | 使用哪些现有标识/事件,何时失效 | +| CSS 视觉规则 | visibility 覆盖、opacity、hidden、单轴 overflow | 可合成状态和不支持情况 | +| 时效容差 | 静止页面测量噪声、可控移动/缩放/裁剪变化 | 容差单位、比较规则和失败行为 | +| 预算 | 调用方 max_tokens 使用方式、密集画布样例 | 提示预留、K、限额与截断规则 | +| 去重 | 重合、近重合、不同父节点、相邻 tile | 首版等价规则和 Q 上限 | + +验证结果与最终决定见 [PR 0 证据及决策](visual-observation-evidence.md)。首版精确几何支持收敛到已验证的轴向矩形、正向轴对齐平移/缩放、普通 frame/overflow 链。旋转、透视、clip-path、mask 和活动 pinch-zoom 不承诺精确区域 ref:返回 geometry-unsupported 状态并提示使用现有视口截图。后续扩展必须独立验证。浏览器 UI zoom 与 CSS zoom 分开验证,不能用 pinch emulation 代替 UI zoom。 + +进一步限定:CSS zoom/DOM transform 作用于 overflow 裁剪祖先的组合,首版也进入 geometry-unsupported。 +实验表明 snapshot.bounds 已缩放,而 clientRects/offsetRects 仍有未缩放和整数取整语义,不能靠一个宽度比准确恢复所有边。 +普通 Canvas 的 CSS zoom bounds 与 iframe content quad 缩放仍可使用;这不意味着缩放后的任意 DOM scrollport 都已获得精确支持。 +Facts 必须保留 client box、transform/zoom 及 clip-path/mask 等支持性证据,discovery 返回状态而不是另写近似投影。 + +## 10. PR 划分与依赖 + +PR 0 是设计与证据入口;PR 1—6 建立模块;PR 7 接入公开 observe;PR 8 完成跨模块验收和迁移收尾。编号是顺序计划,不是实际 GitHub PR 编号。 + +```text +PR 0 设计规格与实验 + └─ PR 1 几何与身份契约 + └─ PR 2 采集批次与共享事实 + └─ PR 3 视觉发现 + └─ PR 4 选择、去重与预算 + +PR 1 + PR 2 ──→ PR 5 类型化 ref +PR 1 + PR 5 ──→ PR 6 视觉截图执行 + +PR 2 + PR 3 + PR 4 + PR 5 + PR 6 + └─ PR 7 observe 组合与接线 + └─ PR 8 整体验收与迁移收尾 +``` + +独立模块可以使用人工输入测试,不必等待真实上游接线。实现可依赖前序已合入 main 的 PR,避免多个大型长期分支互相漂移。阶段未接线的模块必须有紧邻的消费计划,不保留无期限死代码或对用户暴露半成品。 + +### PR 0:规格收敛、最小复现与基线 + +完成: + +- 审核现有分支 fixture,区分正确规格与需要推翻的预期。 +- 记录 嵌入式 Canvas 表格 原始失败及 main 基线,建立独立于站点的最小 fixture。 +- 完成第 9 节实验,锁定核心数据类型、支持范围、容差和资源参数。 +- 固定 #165 的相关回归结果和操作数基线;不夸大旧测试覆盖范围。 +- 将行为契约映射到后续每个模块的测试清单。 + +不做:生产算法替换、全目录迁移、正式视觉通道接入。 + +验收:核心身份、坐标、裁剪、预算和失效规则不存在未指定行为;未支持情况有明确策略。若仍有未知项,先调整范围而不是让后续 PR 猜测。 + +### PR 1:统一几何与节点身份契约 + +完成: + +- 引入有 owner 的坐标和地址类型,区分 frame/target/top/CSS/image 空间。 +- 统一 metrics 解释、frame projection 和 owner geometry 查询。 +- 共享单次操作内的 promise cache;保证采集和执行使用独立上下文。 +- 给既有 DOM 几何入口做窄适配;移除已被替代的投影路径。 + +不做:Canvas 发现、排序、ref 外部行为变化。 + +验收:scroll、border、同 target、OOPIF、nested、支持范围内的 transform/DPR/zoom 矩阵通过;已有 DOM 点击与截图回归通过;请求计数和几何成本可解释。 + +### PR 2:采集批次、归一化事实和共享索引 + +完成: + +- 将原始解码、采集编排、normalization 明确分开。 +- 按 target 去重采集,记录 document 身份、时间范围和阶段失败。 +- 建立 ObservationFacts、document 隔离 lookup/children 索引和正确的祖先状态结构。 +- 现有 SemanticPipeline 通过 adapter 消费新事实;保持 hover 在静态采集后发生。 +- 删除不再使用的可变中间态和重复解码/归属推断路径。 + +不做:变更语义保留规则、视觉输出和截图政策。 + +验收:语义回归及 frame ownership 通过;deep/wide/orphan/cycle 有明确结果;解码、索引、采集数量符合预算;无每节点完整祖先复制。 + +### PR 3:纯数据视觉候选发现 + +完成: + +- 实现 VisualCandidate 及 discoverVisualCandidates。 +- 落实无标签、有 AX、aria-hidden/inert/pointer-events、透明/隐藏、裁剪规则。 +- 候选关联 document/anchor/geometry,语义上下文只作为可选信息。 +- 返回发现失败或不完整状态,避免与无候选混淆。 + +不做:ref 分配、截图、token 排序、逻辑画布合并。 + +验收:每条发现规则都有正反 fixture;输入同一份事实输出确定;索引后的候选发现不重复扫 DOM 或走完整祖先链;不增加 CDP 调用。 + +### PR 4:有界选择、保守去重与输出预算计划 + +完成: + +- 明确几何优先排序及确定性 tie-breaker,预计算比较键。 +- 实现有界 top-K,K 受文本预算和安全限额约束。 +- 实现精确覆盖 key 去重及索引容量耗尽后“继续未去重选择”的退化行为;首版 Q=0,不进行模糊两两比较。 +- 建立纯 BudgetPlan 和视觉摘要/条目格式,统一预算核算契约。 +- 明确候选数、输出数、遗漏数及去重退化信息的口径。 + +不做:多 anchor target、业务画布推断、生产 observe 接线。 + +验收:密集小图、无名主画布、全部重合、同 bucket 不等价、极小预算均有确定结果;比较次数、堆大小、输出成本有界。 + +### PR 5:类型化 ref 注册与工具解析 + +完成: + +- RefStore 保存判别联合,绑定 document 及 observation 信息。 +- kind 派生工具适用性,删除可独立构造的矛盾 capability 状态。 +- 审核并迁移截图、交互、get_html、人工帮助等全部 ref 消费者。 +- 保持 eN 外部语义,文档明确跨代字符串识别限制。 + +不做:引入新版 ref 协议、自动把视觉 ref 当 DOM anchor 使用。 + +验收:跨 session/tab 隔离;document 失效;全部工具矩阵通过;拒绝在输入派发前发生;旧 DOM ref 行为保持。 + +### PR 6:视觉截图的区域解析、像素规划与执行 + +完成: + +- 分离 resolve-region、pixel-plan、capture executor。 +- 实现 visual 不滚动、身份验证、几何容差及重新观察错误。 +- 实现真实输出尺寸后置条件、有界重试和准确失败归因。 +- 复用 overlay suppression、取消和 dialog 机制。 + +不做:Canvas 内部截图、像素冻结、多节点 union、内部点交互。 + +验收:移动/缩放/裁剪变化、节点移除、document 替换、DPR/zoom、无效图像、超预算和取消路径通过;成功图像符合像素预算;现有 DOM 截图回归通过。 + +### PR 7:观察结果组合与 observe 接线 + +完成: + +- 将 Facts 的语义/视觉两路结果交给统一输出规划和 renderer。 +- 保留 #165 显式栈和 DOM ref 相对顺序,追加视觉条目。 +- 仅对已写入条目提交 ref,失败/截断状态正确传递。 +- 视觉提示参与预先预算,避免 DOM 耗尽预算后完全无提示。 +- 仅在 observe 默认启用新视觉通道;必要的 CLI/skill 使用说明同步更新。 + +不做:顺带修改 snapshot、recording 或 hover 默认策略。 + +验收:从人工 facts 到文本/ref 的集成测试、真实单 Canvas observe → screenshot 闭环通过;无 Canvas 页面回归通过;极小预算无悬空 ref。 + +### PR 8:跨模块验收、性能证据与收尾 + +完成: + +- 在真实浏览器运行完整 fixture 矩阵和 嵌入式 Canvas 表格 原始场景,保存可复现结果。 +- 验证整个管线的请求数量、集合规模、比较次数、深树安全和真实耗时。 +- 检查新模块是否存在重复解释、长期 adapter、无消费者代码或语义影响扩散。 +- 收尾剩余迁移文档、调试信息说明、支持范围和已知限制。 + +不做:把此前未测试的性能正确性全部拖到本 PR,也不接纳新的功能范围。 + +验收:第 11 节全部通过;若发现某模块契约错误,修正在该模块并补对应验收,不在最终接线处加站点特判。 + +## 11. 总体验收矩阵 + +本节是范围索引,具体怎么做、什么算成功见 [验收清单](visual-observation-acceptance.md)。新会话按当前 PR 执行并直接说明结果即可,无需报告模板或逐项登记。修改契约时同步清单,避免两套标准。 + +| 维度 | 必测场景 | 主要责任 | +|---|---|---| +| 发现 | 无名、有 AX、aria-hidden、inert、pointer-events:none | PR 3 | +| 视觉状态 | 零尺寸、透明祖先、visibility 覆盖、支持范围内 hidden 状态 | PR 0/2/3 | +| 裁剪 | viewport、单轴/嵌套 overflow、支持范围内变换 | PR 1/2/3 | +| frame | 同 target、OOPIF、nested、border、缺失 owner | PR 1/2 | +| 身份 | target 冲突、节点移除、导航、document 替换 | PR 2/5/6 | +| 输出 | DOM 顺序、视觉提示、modal 策略、极小预算、遗漏口径 | PR 4/7 | +| ref | 工具矩阵、跨 tab/session、无悬空 ref | PR 5/7 | +| 截图 | 几何变化、DPR/zoom、实际像素、重试、overlay | PR 6 | +| 算法 | 深树、宽树、高密度、全部重合、索引退化 | 各模块 PR | +| 回归 | #165、普通 DOM 点击/截图、snapshot、recording、hover | PR 1/2/7/8 | +| 产品结果 | main 原始失败与新链路的 嵌入式 Canvas 表格 场景对照 | PR 0/8 | + +## 12. 相比旧蓝图的明确调整 + +| 旧方向 | 本文调整 | 原因 | +|---|---|---| +| 全链路 transaction 先行 | 建立来源明确的 capture batch;不承诺原子快照 | 生命周期模型不能代替一致性证据 | +| candidate → stack → logical surface 必经分层 | candidate + 输出覆盖去重;logical surface 不进入本次 | 限制身份模型及截图复杂度 | +| 统一祖先 excluded | 视觉、语义、交互状态分开 | 避免加速错误规则 | +| 各阶段一律兼容 adapter | 仅消费者迁移需要时使用,并随切换删除 | 避免临时抽象堆积 | +| 性能作为后续优化阶段 | 每个模块设计/实现均有复杂度与计数验收 | 防止功能完成后再补算法 | +| renderer 最后尝试附录 | 先规划预算,再分阶段输出 | 避免视觉入口系统性消失 | +| observed rect 与 live rect 求交 | 身份和几何分别验证 | 避免成功返回错误截图片段 | +| generation 暗示 freshness 已解决 | 明确现有 eN 重用限制 | 防止无法实现的旧字符串识别承诺 | +| 当前测试全部视为兼容基准 | 先审核期望,再保留正确回归 | 测试不能替代行为规格 | + +## 13. 完成定义与设计变更纪律 + +工程完成必须同时满足: + +- 五条核心结果全部成立,真实 嵌入式 Canvas 表格 场景及独立 fixture 均有证据。 +- 每个模块只有一个权威实现,依赖方向与本文一致。 +- 坐标和节点地址跨边界时身份明确,失败可归因。 +- 视觉发现不依赖语义树保留结果,不混淆视觉与交互属性。 +- 性能上限及退化可测试;复杂度没有遗漏协议输入量和几何工作量。 +- 现有语义、DOM 操作及 #165 成果受到明确回归保护。 +- 当前支持范围、近似/拒绝行为和 ref freshness 限制已记录。 +- 不存在为了通过原始站点而增加的生产特判。 + +每个实现 PR 的描述必须写明:解决哪个独立问题、改变哪个契约、哪些行为允许变化、验证哪些正确性与性能要求、删除哪条旧路径。若没有删除旧路径,应说明保留原因和明确的后续消费/移除 PR。 + +新的案例若属于已定规则范围,应修复负责该规则的模块;若要求改变目标身份、几何支持或对外协议,应先形成设计变更。不得在 observation 接线、renderer 或截图执行末端不断补跨层例外。 + +## 14. 参考与证据来源 + +- 本地 main:`3c5f838`;当前分析分支:`fix/vom-canvas-observation`。 +- PR #165 本地合并提交:`5ed8287`;实现提交:`641d934`。涉及 renderer 上下文索引、显式栈等优化。 +- 主要代码范围:`apps/extension/src/tools/observation.ts`、`tools/vom/capture.ts`、`tools/vom/frame-capture.ts`、`tools/vom/semantic-graph/`、`tools/frame-geometry.ts`、`session-manager/ref-store.ts`、`packages/vom/src/render.ts`。 +- 当前分支参考:`tools/vom/rendered-surfaces/`、`tools/visual-screenshot-plan.ts` 及相关 fixture/test。 +- 原 visual-surface 架构蓝图作为历史参考,不是本设计的执行规格。 +- 浏览器语义参考:[aria-hidden](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden)、[inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert)、[visibility](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/visibility)。本次 CDP 单位、frame 和身份实验结论见验证结果文档;支持范围之外的行为不作推断。 + +本文中的路径为代码职责定位,最终目录命名可适配工程规范;行为契约、问题边界、复杂度预算和验收要求不能因此省略。