From 41cb576256fc0f801c15fd686640b4c3337657d0 Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Thu, 20 Aug 2026 15:14:41 +0800 Subject: [PATCH 1/5] feat(vom): function refactor --- .../src/tools/__tests__/observation.test.ts | 10 +- apps/extension/src/tools/observation.ts | 955 ++++-------------- .../tools/vom/__tests__/frame-capture.test.ts | 63 ++ .../vom/__tests__/semantic-graph.test.ts | 215 ++++ apps/extension/src/tools/vom/capture.ts | 8 +- apps/extension/src/tools/vom/frame-capture.ts | 46 +- .../src/tools/vom/semantic-graph/build.ts | 154 +++ .../src/tools/vom/semantic-graph/index.ts | 26 + .../src/tools/vom/semantic-graph/project.ts | 242 +++++ .../src/tools/vom/semantic-graph/resolve.ts | 527 ++++++++++ .../src/tools/vom/semantic-graph/types.ts | 69 ++ packages/vom/src/render.ts | 5 +- packages/vom/src/types.ts | 2 + 13 files changed, 1525 insertions(+), 797 deletions(-) create mode 100644 apps/extension/src/tools/vom/__tests__/semantic-graph.test.ts create mode 100644 apps/extension/src/tools/vom/semantic-graph/build.ts create mode 100644 apps/extension/src/tools/vom/semantic-graph/index.ts create mode 100644 apps/extension/src/tools/vom/semantic-graph/project.ts create mode 100644 apps/extension/src/tools/vom/semantic-graph/resolve.ts create mode 100644 apps/extension/src/tools/vom/semantic-graph/types.ts diff --git a/apps/extension/src/tools/__tests__/observation.test.ts b/apps/extension/src/tools/__tests__/observation.test.ts index bd8200bb..a560f3e4 100644 --- a/apps/extension/src/tools/__tests__/observation.test.ts +++ b/apps/extension/src/tools/__tests__/observation.test.ts @@ -1656,7 +1656,7 @@ describe("buildVomScene", () => { const scene = buildVomScene(axNodes, captured); expect(scene.nodes.find((n) => n.id === 20)).toEqual( - expect.objectContaining({ id: 20, role: "generic", cursor: "pointer" }), + expect.objectContaining({ id: 20, role: "button", name: "close", cursor: "pointer" }), ); const rendered = renderVom(scene); expect(rendered.text).toContain('@e1 button "close"'); @@ -1732,7 +1732,7 @@ describe("buildVomScene", () => { }; const scene = buildVomScene(axNodes, captured); - expect(scene.nodes.find((n) => n.id === 20)?.role).toBe("generic"); + expect(scene.nodes.find((n) => n.id === 20)).toBeUndefined(); expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("link"); }); @@ -1801,9 +1801,9 @@ describe("buildVomScene", () => { const scene = buildVomScene(axNodes, captured); expect(scene.nodes.find((n) => n.id === 20)).toEqual( - expect.objectContaining({ id: 20, role: "generic", attrs: { "aria-label": "收藏" } }), + expect.objectContaining({ id: 20, role: "button", attrs: { "aria-label": "收藏" } }), ); - expect(scene.nodes.find((n) => n.id === 30)?.role).toBe("generic"); + expect(scene.nodes.find((n) => n.id === 30)).toBeUndefined(); const rendered = renderVom(scene); expect(rendered.text).toContain('@e1 button "收藏"'); expect(rendered.refs.map(({ ref, backendNodeId }) => ({ ref, backendNodeId }))).toEqual([ @@ -2131,7 +2131,7 @@ describe("buildVomScene", () => { }); expect(scene.surfaces).toEqual([ - { triggerId: 21, triggerAction: "hover", subItems: ["My profile", "Sign out"] }, + { triggerId: 20, triggerAction: "hover", subItems: ["My profile", "Sign out"] }, ]); expect(renderVom(scene).text).toContain( '@e1 button "image" [hover first: My profile | Sign out]', diff --git a/apps/extension/src/tools/observation.ts b/apps/extension/src/tools/observation.ts index f25d97b3..ed290d96 100644 --- a/apps/extension/src/tools/observation.ts +++ b/apps/extension/src/tools/observation.ts @@ -58,6 +58,8 @@ import { type CaptureVomObservationResult, projectRecordSafeObservation, } from "./vom/record-safe-observation"; +import type { FrameDocument } from "./vom/frame-document"; +import { buildSemanticVomScene, type SemanticAxNode } from "./vom/semantic-graph"; // --------------------------------------------------------------------------- // Shared helpers (legacy aliases — observation.ts kept exporting these @@ -374,316 +376,17 @@ export async function handleScreenshot( export type CdpRunner = SharedCdpRunner; /** Subset of CDP `AXNode` we care about — see `Accessibility.AXNode`. */ -export interface CdpAxNode { - nodeId: string; - frameId?: string; - parentId?: string; - backendDOMNodeId?: number; - ignored?: boolean; - role?: { type: string; value?: string }; - name?: { type: string; value?: string }; - description?: { value?: string }; - value?: { value?: string | number | boolean }; - properties?: Array<{ name?: string; value?: { value?: string | number | boolean } }>; - childIds?: string[]; -} - -function axValue(field?: { value?: string | number | boolean }): string | undefined { - const value = field?.value; - return value === undefined ? undefined : String(value); -} - -function axString(field?: { value?: string | number | boolean }): string | undefined { - const value = axValue(field)?.replace(/\s+/g, " ").trim(); - return value ? value : undefined; -} +export type CdpAxNode = SemanticAxNode; function normalizeTag(tag: string | undefined): string { return tag?.toLowerCase() ?? ""; } -function isModalSignal( - axNode: CdpAxNode | undefined, - capturedNode: CapturedNode | undefined, -): boolean { - const role = axString(axNode?.role)?.toLowerCase(); - if (role === "dialog" || role === "alertdialog") return true; - if (normalizeTag(capturedNode?.tag) === "dialog") return true; - - const attrs = capturedNode?.attrs ?? {}; - return (attrs["aria-modal"] ?? "").toLowerCase() === "true" || attrs.role === "dialog"; -} - -function isSensitive(capturedNode: CapturedNode | undefined): boolean { - const attrs = capturedNode?.attrs ?? {}; - const type = (attrs.type ?? "").toLowerCase(); - if (type === "password") return true; - const autocomplete = (attrs.autocomplete ?? "").toLowerCase(); - return ( - autocomplete.startsWith("cc-") || - autocomplete === "one-time-code" || - autocomplete === "current-password" || - autocomplete === "new-password" - ); -} - -interface AxNodeSignals { - hasPopup: boolean; - expanded: boolean; - selected: boolean; - controls: string; - sensitive: boolean; - aggregatedText?: string; -} - -const AX_TEXT_AGGREGATE_ROLES = new Set([ - "paragraph", - "listitem", - "term", - "definition", - "cell", - "gridcell", - "caption", - "figcaption", - "blockquote", - "note", - "status", - "log", - "generic", - "section", -]); -const AX_TEXT_LEAF_ROLES = new Set(["inlinetextbox", "statictext", "text"]); -const AX_TEXT_STOP_ROLES = new Set([ - "button", - "link", - "combobox", - "listbox", - "menuitem", - "menuitemcheckbox", - "menuitemradio", - "option", - "radio", - "checkbox", - "textbox", - "searchbox", - "spinbutton", - "slider", - "switch", - "tab", - "treeitem", - "columnheader", - "rowheader", -]); -const SENSITIVE_AX_INPUT_TYPES = new Set([ - "password", - "credit-card", - "one-time-code", - "current-password", - "new-password", -]); - -function axPropertyString(axNode: CdpAxNode, name: string): string | undefined { - const prop = axNode.properties?.find((item) => item.name === name); - return axString(prop?.value); -} - -function axSiblingLabel(axNode: CdpAxNode, axById: Map): string | undefined { - const parent = axNode.parentId ? axById.get(axNode.parentId) : undefined; - const siblings = parent?.childIds ?? []; - const index = siblings.indexOf(axNode.nodeId); - if (index <= 0) return undefined; - - for (let i = index - 1; i >= Math.max(0, index - 4); i -= 1) { - const sibling = axById.get(siblings[i]); - if (!sibling) continue; - const role = axString(sibling.role)?.toLowerCase() ?? ""; - if (AX_TEXT_STOP_ROLES.has(role)) break; - if (!["statictext", "labeltext", "text", "generic"].includes(role)) continue; - const label = cleanAttr(axString(sibling.name) ?? axString(sibling.value))?.replace( - /[::]\s*$/, - "", - ); - if (label && label.length <= 40) return label; - } - return undefined; -} - -function buildAxSignals(axNodes: CdpAxNode[]): Map { - const axByNodeId = new Map(axNodes.map((node) => [node.nodeId, node])); - const virtualText = new Map(); - for (const node of axNodes) { - if (typeof node.backendDOMNodeId === "number") continue; - const role = axString(node.role)?.toLowerCase() ?? ""; - const name = axString(node.name); - if (name && AX_TEXT_LEAF_ROLES.has(role)) virtualText.set(node.nodeId, name); - } - - const collectLeafText = (nodeId: string, depth: number): string[] => { - if (depth > 8) return []; - const node = axByNodeId.get(nodeId); - if (!node) return []; - const parts: string[] = []; - for (const childId of node.childIds ?? []) { - const vtext = virtualText.get(childId); - if (vtext) { - parts.push(vtext); - continue; - } - const child = axByNodeId.get(childId); - if (!child) continue; - const childRole = axString(child.role)?.toLowerCase() ?? ""; - if (AX_TEXT_LEAF_ROLES.has(childRole)) continue; - if (AX_TEXT_STOP_ROLES.has(childRole)) continue; - const childName = axString(child.name); - if (childName && !AX_TEXT_AGGREGATE_ROLES.has(childRole)) { - parts.push(childName); - } else { - parts.push(...collectLeafText(childId, depth + 1)); - } - } - return parts; - }; - - const signals = new Map(); - for (const node of axNodes) { - const role = axString(node.role)?.toLowerCase() ?? ""; - const expanded = axPropertyString(node, "expanded") === "true"; - const selected = axPropertyString(node, "selected") === "true"; - const controls = axPropertyString(node, "controls") ?? ""; - const hasPopupValue = axPropertyString(node, "hasPopup") ?? ""; - const inputType = axPropertyString(node, "inputType") ?? ""; - let aggregatedText: string | undefined; - if (AX_TEXT_AGGREGATE_ROLES.has(role) && !axString(node.name)) { - aggregatedText = cleanAttr(collectLeafText(node.nodeId, 0).join(" ")); - } - signals.set(node.nodeId, { - hasPopup: hasPopupValue !== "" && hasPopupValue !== "false", - expanded, - selected, - controls, - sensitive: SENSITIVE_AX_INPUT_TYPES.has(inputType), - aggregatedText, - }); - } - return signals; -} - -function findAxAncestor( - axNode: CdpAxNode, - axById: Map, - select: (node: CdpAxNode) => T | undefined, -): T | undefined { - let parentId = axNode.parentId; - while (parentId) { - const parent = axById.get(parentId); - if (!parent) break; - const hit = select(parent); - if (hit !== undefined) return hit; - parentId = parent.parentId; - } - return undefined; -} - -function nearestBackendParent(axNode: CdpAxNode, axById: Map): number | null { - return ( - findAxAncestor(axNode, axById, (parent) => - typeof parent.backendDOMNodeId === "number" ? parent.backendDOMNodeId : undefined, - ) ?? null - ); -} - -const IFRAME_RENDERABLE_TAGS = new Set(["input", "button", "a", "select", "textarea"]); - -function iframeRoleFor(node: CapturedNode): string | undefined { - const tag = normalizeTag(node.tag); - if (tag === "input" || tag === "textarea") return "textbox"; - if (tag === "button") return "button"; - if (tag === "a") return "link"; - if (tag === "select") return "combobox"; - return undefined; -} - -function iframeNameFor(node: CapturedNode): string | undefined { - const tag = normalizeTag(node.tag); - const ariaLabel = node.attrs["aria-label"]?.replace(/\s+/g, " ").trim(); - if (ariaLabel) return ariaLabel; - - const text = node.textContent?.replace(/\s+/g, " ").trim(); - if (text) return text; - - if (tag === "input" || tag === "textarea") { - const placeholder = node.attrs.placeholder?.replace(/\s+/g, " ").trim(); - if (placeholder) return placeholder; - } - - const id = node.attrs.id?.replace(/\s+/g, " ").trim(); - return id ? id : undefined; -} - -function isRenderableIframeControl(node: CapturedNode): boolean { - const tag = normalizeTag(node.tag); - if (!IFRAME_RENDERABLE_TAGS.has(tag)) return false; - if (!node.localRect && !node.rect) return false; - if (node.pointerEvents === "none") return false; - if ((node.attrs.type ?? "").toLowerCase() === "hidden") return false; - - const name = iframeNameFor(node); - return tag !== "a" || name !== undefined; -} - -function normalizedControlType(node: VomNode | CapturedNode): string { - const attrs = node.attrs ?? {}; - const tag = normalizeTag(node.tag); - if (tag === "input") return (attrs.type ?? "text").toLowerCase(); - if ("sensitive" in node && node.sensitive) return "password"; - return tag; -} - -function sameLogicalIframeControl(existing: VomNode, candidate: CapturedNode, iframeId: number) { - const role = iframeRoleFor(candidate); - const name = cleanAttr(iframeNameFor(candidate)); - if (!role || !name) return false; - if ((existing.role ?? "").toLowerCase() !== role) return false; - if (cleanAttr(existing.name) !== name) return false; - - const candidateType = normalizedControlType(candidate); - const existingType = normalizedControlType(existing); - if (candidateType !== existingType) return false; - - return ( - existing.parentId === iframeId || - existing.domParentId === iframeId || - existing.domAncestorIds?.includes(iframeId) === true - ); -} - -function hasEquivalentIframeControl( - nodes: VomNode[], - candidate: CapturedNode, - iframeId: number, -): boolean { - return nodes.some((node) => sameLogicalIframeControl(node, candidate, iframeId)); -} - -function capturedIframeNameFor(node: CapturedNode): string | undefined { - const ariaLabel = node.attrs["aria-label"]?.replace(/\s+/g, " ").trim(); - if (ariaLabel) return ariaLabel; - - const title = node.attrs.title?.replace(/\s+/g, " ").trim(); - if (title) return title; - - const id = node.attrs.id?.replace(/\s+/g, " ").trim(); - return id ? id : undefined; -} - function cleanAttr(value: string | undefined): string | undefined { const trimmed = value?.replace(/\s+/g, " ").trim(); return trimmed ? trimmed : undefined; } -const FORM_CONTROL_TAGS = new Set(["input", "textarea", "select"]); - -const NATIVE_CONTROL_TAGS = new Set(["button", "input", "select", "textarea"]); const ACTIVE_SCOPE_MAX_BLOCKS = 8; const ACTIVE_SCOPE_MAX_LINES = 40; const ACTIVE_SCOPE_MAX_LINE_LENGTH = 160; @@ -701,264 +404,11 @@ function buildCapturedChildren(capturedNodes: CapturedNode[]): Map, -): number[] { - const ancestors: number[] = []; - let parentId = node.parentBackendNodeId; - let guard = 0; - while (parentId !== null && guard < capturedByBackendId.size) { - ancestors.push(parentId); - parentId = capturedByBackendId.get(parentId)?.parentBackendNodeId ?? null; - guard += 1; - } - return ancestors; -} - -function capturedHasNativeDescendant( - node: CapturedNode, - childrenByParentId: Map, -): boolean { - const stack = [...(childrenByParentId.get(node.backendNodeId) ?? [])]; - while (stack.length > 0) { - const child = stack.pop() as CapturedNode; - if (NATIVE_CONTROL_TAGS.has(normalizeTag(child.tag))) return true; - stack.push(...(childrenByParentId.get(child.backendNodeId) ?? [])); - } - return false; -} - -function capturedInsideNative( - node: CapturedNode, - capturedByBackendId: Map, -): boolean { - let parentId = node.parentBackendNodeId; - let guard = 0; - while (parentId !== null && guard < capturedByBackendId.size) { - const parent = capturedByBackendId.get(parentId); - if (!parent) break; - if (NATIVE_CONTROL_TAGS.has(normalizeTag(parent.tag))) return true; - parentId = parent.parentBackendNodeId; - guard += 1; - } - return false; -} - -function nearbyTextFor( - node: CapturedNode, - childrenByParentId: Map, -): string | undefined { - if (node.parentBackendNodeId === null) return undefined; - const siblings = childrenByParentId.get(node.parentBackendNodeId) ?? []; - const index = siblings.findIndex((sibling) => sibling.backendNodeId === node.backendNodeId); - if (index < 0) return undefined; - - const labels: string[] = []; - for (const sibling of siblings.slice(Math.max(0, index - 3), index)) { - const text = cleanAttr(sibling.textContent); - if (text) labels.push(text); - } - for (const sibling of siblings.slice(index + 1, index + 4)) { - const text = cleanAttr(sibling.textContent); - if (text) labels.push(text); - } - return labels.length > 0 ? labels.join(" ") : undefined; -} - -function previousSiblingTextFor( - node: CapturedNode, - childrenByParentId: Map, -): string | undefined { - if (node.parentBackendNodeId === null) return undefined; - const siblings = childrenByParentId.get(node.parentBackendNodeId) ?? []; - const index = siblings.findIndex((sibling) => sibling.backendNodeId === node.backendNodeId); - if (index <= 0) return undefined; - - for (let i = index - 1; i >= Math.max(0, index - 4); i -= 1) { - const sibling = siblings[i]; - if (["input", "textarea", "select", "button", "a"].includes(normalizeTag(sibling.tag))) break; - const text = cleanAttr(sibling.textContent)?.replace(/[::]\s*$/, ""); - if (text && text.length <= 40) return text; - } - return undefined; -} - interface VomNodeDomSignals { capturedByBackendId: Map; childrenByParentId: Map; } -function applyCapturedSignals( - node: VomNode, - capturedNode: CapturedNode | undefined, - signals: VomNodeDomSignals, -): VomNode { - if (!capturedNode) return node; - const attrs = - node.sensitive && Object.prototype.hasOwnProperty.call(capturedNode.attrs, "value") - ? Object.fromEntries( - Object.entries(capturedNode.attrs).filter(([name]) => name.toLowerCase() !== "value"), - ) - : capturedNode.attrs; - const text = cleanAttr(capturedNode.textContent); - const nearbyText = nearbyTextFor(capturedNode, signals.childrenByParentId); - const placeholder = cleanAttr(capturedNode.formPlaceholder) ?? cleanAttr(attrs.placeholder); - return { - ...node, - domParentId: capturedNode.parentBackendNodeId, - domAncestorIds: capturedDomAncestorIds(capturedNode, signals.capturedByBackendId), - cursor: capturedNode.cursor, - attrs, - ...(text ? { text } : {}), - ...(nearbyText ? { nearbyText } : {}), - ...(placeholder ? { placeholder } : {}), - disabled: - Object.prototype.hasOwnProperty.call(attrs, "disabled") || - (attrs["aria-disabled"] ?? "").toLowerCase() === "true", - inert: Object.prototype.hasOwnProperty.call(attrs, "inert"), - hasNativeDescendant: capturedHasNativeDescendant(capturedNode, signals.childrenByParentId), - insideNative: capturedInsideNative(capturedNode, signals.capturedByBackendId), - }; -} - -function inputStateFor( - capturedNode: CapturedNode | undefined, - value: string | undefined, -): VomNode["inputState"] { - if (!capturedNode || !FORM_CONTROL_TAGS.has(normalizeTag(capturedNode.tag))) return undefined; - if (capturedNode.formState) return capturedNode.formState; - const formValue = capturedNode.formValue; - if (formValue !== undefined) { - if (formValue === "") return "empty"; - if (formValue === (capturedNode.formDefaultValue ?? "")) return "default"; - return "filled"; - } - return value === undefined || value === "" ? "empty" : "filled"; -} - -/** - * Name for a native form control. VOM models the *perceived* viewport - * (spec §1): an empty field displays its placeholder, so when the field has - * no value we prefer the placeholder over an accessible name that pages - * frequently pollute by wrapping the `` in a `