sentienceapi 0.90.16__py3-none-any.whl → 0.98.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sentienceapi might be problematic. Click here for more details.

Files changed (90) hide show
  1. sentience/__init__.py +120 -6
  2. sentience/_extension_loader.py +156 -1
  3. sentience/action_executor.py +217 -0
  4. sentience/actions.py +758 -30
  5. sentience/agent.py +806 -293
  6. sentience/agent_config.py +3 -0
  7. sentience/agent_runtime.py +840 -0
  8. sentience/asserts/__init__.py +70 -0
  9. sentience/asserts/expect.py +621 -0
  10. sentience/asserts/query.py +383 -0
  11. sentience/async_api.py +89 -1141
  12. sentience/backends/__init__.py +137 -0
  13. sentience/backends/actions.py +372 -0
  14. sentience/backends/browser_use_adapter.py +241 -0
  15. sentience/backends/cdp_backend.py +393 -0
  16. sentience/backends/exceptions.py +211 -0
  17. sentience/backends/playwright_backend.py +194 -0
  18. sentience/backends/protocol.py +216 -0
  19. sentience/backends/sentience_context.py +469 -0
  20. sentience/backends/snapshot.py +483 -0
  21. sentience/base_agent.py +95 -0
  22. sentience/browser.py +678 -39
  23. sentience/browser_evaluator.py +299 -0
  24. sentience/canonicalization.py +207 -0
  25. sentience/cloud_tracing.py +507 -42
  26. sentience/constants.py +6 -0
  27. sentience/conversational_agent.py +77 -43
  28. sentience/cursor_policy.py +142 -0
  29. sentience/element_filter.py +136 -0
  30. sentience/expect.py +98 -2
  31. sentience/extension/background.js +56 -185
  32. sentience/extension/content.js +150 -287
  33. sentience/extension/injected_api.js +1088 -1368
  34. sentience/extension/manifest.json +1 -1
  35. sentience/extension/pkg/sentience_core.d.ts +22 -22
  36. sentience/extension/pkg/sentience_core.js +275 -433
  37. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  38. sentience/extension/release.json +47 -47
  39. sentience/failure_artifacts.py +241 -0
  40. sentience/formatting.py +9 -53
  41. sentience/inspector.py +183 -1
  42. sentience/integrations/__init__.py +6 -0
  43. sentience/integrations/langchain/__init__.py +12 -0
  44. sentience/integrations/langchain/context.py +18 -0
  45. sentience/integrations/langchain/core.py +326 -0
  46. sentience/integrations/langchain/tools.py +180 -0
  47. sentience/integrations/models.py +46 -0
  48. sentience/integrations/pydanticai/__init__.py +15 -0
  49. sentience/integrations/pydanticai/deps.py +20 -0
  50. sentience/integrations/pydanticai/toolset.py +468 -0
  51. sentience/llm_interaction_handler.py +191 -0
  52. sentience/llm_provider.py +765 -66
  53. sentience/llm_provider_utils.py +120 -0
  54. sentience/llm_response_builder.py +153 -0
  55. sentience/models.py +595 -3
  56. sentience/ordinal.py +280 -0
  57. sentience/overlay.py +109 -2
  58. sentience/protocols.py +228 -0
  59. sentience/query.py +67 -5
  60. sentience/read.py +95 -3
  61. sentience/recorder.py +223 -3
  62. sentience/schemas/trace_v1.json +128 -9
  63. sentience/screenshot.py +48 -2
  64. sentience/sentience_methods.py +86 -0
  65. sentience/snapshot.py +599 -55
  66. sentience/snapshot_diff.py +126 -0
  67. sentience/text_search.py +120 -5
  68. sentience/trace_event_builder.py +148 -0
  69. sentience/trace_file_manager.py +197 -0
  70. sentience/trace_indexing/index_schema.py +95 -7
  71. sentience/trace_indexing/indexer.py +105 -48
  72. sentience/tracer_factory.py +120 -9
  73. sentience/tracing.py +172 -8
  74. sentience/utils/__init__.py +40 -0
  75. sentience/utils/browser.py +46 -0
  76. sentience/{utils.py → utils/element.py} +3 -42
  77. sentience/utils/formatting.py +59 -0
  78. sentience/verification.py +618 -0
  79. sentience/visual_agent.py +2058 -0
  80. sentience/wait.py +68 -2
  81. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +199 -40
  82. sentienceapi-0.98.0.dist-info/RECORD +92 -0
  83. sentience/extension/test-content.js +0 -4
  84. sentienceapi-0.90.16.dist-info/RECORD +0 -50
  85. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
  86. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
  87. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
  88. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
  89. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
  90. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
