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)'); + }); +}); 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; }