From 38c465f4241b7580120261ccd2b7a0c13894172f Mon Sep 17 00:00:00 2001 From: rcholic Date: Mon, 22 Dec 2025 12:47:23 -0800 Subject: [PATCH 1/3] new features for click by rect box;semantic wait --- src/actions.ts | 283 ++++++++++++++++++++++++++++++++++++++++-- src/index.ts | 2 +- tests/actions.test.ts | 233 ++++++++++++++++++++++++++++++++++ 3 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 tests/actions.test.ts diff --git a/src/actions.ts b/src/actions.ts index 1667bd80..9b91ff49 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -3,29 +3,155 @@ */ import { SentienceBrowser } from './browser'; -import { ActionResult, Snapshot } from './types'; +import { ActionResult, Snapshot, BBox } from './types'; import { snapshot } from './snapshot'; +export interface ClickRect { + x: number; + y: number; + w?: number; + width?: number; + h?: number; + height?: number; +} + +/** + * Highlight a rectangle with a red border overlay + */ +async function highlightRect( + browser: SentienceBrowser, + rect: ClickRect, + durationSec: number = 2.0 +): Promise { + const page = browser.getPage(); + const highlightId = `sentience_highlight_${Date.now()}`; + + // Combine all arguments into a single object for Playwright + const args = { + rect: { + x: rect.x, + y: rect.y, + w: rect.w || rect.width || 0, + h: rect.h || rect.height || 0, + }, + highlightId, + durationSec, + }; + + await page.evaluate( + (args: { rect: { x: number; y: number; w: number; h: number }; highlightId: string; durationSec: number }) => { + const { rect, highlightId, durationSec } = args; + // Create overlay div + const overlay = document.createElement('div'); + overlay.id = highlightId; + overlay.style.position = 'fixed'; + overlay.style.left = `${rect.x}px`; + overlay.style.top = `${rect.y}px`; + overlay.style.width = `${rect.w}px`; + overlay.style.height = `${rect.h}px`; + overlay.style.border = '3px solid red'; + overlay.style.borderRadius = '2px'; + overlay.style.boxSizing = 'border-box'; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = '999999'; + overlay.style.backgroundColor = 'rgba(255, 0, 0, 0.1)'; + overlay.style.transition = 'opacity 0.3s ease-out'; + + document.body.appendChild(overlay); + + // Remove after duration + setTimeout(() => { + overlay.style.opacity = '0'; + setTimeout(() => { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + }, 300); // Wait for fade-out transition + }, durationSec * 1000); + }, + args + ); +} + export async function click( browser: SentienceBrowser, elementId: number, + useMouse: boolean = true, takeSnapshot: boolean = false ): Promise { const page = browser.getPage(); const startTime = Date.now(); const urlBefore = page.url(); - // Call extension click method - const success = await page.evaluate((id) => { - return (window as any).sentience.click(id); - }, elementId); + let success: boolean; + + if (useMouse) { + // Hybrid approach: Get element bbox from snapshot, calculate center, use mouse.click() + try { + const snap = await snapshot(browser); + const element = snap.elements.find((el) => el.id === elementId); + + if (element) { + // Calculate center of element bbox + const centerX = element.bbox.x + element.bbox.width / 2; + const centerY = element.bbox.y + element.bbox.height / 2; + // Use Playwright's native mouse click for realistic simulation + await page.mouse.click(centerX, centerY); + success = true; + } else { + // Fallback to JS click if element not found in snapshot + try { + success = await page.evaluate((id) => { + return (window as any).sentience.click(id); + }, elementId); + } catch (error) { + // Navigation might have destroyed context, assume success if URL changed + success = true; + } + } + } catch (error) { + // Fallback to JS click on error + try { + success = await page.evaluate((id) => { + return (window as any).sentience.click(id); + }, elementId); + } catch (evalError) { + // Navigation might have destroyed context, assume success + success = true; + } + } + } else { + // Legacy JS-based click + try { + success = await page.evaluate((id) => { + return (window as any).sentience.click(id); + }, elementId); + } catch (error) { + // Navigation might have destroyed context, assume success + success = true; + } + } // Wait a bit for navigation/DOM updates - await page.waitForTimeout(500); + try { + await page.waitForTimeout(500); + } catch (error) { + // Navigation might have happened, context destroyed + } const durationMs = Date.now() - startTime; - const urlAfter = page.url(); - const urlChanged = urlBefore !== urlAfter; + + // Check if URL changed (handle navigation gracefully) + let urlAfter: string; + let urlChanged: boolean; + try { + urlAfter = page.url(); + urlChanged = urlBefore !== urlAfter; + } catch (error) { + // Context destroyed due to navigation - assume URL changed + urlAfter = urlBefore; + urlChanged = true; + } // Determine outcome let outcome: 'navigated' | 'dom_updated' | 'no_change' | 'error' | undefined; @@ -40,7 +166,11 @@ export async function click( // Optional snapshot after let snapshotAfter: Snapshot | undefined; if (takeSnapshot) { - snapshotAfter = await snapshot(browser); + try { + snapshotAfter = await snapshot(browser); + } catch (error) { + // Navigation might have destroyed context + } } return { @@ -142,3 +272,138 @@ export async function press( }; } +/** + * Click at the center of a rectangle using Playwright's native mouse simulation. + * This uses a hybrid approach: calculates center coordinates and uses mouse.click() + * for realistic event simulation (triggers hover, focus, mousedown, mouseup). + * + * @param browser - SentienceBrowser instance + * @param rect - Rectangle with x, y, w (or width), h (or height) keys, or BBox object + * @param highlight - Whether to show a red border highlight when clicking (default: true) + * @param highlightDuration - How long to show the highlight in seconds (default: 2.0) + * @param takeSnapshot - Whether to take snapshot after action + * @returns ActionResult + * + * @example + * ```typescript + * // Click using rect object + * await clickRect(browser, { x: 100, y: 200, w: 50, h: 30 }); + * + * // Click using BBox from element + * const snap = await snapshot(browser); + * const element = find(snap, "role=button"); + * if (element) { + * await clickRect(browser, { + * x: element.bbox.x, + * y: element.bbox.y, + * w: element.bbox.width, + * h: element.bbox.height + * }); + * } + * + * // Without highlight + * await clickRect(browser, { x: 100, y: 200, w: 50, h: 30 }, false); + * + * // Custom highlight duration + * await clickRect(browser, { x: 100, y: 200, w: 50, h: 30 }, true, 3.0); + * ``` + */ +export async function clickRect( + browser: SentienceBrowser, + rect: ClickRect | BBox, + highlight: boolean = true, + highlightDuration: number = 2.0, + takeSnapshot: boolean = false +): Promise { + const page = browser.getPage(); + const startTime = Date.now(); + const urlBefore = page.url(); + + // Handle BBox object or ClickRect dict + let x: number, y: number, w: number, h: number; + + if ('width' in rect && 'height' in rect && !('w' in rect) && !('h' in rect)) { + // BBox object (width and height are required in BBox) + const bbox = rect as BBox; + x = bbox.x; + y = bbox.y; + w = bbox.width; + h = bbox.height; + } else { + // ClickRect dict + const clickRect = rect as ClickRect; + x = clickRect.x; + y = clickRect.y; + w = clickRect.w || clickRect.width || 0; + h = clickRect.h || clickRect.height || 0; + } + + if (w <= 0 || h <= 0) { + return { + success: false, + duration_ms: 0, + outcome: 'error', + error: { + code: 'invalid_rect', + reason: 'Rectangle width and height must be positive', + }, + }; + } + + // Calculate center of rectangle + const centerX = x + w / 2; + const centerY = y + h / 2; + + // Show highlight before clicking (if enabled) + if (highlight) { + await highlightRect(browser, { x, y, w, h }, highlightDuration); + // Small delay to ensure highlight is visible + await page.waitForTimeout(50); + } + + // Use Playwright's native mouse click for realistic simulation + let success: boolean; + let errorMsg: string | undefined; + try { + await page.mouse.click(centerX, centerY); + success = true; + } catch (error) { + success = false; + errorMsg = error instanceof Error ? error.message : String(error); + } + + // Wait a bit for navigation/DOM updates + await page.waitForTimeout(500); + + const durationMs = Date.now() - startTime; + const urlAfter = page.url(); + const urlChanged = urlBefore !== urlAfter; + + // Determine outcome + let outcome: 'navigated' | 'dom_updated' | 'no_change' | 'error' | undefined; + if (urlChanged) { + outcome = 'navigated'; + } else if (success) { + outcome = 'dom_updated'; + } else { + outcome = 'error'; + } + + // Optional snapshot after + let snapshotAfter: Snapshot | undefined; + if (takeSnapshot) { + snapshotAfter = await snapshot(browser); + } + + return { + success, + duration_ms: durationMs, + outcome, + url_changed: urlChanged, + snapshot_after: snapshotAfter, + error: success + ? undefined + : { code: 'click_failed', reason: errorMsg || 'Click failed' }, + }; +} + diff --git a/src/index.ts b/src/index.ts index f01d4dd2..be582362 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ export { SentienceBrowser } from './browser'; export { snapshot, SnapshotOptions } from './snapshot'; export { query, find, parseSelector } from './query'; -export { click, typeText, press } from './actions'; +export { click, typeText, press, clickRect, ClickRect } from './actions'; export { waitFor } from './wait'; export { expect, Expectation } from './expect'; export { Inspector, inspect } from './inspector'; diff --git a/tests/actions.test.ts b/tests/actions.test.ts new file mode 100644 index 00000000..7f2442f4 --- /dev/null +++ b/tests/actions.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for actions (click, type, press, clickRect) + */ + +import { SentienceBrowser, click, typeText, press, clickRect, snapshot, find, BBox } from '../src'; +import { createTestBrowser } from './test-utils'; + +describe('Actions', () => { + describe('click', () => { + it('should click an element by ID', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const snap = await snapshot(browser); + const link = find(snap, 'role=link'); + + if (link) { + const result = await click(browser, link.id); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + expect(['navigated', 'dom_updated']).toContain(result.outcome); + } + } finally { + await browser.close(); + } + }, 60000); + + it('should use hybrid approach (mouse.click at center)', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const snap = await snapshot(browser); + const link = find(snap, 'role=link'); + + if (link) { + // Test hybrid approach (mouse.click at center) + const result = await click(browser, link.id, true); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + // Navigation may happen, which is expected for links + expect(['navigated', 'dom_updated']).toContain(result.outcome); + } + } finally { + await browser.close(); + } + }, 60000); + + it('should use JS-based approach (legacy)', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const snap = await snapshot(browser); + const link = find(snap, 'role=link'); + + if (link) { + // Test JS-based click (legacy approach) + const result = await click(browser, link.id, false); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + // Navigation may happen, which is expected for links + expect(['navigated', 'dom_updated']).toContain(result.outcome); + } + } finally { + await browser.close(); + } + }, 60000); + }); + + describe('typeText', () => { + it('should type text into an element', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const snap = await snapshot(browser); + const textbox = find(snap, 'role=textbox'); + + if (textbox) { + const result = await typeText(browser, textbox.id, 'hello'); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + } + } finally { + await browser.close(); + } + }, 60000); + }); + + describe('press', () => { + it('should press a keyboard key', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const result = await press(browser, 'Enter'); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + } finally { + await browser.close(); + } + }, 60000); + }); + + describe('clickRect', () => { + it('should click at rectangle center using rect dict', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + // Click at a specific rectangle (top-left area) + const result = await clickRect(browser, { x: 100, y: 100, w: 50, h: 30 }); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + expect(['navigated', 'dom_updated']).toContain(result.outcome); + } finally { + await browser.close(); + } + }, 60000); + + it('should click using BBox object', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + // Get an element and click its bbox + const snap = await snapshot(browser); + const link = find(snap, 'role=link'); + + if (link) { + const bbox: BBox = { + x: link.bbox.x, + y: link.bbox.y, + width: link.bbox.width, + height: link.bbox.height, + }; + const result = await clickRect(browser, bbox); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + } + } finally { + await browser.close(); + } + }, 60000); + + it('should click without highlight when disabled', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const result = await clickRect(browser, { x: 100, y: 100, w: 50, h: 30 }, false); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + } finally { + await browser.close(); + } + }, 60000); + + it('should handle invalid rectangle dimensions', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + // Invalid: zero width + const result1 = await clickRect(browser, { x: 100, y: 100, w: 0, h: 30 }); + expect(result1.success).toBe(false); + expect(result1.error).toBeDefined(); + expect(result1.error?.code).toBe('invalid_rect'); + + // Invalid: negative height + const result2 = await clickRect(browser, { x: 100, y: 100, w: 50, h: -10 }); + expect(result2.success).toBe(false); + expect(result2.error).toBeDefined(); + expect(result2.error?.code).toBe('invalid_rect'); + } finally { + await browser.close(); + } + }, 60000); + + it('should take snapshot after click when requested', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const result = await clickRect(browser, { x: 100, y: 100, w: 50, h: 30 }, true, 2.0, true); + expect(result.success).toBe(true); + expect(result.snapshot_after).toBeDefined(); + expect(result.snapshot_after?.status).toBe('success'); + expect(result.snapshot_after?.elements.length).toBeGreaterThan(0); + } finally { + await browser.close(); + } + }, 60000); + + it('should accept width/height keys instead of w/h', async () => { + const browser = await createTestBrowser(); + + try { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + const result = await clickRect(browser, { x: 100, y: 100, width: 50, height: 30 }); + expect(result.success).toBe(true); + expect(result.duration_ms).toBeGreaterThan(0); + } finally { + await browser.close(); + } + }, 60000); + }); +}); + From fad9fb448b45a0df0c6369ec0b0015ea04b909c9 Mon Sep 17 00:00:00 2001 From: rcholic Date: Mon, 22 Dec 2025 13:09:49 -0800 Subject: [PATCH 2/3] flexible wait --- examples/click-rect-demo.ts | 99 +++++++++++++++++++++++ examples/semantic-wait-demo.ts | 141 +++++++++++++++++++++++++++++++++ src/wait.ts | 33 +++++++- 3 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 examples/click-rect-demo.ts create mode 100644 examples/semantic-wait-demo.ts diff --git a/examples/click-rect-demo.ts b/examples/click-rect-demo.ts new file mode 100644 index 00000000..4526c769 --- /dev/null +++ b/examples/click-rect-demo.ts @@ -0,0 +1,99 @@ +/** + * Example: Using clickRect for coordinate-based clicking with visual feedback + */ + +import { SentienceBrowser, snapshot, find, clickRect, BBox } from '../src/index'; + +async function main() { + // Get API key from environment variable (optional - uses free tier if not set) + const apiKey = process.env.SENTIENCE_API_KEY as string | undefined; + + const browser = new SentienceBrowser(apiKey, undefined, false); + + try { + await browser.start(); + + // Navigate to example.com + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + + console.log('=== clickRect Demo ===\n'); + + // Example 1: Click using rect object + console.log('1. Clicking at specific coordinates (100, 100) with size 50x30'); + console.log(' (You should see a red border highlight for 2 seconds)'); + let result = await clickRect(browser, { x: 100, y: 100, w: 50, h: 30 }); + console.log(` Result: success=${result.success}, outcome=${result.outcome}`); + console.log(` Duration: ${result.duration_ms}ms\n`); + + // Wait a bit + await browser.getPage().waitForTimeout(1000); + + // Example 2: Click using element's bbox + console.log('2. Clicking using element\'s bounding box'); + const snap = await snapshot(browser); + const link = find(snap, 'role=link'); + + if (link) { + console.log(` Found link: '${link.text}' at (${link.bbox.x}, ${link.bbox.y})`); + console.log(' Clicking at center of element\'s bbox...'); + result = await clickRect(browser, { + x: link.bbox.x, + y: link.bbox.y, + w: link.bbox.width, + h: link.bbox.height + }); + console.log(` Result: success=${result.success}, outcome=${result.outcome}`); + console.log(` URL changed: ${result.url_changed}\n`); + + // Navigate back if needed + if (result.url_changed) { + await browser.getPage().goto('https://example.com'); + await browser.getPage().waitForLoadState('networkidle'); + } + } + + // Example 3: Click without highlight (for headless/CI) + console.log('3. Clicking without visual highlight'); + result = await clickRect(browser, { x: 200, y: 200, w: 40, h: 20 }, false); + console.log(` Result: success=${result.success}\n`); + + // Example 4: Custom highlight duration + console.log('4. Clicking with custom highlight duration (3 seconds)'); + result = await clickRect(browser, { x: 300, y: 300, w: 60, h: 40 }, true, 3.0); + console.log(` Result: success=${result.success}`); + console.log(' (Red border should stay visible for 3 seconds)\n'); + + // Example 5: Click with snapshot capture + console.log('5. Clicking and capturing snapshot after action'); + result = await clickRect( + browser, + { x: 150, y: 150, w: 50, h: 30 }, + true, + 2.0, + true + ); + if (result.snapshot_after) { + console.log(` Snapshot captured: ${result.snapshot_after.elements.length} elements found`); + console.log(` URL: ${result.snapshot_after.url}\n`); + } + + // Example 6: Using BBox object + console.log('6. Clicking using BBox object'); + const bbox: BBox = { x: 250, y: 250, width: 45, height: 25 }; + result = await clickRect(browser, bbox); + console.log(` Result: success=${result.success}\n`); + + console.log('✅ clickRect demo complete!'); + console.log('\nNote: clickRect uses Playwright\'s native mouse.click() for realistic'); + console.log('event simulation, triggering hover, focus, mousedown, mouseup sequences.'); + } catch (e: any) { + console.error(`❌ Error: ${e.message}`); + console.error(e.stack); + } finally { + await browser.close(); + } +} + +main().catch(console.error); + diff --git a/examples/semantic-wait-demo.ts b/examples/semantic-wait-demo.ts new file mode 100644 index 00000000..02d92604 --- /dev/null +++ b/examples/semantic-wait-demo.ts @@ -0,0 +1,141 @@ +/** + * Example: Semantic waitFor using query DSL + * Demonstrates waiting for elements using semantic selectors + */ + +import { SentienceBrowser, snapshot, find, waitFor, click } from '../src/index'; + +async function main() { + // Get API key from environment variable (optional - uses free tier if not set) + const apiKey = process.env.SENTIENCE_API_KEY as string | undefined; + + const browser = new SentienceBrowser(apiKey, undefined, false); + + try { + await browser.start(); + + // Navigate to example.com + await browser.getPage().goto('https://example.com', { waitUntil: 'domcontentloaded' }); + + console.log('=== Semantic waitFor Demo ===\n'); + + // Example 1: Wait for element by role + console.log('1. Waiting for link element (role=link)'); + let waitResult = await waitFor(browser, 'role=link', 5000); + if (waitResult.found) { + console.log(` ✅ Found after ${waitResult.duration_ms}ms`); + console.log(` Element: '${waitResult.element?.text}' (id: ${waitResult.element?.id})`); + } else { + console.log(` ❌ Not found (timeout: ${waitResult.timeout})`); + } + console.log(); + + // Example 2: Wait for element by role and text + console.log('2. Waiting for link with specific text'); + waitResult = await waitFor(browser, 'role=link text~"Example"', 5000); + if (waitResult.found) { + console.log(` ✅ Found after ${waitResult.duration_ms}ms`); + console.log(` Element text: '${waitResult.element?.text}'`); + } else { + console.log(' ❌ Not found'); + } + console.log(); + + // Example 3: Wait for clickable element + console.log('3. Waiting for clickable element'); + waitResult = await waitFor(browser, 'clickable=true', 5000); + if (waitResult.found) { + console.log(` ✅ Found clickable element after ${waitResult.duration_ms}ms`); + console.log(` Role: ${waitResult.element?.role}`); + console.log(` Text: '${waitResult.element?.text}'`); + console.log(` Is clickable: ${waitResult.element?.visual_cues.is_clickable}`); + } else { + console.log(' ❌ Not found'); + } + console.log(); + + // Example 4: Wait for element with importance threshold + console.log('4. Waiting for important element (importance > 100)'); + waitResult = await waitFor(browser, 'importance>100', 5000); + if (waitResult.found) { + console.log(` ✅ Found important element after ${waitResult.duration_ms}ms`); + console.log(` Importance: ${waitResult.element?.importance}`); + console.log(` Role: ${waitResult.element?.role}`); + } else { + console.log(' ❌ Not found'); + } + console.log(); + + // Example 5: Wait and then click + console.log('5. Wait for element, then click it'); + waitResult = await waitFor(browser, 'role=link', 5000); + if (waitResult.found && waitResult.element) { + console.log(' ✅ Found element, clicking...'); + const clickResult = await click(browser, waitResult.element.id); + console.log(` Click result: success=${clickResult.success}, outcome=${clickResult.outcome}`); + if (clickResult.url_changed) { + console.log(` ✅ Navigation occurred: ${browser.getPage().url()}`); + } + } else { + console.log(' ❌ Element not found, cannot click'); + } + console.log(); + + // Example 6: Using local extension (fast polling) + console.log('6. Using local extension with auto-optimized interval'); + console.log(' When useApi=false, interval auto-adjusts to 250ms (fast)'); + waitResult = await waitFor(browser, 'role=link', 5000, undefined, false); + if (waitResult.found) { + console.log(` ✅ Found after ${waitResult.duration_ms}ms`); + console.log(' (Used local extension, polled every 250ms)'); + } + console.log(); + + // Example 7: Using remote API (slower polling) + console.log('7. Using remote API with auto-optimized interval'); + console.log(' When useApi=true, interval auto-adjusts to 1500ms (network-friendly)'); + if (apiKey) { + waitResult = await waitFor(browser, 'role=link', 5000, undefined, true); + if (waitResult.found) { + console.log(` ✅ Found after ${waitResult.duration_ms}ms`); + console.log(' (Used remote API, polled every 1500ms)'); + } + } else { + console.log(' ⚠️ Skipped (no API key set)'); + } + console.log(); + + // Example 8: Custom interval override + console.log('8. Custom interval override (manual control)'); + console.log(' You can still specify custom interval if needed'); + waitResult = await waitFor(browser, 'role=link', 5000, 500, false); + if (waitResult.found) { + console.log(` ✅ Found after ${waitResult.duration_ms}ms`); + console.log(' (Custom interval: 500ms)'); + } + console.log(); + + // Example 9: Wait for visible element (not occluded) + console.log('9. Waiting for visible element (not occluded)'); + waitResult = await waitFor(browser, 'role=link visible=true', 5000); + if (waitResult.found) { + console.log(` ✅ Found visible element after ${waitResult.duration_ms}ms`); + console.log(` Is occluded: ${waitResult.element?.is_occluded}`); + console.log(` In viewport: ${waitResult.element?.in_viewport}`); + } + console.log(); + + console.log('✅ Semantic waitFor demo complete!'); + console.log('\nNote: waitFor uses the semantic query DSL to find elements.'); + console.log('This is more robust than CSS selectors because it understands'); + console.log('the semantic meaning of elements (role, text, clickability, etc.).'); + } catch (e: any) { + console.error(`❌ Error: ${e.message}`); + console.error(e.stack); + } finally { + await browser.close(); + } +} + +main().catch(console.error); + diff --git a/src/wait.ts b/src/wait.ts index 3235be9e..0d17afc0 100644 --- a/src/wait.ts +++ b/src/wait.ts @@ -7,17 +7,44 @@ import { WaitResult, Element, QuerySelector } from './types'; import { snapshot } from './snapshot'; import { find } from './query'; +/** + * Wait for element matching selector to appear + * + * @param browser - SentienceBrowser instance + * @param selector - String DSL or dict query + * @param timeout - Maximum time to wait (milliseconds). Default: 10000ms (10 seconds) + * @param interval - Polling interval (milliseconds). If undefined, auto-detects: + * - 250ms for local extension (useApi=false, fast) + * - 1500ms for remote API (useApi=true or default, network latency) + * @param useApi - Force use of server-side API if true, local extension if false. + * If undefined, uses API if apiKey is set, otherwise uses local extension. + * @returns WaitResult + */ export async function waitFor( browser: SentienceBrowser, selector: QuerySelector, timeout: number = 10000, - interval: number = 250 + interval?: number, + useApi?: boolean ): Promise { + // Auto-detect optimal interval based on API usage + if (interval === undefined) { + // Determine if using API + const willUseApi = useApi !== undefined + ? useApi + : (browser.getApiKey() !== undefined); + if (willUseApi) { + interval = 1500; // Longer interval for API calls (network latency) + } else { + interval = 250; // Shorter interval for local extension (fast) + } + } + const startTime = Date.now(); while (Date.now() - startTime < timeout) { - // Take snapshot - const snap = await snapshot(browser); + // Take snapshot (may be local extension or remote API) + const snap = await snapshot(browser, { use_api: useApi }); // Try to find element const element = find(snap, selector); From a3895dc4a547b80713d662b293865fe486ca044b Mon Sep 17 00:00:00 2001 From: rcholic Date: Mon, 22 Dec 2025 13:17:40 -0800 Subject: [PATCH 3/3] updated readme --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 667aaca7..9934c4e3 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ const firstRow = query(snap, 'bbox.y<600'); ### Actions - Interact with Elements - **`click(browser, elementId)`** - Click element by ID +- **`clickRect(browser, rect)`** - Click at center of rectangle (coordinate-based) - **`typeText(browser, elementId, text)`** - Type into input fields - **`press(browser, key)`** - Press keyboard keys (Enter, Escape, Tab, etc.) @@ -209,18 +210,55 @@ console.log(`Duration: ${result.duration_ms}ms`); console.log(`URL changed: ${result.url_changed}`); ``` +**Coordinate-based clicking:** +```typescript +import { clickRect } from './src'; + +// Click at center of rectangle (x, y, width, height) +await clickRect(browser, { x: 100, y: 200, w: 50, h: 30 }); + +// With visual highlight (default: red border for 2 seconds) +await clickRect(browser, { x: 100, y: 200, w: 50, h: 30 }, true, 2.0); + +// Using element's bounding box +const snap = await snapshot(browser); +const element = find(snap, 'role=button'); +if (element) { + await clickRect(browser, { + x: element.bbox.x, + y: element.bbox.y, + w: element.bbox.width, + h: element.bbox.height + }); +} +``` + ### Wait & Assertions -- **`waitFor(browser, selector, timeout?)`** - Wait for element to appear +- **`waitFor(browser, selector, timeout?, interval?, useApi?)`** - Wait for element to appear - **`expect(browser, selector)`** - Assertion helper with fluent API **Examples:** ```typescript -// Wait for element +// Wait for element (auto-detects optimal interval based on API usage) const result = await waitFor(browser, 'role=button text="Submit"', 10000); if (result.found) { console.log(`Found after ${result.duration_ms}ms`); } +// Use local extension with fast polling (250ms interval) +const result = await waitFor(browser, 'role=button', 5000, undefined, false); + +// Use remote API with network-friendly polling (1500ms interval) +const result = await waitFor(browser, 'role=button', 5000, undefined, true); + +// Custom interval override +const result = await waitFor(browser, 'role=button', 5000, 500, false); + +// Semantic wait conditions +await waitFor(browser, 'clickable=true', 5000); // Wait for clickable element +await waitFor(browser, 'importance>100', 5000); // Wait for important element +await waitFor(browser, 'role=link visible=true', 5000); // Wait for visible link + // Assertions await expect(browser, 'role=button text="Submit"').toExist(5000); await expect(browser, 'role=heading').toBeVisible();