From 63faafbffbbd7610c86b2fbe0750654f4efc6ad4 Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 24 Mar 2026 23:35:29 +0800 Subject: [PATCH 1/4] Add search element heuristics and label wrapper detection - Add SEARCH_INDICATORS set to detect search-related elements by class/id - Add hasFormControlDescendant helper to detect form controls within wrapper elements - Add isSearchElement function to identify search buttons/inputs - Enhance isInteractive to detect: - Labels wrapping form controls (label > span > input pattern) - Span elements containing form controls - Search-related elements This improves detection of UI patterns where clickable elements are wrapped in non-standard containers like labels and spans. Ref: browser-use ClickableElementDetector research --- src/browser/dom-snapshot.ts | 55 ++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts index 47735e7b..c8c9c0f8 100644 --- a/src/browser/dom-snapshot.ts +++ b/src/browser/dom-snapshot.ts @@ -271,6 +271,13 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { const AD_SELECTOR_RE = /\\b(ad[_-]?(?:banner|container|wrapper|slot|unit|block|frame|leaderboard|sidebar)|google[_-]?ad|sponsored|adsbygoogle|banner[_-]?ad)\\b/i; + // Search element indicators for heuristic detection + const SEARCH_INDICATORS = new Set([ + 'search', 'magnify', 'glass', 'lookup', 'find', 'query', + 'search-icon', 'search-btn', 'search-button', 'searchbox', + 'fa-search', 'icon-search', 'btn-search', + ]); + // ── Viewport & Layout Helpers ────────────────────────────────────── const vw = window.innerWidth; @@ -339,19 +346,65 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { // ── Interactivity Detection ──────────────────────────────────────── + // Check if element contains a form control within limited depth (handles label/span wrappers) + function hasFormControlDescendant(el, maxDepth = 2) { + if (maxDepth <= 0) return false; + for (const child of el.children || []) { + const tag = child.tagName?.toLowerCase(); + if (tag === 'input' || tag === 'select' || tag === 'textarea') return true; + if (hasFormControlDescendant(child, maxDepth - 1)) return true; + } + return false; + } + function isInteractive(el) { const tag = el.tagName.toLowerCase(); if (INTERACTIVE_TAGS.has(tag)) { - if (tag === 'label' && el.hasAttribute('for')) return false; + // Skip labels that proxy via "for" to avoid double-activating external inputs + if (tag === 'label') { + if (el.hasAttribute('for')) return false; + // Detect labels that wrap form controls up to two levels deep (label > span > input) + if (hasFormControlDescendant(el, 2)) return true; + } if (el.disabled && (tag === 'button' || tag === 'input')) return false; return true; } + // Span wrappers for UI components - check if they contain form controls + if (tag === 'span') { + if (hasFormControlDescendant(el, 2)) return true; + } const role = el.getAttribute('role'); if (role && INTERACTIVE_ROLES.has(role)) return true; if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('ontouchstart')) return true; if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') return true; try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch {} if (el.isContentEditable && el.getAttribute('contenteditable') !== 'false') return true; + // Search element heuristic detection + if (isSearchElement(el)) return true; + return false; + } + + function isSearchElement(el) { + // Check class names for search indicators + const className = el.className?.toLowerCase() || ''; + const classes = className.split(/\\s+/).filter(Boolean); + for (const cls of classes) { + const cleaned = cls.replace(/[^a-z0-9-]/g, ''); + if (SEARCH_INDICATORS.has(cleaned)) return true; + } + // Check id for search indicators + const id = el.id?.toLowerCase() || ''; + const cleanedId = id.replace(/[^a-z0-9-]/g, ''); + if (SEARCH_INDICATORS.has(cleanedId)) return true; + // Check data-* attributes for search functionality + for (const attr of el.attributes || []) { + if (attr.name.startsWith('data-')) { + const value = attr.value.toLowerCase(); + for (const kw of SEARCH_INDICATORS) { + if (value.includes(kw)) return true; + } + } + } return false; } From aa04a97380e2db758024e2c52255423632f33864 Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 24 Mar 2026 23:37:19 +0800 Subject: [PATCH 2/4] Add CDP-based event listener detection to snapshot - Add AXTreeNode interface and fetchInteractiveNodeIds method to CDPBridge - Add fetchEventListeners method to detect click-related event listeners via DOMDebugger - Add detectListeners and useAXTree options to SnapshotOptions - Add annotateWithListeners to mark elements with click listeners in snapshot This enables more accurate interactivity detection by leveraging Chrome DevTools Protocol to identify elements that have event listeners attached, beyond what can be detected from static HTML attributes alone. Ref: browser-use ClickableElementDetector research --- src/browser/cdp.ts | 164 ++++++++++++++++++++++++++++++++++++++++++++- src/types.ts | 4 ++ 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 335671fd..c0ba7ef2 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -31,6 +31,18 @@ export interface CDPTarget { webSocketDebuggerUrl?: string; } +interface AXTreeNode { + nodeId?: number; + backendNodeId?: number; + role?: string; + name?: string; + properties?: Array<{ name: string; value: string | undefined }>; +} + +interface AXTree { + children?: AXTreeNode[]; +} + interface RuntimeEvaluateResult { result?: { value?: unknown; @@ -176,6 +188,95 @@ export class CDPBridge { this.on(event, handler); }); } + + /** + * Fetch AXTree data and build a map of backendNodeId -> interactive state. + * Returns a Set of backendNodeIds that are clickable/focusable according to AXTree. + */ + async fetchInteractiveNodeIds(): Promise> { + try { + await this.send('Accessibility.enable'); + const result = await this.send('Accessibility.getFullAXTree') as { tree?: AXTree }; + const interactiveIds = new Set(); + + function walkTree(node?: AXTreeNode) { + if (!node) return; + const backendId = node.backendNodeId; + if (backendId) { + // Check if node has interactive role or focusable property + const role = node.role?.toLowerCase() || ''; + const isClickable = role === 'button' || role === 'link' || role === 'menuitem' || + role === 'tab' || role === 'checkbox' || role === 'radio' || + role === 'combobox' || role === 'textbox' || role === 'searchbox'; + const focusable = node.properties?.some(p => + p.name === 'focusable' && (p.value === 'true' || p.value === true) + ); + if (isClickable || focusable) { + interactiveIds.add(backendId); + } + } + // Recurse into children (AXTree uses nested children) + if (Array.isArray(node.children)) { + for (const child of node.children) { + walkTree(child); + } + } + } + + const rootNodes = result.tree?.children || []; + for (const root of rootNodes) { + walkTree(root); + } + + return interactiveIds; + } catch { + // AXTree may not be available in all contexts (e.g., some extension pages) + return new Set(); + } + } + + /** + * Fetch event listeners for all nodes in the document. + * Returns a Map of nodeId -> array of listener types. + */ + async fetchEventListeners(): Promise> { + try { + await this.send('DOM.enable'); + await this.send('DOMDebugger.enable'); + + const docResult = await this.send('DOM.getFlattenedDocument') as { + nodes?: Array<{ nodeId: number; localName?: string; children?: unknown[] }>; + }; + + const listenerMap = new Map(); + + if (!docResult.nodes) return listenerMap; + + // Check listeners for each node (limit to first 100 to avoid excessive calls) + const nodesToCheck = docResult.nodes.slice(0, 100); + for (const node of nodesToCheck) { + if (typeof node.nodeId !== 'number') continue; + + try { + const listenersResult = await this.send('DOMDebugger.getEventListeners', { + nodeId: node.nodeId, + objectId: undefined, + }) as { listeners: Array<{ type: string }> }; + + if (Array.isArray(listenersResult.listeners) && listenersResult.listeners.length > 0) { + const types = listenersResult.listeners.map(l => l.type); + listenerMap.set(node.nodeId, types); + } + } catch { + // Some nodes may not support getting listeners + } + } + + return listenerMap; + } catch { + return new Map(); + } + } } class CDPPage implements IPage { @@ -231,7 +332,17 @@ class CDPPage implements IPage { includeScrollInfo: true, bboxDedup: true, }); - return this.evaluate(snapshotJs); + const result = await this.evaluate(snapshotJs) as Record; + + // Enhance with event listener data if requested + if (opts.detectListeners === true) { + const listenerData = await this.fetchEventListeners(); + if (listenerData.size > 0) { + await annotateWithListeners(this.bridge, result, listenerData); + } + } + + return result; } // ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ── @@ -339,6 +450,57 @@ class CDPPage implements IPage { import { isRecord, saveBase64ToFile } from '../utils.js'; +/** + * Post-process snapshot with event listener data. + * + * This uses DOM.getFlattenedDocument to get nodeIds, then matches them + * against the event listener data from DOMDebugger.getEventListeners. + */ +async function annotateWithListeners( + bridge: CDPBridge, + node: Record, + listenerMap: Map, +): Promise { + const children = isRecord(node.children) ? node.children as Record[] : + Array.isArray(node.children) ? node.children as Record[] : []; + + // Process current node - check for click-like listeners + const ref = node.ref; + if (typeof ref === 'number') { + try { + // Try to find a matching node in the DOM by checking attributes + // For elements with unique IDs, we can match directly + const id = node.id; + if (typeof id === 'string' && id) { + // Query by ID to get nodeId + const queryResult = await bridge.send('DOM.querySelector', { + nodeId: 1, // document node + selector: `#${CSS.escape(id)}`, + }) as { nodeId: number }; + + if (queryResult.nodeId && listenerMap.has(queryResult.nodeId)) { + const listeners = listenerMap.get(queryResult.nodeId)!; + const hasClickListener = listeners.some(t => + t === 'click' || t === 'mousedown' || t === 'mouseup' || t === 'touchstart' + ); + if (hasClickListener) { + (node as Record).hasClickListener = true; + } + } + } + } catch { + // Node not found or other error - skip + } + } + + // Recurse into children + for (const child of children) { + if (isRecord(child)) { + await annotateWithListeners(bridge, child, listenerMap); + } + } +} + function isCookie(value: unknown): value is BrowserCookie { return isRecord(value) && typeof value.name === 'string' diff --git a/src/types.ts b/src/types.ts index e1bc3774..71042083 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,10 @@ export interface SnapshotOptions { raw?: boolean; viewportExpand?: number; maxTextLength?: number; + /** Enable event listener detection via CDP (marks elements with click listeners) */ + detectListeners?: boolean; + /** Enable AXTree-based interactivity detection via CDP */ + useAXTree?: boolean; } export interface WaitOptions { From 2b6bcdde0c3d693f87160a520eef62519f1a4087 Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 24 Mar 2026 23:38:01 +0800 Subject: [PATCH 3/4] Add tests for search element detection and CDP listener detection - Add tests for SEARCH_INDICATORS set in generateSnapshotJs - Add tests for hasFormControlDescendant function - Add tests for isSearchElement function - Add tests for label/span wrapper detection in isInteractive - Add tests for fetchInteractiveNodeIds (AXTree parsing) - Add tests for fetchEventListeners (DOM listener extraction) Total: 254 tests passing --- src/browser/cdp.test.ts | 111 +++++++++++++++++++++++++++++++ src/browser/dom-snapshot.test.ts | 42 ++++++++++++ 2 files changed, 153 insertions(+) diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 480f32ae..d9036120 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -64,3 +64,114 @@ describe('CDPBridge cookies', () => { ]); }); }); + +describe('CDPBridge event listener detection', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it('fetchInteractiveNodeIds returns empty set when AXTree is unavailable', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockRejectedValue(new Error('AXTree not available')); + + const interactiveIds = await bridge.fetchInteractiveNodeIds(); + + expect(interactiveIds).toBeInstanceOf(Set); + expect(interactiveIds.size).toBe(0); + }); + + it('fetchInteractiveNodeIds parses AXTree and extracts interactive nodes', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + let callCount = 0; + vi.spyOn(bridge, 'send').mockImplementation(async () => { + callCount++; + if (callCount === 1) return {}; // Accessibility.enable + if (callCount === 2) { + // Mock AXTree response + return { + tree: { + children: [ + { + backendNodeId: 123, + role: 'button', + name: 'Click me', + properties: [], + }, + { + backendNodeId: 456, + role: 'text', + name: 'Plain text', + properties: [], + }, + { + backendNodeId: 789, + role: 'link', + name: 'A link', + properties: [], + }, + ], + }, + }; + } + return {}; + }); + + const interactiveIds = await bridge.fetchInteractiveNodeIds(); + + expect(interactiveIds.size).toBe(2); + expect(interactiveIds.has(123)).toBe(true); // button + expect(interactiveIds.has(456)).toBe(false); // text + expect(interactiveIds.has(789)).toBe(true); // link + }); + + it('fetchEventListeners returns empty map when DOM is unavailable', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + vi.spyOn(bridge, 'send').mockRejectedValue(new Error('DOM not available')); + + const listeners = await bridge.fetchEventListeners(); + + expect(listeners).toBeInstanceOf(Map); + expect(listeners.size).toBe(0); + }); + + it('fetchEventListeners parses DOM and extracts event listeners', async () => { + vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); + + const bridge = new CDPBridge(); + let callCount = 0; + vi.spyOn(bridge, 'send').mockImplementation(async () => { + callCount++; + if (callCount === 1) return {}; // DOM.enable + if (callCount === 2) return {}; // DOMDebugger.enable + if (callCount === 3) { + // Mock flattened document + return { + nodes: [ + { nodeId: 1, localName: 'div' }, + { nodeId: 2, localName: 'button' }, + ], + }; + } + if (callCount === 4) { + // Mock event listeners for node 2 + return { + listeners: [ + { type: 'click' }, + { type: 'mousedown' }, + ], + }; + } + return { listeners: [] }; + }); + + const listeners = await bridge.fetchEventListeners(); + + expect(listeners.size).toBeGreaterThan(0); + }); +}); diff --git a/src/browser/dom-snapshot.test.ts b/src/browser/dom-snapshot.test.ts index 2a1bb99a..d9957af7 100644 --- a/src/browser/dom-snapshot.test.ts +++ b/src/browser/dom-snapshot.test.ts @@ -247,3 +247,45 @@ describe('getFormStateJs', () => { expect(js).toContain('data-opencli-ref'); }); }); + +describe('Search Element Detection', () => { + it('includes SEARCH_INDICATORS set', () => { + const js = generateSnapshotJs(); + expect(js).toContain('SEARCH_INDICATORS'); + expect(js).toContain('search'); + expect(js).toContain('magnify'); + expect(js).toContain('glass'); + }); + + it('includes hasFormControlDescendant function', () => { + const js = generateSnapshotJs(); + expect(js).toContain('hasFormControlDescendant'); + expect(js).toContain('input'); + expect(js).toContain('select'); + expect(js).toContain('textarea'); + }); + + it('includes isSearchElement function', () => { + const js = generateSnapshotJs(); + expect(js).toContain('isSearchElement'); + expect(js).toContain('className'); + expect(js).toContain('data-'); + }); + + it('checks label wrapper detection in isInteractive', () => { + const js = generateSnapshotJs(); + // Label elements without "for" attribute should check for form control descendants + expect(js).toContain('hasFormControlDescendant(el, 2)'); + }); + + it('checks span wrapper detection in isInteractive', () => { + const js = generateSnapshotJs(); + // Span elements should check for form control descendants + expect(js).toContain("tag === 'span'"); + }); + + it('integrates search element detection into isInteractive', () => { + const js = generateSnapshotJs(); + expect(js).toContain('isSearchElement(el)'); + }); +}); From 9401151ee8ddf6e576c32236f9cdaf9f70d7dca0 Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 24 Mar 2026 23:42:54 +0800 Subject: [PATCH 4/4] refactor(browser): drop unfinished cdp snapshot hooks --- src/browser/cdp.test.ts | 111 --------------------------- src/browser/cdp.ts | 164 +--------------------------------------- src/types.ts | 4 - 3 files changed, 1 insertion(+), 278 deletions(-) diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index d9036120..480f32ae 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -64,114 +64,3 @@ describe('CDPBridge cookies', () => { ]); }); }); - -describe('CDPBridge event listener detection', () => { - beforeEach(() => { - vi.unstubAllEnvs(); - }); - - it('fetchInteractiveNodeIds returns empty set when AXTree is unavailable', async () => { - vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); - - const bridge = new CDPBridge(); - vi.spyOn(bridge, 'send').mockRejectedValue(new Error('AXTree not available')); - - const interactiveIds = await bridge.fetchInteractiveNodeIds(); - - expect(interactiveIds).toBeInstanceOf(Set); - expect(interactiveIds.size).toBe(0); - }); - - it('fetchInteractiveNodeIds parses AXTree and extracts interactive nodes', async () => { - vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); - - const bridge = new CDPBridge(); - let callCount = 0; - vi.spyOn(bridge, 'send').mockImplementation(async () => { - callCount++; - if (callCount === 1) return {}; // Accessibility.enable - if (callCount === 2) { - // Mock AXTree response - return { - tree: { - children: [ - { - backendNodeId: 123, - role: 'button', - name: 'Click me', - properties: [], - }, - { - backendNodeId: 456, - role: 'text', - name: 'Plain text', - properties: [], - }, - { - backendNodeId: 789, - role: 'link', - name: 'A link', - properties: [], - }, - ], - }, - }; - } - return {}; - }); - - const interactiveIds = await bridge.fetchInteractiveNodeIds(); - - expect(interactiveIds.size).toBe(2); - expect(interactiveIds.has(123)).toBe(true); // button - expect(interactiveIds.has(456)).toBe(false); // text - expect(interactiveIds.has(789)).toBe(true); // link - }); - - it('fetchEventListeners returns empty map when DOM is unavailable', async () => { - vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); - - const bridge = new CDPBridge(); - vi.spyOn(bridge, 'send').mockRejectedValue(new Error('DOM not available')); - - const listeners = await bridge.fetchEventListeners(); - - expect(listeners).toBeInstanceOf(Map); - expect(listeners.size).toBe(0); - }); - - it('fetchEventListeners parses DOM and extracts event listeners', async () => { - vi.stubEnv('OPENCLI_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1'); - - const bridge = new CDPBridge(); - let callCount = 0; - vi.spyOn(bridge, 'send').mockImplementation(async () => { - callCount++; - if (callCount === 1) return {}; // DOM.enable - if (callCount === 2) return {}; // DOMDebugger.enable - if (callCount === 3) { - // Mock flattened document - return { - nodes: [ - { nodeId: 1, localName: 'div' }, - { nodeId: 2, localName: 'button' }, - ], - }; - } - if (callCount === 4) { - // Mock event listeners for node 2 - return { - listeners: [ - { type: 'click' }, - { type: 'mousedown' }, - ], - }; - } - return { listeners: [] }; - }); - - const listeners = await bridge.fetchEventListeners(); - - expect(listeners.size).toBeGreaterThan(0); - }); -}); diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index c0ba7ef2..335671fd 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -31,18 +31,6 @@ export interface CDPTarget { webSocketDebuggerUrl?: string; } -interface AXTreeNode { - nodeId?: number; - backendNodeId?: number; - role?: string; - name?: string; - properties?: Array<{ name: string; value: string | undefined }>; -} - -interface AXTree { - children?: AXTreeNode[]; -} - interface RuntimeEvaluateResult { result?: { value?: unknown; @@ -188,95 +176,6 @@ export class CDPBridge { this.on(event, handler); }); } - - /** - * Fetch AXTree data and build a map of backendNodeId -> interactive state. - * Returns a Set of backendNodeIds that are clickable/focusable according to AXTree. - */ - async fetchInteractiveNodeIds(): Promise> { - try { - await this.send('Accessibility.enable'); - const result = await this.send('Accessibility.getFullAXTree') as { tree?: AXTree }; - const interactiveIds = new Set(); - - function walkTree(node?: AXTreeNode) { - if (!node) return; - const backendId = node.backendNodeId; - if (backendId) { - // Check if node has interactive role or focusable property - const role = node.role?.toLowerCase() || ''; - const isClickable = role === 'button' || role === 'link' || role === 'menuitem' || - role === 'tab' || role === 'checkbox' || role === 'radio' || - role === 'combobox' || role === 'textbox' || role === 'searchbox'; - const focusable = node.properties?.some(p => - p.name === 'focusable' && (p.value === 'true' || p.value === true) - ); - if (isClickable || focusable) { - interactiveIds.add(backendId); - } - } - // Recurse into children (AXTree uses nested children) - if (Array.isArray(node.children)) { - for (const child of node.children) { - walkTree(child); - } - } - } - - const rootNodes = result.tree?.children || []; - for (const root of rootNodes) { - walkTree(root); - } - - return interactiveIds; - } catch { - // AXTree may not be available in all contexts (e.g., some extension pages) - return new Set(); - } - } - - /** - * Fetch event listeners for all nodes in the document. - * Returns a Map of nodeId -> array of listener types. - */ - async fetchEventListeners(): Promise> { - try { - await this.send('DOM.enable'); - await this.send('DOMDebugger.enable'); - - const docResult = await this.send('DOM.getFlattenedDocument') as { - nodes?: Array<{ nodeId: number; localName?: string; children?: unknown[] }>; - }; - - const listenerMap = new Map(); - - if (!docResult.nodes) return listenerMap; - - // Check listeners for each node (limit to first 100 to avoid excessive calls) - const nodesToCheck = docResult.nodes.slice(0, 100); - for (const node of nodesToCheck) { - if (typeof node.nodeId !== 'number') continue; - - try { - const listenersResult = await this.send('DOMDebugger.getEventListeners', { - nodeId: node.nodeId, - objectId: undefined, - }) as { listeners: Array<{ type: string }> }; - - if (Array.isArray(listenersResult.listeners) && listenersResult.listeners.length > 0) { - const types = listenersResult.listeners.map(l => l.type); - listenerMap.set(node.nodeId, types); - } - } catch { - // Some nodes may not support getting listeners - } - } - - return listenerMap; - } catch { - return new Map(); - } - } } class CDPPage implements IPage { @@ -332,17 +231,7 @@ class CDPPage implements IPage { includeScrollInfo: true, bboxDedup: true, }); - const result = await this.evaluate(snapshotJs) as Record; - - // Enhance with event listener data if requested - if (opts.detectListeners === true) { - const listenerData = await this.fetchEventListeners(); - if (listenerData.size > 0) { - await annotateWithListeners(this.bridge, result, listenerData); - } - } - - return result; + return this.evaluate(snapshotJs); } // ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ── @@ -450,57 +339,6 @@ class CDPPage implements IPage { import { isRecord, saveBase64ToFile } from '../utils.js'; -/** - * Post-process snapshot with event listener data. - * - * This uses DOM.getFlattenedDocument to get nodeIds, then matches them - * against the event listener data from DOMDebugger.getEventListeners. - */ -async function annotateWithListeners( - bridge: CDPBridge, - node: Record, - listenerMap: Map, -): Promise { - const children = isRecord(node.children) ? node.children as Record[] : - Array.isArray(node.children) ? node.children as Record[] : []; - - // Process current node - check for click-like listeners - const ref = node.ref; - if (typeof ref === 'number') { - try { - // Try to find a matching node in the DOM by checking attributes - // For elements with unique IDs, we can match directly - const id = node.id; - if (typeof id === 'string' && id) { - // Query by ID to get nodeId - const queryResult = await bridge.send('DOM.querySelector', { - nodeId: 1, // document node - selector: `#${CSS.escape(id)}`, - }) as { nodeId: number }; - - if (queryResult.nodeId && listenerMap.has(queryResult.nodeId)) { - const listeners = listenerMap.get(queryResult.nodeId)!; - const hasClickListener = listeners.some(t => - t === 'click' || t === 'mousedown' || t === 'mouseup' || t === 'touchstart' - ); - if (hasClickListener) { - (node as Record).hasClickListener = true; - } - } - } - } catch { - // Node not found or other error - skip - } - } - - // Recurse into children - for (const child of children) { - if (isRecord(child)) { - await annotateWithListeners(bridge, child, listenerMap); - } - } -} - function isCookie(value: unknown): value is BrowserCookie { return isRecord(value) && typeof value.name === 'string' diff --git a/src/types.ts b/src/types.ts index 71042083..e1bc3774 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,10 +22,6 @@ export interface SnapshotOptions { raw?: boolean; viewportExpand?: number; maxTextLength?: number; - /** Enable event listener detection via CDP (marks elements with click listeners) */ - detectListeners?: boolean; - /** Enable AXTree-based interactivity detection via CDP */ - useAXTree?: boolean; } export interface WaitOptions {