sentience/constants.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Sentience SDK constants.
3
+ """
4
+
5
+ # Sentience API endpoint
6
+ SENTIENCE_API_URL = "https://api.sentienceapi.com"
@@ -5,12 +5,13 @@ Enables end users to control web automation using plain English
5
5
 
6
6
  import json
7
7
  import time
8
- from typing import Any
8
+ from typing import Any, Union
9
9
 
10
10
  from .agent import SentienceAgent
11
11
  from .browser import SentienceBrowser
12
12
  from .llm_provider import LLMProvider
13
- from .models import Snapshot, SnapshotOptions
13
+ from .models import ExtractionResult, Snapshot, SnapshotOptions, StepExecutionResult
14
+ from .protocols import BrowserProtocol
14
15
  from .snapshot import snapshot
15
16
 
16
17
 
@@ -29,12 +30,18 @@ class ConversationalAgent:
29
30
  The top result is from amazon.com selling the Apple Magic Mouse 2 for $79."
30
31
  """
31
32
 
32
- def __init__(self, browser: SentienceBrowser, llm: LLMProvider, verbose: bool = True):
33
+ def __init__(
34
+ self,
35
+ browser: SentienceBrowser | BrowserProtocol,
36
+ llm: LLMProvider,
37
+ verbose: bool = True,
38
+ ):
33
39
  """
34
40
  Initialize conversational agent
35
41
 
36
42
  Args:
37
- browser: SentienceBrowser instance
43
+ browser: SentienceBrowser instance or BrowserProtocol-compatible object
44
+ (for testing, can use mock objects that implement BrowserProtocol)
38
45
  llm: LLM provider (OpenAI, Anthropic, LocalLLM, etc.)
39
46
  verbose: Print step-by-step execution logs (default: True)
40
47
  """
@@ -90,7 +97,7 @@ class ConversationalAgent:
90
97
  step_result = self._execute_step(step)
91
98
  execution_results.append(step_result)
92
99
 
93
- if not step_result.get("success", False):
100
+ if not step_result.success:
94
101
  # Early exit on failure
95
102
  if self.verbose:
96
103
  print(f"⚠️ Step failed: {step['description']}")
@@ -203,7 +210,7 @@ Create a step-by-step execution plan."""
203
210
  "expected_outcome": "Complete user request",
204
211
  }
205
212
 
206
- def _execute_step(self, step: dict[str, Any]) -> dict[str, Any]:
213
+ def _execute_step(self, step: dict[str, Any]) -> StepExecutionResult:
207
214
  """
208
215
  Execute a single atomic step from the plan
209
216
 
