diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 311a7d6..9277f45 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -299,17 +299,17 @@ jobs: sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') print("=== Final Pre-Test Verification ===") - + # First, verify the source file directly source_file = 'sentience/agent_runtime.py' print(f"=== Checking source file: {source_file} ===") if not os.path.exists(source_file): print(f"ERROR: Source file {source_file} not found!") sys.exit(1) - + with open(source_file, 'r', encoding='utf-8') as f: source_content = f.read() - + # Check if the bug exists and try to fix it one more time (in case auto-fix didn't run) if 'self.assertTrue(' in source_content: print('WARNING: Found self.assertTrue( in source file. Attempting to fix...') @@ -332,11 +332,11 @@ jobs: print('OK: Source file uses self.assert_( correctly') else: print('WARNING: Could not find assert_ method in source file') - + # Now check the installed package print("\n=== Checking installed package ===") import sentience.agent_runtime - + # Verify it's using local source (editable install) import sentience pkg_path = os.path.abspath(sentience.__file__) @@ -348,7 +348,7 @@ jobs: print(f' This might be using PyPI package instead of local source!') else: print(f'OK: Package is from local source: {pkg_path}') - + src = inspect.getsource(sentience.agent_runtime.AgentRuntime.assert_done) print("assert_done method source:") diff --git a/README.md b/README.md index cc24c41..4a38d47 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,20 @@ Use `AgentRuntime` to add Jest-style assertions to your agent loops. Verify brow ```python import asyncio from sentience import AsyncSentienceBrowser, AgentRuntime -from sentience.verification import url_contains, exists, all_of +from sentience.verification import ( + url_contains, + exists, + all_of, + is_enabled, + is_checked, + value_equals, +) from sentience.tracing import Tracer, JsonlTraceSink async def main(): # Create tracer tracer = Tracer(run_id="my-run", sink=JsonlTraceSink("trace.jsonl")) - + # Create browser and runtime async with AsyncSentienceBrowser() as browser: page = await browser.new_page() @@ -46,30 +53,43 @@ async def main(): page=page, tracer=tracer ) - + # Navigate and take snapshot await page.goto("https://example.com") runtime.begin_step("Verify page loaded") await runtime.snapshot() - - # Run assertions (Jest-style) + + # v1: deterministic assertions (Jest-style) runtime.assert_(url_contains("example.com"), label="on_correct_domain") runtime.assert_(exists("role=heading"), label="has_heading") runtime.assert_(all_of([ exists("role=button"), exists("role=link") ]), label="has_interactive_elements") - + + # v1: state-aware assertions (when Gateway refinement is enabled) + runtime.assert_(is_enabled("role=button"), label="button_enabled") + runtime.assert_(is_checked("role=checkbox name~'subscribe'"), label="subscribe_checked_if_present") + runtime.assert_(value_equals("role=textbox name~'email'", "user@example.com"), label="email_value_if_present") + + # v2: retry loop with snapshot confidence gating + exhaustion + ok = await runtime.check( + exists("role=heading"), + label="heading_eventually_visible", + required=True, + ).eventually(timeout_s=10.0, poll_s=0.25, min_confidence=0.7, max_snapshot_attempts=3) + print("eventually() result:", ok) + # Check task completion if runtime.assert_done(exists("text~'Example'"), label="task_complete"): print("✅ Task completed!") - + print(f"Task done: {runtime.is_task_done}") asyncio.run(main()) ``` -**See example:** [`examples/agent_runtime_verification.py`](examples/agent_runtime_verification.py) +**See examples:** [`examples/asserts/`](examples/asserts/) ## 🚀 Quick Start: Choose Your Abstraction Level @@ -183,56 +203,35 @@ scroll_to(browser, button.id, behavior='instant', block='start') ---
-

💼 Real-World Example: Amazon Shopping Bot

+

💼 Real-World Example: Assertion-driven navigation

