diff --git a/src/query.ts b/src/query.ts index 32e612a6..da4f582e 100644 --- a/src/query.ts +++ b/src/query.ts @@ -5,11 +5,28 @@ import { Snapshot, Element, QuerySelector, QuerySelectorObject } from './types'; export function parseSelector(selector: string): QuerySelectorObject { - const query: QuerySelectorObject = {}; + const query: QuerySelectorObject & { + role_exclude?: string; + text_contains?: string; + text_prefix?: string; + text_suffix?: string; + visible?: boolean; + tag?: string; + importance?: number; + importance_min?: number; + importance_max?: number; + z_index_min?: number; + z_index_max?: number; + in_viewport?: boolean; + is_occluded?: boolean; + [key: string]: any; // For bbox.* and attr.*, css.* + } = {}; - // Match patterns like: key=value, key~'value', key!="value" - // This regex matches: key, operator (=, ~, !=), and value (quoted or unquoted) - const pattern = /(\w+)([=~!]+)((?:'[^']+'|"[^"]+"|[^\s]+))/g; + // Match patterns like: key=value, key~'value', key!="value", key>123, key^='prefix', key$='suffix' + // Updated regex to support: =, !=, ~, ^=, $=, >, >=, <, <= + // Supports dot notation: attr.id, css.color + // Note: Handle ^= and $= first (before single char operators) to avoid regex conflicts + const pattern = /([\w.]+)(\^=|\$=|>=|<=|!=|[=~<>])((?:'[^']+'|"[^"]+"|[^\s]+))/g; let match; while ((match = pattern.exec(selector)) !== null) { @@ -20,23 +37,118 @@ export function parseSelector(selector: string): QuerySelectorObject { // Remove quotes from value value = value.replace(/^["']|["']$/g, ''); + // Handle numeric comparisons + let isNumeric = false; + let numericValue = 0; + const parsedNum = parseFloat(value); + if (!isNaN(parsedNum) && isFinite(parsedNum)) { + isNumeric = true; + numericValue = parsedNum; + } + if (op === '!=') { if (key === 'role') { - (query as any).role_exclude = value; + query.role_exclude = value; } else if (key === 'clickable') { query.clickable = false; + } else if (key === 'visible') { + query.visible = false; } } else if (op === '~') { + // Substring match (case-insensitive) + if (key === 'text' || key === 'name') { + query.text_contains = value; + } + } else if (op === '^=') { + // Prefix match + if (key === 'text' || key === 'name') { + query.text_prefix = value; + } + } else if (op === '$=') { + // Suffix match if (key === 'text' || key === 'name') { - (query as any).text_contains = value; + query.text_suffix = value; + } + } else if (op === '>') { + // Greater than + if (isNumeric) { + if (key === 'importance') { + query.importance_min = numericValue + 0.0001; // Exclusive + } else if (key.startsWith('bbox.')) { + query[`${key}_min`] = numericValue + 0.0001; + } else if (key === 'z_index') { + query.z_index_min = numericValue + 0.0001; + } + } else if (key.startsWith('attr.') || key.startsWith('css.')) { + query[`${key}_gt`] = value; + } + } else if (op === '>=') { + // Greater than or equal + if (isNumeric) { + if (key === 'importance') { + query.importance_min = numericValue; + } else if (key.startsWith('bbox.')) { + query[`${key}_min`] = numericValue; + } else if (key === 'z_index') { + query.z_index_min = numericValue; + } + } else if (key.startsWith('attr.') || key.startsWith('css.')) { + query[`${key}_gte`] = value; + } + } else if (op === '<') { + // Less than + if (isNumeric) { + if (key === 'importance') { + query.importance_max = numericValue - 0.0001; // Exclusive + } else if (key.startsWith('bbox.')) { + query[`${key}_max`] = numericValue - 0.0001; + } else if (key === 'z_index') { + query.z_index_max = numericValue - 0.0001; + } + } else if (key.startsWith('attr.') || key.startsWith('css.')) { + query[`${key}_lt`] = value; + } + } else if (op === '<=') { + // Less than or equal + if (isNumeric) { + if (key === 'importance') { + query.importance_max = numericValue; + } else if (key.startsWith('bbox.')) { + query[`${key}_max`] = numericValue; + } else if (key === 'z_index') { + query.z_index_max = numericValue; + } + } else if (key.startsWith('attr.') || key.startsWith('css.')) { + query[`${key}_lte`] = value; } } else if (op === '=') { + // Exact match if (key === 'role') { query.role = value; } else if (key === 'clickable') { query.clickable = value.toLowerCase() === 'true'; + } else if (key === 'visible') { + query.visible = value.toLowerCase() === 'true'; + } else if (key === 'tag') { + query.tag = value; } else if (key === 'name' || key === 'text') { query.text = value; + } else if (key === 'importance' && isNumeric) { + query.importance = numericValue; + } else if (key.startsWith('attr.')) { + // Dot notation for attributes: attr.id="submit-btn" + const attrKey = key.substring(5); // Remove "attr." prefix + if (!query.attr) { + query.attr = {}; + } + (query.attr as any)[attrKey] = value; + } else if (key.startsWith('css.')) { + // Dot notation for CSS: css.color="red" + const cssKey = key.substring(4); // Remove "css." prefix + if (!query.css) { + query.css = {}; + } + (query.css as any)[cssKey] = value; } } } @@ -44,7 +156,25 @@ export function parseSelector(selector: string): QuerySelectorObject { return query; } -function matchElement(element: Element, query: QuerySelectorObject & { role_exclude?: string; text_contains?: string }): boolean { +function matchElement( + element: Element, + query: QuerySelectorObject & { + role_exclude?: string; + text_contains?: string; + text_prefix?: string; + text_suffix?: string; + visible?: boolean; + tag?: string; + importance?: number; + importance_min?: number; + importance_max?: number; + z_index_min?: number; + z_index_max?: number; + in_viewport?: boolean; + is_occluded?: boolean; + [key: string]: any; // For bbox.* and attr.*, css.* + } +): boolean { // Role exact match if (query.role !== undefined) { if (element.role !== query.role) { @@ -66,6 +196,20 @@ function matchElement(element: Element, query: QuerySelectorObject & { role_excl } } + // Visible (using in_viewport and !is_occluded) + if (query.visible !== undefined) { + const isVisible = element.in_viewport && !element.is_occluded; + if (isVisible !== query.visible) { + return false; + } + } + + // Tag (not yet in Element model, but prepare for future) + if (query.tag !== undefined) { + // For now, this will always fail since tag is not in Element model + // This is a placeholder for future implementation + } + // Text exact match if (query.text !== undefined) { if (!element.text || element.text !== query.text) { @@ -83,12 +227,129 @@ function matchElement(element: Element, query: QuerySelectorObject & { role_excl } } + // Text prefix match + if (query.text_prefix !== undefined) { + if (!element.text) { + return false; + } + if (!element.text.toLowerCase().startsWith(query.text_prefix.toLowerCase())) { + return false; + } + } + + // Text suffix match + if (query.text_suffix !== undefined) { + if (!element.text) { + return false; + } + if (!element.text.toLowerCase().endsWith(query.text_suffix.toLowerCase())) { + return false; + } + } + + // Importance filtering + if (query.importance !== undefined) { + if (element.importance !== query.importance) { + return false; + } + } + if (query.importance_min !== undefined) { + if (element.importance < query.importance_min) { + return false; + } + } + if (query.importance_max !== undefined) { + if (element.importance > query.importance_max) { + return false; + } + } + + // BBox filtering (spatial) + if (query['bbox.x_min'] !== undefined) { + if (element.bbox.x < query['bbox.x_min']) { + return false; + } + } + if (query['bbox.x_max'] !== undefined) { + if (element.bbox.x > query['bbox.x_max']) { + return false; + } + } + if (query['bbox.y_min'] !== undefined) { + if (element.bbox.y < query['bbox.y_min']) { + return false; + } + } + if (query['bbox.y_max'] !== undefined) { + if (element.bbox.y > query['bbox.y_max']) { + return false; + } + } + if (query['bbox.width_min'] !== undefined) { + if (element.bbox.width < query['bbox.width_min']) { + return false; + } + } + if (query['bbox.width_max'] !== undefined) { + if (element.bbox.width > query['bbox.width_max']) { + return false; + } + } + if (query['bbox.height_min'] !== undefined) { + if (element.bbox.height < query['bbox.height_min']) { + return false; + } + } + if (query['bbox.height_max'] !== undefined) { + if (element.bbox.height > query['bbox.height_max']) { + return false; + } + } + + // Z-index filtering + if (query.z_index_min !== undefined) { + if (element.z_index < query.z_index_min) { + return false; + } + } + if (query.z_index_max !== undefined) { + if (element.z_index > query.z_index_max) { + return false; + } + } + + // In viewport filtering + if (query.in_viewport !== undefined) { + if (element.in_viewport !== query.in_viewport) { + return false; + } + } + + // Occlusion filtering + if (query.is_occluded !== undefined) { + if (element.is_occluded !== query.is_occluded) { + return false; + } + } + + // Attribute filtering (dot notation: attr.id="submit-btn") + if (query.attr !== undefined) { + // This requires DOM access, which is not available in the Element model + // This is a placeholder for future implementation when we add DOM access + } + + // CSS property filtering (dot notation: css.color="red") + if (query.css !== undefined) { + // This requires DOM access, which is not available in the Element model + // This is a placeholder for future implementation when we add DOM access + } + return true; } export function query(snapshot: Snapshot, selector: QuerySelector): Element[] { // Parse selector if string - const queryObj = typeof selector === 'string' ? parseSelector(selector) : selector; + const queryObj = typeof selector === 'string' ? parseSelector(selector) : (selector as any); // Filter elements const matches = snapshot.elements.filter((el) => matchElement(el, queryObj)); diff --git a/tests/query.test.ts b/tests/query.test.ts new file mode 100644 index 00000000..339ccef5 --- /dev/null +++ b/tests/query.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for query engine + */ + +import { parseSelector, query, find } from '../src/query'; +import { Element, Snapshot, BBox, VisualCues } from '../src/types'; + +describe('parseSelector', () => { + it('should parse simple role selector', () => { + const q = parseSelector('role=button'); + expect(q.role).toBe('button'); + }); + + it('should parse text contains selector', () => { + const q = parseSelector("text~'Sign in'"); + expect((q as any).text_contains).toBe('Sign in'); + }); + + it('should parse clickable selector', () => { + const q = parseSelector('clickable=true'); + expect(q.clickable).toBe(true); + }); + + it('should parse combined selectors', () => { + const q = parseSelector("role=button text~'Submit'"); + expect(q.role).toBe('button'); + expect((q as any).text_contains).toBe('Submit'); + }); + + it('should parse negation selector', () => { + const q = parseSelector('role!=link'); + expect((q as any).role_exclude).toBe('link'); + }); + + it('should parse prefix selector', () => { + const q = parseSelector("text^='Sign'"); + expect((q as any).text_prefix).toBe('Sign'); + }); + + it('should parse suffix selector', () => { + const q = parseSelector("text$='in'"); + expect((q as any).text_suffix).toBe('in'); + }); + + it('should parse importance greater than', () => { + const q = parseSelector('importance>500'); + expect((q as any).importance_min).toBeGreaterThan(500); + }); + + it('should parse importance greater than or equal', () => { + const q = parseSelector('importance>=500'); + expect((q as any).importance_min).toBe(500); + }); + + it('should parse importance less than', () => { + const q = parseSelector('importance<1000'); + expect((q as any).importance_max).toBeLessThan(1000); + }); + + it('should parse importance less than or equal', () => { + const q = parseSelector('importance<=1000'); + expect((q as any).importance_max).toBe(1000); + }); + + it('should parse visible selector', () => { + const q = parseSelector('visible=true'); + expect((q as any).visible).toBe(true); + const q2 = parseSelector('visible=false'); + expect((q2 as any).visible).toBe(false); + }); + + it('should parse tag selector', () => { + const q = parseSelector('tag=button'); + expect((q as any).tag).toBe('button'); + }); +}); + +describe('query', () => { + const createTestSnapshot = (): Snapshot => { + const elements: Element[] = [ + { + id: 1, + role: 'button', + text: 'Sign In', + importance: 1000, + bbox: { x: 10, y: 20, width: 100, height: 40 }, + visual_cues: { is_primary: true, background_color_name: null, is_clickable: true }, + in_viewport: true, + is_occluded: false, + z_index: 10, + }, + { + id: 2, + role: 'button', + text: 'Sign Out', + importance: 500, + bbox: { x: 120, y: 20, width: 100, height: 40 }, + visual_cues: { is_primary: false, background_color_name: null, is_clickable: true }, + in_viewport: true, + is_occluded: false, + z_index: 5, + }, + { + id: 3, + role: 'link', + text: 'More information', + importance: 200, + bbox: { x: 10, y: 70, width: 150, height: 20 }, + visual_cues: { is_primary: false, background_color_name: null, is_clickable: true }, + in_viewport: true, + is_occluded: false, + z_index: 1, + }, + ]; + + return { + status: 'success', + url: 'https://example.com', + elements, + }; + }; + + it('should filter by importance greater than', () => { + const snap = createTestSnapshot(); + const results = query(snap, 'importance>500'); + expect(results.length).toBe(1); + expect(results[0].id).toBe(1); + }); + + it('should filter by importance less than', () => { + const snap = createTestSnapshot(); + const results = query(snap, 'importance<300'); + expect(results.length).toBe(1); + expect(results[0].id).toBe(3); + }); + + it('should filter by text prefix', () => { + const snap = createTestSnapshot(); + const results = query(snap, "text^='Sign'"); + expect(results.length).toBe(2); + expect(results.map((el) => el.text)).toEqual(['Sign In', 'Sign Out']); + }); + + it('should filter by text suffix', () => { + const snap = createTestSnapshot(); + const results = query(snap, "text$='In'"); + expect(results.length).toBe(1); + expect(results[0].text).toBe('Sign In'); + }); + + it('should filter by bbox x position', () => { + const snap = createTestSnapshot(); + const results = query(snap, 'bbox.x>100'); + expect(results.length).toBe(1); + expect(results[0].id).toBe(2); + }); + + it('should filter by combined selectors', () => { + const snap = createTestSnapshot(); + const results = query(snap, 'role=button importance>500'); + expect(results.length).toBe(1); + expect(results[0].id).toBe(1); + }); + + it('should filter by visible', () => { + const snap = createTestSnapshot(); + const results = query(snap, 'visible=true'); + expect(results.length).toBe(3); // All are visible + }); + + it('should filter by z-index', () => { + const snap = createTestSnapshot(); + const results = query(snap, 'z_index>5'); + expect(results.length).toBe(1); + expect(results[0].id).toBe(1); + }); + + it('should filter by in_viewport', () => { + const snap = createTestSnapshot(); + const results = query(snap, 'in_viewport=true'); + expect(results.length).toBe(3); + }); + + it('should filter by is_occluded', () => { + const snap = createTestSnapshot(); + const results = query(snap, 'is_occluded=false'); + expect(results.length).toBe(3); + }); +}); + +describe('find', () => { + it('should find first matching element', () => { + const snap: Snapshot = { + status: 'success', + url: 'https://example.com', + elements: [ + { + id: 1, + role: 'button', + text: 'Submit', + importance: 1000, + bbox: { x: 10, y: 20, width: 100, height: 40 }, + visual_cues: { is_primary: true, background_color_name: null, is_clickable: true }, + in_viewport: true, + is_occluded: false, + z_index: 10, + }, + { + id: 2, + role: 'button', + text: 'Cancel', + importance: 500, + bbox: { x: 120, y: 20, width: 100, height: 40 }, + visual_cues: { is_primary: false, background_color_name: null, is_clickable: true }, + in_viewport: true, + is_occluded: false, + z_index: 5, + }, + ], + }; + + const result = find(snap, 'role=button'); + expect(result).not.toBeNull(); + expect(result?.id).toBe(1); // Highest importance + }); + + it('should return null if no match', () => { + const snap: Snapshot = { + status: 'success', + url: 'https://example.com', + elements: [ + { + id: 1, + role: 'button', + text: 'Submit', + importance: 1000, + bbox: { x: 10, y: 20, width: 100, height: 40 }, + visual_cues: { is_primary: true, background_color_name: null, is_clickable: true }, + in_viewport: true, + is_occluded: false, + z_index: 10, + }, + ], + }; + + const result = find(snap, 'role=link'); + expect(result).toBeNull(); + }); +}); +