@@ -230,46 +237,42 @@ Create a step-by-step execution plan."""
230
237
  self.execution_context["current_url"] = url
231
238
  time.sleep(1) # Brief wait for page to settle
232
239
 
233
- return {"success": True, "action": action, "data": {"url": url}}
240
+ return StepExecutionResult(success=True, action=action, data={"url": url})
234
241
 
235
242
  elif action == "FIND_AND_CLICK":
236
243
  element_desc = params["element_description"]
237
244
  # Use technical agent to find and click (returns AgentActionResult)
238
245
  result = self.technical_agent.act(f"Click the {element_desc}")
239
- return {
240
- "success": result.success, # Use attribute access
241
- "action": action,
242
- "data": result.model_dump(), # Convert to dict for flexibility
243
- }
246
+ return StepExecutionResult(
247
+ success=result.success,
248
+ action=action,
249
+ data=result.model_dump(), # Convert to dict for flexibility
250
+ )
244
251
 
245
252
  elif action == "FIND_AND_TYPE":
246
253
  element_desc = params["element_description"]
247
254
  text = params["text"]
248
255
  # Use technical agent to find input and type (returns AgentActionResult)
249
256
  result = self.technical_agent.act(f"Type '{text}' into {element_desc}")
250
- return {
251
- "success": result.success, # Use attribute access
252
- "action": action,
253
- "data": {"text": text, "result": result.model_dump()},
254
- }
257
+ return StepExecutionResult(
258
+ success=result.success,
259
+ action=action,
260
+ data={"text": text, "result": result.model_dump()},
261
+ )
255
262
 
256
263
  elif action == "PRESS_KEY":
257
264
  key = params["key"]
258
265
  result = self.technical_agent.act(f"Press {key} key")
259
- return {
260
- "success": result.success, # Use attribute access
261
- "action": action,
262
- "data": {"key": key, "result": result.model_dump()},
263
- }
266
+ return StepExecutionResult(
267
+ success=result.success,
268
+ action=action,
269
+ data={"key": key, "result": result.model_dump()},
270
+ )
264
271
 
265
272
  elif action == "WAIT":
266
273
  duration = params.get("duration", 2.0)
267
274
  time.sleep(duration)
268
- return {
269
- "success": True,
270
- "action": action,
271
- "data": {"duration": duration},
272
- }
275
+ return StepExecutionResult(success=True, action=action, data={"duration": duration})
273
276
 
274
277
  elif action == "EXTRACT_INFO":
275
278
  info_type = params["info_type"]
@@ -279,21 +282,28 @@ Create a step-by-step execution plan."""
279
282
  # Use LLM to extract specific information
280
283
  extracted = self._extract_information(snap, info_type)
281
284
 
282
- return {
283
- "success": True,
284
- "action": action,
285
- "data": {"extracted": extracted, "info_type": info_type},
286
- }
285
+ return StepExecutionResult(
286
+ success=True,
287
+ action=action,
288
+ data={
289
+ "extracted": (
290
+ extracted.model_dump()
291
+ if isinstance(extracted, ExtractionResult)
292
+ else extracted
293
+ ),
294
+ "info_type": info_type,
295
+ },
296
+ )
287
297
 
288
298
  elif action == "VERIFY":
289
299
  condition = params["condition"]
290
300
  # Verify condition using current page state
291
301
  is_verified = self._verify_condition(condition)
292
- return {
293
- "success": is_verified,
294
- "action": action,
295
- "data": {"condition": condition, "verified": is_verified},
296
- }
302
+ return StepExecutionResult(
303
+ success=is_verified,
304
+ action=action,
305
+ data={"condition": condition, "verified": is_verified},
306
+ )
297
307
 
298
308
  else:
299
309
  raise ValueError(f"Unknown action: {action}")
@@ -301,9 +311,9 @@ Create a step-by-step execution plan."""
301
311
  except Exception as e:
302
312
  if self.verbose:
303
313
  print(f"❌ Step failed: {e}")
304
- return {"success": False, "action": action, "error": str(e)}
314
+ return StepExecutionResult(success=False, action=action, error=str(e))
305
315
 
306
- def _extract_information(self, snap: Snapshot, info_type: str) -> dict[str, Any]:
316
+ def _extract_information(self, snap: Snapshot, info_type: str) -> ExtractionResult:
307
317
  """
308
318
  Extract specific information from snapshot using LLM
309
319
 
@@ -403,14 +413,38 @@ Return JSON:
403
413
  Human-readable response string
