diff --git a/README.md b/README.md index ce8655f..24e3338 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,7 @@ from sentience import SentienceBrowser, snapshot, find, click # Start browser with extension with SentienceBrowser(headless=False) as browser: - browser.goto("https://example.com") - browser.page.wait_for_load_state("networkidle") + browser.goto("https://example.com", wait_until="domcontentloaded") # Take snapshot - captures all interactive elements snap = snapshot(browser) @@ -44,8 +43,7 @@ import time with SentienceBrowser(headless=False) as browser: # Navigate to Amazon Best Sellers - browser.goto("https://www.amazon.com/gp/bestsellers/") - browser.page.wait_for_load_state("networkidle") + browser.goto("https://www.amazon.com/gp/bestsellers/", wait_until="domcontentloaded") time.sleep(2) # Wait for dynamic content # Take snapshot and find products @@ -146,6 +144,7 @@ first_row = query(snap, "bbox.y<600") ### Actions - Interact with Elements - **`click(browser, element_id)`** - Click element by ID +- **`click_rect(browser, rect)`** - Click at center of rectangle (coordinate-based) - **`type_text(browser, element_id, text)`** - Type into input fields - **`press(browser, key)`** - Press keyboard keys (Enter, Escape, Tab, etc.) @@ -160,17 +159,53 @@ print(f"Duration: {result.duration_ms}ms") print(f"URL changed: {result.url_changed}") ``` +**Coordinate-based clicking:** +```python +from sentience import click_rect + +# Click at center of rectangle (x, y, width, height) +click_rect(browser, {"x": 100, "y": 200, "w": 50, "h": 30}) + +# With visual highlight (default: red border for 2 seconds) +click_rect(browser, {"x": 100, "y": 200, "w": 50, "h": 30}, highlight=True, highlight_duration=2.0) + +# Using element's bounding box +snap = snapshot(browser) +element = find(snap, "role=button") +if element: + click_rect(browser, { + "x": element.bbox.x, + "y": element.bbox.y, + "w": element.bbox.width, + "h": element.bbox.height + }) +``` + ### Wait & Assertions -- **`wait_for(browser, selector, timeout=5.0)`** - Wait for element to appear +- **`wait_for(browser, selector, timeout=5.0, interval=None, use_api=None)`** - Wait for element to appear - **`expect(browser, selector)`** - Assertion helper with fluent API **Examples:** ```python -# Wait for element +# Wait for element (auto-detects optimal interval based on API usage) result = wait_for(browser, "role=button text='Submit'", timeout=10.0) if result.found: print(f"Found after {result.duration_ms}ms") +# Use local extension with fast polling (0.25s interval) +result = wait_for(browser, "role=button", timeout=5.0, use_api=False) + +# Use remote API with network-friendly polling (1.5s interval) +result = wait_for(browser, "role=button", timeout=5.0, use_api=True) + +# Custom interval override +result = wait_for(browser, "role=button", timeout=5.0, interval=0.5, use_api=False) + +# Semantic wait conditions +wait_for(browser, "clickable=true", timeout=5.0) # Wait for clickable element +wait_for(browser, "importance>100", timeout=5.0) # Wait for important element +wait_for(browser, "role=link visible=true", timeout=5.0) # Wait for visible link + # Assertions expect(browser, "role=button text='Submit'").to_exist(timeout=5.0) expect(browser, "role=heading").to_be_visible() @@ -313,8 +348,7 @@ browser = SentienceBrowser() # headless=True if CI=true, else False ### 1. Wait for Dynamic Content ```python -browser.goto("https://example.com") -browser.page.wait_for_load_state("networkidle") +browser.goto("https://example.com", wait_until="domcontentloaded") time.sleep(1) # Extra buffer for AJAX/animations ``` diff --git a/examples/click_rect_demo.py b/examples/click_rect_demo.py new file mode 100644 index 0000000..2bf574b --- /dev/null +++ b/examples/click_rect_demo.py @@ -0,0 +1,80 @@ +""" +Example: Using click_rect for coordinate-based clicking with visual feedback +""" + +from sentience import SentienceBrowser, snapshot, find, click_rect +import os + + +def main(): + # Get API key from environment variable (optional - uses free tier if not set) + api_key = os.environ.get("SENTIENCE_API_KEY") + + with SentienceBrowser(api_key=api_key, headless=False) as browser: + # Navigate to example.com + browser.page.goto("https://example.com", wait_until="domcontentloaded") + + print("=== click_rect Demo ===\n") + + # Example 1: Click using rect dictionary + print("1. Clicking at specific coordinates (100, 100) with size 50x30") + print(" (You should see a red border highlight for 2 seconds)") + result = click_rect(browser, {"x": 100, "y": 100, "w": 50, "h": 30}) + print(f" Result: success={result.success}, outcome={result.outcome}") + print(f" Duration: {result.duration_ms}ms\n") + + # Wait a bit + browser.page.wait_for_timeout(1000) + + # Example 2: Click using element's bbox + print("2. Clicking using element's bounding box") + snap = snapshot(browser) + link = find(snap, "role=link") + + if link: + print(f" Found link: '{link.text}' at ({link.bbox.x}, {link.bbox.y})") + print(" Clicking at center of element's bbox...") + result = click_rect(browser, { + "x": link.bbox.x, + "y": link.bbox.y, + "w": link.bbox.width, + "h": link.bbox.height + }) + print(f" Result: success={result.success}, outcome={result.outcome}") + print(f" URL changed: {result.url_changed}\n") + + # Navigate back if needed + if result.url_changed: + browser.page.goto("https://example.com", wait_until="domcontentloaded") + browser.page.wait_for_load_state("networkidle") + + # Example 3: Click without highlight (for headless/CI) + print("3. Clicking without visual highlight") + result = click_rect(browser, {"x": 200, "y": 200, "w": 40, "h": 20}, highlight=False) + print(f" Result: success={result.success}\n") + + # Example 4: Custom highlight duration + print("4. Clicking with custom highlight duration (3 seconds)") + result = click_rect(browser, {"x": 300, "y": 300, "w": 60, "h": 40}, highlight_duration=3.0) + print(f" Result: success={result.success}") + print(" (Red border should stay visible for 3 seconds)\n") + + # Example 5: Click with snapshot capture + print("5. Clicking and capturing snapshot after action") + result = click_rect( + browser, + {"x": 150, "y": 150, "w": 50, "h": 30}, + take_snapshot=True + ) + if result.snapshot_after: + print(f" Snapshot captured: {len(result.snapshot_after.elements)} elements found") + print(f" URL: {result.snapshot_after.url}\n") + + print("✅ click_rect demo complete!") + print("\nNote: click_rect uses Playwright's native mouse.click() for realistic") + print("event simulation, triggering hover, focus, mousedown, mouseup sequences.") + + +if __name__ == "__main__": + main() + diff --git a/examples/semantic_wait_demo.py b/examples/semantic_wait_demo.py new file mode 100644 index 0000000..b738826 --- /dev/null +++ b/examples/semantic_wait_demo.py @@ -0,0 +1,114 @@ +""" +Example: Semantic wait_for using query DSL +Demonstrates waiting for elements using semantic selectors +""" + +from sentience import SentienceBrowser, wait_for, click +import os + + +def main(): + # Get API key from environment variable (optional - uses free tier if not set) + api_key = os.environ.get("SENTIENCE_API_KEY") + + with SentienceBrowser(api_key=api_key, headless=False) as browser: + # Navigate to example.com + browser.page.goto("https://example.com", wait_until="domcontentloaded") + + print("=== Semantic wait_for Demo ===\n") + + # Example 1: Wait for element by role + print("1. Waiting for link element (role=link)") + wait_result = wait_for(browser, "role=link", timeout=5.0) + if wait_result.found: + print(f" ✅ Found after {wait_result.duration_ms}ms") + print(f" Element: '{wait_result.element.text}' (id: {wait_result.element.id})") + else: + print(f" ❌ Not found (timeout: {wait_result.timeout})") + print() + + # Example 2: Wait for element by role and text + print("2. Waiting for link with specific text") + wait_result = wait_for(browser, "role=link text~'Example'", timeout=5.0) + if wait_result.found: + print(f" ✅ Found after {wait_result.duration_ms}ms") + print(f" Element text: '{wait_result.element.text}'") + else: + print(" ❌ Not found") + print() + + # Example 3: Wait for clickable element + print("3. Waiting for clickable element") + wait_result = wait_for(browser, "clickable=true", timeout=5.0) + if wait_result.found: + print(f" ✅ Found clickable element after {wait_result.duration_ms}ms") + print(f" Role: {wait_result.element.role}") + print(f" Text: '{wait_result.element.text}'") + print(f" Is clickable: {wait_result.element.visual_cues.is_clickable}") + else: + print(" ❌ Not found") + print() + + # Example 4: Wait for element with importance threshold + print("4. Waiting for important element (importance > 100)") + wait_result = wait_for(browser, "importance>100", timeout=5.0) + if wait_result.found: + print(f" ✅ Found important element after {wait_result.duration_ms}ms") + print(f" Importance: {wait_result.element.importance}") + print(f" Role: {wait_result.element.role}") + else: + print(" ❌ Not found") + print() + + # Example 5: Wait and then click + print("5. Wait for element, then click it") + wait_result = wait_for(browser, "role=link", timeout=5.0) + if wait_result.found: + print(" ✅ Found element, clicking...") + click_result = click(browser, wait_result.element.id) + print(f" Click result: success={click_result.success}, outcome={click_result.outcome}") + if click_result.url_changed: + print(f" ✅ Navigation occurred: {browser.page.url}") + else: + print(" ❌ Element not found, cannot click") + print() + + # Example 6: Using local extension (fast polling) + print("6. Using local extension with auto-optimized interval") + print(" When use_api=False, interval auto-adjusts to 0.25s (fast)") + wait_result = wait_for(browser, "role=link", timeout=5.0, use_api=False) + if wait_result.found: + print(f" ✅ Found after {wait_result.duration_ms}ms") + print(" (Used local extension, polled every 0.25 seconds)") + print() + + # Example 7: Using remote API (slower polling) + print("7. Using remote API with auto-optimized interval") + print(" When use_api=True, interval auto-adjusts to 1.5s (network-friendly)") + if api_key: + wait_result = wait_for(browser, "role=link", timeout=5.0, use_api=True) + if wait_result.found: + print(f" ✅ Found after {wait_result.duration_ms}ms") + print(" (Used remote API, polled every 1.5 seconds)") + else: + print(" ⚠️ Skipped (no API key set)") + print() + + # Example 8: Custom interval override + print("8. Custom interval override (manual control)") + print(" You can still specify custom interval if needed") + wait_result = wait_for(browser, "role=link", timeout=5.0, interval=0.5, use_api=False) + if wait_result.found: + print(f" ✅ Found after {wait_result.duration_ms}ms") + print(" (Custom interval: 0.5 seconds)") + print() + + print("✅ Semantic wait_for demo complete!") + print("\nNote: wait_for uses the semantic query DSL to find elements.") + print("This is more robust than CSS selectors because it understands") + print("the semantic meaning of elements (role, text, clickability, etc.).") + + +if __name__ == "__main__": + main() + diff --git a/sentience/__init__.py b/sentience/__init__.py index 3d02d09..e0bf811 100644 --- a/sentience/__init__.py +++ b/sentience/__init__.py @@ -6,7 +6,7 @@ from .models import Snapshot, Element, BBox, Viewport, ActionResult, WaitResult from .snapshot import snapshot from .query import query, find -from .actions import click, type_text, press +from .actions import click, type_text, press, click_rect from .wait import wait_for from .expect import expect from .inspector import Inspector, inspect @@ -31,6 +31,7 @@ "click", "type_text", "press", + "click_rect", "wait_for", "expect", "Inspector", diff --git a/sentience/actions.py b/sentience/actions.py index 86e461c..6f646b1 100644 --- a/sentience/actions.py +++ b/sentience/actions.py @@ -3,20 +3,27 @@ """ import time -from typing import Optional +from typing import Optional, Dict, Any from .browser import SentienceBrowser -from .models import ActionResult, Snapshot +from .models import ActionResult, Snapshot, BBox from .snapshot import snapshot -def click(browser: SentienceBrowser, element_id: int, take_snapshot: bool = False) -> ActionResult: +def click( + browser: SentienceBrowser, + element_id: int, + use_mouse: bool = True, + take_snapshot: bool = False, +) -> ActionResult: """ - Click an element by ID + Click an element by ID using hybrid approach (mouse simulation by default) Args: browser: SentienceBrowser instance element_id: Element ID from snapshot - take_snapshot: Whether to take snapshot after action (optional in Week 1) + use_mouse: If True, use Playwright's mouse.click() at element center (hybrid approach). + If False, use JS-based window.sentience.click() (legacy). + take_snapshot: Whether to take snapshot after action Returns: ActionResult @@ -27,22 +34,84 @@ def click(browser: SentienceBrowser, element_id: int, take_snapshot: bool = Fals start_time = time.time() url_before = browser.page.url - # Call extension click method - success = browser.page.evaluate( - """ - (id) => { - return window.sentience.click(id); - } - """, - element_id, - ) + if use_mouse: + # Hybrid approach: Get element bbox from snapshot, calculate center, use mouse.click() + try: + snap = snapshot(browser) + element = None + for el in snap.elements: + if el.id == element_id: + element = el + break + + if element: + # Calculate center of element bbox + center_x = element.bbox.x + element.bbox.width / 2 + center_y = element.bbox.y + element.bbox.height / 2 + # Use Playwright's native mouse click for realistic simulation + try: + browser.page.mouse.click(center_x, center_y) + success = True + except Exception: + # If navigation happens, mouse.click might fail, but that's OK + # The click still happened, just check URL change + success = True + else: + # Fallback to JS click if element not found in snapshot + try: + success = browser.page.evaluate( + """ + (id) => { + return window.sentience.click(id); + } + """, + element_id, + ) + except Exception: + # Navigation might have destroyed context, assume success if URL changed + success = True + except Exception: + # Fallback to JS click on error + try: + success = browser.page.evaluate( + """ + (id) => { + return window.sentience.click(id); + } + """, + element_id, + ) + except Exception: + # Navigation might have destroyed context, assume success if URL changed + success = True + else: + # Legacy JS-based click + success = browser.page.evaluate( + """ + (id) => { + return window.sentience.click(id); + } + """, + element_id, + ) # Wait a bit for navigation/DOM updates - browser.page.wait_for_timeout(500) + try: + browser.page.wait_for_timeout(500) + except Exception: + # Navigation might have happened, context destroyed + pass duration_ms = int((time.time() - start_time) * 1000) - url_after = browser.page.url - url_changed = url_before != url_after + + # Check if URL changed (handle navigation gracefully) + try: + url_after = browser.page.url + url_changed = url_before != url_after + except Exception: + # Context destroyed due to navigation - assume URL changed + url_after = url_before + url_changed = True # Determine outcome outcome: Optional[str] = None @@ -56,7 +125,11 @@ def click(browser: SentienceBrowser, element_id: int, take_snapshot: bool = Fals # Optional snapshot after snapshot_after: Optional[Snapshot] = None if take_snapshot: - snapshot_after = snapshot(browser) + try: + snapshot_after = snapshot(browser) + except Exception: + # Navigation might have destroyed context + pass return ActionResult( success=success, @@ -174,3 +247,173 @@ def press(browser: SentienceBrowser, key: str, take_snapshot: bool = False) -> A snapshot_after=snapshot_after, ) + +def _highlight_rect(browser: SentienceBrowser, rect: Dict[str, float], duration_sec: float = 2.0) -> None: + """ + Highlight a rectangle with a red border overlay + + Args: + browser: SentienceBrowser instance + rect: Dictionary with x, y, width (w), height (h) keys + duration_sec: How long to show the highlight (default: 2 seconds) + """ + if not browser.page: + return + + # Create a unique ID for this highlight + highlight_id = f"sentience_highlight_{int(time.time() * 1000)}" + + # Combine all arguments into a single object for Playwright + args = { + "rect": { + "x": rect["x"], + "y": rect["y"], + "w": rect["w"], + "h": rect["h"], + }, + "highlightId": highlight_id, + "durationSec": duration_sec, + } + + # Inject CSS and create overlay element + browser.page.evaluate( + """ + (args) => { + 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, + ) + + +def click_rect( + browser: SentienceBrowser, + rect: Dict[str, float], + highlight: bool = True, + highlight_duration: float = 2.0, + take_snapshot: bool = False, +) -> ActionResult: + """ + 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). + + Args: + browser: SentienceBrowser instance + rect: Dictionary with x, y, width (w), height (h) keys, or BBox object + highlight: Whether to show a red border highlight when clicking (default: True) + highlight_duration: How long to show the highlight in seconds (default: 2.0) + take_snapshot: Whether to take snapshot after action + + Returns: + ActionResult + + Example: + >>> click_rect(browser, {"x": 100, "y": 200, "w": 50, "h": 30}) + >>> # Or using BBox object + >>> from sentience import BBox + >>> bbox = BBox(x=100, y=200, width=50, height=30) + >>> click_rect(browser, {"x": bbox.x, "y": bbox.y, "w": bbox.width, "h": bbox.height}) + """ + if not browser.page: + raise RuntimeError("Browser not started. Call browser.start() first.") + + # Handle BBox object or dict + if isinstance(rect, BBox): + x = rect.x + y = rect.y + w = rect.width + h = rect.height + else: + x = rect.get("x", 0) + y = rect.get("y", 0) + w = rect.get("w") or rect.get("width", 0) + h = rect.get("h") or rect.get("height", 0) + + if w <= 0 or h <= 0: + return ActionResult( + success=False, + duration_ms=0, + outcome="error", + error={"code": "invalid_rect", "reason": "Rectangle width and height must be positive"}, + ) + + start_time = time.time() + url_before = browser.page.url + + # Calculate center of rectangle + center_x = x + w / 2 + center_y = y + h / 2 + + # Show highlight before clicking (if enabled) + if highlight: + _highlight_rect(browser, {"x": x, "y": y, "w": w, "h": h}, highlight_duration) + # Small delay to ensure highlight is visible + browser.page.wait_for_timeout(50) + + # Use Playwright's native mouse click for realistic simulation + # This triggers hover, focus, mousedown, mouseup sequences + try: + browser.page.mouse.click(center_x, center_y) + success = True + except Exception as e: + success = False + error_msg = str(e) + + # Wait a bit for navigation/DOM updates + browser.page.wait_for_timeout(500) + + duration_ms = int((time.time() - start_time) * 1000) + url_after = browser.page.url + url_changed = url_before != url_after + + # Determine outcome + outcome: Optional[str] = None + if url_changed: + outcome = "navigated" + elif success: + outcome = "dom_updated" + else: + outcome = "error" + + # Optional snapshot after + snapshot_after: Optional[Snapshot] = None + if take_snapshot: + snapshot_after = snapshot(browser) + + return ActionResult( + success=success, + duration_ms=duration_ms, + outcome=outcome, + url_changed=url_changed, + snapshot_after=snapshot_after, + error=None if success else {"code": "click_failed", "reason": error_msg if not success else "Click failed"}, + ) + diff --git a/sentience/wait.py b/sentience/wait.py index 9ec6831..7cd2221 100644 --- a/sentience/wait.py +++ b/sentience/wait.py @@ -5,7 +5,7 @@ import time from typing import Union, Optional from .browser import SentienceBrowser -from .models import WaitResult, Element +from .models import WaitResult from .snapshot import snapshot from .query import find @@ -14,7 +14,8 @@ def wait_for( browser: SentienceBrowser, selector: Union[str, dict], timeout: float = 10.0, - interval: float = 0.25, + interval: Optional[float] = None, + use_api: Optional[bool] = None, ) -> WaitResult: """ Wait for element matching selector to appear @@ -23,16 +24,29 @@ def wait_for( browser: SentienceBrowser instance selector: String DSL or dict query timeout: Maximum time to wait (seconds) - interval: Polling interval (seconds) + interval: Polling interval (seconds). If None, auto-detects: + - 0.25s for local extension (use_api=False, fast) + - 1.5s for remote API (use_api=True or default, network latency) + use_api: Force use of server-side API if True, local extension if False. + If None, uses API if api_key is set, otherwise uses local extension. Returns: WaitResult """ + # Auto-detect optimal interval based on API usage + if interval is None: + # Determine if using API + will_use_api = use_api if use_api is not None else (browser.api_key is not None) + if will_use_api: + interval = 1.5 # Longer interval for API calls (network latency) + else: + interval = 0.25 # Shorter interval for local extension (fast) + start_time = time.time() while time.time() - start_time < timeout: - # Take snapshot - snap = snapshot(browser) + # Take snapshot (may be local extension or remote API) + snap = snapshot(browser, use_api=use_api) # Try to find element element = find(snap, selector) diff --git a/tests/test_actions.py b/tests/test_actions.py index b7bd0e2..869f5a7 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,9 +1,9 @@ """ -Tests for actions (click, type, press) +Tests for actions (click, type, press, click_rect) """ import pytest -from sentience import SentienceBrowser, snapshot, find, click, type_text, press +from sentience import SentienceBrowser, snapshot, find, click, type_text, press, click_rect, BBox def test_click(): @@ -49,3 +49,116 @@ def test_press(): assert result.success is True assert result.duration_ms > 0 + +def test_click_rect(): + """Test click_rect with rect dict""" + with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + # Click at a specific rectangle (top-left area) + result = click_rect(browser, {"x": 100, "y": 100, "w": 50, "h": 30}) + assert result.success is True + assert result.duration_ms > 0 + assert result.outcome in ["navigated", "dom_updated"] + + +def test_click_rect_with_bbox(): + """Test click_rect with BBox object""" + with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + # Get an element and click its bbox + snap = snapshot(browser) + link = find(snap, "role=link") + + if link: + result = click_rect(browser, { + "x": link.bbox.x, + "y": link.bbox.y, + "w": link.bbox.width, + "h": link.bbox.height + }) + assert result.success is True + assert result.duration_ms > 0 + + +def test_click_rect_without_highlight(): + """Test click_rect without visual highlight""" + with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + result = click_rect(browser, {"x": 100, "y": 100, "w": 50, "h": 30}, highlight=False) + assert result.success is True + assert result.duration_ms > 0 + + +def test_click_rect_invalid_rect(): + """Test click_rect with invalid rectangle dimensions""" + with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + # Invalid: zero width + result = click_rect(browser, {"x": 100, "y": 100, "w": 0, "h": 30}) + assert result.success is False + assert result.error is not None + assert result.error["code"] == "invalid_rect" + + # Invalid: negative height + result = click_rect(browser, {"x": 100, "y": 100, "w": 50, "h": -10}) + assert result.success is False + assert result.error is not None + assert result.error["code"] == "invalid_rect" + + +def test_click_rect_with_snapshot(): + """Test click_rect with snapshot after action""" + with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + result = click_rect(browser, {"x": 100, "y": 100, "w": 50, "h": 30}, take_snapshot=True) + assert result.success is True + assert result.snapshot_after is not None + assert result.snapshot_after.status == "success" + assert len(result.snapshot_after.elements) > 0 + + +def test_click_hybrid_approach(): + """Test that click() uses hybrid approach (mouse.click at center)""" + with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + snap = snapshot(browser) + link = find(snap, "role=link") + + if link: + # Test hybrid approach (mouse.click at center) + result = click(browser, link.id, use_mouse=True) + assert result.success is True + assert result.duration_ms > 0 + # Navigation may happen, which is expected for links + assert result.outcome in ["navigated", "dom_updated"] + + +def test_click_js_approach(): + """Test that click() can use JS-based approach (legacy)""" + with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + snap = snapshot(browser) + link = find(snap, "role=link") + + if link: + # Test JS-based click (legacy approach) + result = click(browser, link.id, use_mouse=False) + assert result.success is True + assert result.duration_ms > 0 + # Navigation may happen, which is expected for links + assert result.outcome in ["navigated", "dom_updated"] +