Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/browser/dom-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
});
});
55 changes: 54 additions & 1 deletion src/browser/dom-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
Loading