404
414
  """
405
415
  # Build summary of what happened
406
- successful_steps = [r for r in execution_results if r.get("success")]
407
- failed_steps = [r for r in execution_results if not r.get("success")]
416
+ successful_steps = [
417
+ r
418
+ for r in execution_results
419
+ if (isinstance(r, StepExecutionResult) and r.success)
420
+ or (isinstance(r, dict) and r.get("success", False))
421
+ ]
422
+ failed_steps = [
423
+ r
424
+ for r in execution_results
425
+ if (isinstance(r, StepExecutionResult) and not r.success)
426
+ or (isinstance(r, dict) and not r.get("success", False))
427
+ ]
408
428
 
409
429
  # Extract key data
410
430
  extracted_data = []
411
431
  for result in execution_results:
412
- if result.get("action") == "EXTRACT_INFO":
413
- extracted_data.append(result.get("data", {}).get("extracted", {}))
432
+ if isinstance(result, StepExecutionResult):
433
+ action = result.action
434
+ data = result.data
435
+ else:
436
+ action = result.get("action")
437
+ data = result.get("data", {})
438
+
439
+ if action == "EXTRACT_INFO":
440
+ extracted = data.get("extracted", {})
441
+ if isinstance(extracted, dict):
442
+ extracted_data.append(extracted)
443
+ else:
444
+ # If it's an ExtractionResult model, convert to dict
445
+ extracted_data.append(
446
+ extracted.model_dump() if hasattr(extracted, "model_dump") else extracted
447
+ )
414
448
 
415
449
  # Use LLM to create natural response
416
450
  system_prompt = """You are a helpful assistant that summarizes web automation results
