// Observation handlers — `tool.snapshot`, `tool.get_html`, `tool.screenshot`, // and semantic `tool.observe` (design §7). Each handler resolves the target // tab (defaulting to the Agent Window's active tab when omitted) and // returns a payload that mirrors the bsk-protocol Rust structs. import { type ActiveScopeBlock, applyVomInteractionRecovery, type CondSurface, isVomReferenceNode, type RenderedRef, renderVom, type VomNode, type VomOptions, type VomScene, } from "@browser-skill/vom"; import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; import type { CdpTarget } from "@/browser-driver/frame-graph"; import { type CaptureSuppressSendToTab, withOverlaysHiddenForCapture, } from "@/lib/capture-suppress-bridge"; import type { SessionContext, SessionManager } from "@/session-manager/manager"; import type { GetHtmlParams, GetHtmlResult, ObserveParams, ObserveResult, RpcError, ScreenshotParams, ScreenshotResult, SnapshotParams, SnapshotResult, } from "@/transport/types"; import { attachDialogs, markDialogCursor } from "./dialogs"; import { rpcError } from "./errors"; import { resolveNodeGeometry } from "./frame-geometry"; import { type ChromeTabsApi, enforceToolTargetScope, isRpcError, lookupSession, type ResolvedTargetTab, resolveCdpAccessibleTargetTab, type CdpRunner as SharedCdpRunner, sendToCdpTarget, normaliseRef as sharedNormaliseRef, type ToolEffect, } from "./shared"; import { resolveSnapshotRef } from "./snapshot-ref"; import { type CapturedNode, type CapturedSurfaceProbe, type CapturedViewModel, captureViewModel, collectOverlayExcludedBackendIds, } from "./vom/capture"; import { type CapturedFrameDocument, captureFrameData } from "./vom/frame-capture"; // --------------------------------------------------------------------------- // Shared helpers (legacy aliases — observation.ts kept exporting these // for M6 callers; the live implementations now live in `./shared`). // --------------------------------------------------------------------------- export interface ChromeTabsCaptureApi extends ChromeTabsApi { captureVisibleTab(windowId: number, opts: chrome.tabs.CaptureVisibleTabOptions): Promise; } export const chromeTabsCaptureApi: ChromeTabsCaptureApi = { captureVisibleTab: (windowId, opts) => chrome.tabs.captureVisibleTab(windowId, opts), get: (tabId) => chrome.tabs.get(tabId), query: (q) => chrome.tabs.query(q), }; /** Re-export so the M6 test suite keeps its import path. */ export const normaliseRef = sharedNormaliseRef; // --------------------------------------------------------------------------- // screenshot — `tool.screenshot` // --------------------------------------------------------------------------- /** * Strip the `data:image/...;base64,` prefix from a Chrome * `captureVisibleTab` dataURL and return the raw base64 payload. * Falls back to the input untouched when the prefix is missing * (defensive — Chrome has always included it but we don't want to * crash if a fork changes behaviour). */ export function stripDataUrlPrefix(dataUrl: string): string { const m = /^data:image\/[a-z+]+;base64,/i.exec(dataUrl); return m ? dataUrl.slice(m[0].length) : dataUrl; } /** * Parse a PNG's IHDR chunk and return `(width, height)`. Returns * `null` on any malformed input so callers fall back to `0/0` instead * of throwing. * * PNG layout: 8-byte signature, then a 4-byte length, 4-byte type * ("IHDR"), then the chunk data — width is bytes 16-19 BE, height is * 20-23 BE. */ export function parsePngDimensions(base64: string): { width: number; height: number } | null { try { // atob is available in MV3 service workers. const head = base64.length > 64 ? base64.slice(0, 64) : base64; const bin = atob(head); if (bin.length < 24) return null; if (bin.charCodeAt(0) !== 0x89 || bin.charCodeAt(1) !== 0x50 || bin.charCodeAt(2) !== 0x4e) { return null; } const u32 = (off: number) => (bin.charCodeAt(off) << 24) | (bin.charCodeAt(off + 1) << 16) | (bin.charCodeAt(off + 2) << 8) | bin.charCodeAt(off + 3); const width = u32(16) >>> 0; const height = u32(20) >>> 0; if (width === 0 || height === 0) return null; return { width, height }; } catch { return null; } } export interface ScreenshotDeps { cdp?: SharedCdpRunner; tabsApi: ChromeTabsApi; captureApi: ChromeTabsCaptureApi; /** * Bridge used to hide the in-page overlay while a screenshot is taken, * so captured frames only contain page content. Defaults to * `chrome.tabs.sendMessage`; tests inject a fake. */ sendToTab?: CaptureSuppressSendToTab; } function defaultScreenshotDeps(): ScreenshotDeps { return { cdp: new ChromiumCdp(), tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi, }; } function cancelled(tool: string): RpcError { return { code: "cancelled", message: `${tool} aborted` }; } function abortError(tool: string): Error { const error = new Error(`${tool} aborted`); error.name = "AbortError"; return error; } function isAbortError(error: unknown): boolean { return ( typeof error === "object" && error !== null && (error as { name?: string }).name === "AbortError" ); } function throwIfAborted(signal: AbortSignal | undefined, tool: string): void { if (signal?.aborted) throw abortError(tool); } async function captureElementScreenshot( cdp: SharedCdpRunner, tabId: number, target: CdpTarget, backendNodeId: number, frameId?: string, signal?: AbortSignal, ): Promise<{ image_base64: string; width: number; height: number } | RpcError> { if (signal?.aborted) return cancelled("screenshot"); const geometry = await resolveNodeGeometry( cdp, tabId, { target, backendNodeId, ...(frameId ? { frameId } : {}) }, { scrollIntoView: true }, ); if (isRpcError(geometry)) return geometry; if (signal?.aborted) return cancelled("screenshot"); const rect = geometry.topBounds; try { const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", { format: "png", clip: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1, }, }); if (signal?.aborted) return cancelled("screenshot"); const image_base64 = shot.data ?? ""; if (!image_base64) { return { code: "cdp_failed", message: "Page.captureScreenshot returned no data" }; } const dims = parsePngDimensions(image_base64) ?? { width: Math.round(rect.width), height: Math.round(rect.height), }; return { image_base64, width: dims.width, height: dims.height }; } catch (err) { return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err), }; } } /** * Full-tab PNG capture. The primary path is `chrome.tabs.captureVisibleTab`; * when it rejects, fall back to CDP `Page.captureScreenshot` with * `fromSurface: true`. `captureVisibleTab` reads back the window surface, * which fails outright on some Windows/Chrome combinations (Chromium's * FAILURE_REASON_READBACK_FAILED — "Failed to capture tab: image readback * failed"); the CDP path captures through the renderer's BeginFrame * pipeline instead, which does not depend on that readback. * * When both paths fail the returned error carries * `data.reason = "screenshot_capture_failed"` and both underlying messages * so the CLI can point the user at the browser-side cause. */ async function captureFullTabPng( deps: ScreenshotDeps, ctx: SessionContext, target: ResolvedTargetTab, signal?: AbortSignal, ): Promise { if (signal?.aborted) return cancelled("screenshot"); try { const dataUrl = await deps.captureApi.captureVisibleTab(target.windowId, { format: "png" }); if (signal?.aborted) return cancelled("screenshot"); return stripDataUrlPrefix(dataUrl); } catch (primaryErr) { if (signal?.aborted) return cancelled("screenshot"); const cdp = deps.cdp; if (!cdp) { return { code: "cdp_failed", message: primaryErr instanceof Error ? primaryErr.message : String(primaryErr), }; } let fallbackMsg: string; try { if (signal?.aborted) return cancelled("screenshot"); cdp.trackSessionTab?.(ctx.sessionId, target.tabId); await cdp.ensureAttachedToUrl?.(target.tabId, target.url); if (signal?.aborted) return cancelled("screenshot"); const shot = await cdp.send<{ data?: string }>(target.tabId, "Page.captureScreenshot", { format: "png", fromSurface: true, }); if (signal?.aborted) return cancelled("screenshot"); if (shot.data) return shot.data; fallbackMsg = "Page.captureScreenshot returned no data"; } catch (fallbackErr) { fallbackMsg = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr); } const primaryMsg = primaryErr instanceof Error ? primaryErr.message : String(primaryErr); return rpcError( "cdp_failed", "screenshot_capture_failed", `captureVisibleTab failed: ${primaryMsg}; CDP Page.captureScreenshot fallback failed: ${fallbackMsg}`, ); } } export async function handleScreenshot( manager: SessionManager, params: ScreenshotParams, deps: ScreenshotDeps = defaultScreenshotDeps(), signal?: AbortSignal, ): Promise { if (signal?.aborted) return cancelled("screenshot"); const ctxOrErr = lookupSession(manager, params, "screenshot"); if (isRpcError(ctxOrErr)) return ctxOrErr; const ctx = ctxOrErr; const target = await resolveCdpAccessibleTargetTab( manager, ctx, params.tab_id, deps.tabsApi, "screenshot", ); if (isRpcError(target)) return target; if (signal?.aborted) return cancelled("screenshot"); const dialogCursor = deps.cdp ? markDialogCursor(deps.cdp, target.tabId) : 0; const withShotDialogs = (result: T) => deps.cdp ? attachDialogs(deps.cdp, target.tabId, dialogCursor, result) : result; const ref = typeof params.ref === "string" && params.ref.length > 0 ? params.ref : null; if (ref) { if (!deps.cdp) { return { code: "cdp_failed", message: "screenshot ref capture requires CDP" }; } const node = resolveSnapshotRef(ctx, ref, target.tabId); if (isRpcError(node)) return node; if (signal?.aborted) return cancelled("screenshot"); deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url); if (signal?.aborted) return cancelled("screenshot"); const cdp = deps.cdp; const nodeTarget = { tabId: target.tabId, ...(node.cdpSessionId ? { sessionId: node.cdpSessionId } : {}), }; const captured = await withOverlaysHiddenForCapture( target.tabId, () => captureElementScreenshot( cdp, target.tabId, nodeTarget, node.backendNodeId, node.frameId, signal, ), deps.sendToTab, ); if (isRpcError(captured)) return captured; if (signal?.aborted) return cancelled("screenshot"); return withShotDialogs({ image_base64: captured.image_base64, width: captured.width, height: captured.height, format: "png", tab_id: target.tabId, }); } if (!target.active) { return rpcError( "invalid_params", "tab_not_active", `tab ${target.tabId} is not active; screenshot can only capture the visible tab`, ); } const captured = await withOverlaysHiddenForCapture( target.tabId, () => captureFullTabPng(deps, ctx, target, signal), deps.sendToTab, ); if (isRpcError(captured)) return captured; if (signal?.aborted) return cancelled("screenshot"); const image_base64 = captured; const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 }; return withShotDialogs({ image_base64, width: dims.width, height: dims.height, format: "png", tab_id: target.tabId, }); } // --------------------------------------------------------------------------- // snapshot — `tool.snapshot` // --------------------------------------------------------------------------- /** * Minimal CDP surface the snapshot algorithm depends on. Backed by * `ChromiumCdp` in production; tests inject a fake. Re-exported from * `./shared` so M6 callers see the same type. */ 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; } 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; const ACTIVE_SCOPE_MAX_TOTAL_CHARS = 8_000; const ACTIVE_SCOPE_SKIP_TAGS = new Set(["script", "style", "noscript", "template"]); function buildCapturedChildren(capturedNodes: CapturedNode[]): Map { const children = new Map(); for (const node of capturedNodes) { if (node.parentBackendNodeId === null) continue; const siblings = children.get(node.parentBackendNodeId); if (siblings) siblings.push(node); else children.set(node.parentBackendNodeId, [node]); } return children; } function capturedDomAncestorIds( node: CapturedNode, capturedByBackendId: 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 `