-This example demonstrates navigating Amazon, finding products, and adding items to cart: +This example shows how to use **assertions + `.eventually()`** to make an agent loop resilient: ```python -from sentience import SentienceBrowser, snapshot, find, click -import time - -with SentienceBrowser(headless=False) as browser: - # Navigate to Amazon Best Sellers - browser.goto("https://www.amazon.com/gp/bestsellers/", wait_until="domcontentloaded") - time.sleep(2) # Wait for dynamic content - - # Take snapshot and find products - snap = snapshot(browser) - print(f"Found {len(snap.elements)} elements") - - # Find first product in viewport using spatial filtering - products = [ - el for el in snap.elements - if el.role == "link" - and el.visual_cues.is_clickable - and el.in_viewport - and not el.is_occluded - and el.bbox.y < 600 # First row - ] - - if products: - # Sort by position (left to right, top to bottom) - products.sort(key=lambda e: (e.bbox.y, e.bbox.x)) - first_product = products[0] +import asyncio +import os +from sentience import AsyncSentienceBrowser, AgentRuntime +from sentience.tracing import Tracer, JsonlTraceSink +from sentience.verification import url_contains, exists - print(f"Clicking: {first_product.text}") - result = click(browser, first_product.id) +async def main(): + tracer = Tracer(run_id="verified-run", sink=JsonlTraceSink("trace_verified.jsonl")) + async with AsyncSentienceBrowser(headless=True) as browser: + page = await browser.new_page() + runtime = await AgentRuntime.from_sentience_browser(browser=browser, page=page, tracer=tracer) + runtime.sentience_api_key = os.getenv("SENTIENCE_API_KEY") # optional, enables Gateway diagnostics - # Wait for product page - browser.page.wait_for_load_state("networkidle") - time.sleep(2) + await page.goto("https://example.com") + runtime.begin_step("Verify we're on the right page") - # Find and click "Add to Cart" button - product_snap = snapshot(browser) - add_to_cart = find(product_snap, "role=button text~'add to cart'") + await runtime.check(url_contains("example.com"), label="on_domain", required=True).eventually( + timeout_s=10.0, poll_s=0.25, min_confidence=0.7, max_snapshot_attempts=3 + ) + runtime.assert_(exists("role=heading"), label="heading_present") - if add_to_cart: - cart_result = click(browser, add_to_cart.id) - print(f"Added to cart: {cart_result.success}") +asyncio.run(main()) ``` -**📖 See the complete tutorial:** [Amazon Shopping Guide](../docs/AMAZON_SHOPPING_GUIDE.md) -
--- diff --git a/examples/asserts/README.md b/examples/asserts/README.md new file mode 100644 index 0000000..39a4c75 --- /dev/null +++ b/examples/asserts/README.md @@ -0,0 +1,15 @@ +# Assertions examples (v1 + v2) + +These examples focus on **AgentRuntime assertions**: + +- **v1**: deterministic, state-aware assertions (enabled/checked/value/expanded) + failure intelligence +- **v2**: `.eventually()` retry loops with `min_confidence` gating + snapshot exhaustion, plus optional Python vision fallback + +Run examples: + +```bash +cd sdk-python +python examples/asserts/v1_state_assertions.py +python examples/asserts/v2_eventually_min_confidence.py +python examples/asserts/v2_vision_fallback.py +``` diff --git a/examples/asserts/eventually_min_confidence.py b/examples/asserts/eventually_min_confidence.py new file mode 100644 index 0000000..674c9af --- /dev/null +++ b/examples/asserts/eventually_min_confidence.py @@ -0,0 +1,50 @@ +""" +v2: `.check(...).eventually(...)` with snapshot confidence gating + exhaustion. + +This example shows: +- retry loop semantics +- `min_confidence` gating (snapshot_low_confidence -> snapshot_exhausted) +- structured assertion records in traces +""" + +import asyncio +import os + +from sentience import AgentRuntime, AsyncSentienceBrowser +from sentience.tracing import JsonlTraceSink, Tracer +from sentience.verification import exists + + +async def main() -> None: + tracer = Tracer(run_id="asserts-v2", sink=JsonlTraceSink("trace_asserts_v2.jsonl")) + sentience_api_key = os.getenv("SENTIENCE_API_KEY") + + async with AsyncSentienceBrowser(headless=True) as browser: + page = await browser.new_page() + runtime = await AgentRuntime.from_sentience_browser( + browser=browser, page=page, tracer=tracer + ) + if sentience_api_key: + runtime.sentience_api_key = sentience_api_key + + await page.goto("https://example.com") + runtime.begin_step("Assert v2 eventually") + + ok = await runtime.check( + exists("role=heading"), + label="heading_eventually_visible", + required=True, + ).eventually( + timeout_s=10.0, + poll_s=0.25, + # If the Gateway reports snapshot.diagnostics.confidence, gate on it: + min_confidence=0.7, + max_snapshot_attempts=3, + ) + + print("eventually() result:", ok) + print("Final assertion:", runtime.get_assertions_for_step_end()["assertions"]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/asserts/state_assertions.py b/examples/asserts/state_assertions.py new file mode 100644 index 0000000..4ec5ecb --- /dev/null +++ b/examples/asserts/state_assertions.py @@ -0,0 +1,68 @@ +""" +v1: State-aware assertions with AgentRuntime. + +This example is meant to be run with a Pro/Enterprise API key so the Gateway +can refine raw elements into SmartElements with state fields (enabled/checked/value/etc). + +Env vars: + - SENTIENCE_API_KEY (optional but recommended for v1 state assertions) +""" + +import asyncio +import os + +from sentience import AgentRuntime, AsyncSentienceBrowser +from sentience.tracing import JsonlTraceSink, Tracer +from sentience.verification import ( + exists, + is_checked, + is_disabled, + is_enabled, + is_expanded, + value_contains, +) + + +async def main() -> None: + tracer = Tracer(run_id="asserts-v1", sink=JsonlTraceSink("trace_asserts_v1.jsonl")) + + sentience_api_key = os.getenv("SENTIENCE_API_KEY") + + async with AsyncSentienceBrowser(headless=True) as browser: + page = await browser.new_page() + runtime = await AgentRuntime.from_sentience_browser( + browser=browser, page=page, tracer=tracer + ) + + # If you have a Pro/Enterprise key, set it on the runtime so snapshots use the Gateway. + # (This improves selector quality and unlocks state-aware fields for assertions.) + if sentience_api_key: + runtime.sentience_api_key = sentience_api_key + + await page.goto("https://example.com") + runtime.begin_step("Assert v1 state") + await runtime.snapshot() + + # v1: state-aware assertions (examples) + runtime.assert_(exists("role=heading"), label="has_heading") + runtime.assert_(is_enabled("role=link"), label="some_link_enabled") + runtime.assert_( + is_disabled("role=button text~'continue'"), label="continue_disabled_if_present" + ) + runtime.assert_( + is_checked("role=checkbox name~'subscribe'"), label="subscribe_checked_if_present" + ) + runtime.assert_(is_expanded("role=button name~'more'"), label="more_is_expanded_if_present") + runtime.assert_( + value_contains("role=textbox name~'email'", "@"), label="email_has_at_if_present" + ) + + # Failure intelligence: if something fails you’ll see: + # - details.reason_code + # - details.nearest_matches (suggestions) + + print("Assertions recorded:", runtime.get_assertions_for_step_end()["assertions"]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/asserts/vision_fallback.py b/examples/asserts/vision_fallback.py new file mode 100644 index 0000000..9c74410 --- /dev/null +++ b/examples/asserts/vision_fallback.py @@ -0,0 +1,58 @@ +""" +v2 (Python-only): vision fallback after snapshot exhaustion. + +When `min_confidence` gating keeps failing (snapshot_exhausted), you can pass a +vision-capable LLMProvider to `eventually()` and ask it for a strict YES/NO +verification using a screenshot. + +Env vars: + - OPENAI_API_KEY (if using OpenAIProvider) + - SENTIENCE_API_KEY (optional, recommended so diagnostics/confidence is present) +""" + +import asyncio +import os + +from sentience import AgentRuntime, AsyncSentienceBrowser +from sentience.llm_provider import OpenAIProvider +from sentience.tracing import JsonlTraceSink, Tracer +from sentience.verification import exists + + +async def main() -> None: + tracer = Tracer( + run_id="asserts-v2-vision", sink=JsonlTraceSink("trace_asserts_v2_vision.jsonl") + ) + sentience_api_key = os.getenv("SENTIENCE_API_KEY") + + # Any provider implementing supports_vision() + generate_with_image() works. + vision = OpenAIProvider(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o") + + async with AsyncSentienceBrowser(headless=True) as browser: + page = await browser.new_page() + runtime = await AgentRuntime.from_sentience_browser( + browser=browser, page=page, tracer=tracer + ) + if sentience_api_key: + runtime.sentience_api_key = sentience_api_key + + await page.goto("https://example.com") + runtime.begin_step("Assert v2 vision fallback") + + ok = await runtime.check( + exists("text~'Example Domain'"), label="example_domain_text" + ).eventually( + timeout_s=10.0, + poll_s=0.25, + min_confidence=0.7, + max_snapshot_attempts=2, + vision_provider=vision, + vision_system_prompt="You are a strict visual verifier. Answer only YES or NO.", + vision_user_prompt="In the screenshot, is the phrase 'Example Domain' visible? Answer YES or NO.", + ) + + print("eventually() w/ vision result:", ok) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sentience/__init__.py b/sentience/__init__.py index 2cb1f1e..edb6bc6 100644 --- a/sentience/__init__.py +++ b/sentience/__init__.py @@ -14,7 +14,7 @@ from .actions import click, click_rect, press, scroll_to, type_text from .agent import SentienceAgent, SentienceAgentAsync from .agent_config import AgentConfig -from .agent_runtime import AgentRuntime +from .agent_runtime import AgentRuntime, AssertionHandle # Backend-agnostic actions (aliased to avoid conflict with existing actions) # Browser backends (for browser-use integration) @@ -114,9 +114,17 @@ custom, element_count, exists, + is_checked, + is_collapsed, + is_disabled, + is_enabled, + is_expanded, + is_unchecked, not_exists, url_contains, url_matches, + value_contains, + value_equals, ) from .visual_agent import SentienceVisualAgent, SentienceVisualAgentAsync from .wait import wait_for diff --git a/sentience/agent_runtime.py b/sentience/agent_runtime.py index a6364bb..ca5ab7c 100644 --- a/sentience/agent_runtime.py +++ b/sentience/agent_runtime.py @@ -63,11 +63,15 @@ from __future__ import annotations +import asyncio +import difflib +import time import uuid +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from .models import Snapshot, SnapshotOptions -from .verification import AssertContext, Predicate +from .verification import AssertContext, AssertOutcome, Predicate if TYPE_CHECKING: from playwright.async_api import Page @@ -298,29 +302,23 @@ def assert_( True if assertion passed, False otherwise """ outcome = predicate(self._ctx()) + self._record_outcome( + outcome=outcome, + label=label, + required=required, + kind="assert", + record_in_step=True, + ) + return outcome.passed - record = { - "label": label, - "passed": outcome.passed, - "required": required, - "reason": outcome.reason, - "details": outcome.details, - } - self._assertions_this_step.append(record) + def check(self, predicate: Predicate, label: str, required: bool = False) -> AssertionHandle: + """ + Create an AssertionHandle for fluent `.once()` / `.eventually()` usage. - # Emit dedicated verification event (Option B from design doc) - # This makes assertions visible in Studio timeline - self.tracer.emit( - "verification", - data={ - "kind": "assert", - "passed": outcome.passed, - **record, - }, - step_id=self.step_id, - ) + This does NOT evaluate the predicate immediately. + """ - return outcome.passed + return AssertionHandle(runtime=self, predicate=predicate, label=label, required=required) def assert_done( self, @@ -342,7 +340,8 @@ def assert_done( Returns: True if task is complete (assertion passed), False otherwise """ - ok = self.assert_(predicate, label=label, required=True) + # Convenience wrapper for assert_ with required=True + ok = self.assertTrue(predicate, label=label, required=True) if ok: self._task_done = True self._task_done_label = label @@ -360,13 +359,86 @@ def assert_done( return ok + def _record_outcome( + self, + *, + outcome: Any, + label: str, + required: bool, + kind: str, + record_in_step: bool, + extra: dict[str, Any] | None = None, + ) -> None: + """ + Internal helper: emit verification event and optionally accumulate for step_end. + """ + details = dict(outcome.details or {}) + + # Failure intelligence: nearest matches for selector-driven assertions + if not outcome.passed and self.last_snapshot is not None and "selector" in details: + selector = str(details.get("selector") or "") + details.setdefault("nearest_matches", self._nearest_matches(selector, limit=3)) + + record = { + "label": label, + "passed": bool(outcome.passed), + "required": required, + "reason": str(outcome.reason or ""), + "details": details, + } + if extra: + record.update(extra) + + if record_in_step: + self._assertions_this_step.append(record) + + self.tracer.emit( + "verification", + data={ + "kind": kind, + "passed": bool(outcome.passed), + **record, + }, + step_id=self.step_id, + ) + + def _nearest_matches(self, selector: str, *, limit: int = 3) -> list[dict[str, Any]]: + """ + Best-effort nearest match suggestions for debugging failed selector assertions. + """ + if self.last_snapshot is None: + return [] + + s = selector.lower().strip() + if not s: + return [] + + scored: list[tuple[float, Any]] = [] + for el in self.last_snapshot.elements: + hay = (getattr(el, "name", None) or getattr(el, "text", None) or "").strip() + if not hay: + continue + score = difflib.SequenceMatcher(None, s, hay.lower()).ratio() + scored.append((score, el)) + + scored.sort(key=lambda t: t[0], reverse=True) + out: list[dict[str, Any]] = [] + for score, el in scored[:limit]: + out.append( + { + "id": getattr(el, "id", None), + "role": getattr(el, "role", None), + "text": (getattr(el, "text", "") or "")[:80], + "name": (getattr(el, "name", "") or "")[:80], + "score": round(float(score), 4), + } + ) + return out + def get_assertions_for_step_end(self) -> dict[str, Any]: """ Get assertions data for inclusion in step_end.data.verify.signals. - This is called when building the step_end event to include - assertion results in the trace. - Returns: Dictionary with 'assertions', 'task_done', 'task_done_label' keys """ @@ -383,12 +455,6 @@ def get_assertions_for_step_end(self) -> dict[str, Any]: def flush_assertions(self) -> list[dict[str, Any]]: """ Get and clear assertions for current step. - - Call this at step end to get accumulated assertions - for the step_end event, then clear for next step. - - Returns: - List of assertion records from this step """ assertions = self._assertions_this_step.copy() self._assertions_this_step = [] @@ -405,20 +471,240 @@ def reset_task_done(self) -> None: self._task_done_label = None def all_assertions_passed(self) -> bool: - """ - Check if all assertions in current step passed. - - Returns: - True if all assertions passed (or no assertions made) - """ + """Return True if all assertions in current step passed (or none).""" return all(a["passed"] for a in self._assertions_this_step) def required_assertions_passed(self) -> bool: + """Return True if all required assertions in current step passed (or none).""" + required = [a for a in self._assertions_this_step if a.get("required")] + return all(a["passed"] for a in required) + + +@dataclass +class AssertionHandle: + runtime: AgentRuntime + predicate: Predicate + label: str + required: bool = False + + def once(self) -> bool: + """Evaluate once (same behavior as runtime.assert_).""" + return self.runtime.assert_(self.predicate, label=self.label, required=self.required) + + async def eventually( + self, + *, + timeout_s: float = 10.0, + poll_s: float = 0.25, + min_confidence: float | None = None, + max_snapshot_attempts: int = 3, + snapshot_kwargs: dict[str, Any] | None = None, + vision_provider: Any | None = None, + vision_system_prompt: str | None = None, + vision_user_prompt: str | None = None, + ) -> bool: """ - Check if all required assertions in current step passed. + Retry until the predicate passes or timeout is reached. - Returns: - True if all required assertions passed (or no required assertions) + Intermediate attempts emit verification events but do NOT accumulate in step_end assertions. + Final result is accumulated once. """ - required = [a for a in self._assertions_this_step if a.get("required")] - return all(a["passed"] for a in required) + deadline = time.monotonic() + timeout_s + attempt = 0 + snapshot_attempt = 0 + last_outcome = None + + while True: + attempt += 1 + await self.runtime.snapshot(**(snapshot_kwargs or {})) + snapshot_attempt += 1 + + # Optional: gate predicate evaluation on snapshot confidence. + # If diagnostics are missing, we don't block (backward compatible). + confidence = None + diagnostics = None + if self.runtime.last_snapshot is not None: + diagnostics = getattr(self.runtime.last_snapshot, "diagnostics", None) + if diagnostics is not None: + confidence = getattr(diagnostics, "confidence", None) + + if ( + min_confidence is not None + and confidence is not None + and isinstance(confidence, (int, float)) + and confidence < min_confidence + ): + last_outcome = AssertOutcome( + passed=False, + reason=f"Snapshot confidence {confidence:.3f} < min_confidence {min_confidence:.3f}", + details={ + "reason_code": "snapshot_low_confidence", + "confidence": confidence, + "min_confidence": min_confidence, + "snapshot_attempt": snapshot_attempt, + "diagnostics": ( + diagnostics.model_dump() + if hasattr(diagnostics, "model_dump") + else diagnostics + ), + }, + ) + + # Emit attempt event (not recorded in step_end) + self.runtime._record_outcome( + outcome=last_outcome, + label=self.label, + required=self.required, + kind="assert", + record_in_step=False, + extra={ + "eventually": True, + "attempt": attempt, + "snapshot_attempt": snapshot_attempt, + }, + ) + + if snapshot_attempt >= max_snapshot_attempts: + # Optional: vision fallback as last resort (Phase 2-lite). + # This keeps the assertion surface invariant; only the perception layer changes. + if ( + vision_provider is not None + and getattr(vision_provider, "supports_vision", lambda: False)() + ): + try: + import base64 + + png_bytes = await self.runtime.backend.screenshot_png() + image_b64 = base64.b64encode(png_bytes).decode("utf-8") + + sys_prompt = vision_system_prompt or ( + "You are a strict visual verifier. Answer only YES or NO." + ) + user_prompt = vision_user_prompt or ( + f"Given the screenshot, is the following condition satisfied?\n\n{self.label}\n\nAnswer YES or NO." + ) + + resp = vision_provider.generate_with_image( + sys_prompt, + user_prompt, + image_base64=image_b64, + temperature=0.0, + ) + text = (resp.content or "").strip().lower() + passed = text.startswith("yes") + + final_outcome = AssertOutcome( + passed=passed, + reason="vision_fallback_yes" if passed else "vision_fallback_no", + details={ + "reason_code": ( + "vision_fallback_pass" if passed else "vision_fallback_fail" + ), + "vision_response": resp.content, + "min_confidence": min_confidence, + "snapshot_attempts": snapshot_attempt, + }, + ) + self.runtime._record_outcome( + outcome=final_outcome, + label=self.label, + required=self.required, + kind="assert", + record_in_step=True, + extra={ + "eventually": True, + "attempt": attempt, + "snapshot_attempt": snapshot_attempt, + "final": True, + "vision_fallback": True, + }, + ) + return passed + except Exception as e: + # If vision fallback fails, fall through to snapshot_exhausted. + last_outcome.details["vision_error"] = str(e) + + final_outcome = AssertOutcome( + passed=False, + reason=f"Snapshot exhausted after {snapshot_attempt} attempt(s) below min_confidence {min_confidence:.3f}", + details={ + "reason_code": "snapshot_exhausted", + "confidence": confidence, + "min_confidence": min_confidence, + "snapshot_attempts": snapshot_attempt, + "diagnostics": last_outcome.details.get("diagnostics"), + }, + ) + self.runtime._record_outcome( + outcome=final_outcome, + label=self.label, + required=self.required, + kind="assert", + record_in_step=True, + extra={ + "eventually": True, + "attempt": attempt, + "snapshot_attempt": snapshot_attempt, + "final": True, + "exhausted": True, + }, + ) + return False + + if time.monotonic() >= deadline: + self.runtime._record_outcome( + outcome=last_outcome, + label=self.label, + required=self.required, + kind="assert", + record_in_step=True, + extra={ + "eventually": True, + "attempt": attempt, + "snapshot_attempt": snapshot_attempt, + "final": True, + "timeout": True, + }, + ) + return False + + await asyncio.sleep(poll_s) + continue + + last_outcome = self.predicate(self.runtime._ctx()) + + # Emit attempt event (not recorded in step_end) + self.runtime._record_outcome( + outcome=last_outcome, + label=self.label, + required=self.required, + kind="assert", + record_in_step=False, + extra={"eventually": True, "attempt": attempt}, + ) + + if last_outcome.passed: + # Record final success once + self.runtime._record_outcome( + outcome=last_outcome, + label=self.label, + required=self.required, + kind="assert", + record_in_step=True, + extra={"eventually": True, "attempt": attempt, "final": True}, + ) + return True + + if time.monotonic() >= deadline: + # Record final failure once + self.runtime._record_outcome( + outcome=last_outcome, + label=self.label, + required=self.required, + kind="assert", + record_in_step=True, + extra={"eventually": True, "attempt": attempt, "final": True, "timeout": True}, + ) + return False + + await asyncio.sleep(poll_s) diff --git a/sentience/extension/background.js b/sentience/extension/background.js index 2923f55..aff49b0 100644 --- a/sentience/extension/background.js +++ b/sentience/extension/background.js @@ -28,14 +28,14 @@ async function handleSnapshotProcessing(rawData, options = {}) { const startTime = performance.now(); try { if (!Array.isArray(rawData)) throw new Error("rawData must be an array"); - if (rawData.length > 1e4 && (rawData = rawData.slice(0, 1e4)), await initWASM(), + if (rawData.length > 1e4 && (rawData = rawData.slice(0, 1e4)), await initWASM(), !wasmReady) throw new Error("WASM module not initialized"); let analyzedElements, prunedRawData; try { const wasmPromise = new Promise((resolve, reject) => { try { let result; - result = options.limit || options.filter ? analyze_page_with_options(rawData, options) : analyze_page(rawData), + result = options.limit || options.filter ? analyze_page_with_options(rawData, options) : analyze_page(rawData), resolve(result); } catch (e) { reject(e); @@ -101,4 +101,4 @@ initWASM().catch(err => {}), chrome.runtime.onMessage.addListener((request, send event.preventDefault(); }), self.addEventListener("unhandledrejection", event => { event.preventDefault(); -}); \ No newline at end of file +}); diff --git a/sentience/extension/content.js b/sentience/extension/content.js index b65cfb5..97923a2 100644 --- a/sentience/extension/content.js +++ b/sentience/extension/content.js @@ -82,7 +82,7 @@ if (!elements || !Array.isArray(elements)) return; removeOverlay(); const host = document.createElement("div"); - host.id = OVERLAY_HOST_ID, host.style.cssText = "\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n width: 100vw !important;\n height: 100vh !important;\n pointer-events: none !important;\n z-index: 2147483647 !important;\n margin: 0 !important;\n padding: 0 !important;\n ", + host.id = OVERLAY_HOST_ID, host.style.cssText = "\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n width: 100vw !important;\n height: 100vh !important;\n pointer-events: none !important;\n z-index: 2147483647 !important;\n margin: 0 !important;\n padding: 0 !important;\n ", document.body.appendChild(host); const shadow = host.attachShadow({ mode: "closed" @@ -94,15 +94,15 @@ let color; color = isTarget ? "#FF0000" : isPrimary ? "#0066FF" : "#00FF00"; const importanceRatio = maxImportance > 0 ? importance / maxImportance : .5, borderOpacity = isTarget ? 1 : isPrimary ? .9 : Math.max(.4, .5 + .5 * importanceRatio), fillOpacity = .2 * borderOpacity, borderWidth = isTarget ? 2 : isPrimary ? 1.5 : Math.max(.5, Math.round(2 * importanceRatio)), hexOpacity = Math.round(255 * fillOpacity).toString(16).padStart(2, "0"), box = document.createElement("div"); - if (box.style.cssText = `\n position: absolute;\n left: ${bbox.x}px;\n top: ${bbox.y}px;\n width: ${bbox.width}px;\n height: ${bbox.height}px;\n border: ${borderWidth}px solid ${color};\n background-color: ${color}${hexOpacity};\n box-sizing: border-box;\n opacity: ${borderOpacity};\n pointer-events: none;\n `, + if (box.style.cssText = `\n position: absolute;\n left: ${bbox.x}px;\n top: ${bbox.y}px;\n width: ${bbox.width}px;\n height: ${bbox.height}px;\n border: ${borderWidth}px solid ${color};\n background-color: ${color}${hexOpacity};\n box-sizing: border-box;\n opacity: ${borderOpacity};\n pointer-events: none;\n `, importance > 0 || isPrimary) { const badge = document.createElement("span"); - badge.textContent = isPrimary ? `⭐${importance}` : `${importance}`, badge.style.cssText = `\n position: absolute;\n top: -18px;\n left: 0;\n background: ${color};\n color: white;\n font-size: 11px;\n font-weight: bold;\n padding: 2px 6px;\n font-family: Arial, sans-serif;\n border-radius: 3px;\n opacity: 0.95;\n white-space: nowrap;\n pointer-events: none;\n `, + badge.textContent = isPrimary ? `⭐${importance}` : `${importance}`, badge.style.cssText = `\n position: absolute;\n top: -18px;\n left: 0;\n background: ${color};\n color: white;\n font-size: 11px;\n font-weight: bold;\n padding: 2px 6px;\n font-family: Arial, sans-serif;\n border-radius: 3px;\n opacity: 0.95;\n white-space: nowrap;\n pointer-events: none;\n `, box.appendChild(badge); } if (isTarget) { const targetIndicator = document.createElement("span"); - targetIndicator.textContent = "🎯", targetIndicator.style.cssText = "\n position: absolute;\n top: -18px;\n right: 0;\n font-size: 16px;\n pointer-events: none;\n ", + targetIndicator.textContent = "🎯", targetIndicator.style.cssText = "\n position: absolute;\n top: -18px;\n right: 0;\n font-size: 16px;\n pointer-events: none;\n ", box.appendChild(targetIndicator); } shadow.appendChild(box); @@ -122,7 +122,7 @@ if (!grids || !Array.isArray(grids)) return; removeOverlay(); const host = document.createElement("div"); - host.id = OVERLAY_HOST_ID, host.style.cssText = "\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n width: 100vw !important;\n height: 100vh !important;\n pointer-events: none !important;\n z-index: 2147483647 !important;\n margin: 0 !important;\n padding: 0 !important;\n ", + host.id = OVERLAY_HOST_ID, host.style.cssText = "\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n width: 100vw !important;\n height: 100vh !important;\n pointer-events: none !important;\n z-index: 2147483647 !important;\n margin: 0 !important;\n padding: 0 !important;\n ", document.body.appendChild(host); const shadow = host.attachShadow({ mode: "closed" @@ -138,10 +138,10 @@ let labelText = grid.label ? `Grid ${grid.grid_id}: ${grid.label}` : `Grid ${grid.grid_id}`; grid.is_dominant && (labelText = `⭐ ${labelText} (dominant)`); const badge = document.createElement("span"); - if (badge.textContent = labelText, badge.style.cssText = `\n position: absolute;\n top: -18px;\n left: 0;\n background: ${color};\n color: white;\n font-size: 11px;\n font-weight: bold;\n padding: 2px 6px;\n font-family: Arial, sans-serif;\n border-radius: 3px;\n opacity: 0.95;\n white-space: nowrap;\n pointer-events: none;\n `, + if (badge.textContent = labelText, badge.style.cssText = `\n position: absolute;\n top: -18px;\n left: 0;\n background: ${color};\n color: white;\n font-size: 11px;\n font-weight: bold;\n padding: 2px 6px;\n font-family: Arial, sans-serif;\n border-radius: 3px;\n opacity: 0.95;\n white-space: nowrap;\n pointer-events: none;\n `, box.appendChild(badge), isTarget) { const targetIndicator = document.createElement("span"); - targetIndicator.textContent = "🎯", targetIndicator.style.cssText = "\n position: absolute;\n top: -18px;\n right: 0;\n font-size: 16px;\n pointer-events: none;\n ", + targetIndicator.textContent = "🎯", targetIndicator.style.cssText = "\n position: absolute;\n top: -18px;\n right: 0;\n font-size: 16px;\n pointer-events: none;\n ", box.appendChild(targetIndicator); } shadow.appendChild(box); @@ -155,7 +155,7 @@ let overlayTimeout = null; function removeOverlay() { const existing = document.getElementById(OVERLAY_HOST_ID); - existing && existing.remove(), overlayTimeout && (clearTimeout(overlayTimeout), + existing && existing.remove(), overlayTimeout && (clearTimeout(overlayTimeout), overlayTimeout = null); } -}(); \ No newline at end of file +}(); diff --git a/sentience/extension/injected_api.js b/sentience/extension/injected_api.js index e70bfb4..136829f 100644 --- a/sentience/extension/injected_api.js +++ b/sentience/extension/injected_api.js @@ -112,7 +112,7 @@ if (labelEl) { let text = ""; try { - if (text = (labelEl.innerText || "").trim(), !text && labelEl.textContent && (text = labelEl.textContent.trim()), + if (text = (labelEl.innerText || "").trim(), !text && labelEl.textContent && (text = labelEl.textContent.trim()), !text && labelEl.getAttribute) { const ariaLabel = labelEl.getAttribute("aria-label"); ariaLabel && (text = ariaLabel.trim()); @@ -281,7 +281,7 @@ }); const checkStable = () => { const timeSinceLastChange = Date.now() - lastChange, totalWait = Date.now() - startTime; - timeSinceLastChange >= quietPeriod || totalWait >= maxWait ? (observer.disconnect(), + timeSinceLastChange >= quietPeriod || totalWait >= maxWait ? (observer.disconnect(), resolve()) : setTimeout(checkStable, 50); }; checkStable(); @@ -301,7 +301,7 @@ }); const checkQuiet = () => { const timeSinceLastChange = Date.now() - lastChange, totalWait = Date.now() - startTime; - timeSinceLastChange >= quietPeriod || totalWait >= maxWait ? (quietObserver.disconnect(), + timeSinceLastChange >= quietPeriod || totalWait >= maxWait ? (quietObserver.disconnect(), resolve()) : setTimeout(checkQuiet, 50); }; checkQuiet(); @@ -468,8 +468,8 @@ const requestId = `iframe-${idx}-${Date.now()}`, timeout = setTimeout(() => { resolve(null); }, 5e3), listener = event => { - "SENTIENCE_IFRAME_SNAPSHOT_RESPONSE" === event.data?.type && event.data, "SENTIENCE_IFRAME_SNAPSHOT_RESPONSE" === event.data?.type && event.data?.requestId === requestId && (clearTimeout(timeout), - window.removeEventListener("message", listener), event.data.error ? resolve(null) : (event.data.snapshot, + "SENTIENCE_IFRAME_SNAPSHOT_RESPONSE" === event.data?.type && event.data, "SENTIENCE_IFRAME_SNAPSHOT_RESPONSE" === event.data?.type && event.data?.requestId === requestId && (clearTimeout(timeout), + window.removeEventListener("message", listener), event.data.error ? resolve(null) : (event.data.snapshot, resolve({ iframe: iframe, data: event.data.snapshot, @@ -485,7 +485,7 @@ ...options, collectIframes: !0 } - }, "*") : (clearTimeout(timeout), window.removeEventListener("message", listener), + }, "*") : (clearTimeout(timeout), window.removeEventListener("message", listener), resolve(null)); } catch (error) { clearTimeout(timeout), window.removeEventListener("message", listener), resolve(null); @@ -535,7 +535,7 @@ }, 25e3), listener = e => { if ("SENTIENCE_SNAPSHOT_RESULT" === e.data.type && e.data.requestId === requestId) { if (resolved) return; - resolved = !0, clearTimeout(timeout), window.removeEventListener("message", listener), + resolved = !0, clearTimeout(timeout), window.removeEventListener("message", listener), e.data.error ? reject(new Error(e.data.error)) : resolve({ elements: e.data.elements, raw_elements: e.data.raw_elements, @@ -552,7 +552,7 @@ options: options }, "*"); } catch (error) { - resolved || (resolved = !0, clearTimeout(timeout), window.removeEventListener("message", listener), + resolved || (resolved = !0, clearTimeout(timeout), window.removeEventListener("message", listener), reject(new Error(`Failed to send snapshot request: ${error.message}`))); } }); @@ -562,7 +562,7 @@ options.screenshot && (screenshot = await function(options) { return new Promise(resolve => { const requestId = Math.random().toString(36).substring(7), listener = e => { - "SENTIENCE_SCREENSHOT_RESULT" === e.data.type && e.data.requestId === requestId && (window.removeEventListener("message", listener), + "SENTIENCE_SCREENSHOT_RESULT" === e.data.type && e.data.requestId === requestId && (window.removeEventListener("message", listener), resolve(e.data.screenshot)); }; window.addEventListener("message", listener), window.postMessage({ @@ -609,15 +609,15 @@ } if (node.nodeType !== Node.ELEMENT_NODE) return; const tag = node.tagName.toLowerCase(); - if ("h1" === tag && (markdown += "\n# "), "h2" === tag && (markdown += "\n## "), - "h3" === tag && (markdown += "\n### "), "li" === tag && (markdown += "\n- "), insideLink || "p" !== tag && "div" !== tag && "br" !== tag || (markdown += "\n"), - "strong" !== tag && "b" !== tag || (markdown += "**"), "em" !== tag && "i" !== tag || (markdown += "_"), - "a" === tag && (markdown += "[", insideLink = !0), node.shadowRoot ? Array.from(node.shadowRoot.childNodes).forEach(walk) : node.childNodes.forEach(walk), + if ("h1" === tag && (markdown += "\n# "), "h2" === tag && (markdown += "\n## "), + "h3" === tag && (markdown += "\n### "), "li" === tag && (markdown += "\n- "), insideLink || "p" !== tag && "div" !== tag && "br" !== tag || (markdown += "\n"), + "strong" !== tag && "b" !== tag || (markdown += "**"), "em" !== tag && "i" !== tag || (markdown += "_"), + "a" === tag && (markdown += "[", insideLink = !0), node.shadowRoot ? Array.from(node.shadowRoot.childNodes).forEach(walk) : node.childNodes.forEach(walk), "a" === tag) { const href = node.getAttribute("href"); markdown += href ? `](${href})` : "]", insideLink = !1; } - "strong" !== tag && "b" !== tag || (markdown += "**"), "em" !== tag && "i" !== tag || (markdown += "_"), + "strong" !== tag && "b" !== tag || (markdown += "**"), "em" !== tag && "i" !== tag || (markdown += "_"), insideLink || "h1" !== tag && "h2" !== tag && "h3" !== tag && "p" !== tag && "div" !== tag || (markdown += "\n"); }(tempDiv), markdown.replace(/\n{3,}/g, "\n\n").trim(); }(document.body) : function(root) { @@ -630,7 +630,7 @@ const style = window.getComputedStyle(node); if ("none" === style.display || "hidden" === style.visibility) return; const isBlock = "block" === style.display || "flex" === style.display || "P" === node.tagName || "DIV" === node.tagName; - isBlock && (text += " "), node.shadowRoot ? Array.from(node.shadowRoot.childNodes).forEach(walk) : node.childNodes.forEach(walk), + isBlock && (text += " "), node.shadowRoot ? Array.from(node.shadowRoot.childNodes).forEach(walk) : node.childNodes.forEach(walk), isBlock && (text += "\n"); } } else text += node.textContent; @@ -729,25 +729,25 @@ } function startRecording(options = {}) { const {highlightColor: highlightColor = "#ff0000", successColor: successColor = "#00ff00", autoDisableTimeout: autoDisableTimeout = 18e5, keyboardShortcut: keyboardShortcut = "Ctrl+Shift+I"} = options; - if (!window.sentience_registry || 0 === window.sentience_registry.length) return alert("Registry empty. Run `await window.sentience.snapshot()` first!"), + if (!window.sentience_registry || 0 === window.sentience_registry.length) return alert("Registry empty. Run `await window.sentience.snapshot()` first!"), () => {}; window.sentience_registry_map = new Map, window.sentience_registry.forEach((el, idx) => { el && window.sentience_registry_map.set(el, idx); }); let highlightBox = document.getElementById("sentience-highlight-box"); - highlightBox || (highlightBox = document.createElement("div"), highlightBox.id = "sentience-highlight-box", - highlightBox.style.cssText = `\n position: fixed;\n pointer-events: none;\n z-index: 2147483647;\n border: 2px solid ${highlightColor};\n background: rgba(255, 0, 0, 0.1);\n display: none;\n transition: all 0.1s ease;\n box-sizing: border-box;\n `, + highlightBox || (highlightBox = document.createElement("div"), highlightBox.id = "sentience-highlight-box", + highlightBox.style.cssText = `\n position: fixed;\n pointer-events: none;\n z-index: 2147483647;\n border: 2px solid ${highlightColor};\n background: rgba(255, 0, 0, 0.1);\n display: none;\n transition: all 0.1s ease;\n box-sizing: border-box;\n `, document.body.appendChild(highlightBox)); let recordingIndicator = document.getElementById("sentience-recording-indicator"); - recordingIndicator || (recordingIndicator = document.createElement("div"), recordingIndicator.id = "sentience-recording-indicator", - recordingIndicator.style.cssText = `\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n height: 3px;\n background: ${highlightColor};\n z-index: 2147483646;\n pointer-events: none;\n `, + recordingIndicator || (recordingIndicator = document.createElement("div"), recordingIndicator.id = "sentience-recording-indicator", + recordingIndicator.style.cssText = `\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n height: 3px;\n background: ${highlightColor};\n z-index: 2147483646;\n pointer-events: none;\n `, document.body.appendChild(recordingIndicator)), recordingIndicator.style.display = "block"; const mouseOverHandler = e => { const el = e.target; if (!el || el === highlightBox || el === recordingIndicator) return; const rect = el.getBoundingClientRect(); - highlightBox.style.display = "block", highlightBox.style.top = rect.top + window.scrollY + "px", - highlightBox.style.left = rect.left + window.scrollX + "px", highlightBox.style.width = rect.width + "px", + highlightBox.style.display = "block", highlightBox.style.top = rect.top + window.scrollY + "px", + highlightBox.style.left = rect.left + window.scrollX + "px", highlightBox.style.width = rect.width + "px", highlightBox.style.height = rect.height + "px"; }, clickHandler = e => { e.preventDefault(), e.stopPropagation(); @@ -824,7 +824,7 @@ debug_snapshot: rawData }, jsonString = JSON.stringify(snippet, null, 2); navigator.clipboard.writeText(jsonString).then(() => { - highlightBox.style.border = `2px solid ${successColor}`, highlightBox.style.background = "rgba(0, 255, 0, 0.2)", + highlightBox.style.border = `2px solid ${successColor}`, highlightBox.style.background = "rgba(0, 255, 0, 0.2)", setTimeout(() => { highlightBox.style.border = `2px solid ${highlightColor}`, highlightBox.style.background = "rgba(255, 0, 0, 0.1)"; }, 500); @@ -834,15 +834,15 @@ }; let timeoutId = null; const stopRecording = () => { - document.removeEventListener("mouseover", mouseOverHandler, !0), document.removeEventListener("click", clickHandler, !0), - document.removeEventListener("keydown", keyboardHandler, !0), timeoutId && (clearTimeout(timeoutId), - timeoutId = null), highlightBox && (highlightBox.style.display = "none"), recordingIndicator && (recordingIndicator.style.display = "none"), + document.removeEventListener("mouseover", mouseOverHandler, !0), document.removeEventListener("click", clickHandler, !0), + document.removeEventListener("keydown", keyboardHandler, !0), timeoutId && (clearTimeout(timeoutId), + timeoutId = null), highlightBox && (highlightBox.style.display = "none"), recordingIndicator && (recordingIndicator.style.display = "none"), window.sentience_registry_map && window.sentience_registry_map.clear(), window.sentience_stopRecording === stopRecording && delete window.sentience_stopRecording; }, keyboardHandler = e => { - (e.ctrlKey || e.metaKey) && e.shiftKey && "I" === e.key && (e.preventDefault(), + (e.ctrlKey || e.metaKey) && e.shiftKey && "I" === e.key && (e.preventDefault(), stopRecording()); }; - return document.addEventListener("mouseover", mouseOverHandler, !0), document.addEventListener("click", clickHandler, !0), + return document.addEventListener("mouseover", mouseOverHandler, !0), document.addEventListener("click", clickHandler, !0), document.addEventListener("keydown", keyboardHandler, !0), autoDisableTimeout > 0 && (timeoutId = setTimeout(() => { stopRecording(); }, autoDisableTimeout)), window.sentience_stopRecording = stopRecording, stopRecording; @@ -911,4 +911,4 @@ } }), window.sentience_iframe_handler_setup = !0)); })(); -}(); \ No newline at end of file +}(); diff --git a/sentience/extension/pkg/sentience_core.js b/sentience/extension/pkg/sentience_core.js index ecba479..2696a64 100644 --- a/sentience/extension/pkg/sentience_core.js +++ b/sentience/extension/pkg/sentience_core.js @@ -47,7 +47,7 @@ function getArrayU8FromWasm0(ptr, len) { let cachedDataViewMemory0 = null; function getDataViewMemory0() { - return (null === cachedDataViewMemory0 || !0 === cachedDataViewMemory0.buffer.detached || void 0 === cachedDataViewMemory0.buffer.detached && cachedDataViewMemory0.buffer !== wasm.memory.buffer) && (cachedDataViewMemory0 = new DataView(wasm.memory.buffer)), + return (null === cachedDataViewMemory0 || !0 === cachedDataViewMemory0.buffer.detached || void 0 === cachedDataViewMemory0.buffer.detached && cachedDataViewMemory0.buffer !== wasm.memory.buffer) && (cachedDataViewMemory0 = new DataView(wasm.memory.buffer)), cachedDataViewMemory0; } @@ -58,7 +58,7 @@ function getStringFromWasm0(ptr, len) { let cachedUint8ArrayMemory0 = null; function getUint8ArrayMemory0() { - return null !== cachedUint8ArrayMemory0 && 0 !== cachedUint8ArrayMemory0.byteLength || (cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer)), + return null !== cachedUint8ArrayMemory0 && 0 !== cachedUint8ArrayMemory0.byteLength || (cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer)), cachedUint8ArrayMemory0; } @@ -87,7 +87,7 @@ function isLikeNone(x) { function passStringToWasm0(arg, malloc, realloc) { if (void 0 === realloc) { const buf = cachedTextEncoder.encode(arg), ptr = malloc(buf.length, 1) >>> 0; - return getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf), WASM_VECTOR_LEN = buf.length, + return getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf), WASM_VECTOR_LEN = buf.length, ptr; } let len = arg.length, ptr = malloc(len, 1) >>> 0; @@ -188,7 +188,7 @@ function __wbg_get_imports() { return Number(getObject(arg0)); }, imports.wbg.__wbg___wbindgen_bigint_get_as_i64_6e32f5e6aff02e1d = function(arg0, arg1) { const v = getObject(arg1), ret = "bigint" == typeof v ? v : void 0; - getDataViewMemory0().setBigInt64(arg0 + 8, isLikeNone(ret) ? BigInt(0) : ret, !0), + getDataViewMemory0().setBigInt64(arg0 + 8, isLikeNone(ret) ? BigInt(0) : ret, !0), getDataViewMemory0().setInt32(arg0 + 0, !isLikeNone(ret), !0); }, imports.wbg.__wbg___wbindgen_boolean_get_dea25b33882b895b = function(arg0) { const v = getObject(arg0), ret = "boolean" == typeof v ? v : void 0; @@ -296,7 +296,7 @@ function __wbg_get_imports() { } function __wbg_finalize_init(instance, module) { - return wasm = instance.exports, __wbg_init.__wbindgen_wasm_module = module, cachedDataViewMemory0 = null, + return wasm = instance.exports, __wbg_init.__wbindgen_wasm_module = module, cachedDataViewMemory0 = null, cachedUint8ArrayMemory0 = null, wasm; } @@ -310,7 +310,7 @@ function initSync(module) { async function __wbg_init(module_or_path) { if (void 0 !== wasm) return wasm; - void 0 !== module_or_path && Object.getPrototypeOf(module_or_path) === Object.prototype && ({module_or_path: module_or_path} = module_or_path), + void 0 !== module_or_path && Object.getPrototypeOf(module_or_path) === Object.prototype && ({module_or_path: module_or_path} = module_or_path), void 0 === module_or_path && (module_or_path = new URL("sentience_core_bg.wasm", import.meta.url)); const imports = __wbg_get_imports(); ("string" == typeof module_or_path || "function" == typeof Request && module_or_path instanceof Request || "function" == typeof URL && module_or_path instanceof URL) && (module_or_path = fetch(module_or_path)); @@ -320,4 +320,4 @@ async function __wbg_init(module_or_path) { export { initSync }; -export default __wbg_init; \ No newline at end of file +export default __wbg_init; diff --git a/sentience/models.py b/sentience/models.py index 6fe7f4a..f2083aa 100644 --- a/sentience/models.py +++ b/sentience/models.py @@ -2,6 +2,8 @@ Pydantic models for Sentience SDK - matches spec/snapshot.schema.json """ +from __future__ import annotations + from dataclasses import dataclass from typing import Any, Literal @@ -64,6 +66,24 @@ class Element(BaseModel): # Hyperlink URL (for link elements) href: str | None = None + # ===== v1 state-aware assertion fields (optional) ===== + # Best-effort accessible name/label for controls (distinct from visible text) + name: str | None = None + # Current value for inputs/textarea/select (PII-aware: may be omitted/redacted) + value: str | None = None + # Input type (e.g., "text", "email", "password") + input_type: str | None = None + # Whether value was redacted for privacy + value_redacted: bool | None = None + # Normalized boolean states (best-effort) + checked: bool | None = None + disabled: bool | None = None + expanded: bool | None = None + # Raw ARIA state strings (tri-state / debugging) + aria_checked: str | None = None + aria_disabled: str | None = None + aria_expanded: str | None = None + # Phase 3.2: Pre-computed dominant group membership (uses fuzzy matching) # This field is computed by the gateway so downstream consumers don't need to # implement fuzzy matching logic themselves. @@ -72,7 +92,7 @@ class Element(BaseModel): # Layout-derived metadata (internal-only in v0, not exposed in API responses) # Per ChatGPT feedback: explicitly optional to prevent users assuming layout is always present # Note: This field is marked with skip_serializing_if in Rust, so it won't appear in API responses - layout: "LayoutHints | None" = None + layout: LayoutHints | None = None class GridPosition(BaseModel): @@ -135,6 +155,8 @@ class Snapshot(BaseModel): requires_license: bool | None = None # Phase 2: Dominant group key for ordinal selection dominant_group_key: str | None = None # The most common group_key (main content group) + # Phase 2: Runtime stability/debug info (confidence/reasons/metrics) + diagnostics: SnapshotDiagnostics | None = None def save(self, filepath: str) -> None: """Save snapshot as JSON file""" @@ -143,6 +165,134 @@ def save(self, filepath: str) -> None: with open(filepath, "w", encoding="utf-8") as f: json.dump(self.model_dump(), f, indent=2) + def get_grid_bounds(self, grid_id: int | None = None) -> list[GridInfo]: + """ + Get grid coordinates (bounding boxes) for detected grids. + + Groups elements by grid_id and computes the overall bounding box, + row/column counts, and item count for each grid. + + Args: + grid_id: Optional grid ID to filter by. If None, returns all grids. + + Returns: + List of GridInfo objects, one per detected grid, sorted by grid_id. + """ + from collections import defaultdict + + # Group elements by grid_id + grid_elements: dict[int, list[Element]] = defaultdict(list) + + for elem in self.elements: + if elem.layout and elem.layout.grid_id is not None: + grid_elements[elem.layout.grid_id].append(elem) + + # Filter by grid_id if specified + if grid_id is not None: + if grid_id not in grid_elements: + return [] + grid_elements = {grid_id: grid_elements[grid_id]} + + grid_infos: list[GridInfo] = [] + + # First pass: compute all grid infos and count dominant group elements + grid_dominant_counts: dict[int, tuple[int, int]] = {} + for gid, elements_in_grid in sorted(grid_elements.items()): + if not elements_in_grid: + continue + + # Count dominant group elements in this grid + dominant_count = sum(1 for elem in elements_in_grid if elem.in_dominant_group is True) + grid_dominant_counts[gid] = (dominant_count, len(elements_in_grid)) + + # Compute bounding box + min_x = min(elem.bbox.x for elem in elements_in_grid) + min_y = min(elem.bbox.y for elem in elements_in_grid) + max_x = max(elem.bbox.x + elem.bbox.width for elem in elements_in_grid) + max_y = max(elem.bbox.y + elem.bbox.height for elem in elements_in_grid) + + # Count rows and columns + row_indices = set() + col_indices = set() + + for elem in elements_in_grid: + if elem.layout and elem.layout.grid_pos: + row_indices.add(elem.layout.grid_pos.row_index) + col_indices.add(elem.layout.grid_pos.col_index) + + # Infer grid label from element patterns (best-effort heuristic) + # Keep the heuristic implementation in one place. + label = SnapshotDiagnostics._infer_grid_label(elements_in_grid) + + grid_infos.append( + GridInfo( + grid_id=gid, + bbox=BBox( + x=min_x, + y=min_y, + width=max_x - min_x, + height=max_y - min_y, + ), + row_count=len(row_indices) if row_indices else 0, + col_count=len(col_indices) if col_indices else 0, + item_count=len(elements_in_grid), + confidence=1.0, + label=label, + is_dominant=False, # Will be set below + ) + ) + + # Second pass: identify dominant grid + # The grid with the highest count (or highest percentage >= 50%) of dominant group elements + if grid_dominant_counts: + # Find grid with highest absolute count + max_dominant_count = max(count for count, _ in grid_dominant_counts.values()) + if max_dominant_count > 0: + # Find grid(s) with highest count + dominant_grids = [ + gid + for gid, (count, _total) in grid_dominant_counts.items() + if count == max_dominant_count + ] + # If multiple grids tie, prefer the one with highest percentage + if len(dominant_grids) > 1: + dominant_grids.sort( + key=lambda gid: ( + grid_dominant_counts[gid][0] / grid_dominant_counts[gid][1] + if grid_dominant_counts[gid][1] > 0 + else 0 + ), + reverse=True, + ) + + # Mark the dominant grid + dominant_gid = dominant_grids[0] + # Only mark as dominant if it has >= 50% dominant group elements or >= 3 elements + dominant_count, total_count = grid_dominant_counts[dominant_gid] + if dominant_count >= 3 or (total_count > 0 and dominant_count / total_count >= 0.5): + for grid_info in grid_infos: + if grid_info.grid_id == dominant_gid: + grid_info.is_dominant = True + break + + return grid_infos + + +class SnapshotDiagnosticsMetrics(BaseModel): + ready_state: str | None = None + quiet_ms: float | None = None + node_count: int | None = None + interactive_count: int | None = None + raw_elements_count: int | None = None + + +class SnapshotDiagnostics(BaseModel): + """Runtime stability/debug information (reserved for diagnostics, not ML metadata).""" + + confidence: float | None = None + reasons: list[str] = [] + metrics: SnapshotDiagnosticsMetrics | None = None + def get_grid_bounds(self, grid_id: int | None = None) -> list[GridInfo]: """ Get grid coordinates (bounding boxes) for detected grids. @@ -272,7 +422,7 @@ def get_grid_bounds(self, grid_id: int | None = None) -> list[GridInfo]: return grid_infos @staticmethod - def _infer_grid_label(elements: list["Element"]) -> str | None: + def _infer_grid_label(elements: list[Element]) -> str | None: """ Infer grid label from element patterns using text fingerprinting (best-effort heuristic). @@ -667,7 +817,7 @@ class StorageState(BaseModel): ) @classmethod - def from_dict(cls, data: dict) -> "StorageState": + def from_dict(cls, data: dict) -> StorageState: """ Create StorageState from dictionary (e.g., loaded from JSON). diff --git a/sentience/query.py b/sentience/query.py index f77537c..44db2f6 100644 --- a/sentience/query.py +++ b/sentience/query.py @@ -52,16 +52,28 @@ def parse_selector(selector: str) -> dict[str, Any]: # noqa: C901 query["visible"] = False elif op == "~": # Substring match (case-insensitive) - if key == "text" or key == "name": + if key == "text": query["text_contains"] = value + elif key == "name": + query["name_contains"] = value + elif key == "value": + query["value_contains"] = value elif op == "^=": # Prefix match - if key == "text" or key == "name": + if key == "text": query["text_prefix"] = value + elif key == "name": + query["name_prefix"] = value + elif key == "value": + query["value_prefix"] = value elif op == "$=": # Suffix match - if key == "text" or key == "name": + if key == "text": query["text_suffix"] = value + elif key == "name": + query["name_suffix"] = value + elif key == "value": + query["value_suffix"] = value elif op == ">": # Greater than if is_numeric: @@ -116,8 +128,14 @@ def parse_selector(selector: str) -> dict[str, Any]: # noqa: C901 query["visible"] = value.lower() == "true" elif key == "tag": query["tag"] = value - elif key == "name" or key == "text": + elif key == "text": query["text"] = value + elif key == "name": + query["name"] = value + elif key == "value": + query["value"] = value + elif key in ("checked", "disabled", "expanded"): + query[key] = value.lower() == "true" elif key == "importance" and is_numeric: query["importance"] = numeric_value elif key.startswith("attr."): @@ -192,6 +210,50 @@ def match_element(element: Element, query: dict[str, Any]) -> bool: # noqa: C90 if not element.text.lower().endswith(query["text_suffix"].lower()): return False + # Name matching (best-effort; fallback to text for backward compatibility) + name_val = element.name or element.text or "" + if "name" in query: + if not name_val or name_val != query["name"]: + return False + if "name_contains" in query: + if not name_val or query["name_contains"].lower() not in name_val.lower(): + return False + if "name_prefix" in query: + if not name_val or not name_val.lower().startswith(query["name_prefix"].lower()): + return False + if "name_suffix" in query: + if not name_val or not name_val.lower().endswith(query["name_suffix"].lower()): + return False + + # Value matching (inputs/textarea/select) + if "value" in query: + if element.value is None or element.value != query["value"]: + return False + if "value_contains" in query: + if element.value is None or query["value_contains"].lower() not in element.value.lower(): + return False + if "value_prefix" in query: + if element.value is None or not element.value.lower().startswith( + query["value_prefix"].lower() + ): + return False + if "value_suffix" in query: + if element.value is None or not element.value.lower().endswith( + query["value_suffix"].lower() + ): + return False + + # State matching (best-effort) + if "checked" in query: + if (element.checked is True) != query["checked"]: + return False + if "disabled" in query: + if (element.disabled is True) != query["disabled"]: + return False + if "expanded" in query: + if (element.expanded is True) != query["expanded"]: + return False + # Importance filtering if "importance" in query: if element.importance != query["importance"]: diff --git a/sentience/snapshot.py b/sentience/snapshot.py index 274102b..4e5ebf5 100644 --- a/sentience/snapshot.py +++ b/sentience/snapshot.py @@ -29,6 +29,13 @@ def _build_snapshot_payload( Shared helper used by both sync and async snapshot implementations. """ + diagnostics = raw_result.get("diagnostics") or {} + client_metrics = None + try: + client_metrics = diagnostics.get("metrics") + except Exception: + client_metrics = None + return { "raw_elements": raw_result.get("raw_elements", []), "url": raw_result.get("url", ""), @@ -38,6 +45,7 @@ def _build_snapshot_payload( "limit": options.limit, "filter": options.filter.model_dump() if options.filter else None, }, + "client_metrics": client_metrics, } @@ -133,6 +141,8 @@ def _merge_api_result_with_local( "screenshot": raw_result.get("screenshot"), # Keep local screenshot "screenshot_format": raw_result.get("screenshot_format"), "error": api_result.get("error"), + # Phase 2: Runtime stability/debug info + "diagnostics": api_result.get("diagnostics", raw_result.get("diagnostics")), # Phase 2: Ordinal support - dominant group key from Gateway "dominant_group_key": api_result.get("dominant_group_key"), } diff --git a/sentience/verification.py b/sentience/verification.py index db80850..3c27eb3 100644 --- a/sentience/verification.py +++ b/sentience/verification.py @@ -157,7 +157,7 @@ def _pred(ctx: AssertContext) -> AssertOutcome: return AssertOutcome( passed=False, reason="no snapshot available", - details={"selector": selector}, + details={"selector": selector, "reason_code": "no_snapshot"}, ) # Import here to avoid circular imports @@ -168,7 +168,11 @@ def _pred(ctx: AssertContext) -> AssertOutcome: return AssertOutcome( passed=ok, reason="" if ok else f"no elements matched selector: {selector}", - details={"selector": selector, "matched": len(matches)}, + details={ + "selector": selector, + "matched": len(matches), + "reason_code": "ok" if ok else "no_match", + }, ) return _pred @@ -197,7 +201,7 @@ def _pred(ctx: AssertContext) -> AssertOutcome: return AssertOutcome( passed=False, reason="no snapshot available", - details={"selector": selector}, + details={"selector": selector, "reason_code": "no_snapshot"}, ) from .query import query @@ -207,7 +211,11 @@ def _pred(ctx: AssertContext) -> AssertOutcome: return AssertOutcome( passed=ok, reason="" if ok else f"found {len(matches)} elements matching: {selector}", - details={"selector": selector, "matched": len(matches)}, + details={ + "selector": selector, + "matched": len(matches), + "reason_code": "ok" if ok else "unexpected_match", + }, ) return _pred @@ -378,3 +386,233 @@ def _pred(ctx: AssertContext) -> AssertOutcome: ) return _pred + + +# ============================================================================ +# v1 state-aware predicates (deterministic, schema-driven) +# ============================================================================ + + +def is_enabled(selector: str) -> Predicate: + """Passes if any matched element is not disabled (disabled=None treated as enabled).""" + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, reason="no snapshot available", details={"selector": selector} + ) + + from .query import query + + matches = query(snap, selector) + if not matches: + return AssertOutcome( + passed=False, + reason=f"no elements matched selector: {selector}", + details={"selector": selector, "matched": 0, "reason_code": "no_match"}, + ) + + ok = any(m.disabled is not True for m in matches) + return AssertOutcome( + passed=ok, + reason="" if ok else f"all matched elements are disabled: {selector}", + details={ + "selector": selector, + "matched": len(matches), + "reason_code": "ok" if ok else "state_mismatch", + }, + ) + + return _pred + + +def is_disabled(selector: str) -> Predicate: + """Passes if any matched element is disabled.""" + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, reason="no snapshot available", details={"selector": selector} + ) + + from .query import query + + matches = query(snap, selector) + ok = any(m.disabled is True for m in matches) + return AssertOutcome( + passed=ok, + reason="" if ok else f"no matched elements are disabled: {selector}", + details={ + "selector": selector, + "matched": len(matches), + "reason_code": "ok" if ok else "state_mismatch", + }, + ) + + return _pred + + +def is_checked(selector: str) -> Predicate: + """Passes if any matched element is checked.""" + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, reason="no snapshot available", details={"selector": selector} + ) + + from .query import query + + matches = query(snap, selector) + ok = any(m.checked is True for m in matches) + return AssertOutcome( + passed=ok, + reason="" if ok else f"no matched elements are checked: {selector}", + details={ + "selector": selector, + "matched": len(matches), + "reason_code": "ok" if ok else "state_mismatch", + }, + ) + + return _pred + + +def is_unchecked(selector: str) -> Predicate: + """Passes if any matched element is not checked (checked=None treated as unchecked).""" + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, reason="no snapshot available", details={"selector": selector} + ) + + from .query import query + + matches = query(snap, selector) + ok = any(m.checked is not True for m in matches) + return AssertOutcome( + passed=ok, + reason="" if ok else f"all matched elements are checked: {selector}", + details={ + "selector": selector, + "matched": len(matches), + "reason_code": "ok" if ok else "state_mismatch", + }, + ) + + return _pred + + +def value_equals(selector: str, expected: str) -> Predicate: + """Passes if any matched element has value exactly equal to expected.""" + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, reason="no snapshot available", details={"selector": selector} + ) + + from .query import query + + matches = query(snap, selector) + ok = any((m.value or "") == expected for m in matches) + return AssertOutcome( + passed=ok, + reason="" if ok else f"no matched elements had value == '{expected}'", + details={ + "selector": selector, + "expected": expected, + "matched": len(matches), + "reason_code": "ok" if ok else "state_mismatch", + }, + ) + + return _pred + + +def value_contains(selector: str, substring: str) -> Predicate: + """Passes if any matched element value contains substring (case-insensitive).""" + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, reason="no snapshot available", details={"selector": selector} + ) + + from .query import query + + matches = query(snap, selector) + ok = any(substring.lower() in (m.value or "").lower() for m in matches) + return AssertOutcome( + passed=ok, + reason="" if ok else f"no matched elements had value containing '{substring}'", + details={ + "selector": selector, + "substring": substring, + "matched": len(matches), + "reason_code": "ok" if ok else "state_mismatch", + }, + ) + + return _pred + + +def is_expanded(selector: str) -> Predicate: + """Passes if any matched element is expanded.""" + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, reason="no snapshot available", details={"selector": selector} + ) + + from .query import query + + matches = query(snap, selector) + ok = any(m.expanded is True for m in matches) + return AssertOutcome( + passed=ok, + reason="" if ok else f"no matched elements are expanded: {selector}", + details={ + "selector": selector, + "matched": len(matches), + "reason_code": "ok" if ok else "state_mismatch", + }, + ) + + return _pred + + +def is_collapsed(selector: str) -> Predicate: + """Passes if any matched element is not expanded (expanded=None treated as collapsed).""" + + def _pred(ctx: AssertContext) -> AssertOutcome: + snap = ctx.snapshot + if snap is None: + return AssertOutcome( + passed=False, reason="no snapshot available", details={"selector": selector} + ) + + from .query import query + + matches = query(snap, selector) + ok = any(m.expanded is not True for m in matches) + return AssertOutcome( + passed=ok, + reason="" if ok else f"all matched elements are expanded: {selector}", + details={ + "selector": selector, + "matched": len(matches), + "reason_code": "ok" if ok else "state_mismatch", + }, + ) + + return _pred diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index 48811be..4cfc2f4 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -283,6 +283,130 @@ def failing_predicate(ctx: AssertContext) -> AssertOutcome: assert result is False assert runtime.is_task_done is False + @pytest.mark.asyncio + async def test_check_eventually_records_final_only(self) -> None: + backend = MockBackend() + tracer = MockTracer() + runtime = AgentRuntime(backend=backend, tracer=tracer) + runtime.begin_step(goal="Test") + + # Two failing snapshots, then success + snaps = [ + MagicMock(url="https://example.com", elements=[]), + MagicMock(url="https://example.com", elements=[]), + MagicMock(url="https://example.com/done", elements=[]), + ] + + async def fake_snapshot(**_kwargs): + runtime.last_snapshot = snaps.pop(0) + return runtime.last_snapshot + + runtime.snapshot = AsyncMock(side_effect=fake_snapshot) # type: ignore[method-assign] + + def pred(ctx: AssertContext) -> AssertOutcome: + ok = (ctx.url or "").endswith("/done") + return AssertOutcome( + passed=ok, + reason="" if ok else "not done", + details={"selector": "text~'Done'", "reason_code": "ok" if ok else "no_match"}, + ) + + handle = runtime.check(pred, label="eventually_done") + ok = await handle.eventually(timeout_s=2.0, poll_s=0.0) + assert ok is True + + # Only the final record is accumulated for step_end + assert len(runtime._assertions_this_step) == 1 + assert runtime._assertions_this_step[0]["label"] == "eventually_done" + assert runtime._assertions_this_step[0]["passed"] is True + assert runtime._assertions_this_step[0].get("final") is True + + # But attempts emitted multiple verification events + assert len(tracer.events) >= 3 + assert all(e["type"] == "verification" for e in tracer.events) + + @pytest.mark.asyncio + async def test_check_eventually_snapshot_exhausted_min_confidence(self) -> None: + backend = MockBackend() + tracer = MockTracer() + runtime = AgentRuntime(backend=backend, tracer=tracer) + runtime.begin_step(goal="Test") + + low_diag = MagicMock() + low_diag.confidence = 0.1 + low_diag.model_dump = lambda: {"confidence": 0.1} + + snaps = [ + MagicMock(url="https://example.com", elements=[], diagnostics=low_diag), + MagicMock(url="https://example.com", elements=[], diagnostics=low_diag), + ] + + async def fake_snapshot(**_kwargs): + runtime.last_snapshot = snaps.pop(0) + return runtime.last_snapshot + + runtime.snapshot = AsyncMock(side_effect=fake_snapshot) # type: ignore[method-assign] + + def pred(_ctx: AssertContext) -> AssertOutcome: + return AssertOutcome(passed=True, reason="would pass", details={}) + + handle = runtime.check(pred, label="min_confidence_gate") + ok = await handle.eventually( + timeout_s=5.0, + poll_s=0.0, + min_confidence=0.7, + max_snapshot_attempts=2, + ) + assert ok is False + + # Only the final record is accumulated for step_end + assert len(runtime._assertions_this_step) == 1 + details = runtime._assertions_this_step[0]["details"] + assert details["reason_code"] == "snapshot_exhausted" + + @pytest.mark.asyncio + async def test_check_eventually_vision_fallback_on_exhaustion(self) -> None: + backend = MockBackend() + tracer = MockTracer() + runtime = AgentRuntime(backend=backend, tracer=tracer) + runtime.begin_step(goal="Test") + + low_diag = MagicMock() + low_diag.confidence = 0.1 + low_diag.model_dump = lambda: {"confidence": 0.1} + + async def fake_snapshot(**_kwargs): + runtime.last_snapshot = MagicMock( + url="https://example.com", elements=[], diagnostics=low_diag + ) + return runtime.last_snapshot + + runtime.snapshot = AsyncMock(side_effect=fake_snapshot) # type: ignore[method-assign] + + class VisionProviderStub: + def supports_vision(self) -> bool: + return True + + def generate_with_image(self, *_args, **_kwargs): + return MagicMock(content="YES") + + def pred(_ctx: AssertContext) -> AssertOutcome: + return AssertOutcome(passed=False, reason="should not run", details={}) + + handle = runtime.check(pred, label="vision_fallback_check") + ok = await handle.eventually( + timeout_s=5.0, + poll_s=0.0, + min_confidence=0.7, + max_snapshot_attempts=1, + vision_provider=VisionProviderStub(), + ) + assert ok is True + assert len(runtime._assertions_this_step) == 1 + rec = runtime._assertions_this_step[0] + assert rec.get("vision_fallback") is True + assert rec["details"]["reason_code"] == "vision_fallback_pass" + class TestAgentRuntimeAssertionHelpers: """Tests for assertion helper methods.""" diff --git a/tests/test_query.py b/tests/test_query.py index f9c71d4..8d5a6a1 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -17,6 +17,20 @@ def test_parse_selector(): q = parse_selector("text~'Sign in'") assert q["text_contains"] == "Sign in" + # Name contains (separate from text) + q = parse_selector("name~'Email'") + assert q["name_contains"] == "Email" + + # Value contains + q = parse_selector("value~'@example.com'") + assert q["value_contains"] == "@example.com" + + # State booleans + q = parse_selector("disabled=true checked=false expanded=true") + assert q["disabled"] is True + assert q["checked"] is False + assert q["expanded"] is True + # Clickable q = parse_selector("clickable=true") assert q["clickable"] is True @@ -76,6 +90,11 @@ def test_match_element(): in_viewport=True, is_occluded=False, z_index=10, + name="Sign In", + value=None, + disabled=False, + checked=None, + expanded=None, ) # Role match @@ -94,6 +113,24 @@ def test_match_element(): assert match_element(element, {"text_suffix": "In"}) is True assert match_element(element, {"text_suffix": "Out"}) is False + # Name contains (should match name, fallback to text if name missing) + assert match_element(element, {"name_contains": "Sign"}) is True + assert match_element(element, {"name_contains": "Logout"}) is False + + # Value matching + element_with_value = element.model_copy(update={"value": "user@example.com"}) + assert match_element(element_with_value, {"value_contains": "@example.com"}) is True + assert match_element(element_with_value, {"value": "user@example.com"}) is True + assert match_element(element_with_value, {"value": "nope"}) is False + + # State matching (best-effort) + element_checked = element.model_copy(update={"checked": True}) + assert match_element(element_checked, {"checked": True}) is True + assert match_element(element_checked, {"checked": False}) is False + element_disabled = element.model_copy(update={"disabled": True}) + assert match_element(element_disabled, {"disabled": True}) is True + assert match_element(element_disabled, {"disabled": False}) is False + # Clickable assert match_element(element, {"clickable": True}) is True assert match_element(element, {"clickable": False}) is False diff --git a/tests/test_verification.py b/tests/test_verification.py index 1f01511..47b6ee5 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -13,9 +13,17 @@ custom, element_count, exists, + is_checked, + is_collapsed, + is_disabled, + is_enabled, + is_expanded, + is_unchecked, not_exists, url_contains, url_matches, + value_contains, + value_equals, ) @@ -238,6 +246,46 @@ def test_first_passes(self): outcome = pred(ctx) assert outcome.passed is True + +class TestStateAwarePredicates: + def test_is_enabled_and_disabled(self): + el1 = make_element(1, role="button", text="Submit") + el2 = make_element(2, role="button", text="Disabled") + el2 = el2.model_copy(update={"disabled": True}) + snap = make_snapshot([el1, el2]) + ctx = AssertContext(snapshot=snap, url=snap.url) + + assert is_enabled("role=button")(ctx).passed is True + assert is_disabled("text~'Disabled'")(ctx).passed is True + + def test_checked_unchecked(self): + el1 = make_element(1, role="checkbox", text="Opt in").model_copy(update={"checked": True}) + el2 = make_element(2, role="checkbox", text="Opt out").model_copy(update={"checked": False}) + snap = make_snapshot([el1, el2]) + ctx = AssertContext(snapshot=snap, url=snap.url) + + assert is_checked("text~'Opt in'")(ctx).passed is True + assert is_unchecked("text~'Opt out'")(ctx).passed is True + + def test_value_equals_contains(self): + el = make_element(1, role="textbox", text=None).model_copy( + update={"value": "user@example.com"} + ) + snap = make_snapshot([el]) + ctx = AssertContext(snapshot=snap, url=snap.url) + + assert value_equals("role=textbox", "user@example.com")(ctx).passed is True + assert value_contains("role=textbox", "@example.com")(ctx).passed is True + + def test_expanded_collapsed(self): + el1 = make_element(1, role="button", text="Menu").model_copy(update={"expanded": True}) + el2 = make_element(2, role="button", text="Details").model_copy(update={"expanded": False}) + snap = make_snapshot([el1, el2]) + ctx = AssertContext(snapshot=snap, url=snap.url) + + assert is_expanded("text~'Menu'")(ctx).passed is True + assert is_collapsed("text~'Details'")(ctx).passed is True + def test_second_passes(self): elements = [make_element(1, role="button", text="Complete")] snap = make_snapshot(elements)