@@ -0,0 +1,142 @@
1
+ """
2
+ Human-like cursor movement policy + metadata.
3
+
4
+ This is intentionally SDK-local (no snapshot schema changes). It is used by actions to:
5
+ - generate more realistic mouse movement (multiple moves with easing, optional overshoot/jitter)
6
+ - emit trace/debug metadata describing the movement path
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import random
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CursorPolicy:
18
+ """
19
+ Policy for cursor movement.
20
+
21
+ - mode="instant": current behavior (single click without multi-step motion)
22
+ - mode="human": move with a curved path + optional jitter/overshoot
23
+ """
24
+
25
+ mode: str = "instant" # "instant" | "human"
26
+
27
+ # Motion shaping (human mode)
28
+ steps: int | None = None
29
+ duration_ms: int | None = None
30
+ jitter_px: float = 1.0
31
+ overshoot_px: float = 6.0
32
+ pause_before_click_ms: int = 20
33
+
34
+ # Determinism hook for tests/repro
35
+ seed: int | None = None
36
+
37
+
38
+ def _clamp(v: float, lo: float, hi: float) -> float:
39
+ return max(lo, min(hi, v))
40
+
41
+
42
+ def _ease_in_out(t: float) -> float:
43
+ # Smoothstep-ish easing
44
+ return t * t * (3 - 2 * t)
45
+
46
+
47
+ def _bezier(
48
+ p0: tuple[float, float],
49
+ p1: tuple[float, float],
50
+ p2: tuple[float, float],
51
+ p3: tuple[float, float],
52
+ t: float,
53
+ ) -> tuple[float, float]:
54
+ u = 1.0 - t
55
+ tt = t * t
56
+ uu = u * u
57
+ uuu = uu * u
58
+ ttt = tt * t
59
+ x = uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0]
60
+ y = uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1]
61
+ return (x, y)
62
+
63
+
64
+ def build_human_cursor_path(
65
+ *,
66
+ start: tuple[float, float],
67
+ target: tuple[float, float],
68
+ policy: CursorPolicy,
69
+ ) -> dict:
70
+ """
71
+ Build a human-like cursor path and metadata.
72
+
73
+ Returns a dict suitable for attaching to ActionResult/trace payloads:
74
+ {
75
+ "mode": "human",
76
+ "from": {"x":..., "y":...},
77
+ "to": {"x":..., "y":...},
78
+ "steps": ...,
79
+ "duration_ms": ...,
80
+ "pause_before_click_ms": ...,
81
+ "jitter_px": ...,
82
+ "overshoot_px": ...,
83
+ "path": [{"x":..., "y":..., "t":...}, ...]
84
+ }
85
+ """
86
+ rng = random.Random(policy.seed)
87
+
88
+ x0, y0 = start
89
+ x1, y1 = target
90
+ dx = x1 - x0
91
+ dy = y1 - y0
92
+ dist = math.hypot(dx, dy)
93
+
94
+ # Defaults based on distance (bounded)
95
+ steps = int(policy.steps if policy.steps is not None else _clamp(10 + dist / 25.0, 12, 40))
96
+ duration_ms = int(
97
+ policy.duration_ms if policy.duration_ms is not None else _clamp(120 + dist * 0.9, 120, 700)
98
+ )
99
+
100
+ # Control points: offset roughly perpendicular to travel direction
101
+ if dist < 1e-6:
102
+ dist = 1.0
103
+ ux, uy = dx / dist, dy / dist
104
+ px, py = -uy, ux
105
+ curve_mag = _clamp(dist / 3.5, 10.0, 140.0)
106
+ curve_mag *= rng.uniform(0.5, 1.2)
107
+
108
+ c1 = (x0 + dx * 0.25 + px * curve_mag, y0 + dy * 0.25 + py * curve_mag)
109
+ c2 = (x0 + dx * 0.75 - px * curve_mag, y0 + dy * 0.75 - py * curve_mag)
110
+
111
+ overshoot = float(policy.overshoot_px or 0.0)
112
+ overshoot_point = (x1 + ux * overshoot, y1 + uy * overshoot) if overshoot > 0 else (x1, y1)
113
+
114
+ pts: list[dict] = []
115
+ for i in range(steps):
116
+ t_raw = 0.0 if steps <= 1 else i / (steps - 1)
117
+ t = _ease_in_out(t_raw)
118
+ bx, by = _bezier((x0, y0), c1, c2, overshoot_point, t)
119
+
120
+ # Small jitter, decaying near target
121
+ jitter_scale = float(policy.jitter_px) * (1.0 - t_raw) * 0.9
122
+ jx = rng.uniform(-jitter_scale, jitter_scale)
123
+ jy = rng.uniform(-jitter_scale, jitter_scale)
124
+
125
+ pts.append({"x": bx + jx, "y": by + jy, "t": round(t_raw, 4)})
126
+
127
+ # If we overshot, add a small correction segment back to target.
128
+ if overshoot > 0:
129
+ pts.append({"x": x1, "y": y1, "t": 1.0})
130
+
131
+ return {
132
+ "mode": "human",
133
+ "from": {"x": x0, "y": y0},
134
+ "to": {"x": x1, "y": y1},
135
+ "steps": steps,
136
+ "duration_ms": duration_ms,
137
+ "pause_before_click_ms": int(policy.pause_before_click_ms),
138
+ "jitter_px": float(policy.jitter_px),
139
+ "overshoot_px": overshoot,
140
+ # Keep path bounded for trace size
141
+ "path": pts[:64],
142
+ }
@@ -0,0 +1,136 @@
1
+ """
2
+ Element filtering utilities for agent-based element selection.
3
+
4
+ This module provides centralized element filtering logic to reduce duplication
5
+ across agent implementations.
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from .models import Element, Snapshot
11
+
12
+
13
+ class ElementFilter:
14
+ """
15
+ Centralized element filtering logic for agent-based element selection.
16
+
17
+ Provides static methods for filtering elements based on:
18
+ - Importance scores
19
+ - Goal-based keyword matching
20
+ - Role and visual properties
21
+ """
22
+
23
+ # Common stopwords for keyword extraction
24
+ STOPWORDS = {
25
+ "the",
26
+ "a",
27
+ "an",
28
+ "and",
29
+ "or",
30
+ "but",
31
+ "in",
32
+ "on",
33
+ "at",
34
+ "to",
35
+ "for",
36
+ "of",
37
+ "with",
38
+ "by",
39
+ "from",
40
+ "as",
41
+ "is",
42
+ "was",
43
+ }
44
+
45
+ @staticmethod
46
+ def filter_by_importance(
47
+ snapshot: Snapshot,
48
+ max_elements: int = 50,
49
+ ) -> list[Element]:
50
+ """
51
+ Filter elements by importance score (simple top-N selection).
52
+
53
+ Args:
54
+ snapshot: Current page snapshot
55
+ max_elements: Maximum number of elements to return
56
+
57
+ Returns:
58
+ Top N elements sorted by importance score
59
+ """
60
+ # Filter out REMOVED elements - they're not actionable and shouldn't be in LLM context
61
+ elements = [el for el in snapshot.elements if el.diff_status != "REMOVED"]
62
+ # Elements are already sorted by importance in snapshot
63
+ return elements[:max_elements]
64
+
65
+ @staticmethod
66
+ def filter_by_goal(
67
+ snapshot: Snapshot,
68
+ goal: str | None,
69
+ max_elements: int = 100,
70
+ ) -> list[Element]:
71
+ """
72
+ Filter elements from snapshot based on goal context.
73
+
74
+ Applies goal-based keyword matching to boost relevant elements
75
+ and filters out irrelevant ones.
76
+
77
+ Args:
78
+ snapshot: Current page snapshot
79
+ goal: User's goal (can inform filtering)
80
+ max_elements: Maximum number of elements to return
81
+
82
+ Returns:
83
+ Filtered list of elements sorted by boosted importance score
84
+ """
85
+ # Filter out REMOVED elements - they're not actionable and shouldn't be in LLM context
86
+ elements = [el for el in snapshot.elements if el.diff_status != "REMOVED"]
87
+
88
+ # If no goal provided, return all elements (up to limit)
89
+ if not goal:
90
+ return elements[:max_elements]
91
+
92
+ goal_lower = goal.lower()
93
+
94
+ # Extract keywords from goal
95
+ keywords = ElementFilter._extract_keywords(goal_lower)
96
+
97
+ # Boost elements matching goal keywords
98
+ scored_elements = []
99
+ for el in elements:
100
+ score = el.importance
101
+
102
+ # Boost if element text matches goal
103
+ if el.text and any(kw in el.text.lower() for kw in keywords):
104
+ score += 0.3
105
+
106
+ # Boost if role matches goal intent
107
+ if "click" in goal_lower and el.visual_cues.is_clickable:
108
+ score += 0.2
109
+ if "type" in goal_lower and el.role in ["textbox", "searchbox"]:
110
+ score += 0.2
111
+ if "search" in goal_lower:
112
+ # Filter out non-interactive elements for search tasks
113
+ if el.role in ["link", "img"] and not el.visual_cues.is_primary:
114
+ score -= 0.5
115
+
116
+ scored_elements.append((score, el))
117
+
118
+ # Re-sort by boosted score
119
+ scored_elements.sort(key=lambda x: x[0], reverse=True)
120
+ elements = [el for _, el in scored_elements]
121
+
122
+ return elements[:max_elements]
123
+
124
+ @staticmethod
125
+ def _extract_keywords(text: str) -> list[str]:
126
+ """
127
+ Extract meaningful keywords from goal text.
128
+
129
+ Args:
130
+ text: Text to extract keywords from
131
+
132
+ Returns:
133
+ List of keywords (non-stopwords, length > 2)
134
+ """
135
+ words = text.split()
136
+ return [w for w in words if w not in ElementFilter.STOPWORDS and len(w) > 2]
sentience/expect.py CHANGED
@@ -2,12 +2,13 @@
2
2
  Expect/Assert functionality
