diff --git a/apps/mcp/src/session.ts b/apps/mcp/src/session.ts index 91206a9..01f74ca 100644 --- a/apps/mcp/src/session.ts +++ b/apps/mcp/src/session.ts @@ -288,13 +288,13 @@ export class SessionState { } /** - * patches fields on an existing ref entry in place. used by stale-ref recovery - * to swap in a fresh `backendDOMNodeId` without replacing the entire ref map. + * updates the cached backend node id for an existing ref. used by stale-ref recovery + * so a fresh node id can replace the stale one without rebuilding the ref map. */ - patchRef(ref: string, patch: Partial>): void { + setRefBackendNodeId(ref: string, backendDOMNodeId: number): void { const entry = this.#refMap.get(ref); if (!entry) return; - Object.assign(entry, patch); + entry.backendDOMNodeId = backendDOMNodeId; } // #endregion diff --git a/apps/mcp/src/tools/interaction.ts b/apps/mcp/src/tools/interaction.ts index 25a276c..ccae1a1 100644 --- a/apps/mcp/src/tools/interaction.ts +++ b/apps/mcp/src/tools/interaction.ts @@ -45,10 +45,8 @@ const isInViewport = async ( }; /** - * gets the clickable center coordinates of an element, with auto-scroll if off-screen. - * if the cached `backendDOMNodeId` is stale (e.g. after a React re-render), the ref is - * refreshed in place via the accessibility tree — only the one entry is updated, the - * rest of the ref map is preserved. + * resolves a ref to viewport-center coordinates, auto-scrolling into view if off-screen + * and refreshing stale refs in place. * * @returns coordinates and whether auto-scroll was performed */ diff --git a/apps/mcp/src/tools/shared.ts b/apps/mcp/src/tools/shared.ts index e0fad8a..5c2377d 100644 --- a/apps/mcp/src/tools/shared.ts +++ b/apps/mcp/src/tools/shared.ts @@ -6,7 +6,7 @@ import { sendCdpCommand } from '../cdp.ts'; import type { RelayConnection } from '../connection.ts'; import type { RefEntry, SessionState } from '../session.ts'; -import { takeSnapshot } from './state.ts'; +import { type CdpAXNode, sanitizeName, takeSnapshot } from './state.ts'; export type TextContent = { type: 'text'; text: string }; @@ -62,23 +62,6 @@ export const resolveRefEntry = (session: SessionState, ref: string): RefEntry => return entry; }; -/** raw CDP AXNode fields we care about for role/name matching */ -interface AxMatchNode { - ignored?: boolean; - role?: { value?: unknown }; - name?: { value?: unknown }; - backendDOMNodeId?: number; -} - -// oxlint-disable-next-line no-misleading-character-class -- matches the snapshot sanitizer -const INVISIBLE_CHARS_REFRESH = /[\uFEFF\u200B\u200C\u200D\u2060\u00A0]/g; - -const axString = (value: unknown): string => { - if (typeof value === 'string') return value.replace(INVISIBLE_CHARS_REFRESH, ''); - if (typeof value === 'number' || typeof value === 'boolean') return String(value); - return ''; -}; - /** * re-locates a stale ref by re-querying `Accessibility.getFullAXTree` in the ref's frame * and finding the `nth` node whose role+name match the stored entry. updates just that @@ -96,12 +79,12 @@ export const refreshRefInPlace = async ( const { role, name, frameId } = entry; const nthTarget = entry.nth ?? 0; - let tree: { nodes: AxMatchNode[] }; + let tree: { nodes: CdpAXNode[] }; try { // oxlint-disable-next-line no-unsafe-type-assertion -- CDP response shape tree = (await sendCdpCommand(relay, session, 'Accessibility.getFullAXTree', frameId ? { frameId } : {}, { frameId, - })) as { nodes: AxMatchNode[] }; + })) as { nodes: CdpAXNode[] }; } catch (err) { throw new Error( `Ref "${ref}" could not be refreshed (frame may have navigated or detached). Run snapshot() to get fresh refs.`, @@ -112,19 +95,18 @@ export const refreshRefInPlace = async ( let matched = 0; for (const node of tree.nodes) { if (node.ignored) continue; - if (axString(node.role?.value) !== role) continue; - if (axString(node.name?.value) !== name) continue; + if (node.role.value !== role) continue; + if (sanitizeName(node.name?.value ?? '') !== name) continue; if (matched === nthTarget) { if (node.backendDOMNodeId == null) break; - session.patchRef(ref, { backendDOMNodeId: node.backendDOMNodeId }); - // oxlint-disable-next-line no-unsafe-type-assertion -- narrowed by backendDOMNodeId guard above - return session.resolveRef(ref) as RefEntry & { backendDOMNodeId: number }; + session.setRefBackendNodeId(ref, node.backendDOMNodeId); + return { ...entry, backendDOMNodeId: node.backendDOMNodeId }; } matched++; } throw new Error( - `Ref "${ref}" is no longer in the DOM (role=${JSON.stringify(role)}, name=${JSON.stringify(name)}). Run snapshot() to refresh.`, + `Ref "${ref}" is no longer in the DOM (role=${role}, name=${JSON.stringify(name)}). Run snapshot() to refresh.`, ); }; diff --git a/apps/mcp/src/tools/state.ts b/apps/mcp/src/tools/state.ts index 8dcaa79..3873ac1 100644 --- a/apps/mcp/src/tools/state.ts +++ b/apps/mcp/src/tools/state.ts @@ -10,13 +10,14 @@ import { notConnectedError, resolveRefToRemoteObject } from './shared.ts'; // #region accessibility tree types /** raw CDP AXNode — flat list with childIds references, not embedded children */ -interface CdpAXNode { +export interface CdpAXNode { nodeId: string; role: { value: string }; name?: { value: string }; childIds?: string[]; properties?: { name: string; value: { value: unknown } }[]; backendDOMNodeId?: number; + ignored?: boolean; } /** tree node with resolved children for recursive traversal */ @@ -114,7 +115,7 @@ const nodeKey = (frameId: string | undefined, backendNodeId: number): string => const INVISIBLE_CHARS = /[\uFEFF\u200B\u200C\u200D\u2060\u00A0]/g; /** strips invisible/zero-width characters from display names */ -const sanitizeName = (name: string): string => name.replace(INVISIBLE_CHARS, ''); +export const sanitizeName = (name: string): string => name.replace(INVISIBLE_CHARS, ''); // #endregion