3
3
  """
4
4
 
5
+ import asyncio
5
6
  import time
6
7
 
7
- from .browser import SentienceBrowser
8
+ from .browser import AsyncSentienceBrowser, SentienceBrowser
8
9
  from .models import Element
9
10
  from .query import query
10
- from .wait import wait_for
11
+ from .wait import wait_for, wait_for_async
11
12
 
12
13
 
13
14
  class Expectation:
@@ -90,3 +91,98 @@ def expect(browser: SentienceBrowser, selector: str | dict) -> Expectation:
90
91
  Expectation helper
91
92
  """
92
93
  return Expectation(browser, selector)
94
+
95
+
96
+ class ExpectationAsync:
97
+ """Assertion helper for element expectations (async)"""
98
+
99
+ def __init__(self, browser: AsyncSentienceBrowser, selector: str | dict):
100
+ self.browser = browser
101
+ self.selector = selector
102
+
103
+ async def to_be_visible(self, timeout: float = 10.0) -> Element:
104
+ """Assert element is visible (exists and in viewport)"""
105
+ result = await wait_for_async(self.browser, self.selector, timeout=timeout)
106
+
107
+ if not result.found:
108
+ raise AssertionError(f"Element not found: {self.selector} (timeout: {timeout}s)")
109
+
110
+ element = result.element
111
+ if not element.in_viewport:
112
+ raise AssertionError(f"Element found but not visible in viewport: {self.selector}")
113
+
114
+ return element
115
+
116
+ async def to_exist(self, timeout: float = 10.0) -> Element:
117
+ """Assert element exists"""
118
+ result = await wait_for_async(self.browser, self.selector, timeout=timeout)
119
+
120
+ if not result.found:
121
+ raise AssertionError(f"Element does not exist: {self.selector} (timeout: {timeout}s)")
122
+
123
+ return result.element
124
+
125
+ async def to_have_text(self, expected_text: str, timeout: float = 10.0) -> Element:
126
+ """Assert element has specific text"""
127
+ result = await wait_for_async(self.browser, self.selector, timeout=timeout)
128
+
129
+ if not result.found:
130
+ raise AssertionError(f"Element not found: {self.selector} (timeout: {timeout}s)")
131
+
132
+ element = result.element
133
+ if not element.text or expected_text not in element.text:
134
+ raise AssertionError(
135
+ f"Element text mismatch. Expected '{expected_text}', got '{element.text}'"
136
+ )
137
+
138
+ return element
139
+
140
+ async def to_have_count(self, expected_count: int, timeout: float = 10.0) -> None:
141
+ """Assert selector matches exactly N elements"""
142
+ from .snapshot import snapshot_async
143
+
144
+ start_time = time.time()
145
+ while time.time() - start_time < timeout:
146
+ snap = await snapshot_async(self.browser)
147
+ matches = query(snap, self.selector)
148
+
149
+ if len(matches) == expected_count:
150
+ return
151
+
152
+ await asyncio.sleep(0.25)
153
+
154
+ # Final check
155
+ snap = await snapshot_async(self.browser)
156
+ matches = query(snap, self.selector)
157
+ actual_count = len(matches)
158
+
159
+ raise AssertionError(
160
+ f"Element count mismatch. Expected {expected_count}, got {actual_count}"
161
+ )
162
+
163
+
164
+ def expect_async(browser: AsyncSentienceBrowser, selector: str | dict) -> ExpectationAsync:
165
+ """
166
+ Create expectation helper for assertions (async)
167
+
168
+ Args:
169
+ browser: AsyncSentienceBrowser instance
170
+ selector: String DSL or dict query
171
+
172
+ Returns:
173
+ ExpectationAsync helper
174
+
175
+ Example:
176
+ # Assert element is visible
177
+ element = await expect_async(browser, "role=button").to_be_visible()
178
+
179
+ # Assert element has text
180
+ element = await expect_async(browser, "h1").to_have_text("Welcome")
181
+
182
+ # Assert element exists
183
+ element = await expect_async(browser, "role=link").to_exist()
184
+
185
+ # Assert count
186
+ await expect_async(browser, "role=button").to_have_count(5)
187
+ """
188
+ return ExpectationAsync(browser, selector)