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.
- sentience/__init__.py +120 -6
- sentience/_extension_loader.py +156 -1
- sentience/action_executor.py +217 -0
- sentience/actions.py +758 -30
- sentience/agent.py +806 -293
- sentience/agent_config.py +3 -0
- sentience/agent_runtime.py +840 -0
- sentience/asserts/__init__.py +70 -0
- sentience/asserts/expect.py +621 -0
- sentience/asserts/query.py +383 -0
- sentience/async_api.py +89 -1141
- sentience/backends/__init__.py +137 -0
- sentience/backends/actions.py +372 -0
- sentience/backends/browser_use_adapter.py +241 -0
- sentience/backends/cdp_backend.py +393 -0
- sentience/backends/exceptions.py +211 -0
- sentience/backends/playwright_backend.py +194 -0
- sentience/backends/protocol.py +216 -0
- sentience/backends/sentience_context.py +469 -0
- sentience/backends/snapshot.py +483 -0
- sentience/base_agent.py +95 -0
- sentience/browser.py +678 -39
- sentience/browser_evaluator.py +299 -0
- sentience/canonicalization.py +207 -0
- sentience/cloud_tracing.py +507 -42
- sentience/constants.py +6 -0
- sentience/conversational_agent.py +77 -43
- sentience/cursor_policy.py +142 -0
- sentience/element_filter.py +136 -0
- sentience/expect.py +98 -2
- sentience/extension/background.js +56 -185
- sentience/extension/content.js +150 -287
- sentience/extension/injected_api.js +1088 -1368
- sentience/extension/manifest.json +1 -1
- sentience/extension/pkg/sentience_core.d.ts +22 -22
- sentience/extension/pkg/sentience_core.js +275 -433
- sentience/extension/pkg/sentience_core_bg.wasm +0 -0
- sentience/extension/release.json +47 -47
- sentience/failure_artifacts.py +241 -0
- sentience/formatting.py +9 -53
- sentience/inspector.py +183 -1
- sentience/integrations/__init__.py +6 -0
- sentience/integrations/langchain/__init__.py +12 -0
- sentience/integrations/langchain/context.py +18 -0
- sentience/integrations/langchain/core.py +326 -0
- sentience/integrations/langchain/tools.py +180 -0
- sentience/integrations/models.py +46 -0
- sentience/integrations/pydanticai/__init__.py +15 -0
- sentience/integrations/pydanticai/deps.py +20 -0
- sentience/integrations/pydanticai/toolset.py +468 -0
- sentience/llm_interaction_handler.py +191 -0
- sentience/llm_provider.py +765 -66
- sentience/llm_provider_utils.py +120 -0
- sentience/llm_response_builder.py +153 -0
- sentience/models.py +595 -3
- sentience/ordinal.py +280 -0
- sentience/overlay.py +109 -2
- sentience/protocols.py +228 -0
- sentience/query.py +67 -5
- sentience/read.py +95 -3
- sentience/recorder.py +223 -3
- sentience/schemas/trace_v1.json +128 -9
- sentience/screenshot.py +48 -2
- sentience/sentience_methods.py +86 -0
- sentience/snapshot.py +599 -55
- sentience/snapshot_diff.py +126 -0
- sentience/text_search.py +120 -5
- sentience/trace_event_builder.py +148 -0
- sentience/trace_file_manager.py +197 -0
- sentience/trace_indexing/index_schema.py +95 -7
- sentience/trace_indexing/indexer.py +105 -48
- sentience/tracer_factory.py +120 -9
- sentience/tracing.py +172 -8
- sentience/utils/__init__.py +40 -0
- sentience/utils/browser.py +46 -0
- sentience/{utils.py → utils/element.py} +3 -42
- sentience/utils/formatting.py +59 -0
- sentience/verification.py +618 -0
- sentience/visual_agent.py +2058 -0
- sentience/wait.py +68 -2
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +199 -40
- sentienceapi-0.98.0.dist-info/RECORD +92 -0
- sentience/extension/test-content.js +0 -4
- sentienceapi-0.90.16.dist-info/RECORD +0 -50
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
- {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assertion DSL for Sentience SDK.
|
|
3
|
+
|
|
4
|
+
This module provides a Playwright/Cypress-like assertion API for verifying
|
|
5
|
+
browser state in agent verification loops.
|
|
6
|
+
|
|
7
|
+
Main exports:
|
|
8
|
+
- E: Element query builder (filters elements by role, text, href, etc.)
|
|
9
|
+
- expect: Expectation builder (creates predicates from queries)
|
|
10
|
+
- in_dominant_list: Query over dominant group elements (ordinal access)
|
|
11
|
+
|
|
12
|
+
Example usage:
|
|
13
|
+
from sentience.asserts import E, expect, in_dominant_list
|
|
14
|
+
|
|
15
|
+
# Basic presence assertions
|
|
16
|
+
runtime.assert_(
|
|
17
|
+
expect(E(role="button", text_contains="Save")).to_exist(),
|
|
18
|
+
label="save_button_visible"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Visibility assertions
|
|
22
|
+
runtime.assert_(
|
|
23
|
+
expect(E(text_contains="Checkout")).to_be_visible(),
|
|
24
|
+
label="checkout_visible"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Global text assertions
|
|
28
|
+
runtime.assert_(
|
|
29
|
+
expect.text_present("Welcome back"),
|
|
30
|
+
label="user_logged_in"
|
|
31
|
+
)
|
|
32
|
+
runtime.assert_(
|
|
33
|
+
expect.no_text("Error"),
|
|
34
|
+
label="no_error_message"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Ordinal assertions on dominant group
|
|
38
|
+
runtime.assert_(
|
|
39
|
+
expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN"),
|
|
40
|
+
label="first_item_is_show_hn"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Task completion
|
|
44
|
+
runtime.assert_done(
|
|
45
|
+
expect.text_present("Order confirmed"),
|
|
46
|
+
label="checkout_complete"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
The DSL compiles to existing Predicate functions, so it works seamlessly
|
|
50
|
+
with AgentRuntime.assert_() and assert_done().
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from .expect import EventuallyConfig, EventuallyWrapper, ExpectBuilder, expect, with_eventually
|
|
54
|
+
from .query import E, ElementQuery, ListQuery, MultiQuery, in_dominant_list
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
# Query builders
|
|
58
|
+
"E",
|
|
59
|
+
"ElementQuery",
|
|
60
|
+
"ListQuery",
|
|
61
|
+
"MultiQuery",
|
|
62
|
+
"in_dominant_list",
|
|
63
|
+
# Expectation builders
|
|
64
|
+
"expect",
|
|
65
|
+
"ExpectBuilder",
|
|
66
|
+
# Eventually helpers
|
|
67
|
+
"with_eventually",
|
|
68
|
+
"EventuallyWrapper",
|
|
69
|
+
"EventuallyConfig",
|
|
70
|
+
]
|
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Expectation builder for assertion DSL.
|
|
3
|
+
|
|
4
|
+
This module provides the expect() builder that creates fluent assertions
|
|
5
|
+
which compile to existing Predicate objects.
|
|
6
|
+
|
|
7
|
+
Key classes:
|
|
8
|
+
- ExpectBuilder: Fluent builder for element-based assertions
|
|
9
|
+
- EventuallyBuilder: Wrapper for retry logic (.eventually())
|
|
10
|
+
|
|
11
|
+
The expect() function is the main entry point. It returns a builder that
|
|
12
|
+
can be chained with matchers:
|
|
13
|
+
expect(E(role="button")).to_exist()
|
|
14
|
+
expect(E(text_contains="Error")).not_to_exist()
|
|
15
|
+
expect.text_present("Welcome")
|
|
16
|
+
|
|
17
|
+
All builders compile to Predicate functions compatible with AgentRuntime.assert_().
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from ..verification import AssertContext, AssertOutcome, Predicate
|
|
28
|
+
from .query import ElementQuery, ListQuery, MultiQuery, _MultiTextPredicate
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ..models import Snapshot
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Default values for .eventually()
|
|
35
|
+
DEFAULT_TIMEOUT = 10 # seconds
|
|
36
|
+
DEFAULT_POLL = 0.2 # seconds
|
|
37
|
+
DEFAULT_MAX_RETRIES = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EventuallyConfig:
|
|
42
|
+
"""Configuration for .eventually() retry logic."""
|
|
43
|
+
|
|
44
|
+
timeout: float = DEFAULT_TIMEOUT # Max time to wait (seconds)
|
|
45
|
+
poll: float = DEFAULT_POLL # Interval between retries (seconds)
|
|
46
|
+
max_retries: int = DEFAULT_MAX_RETRIES # Max number of retry attempts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ExpectBuilder:
|
|
50
|
+
"""
|
|
51
|
+
Fluent builder for element-based assertions.
|
|
52
|
+
|
|
53
|
+
Created by expect(E(...)) or expect(in_dominant_list().nth(k)).
|
|
54
|
+
|
|
55
|
+
Methods return Predicate functions that can be passed to runtime.assert_().
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
expect(E(role="button")).to_exist()
|
|
59
|
+
expect(E(text_contains="Error")).not_to_exist()
|
|
60
|
+
expect(E(role="link")).to_be_visible()
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, query: ElementQuery | MultiQuery | _MultiTextPredicate):
|
|
64
|
+
"""
|
|
65
|
+
Initialize builder with query.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
query: ElementQuery, MultiQuery, or _MultiTextPredicate
|
|
69
|
+
"""
|
|
70
|
+
self._query = query
|
|
71
|
+
|
|
72
|
+
def to_exist(self) -> Predicate:
|
|
73
|
+
"""
|
|
74
|
+
Assert that at least one element matches the query.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Predicate function for use with runtime.assert_()
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
runtime.assert_(
|
|
81
|
+
expect(E(role="button", text_contains="Save")).to_exist(),
|
|
82
|
+
label="save_button_exists"
|
|
83
|
+
)
|
|
84
|
+
"""
|
|
85
|
+
query = self._query
|
|
86
|
+
|
|
87
|
+
def _pred(ctx: AssertContext) -> AssertOutcome:
|
|
88
|
+
snap = ctx.snapshot
|
|
89
|
+
if snap is None:
|
|
90
|
+
return AssertOutcome(
|
|
91
|
+
passed=False,
|
|
92
|
+
reason="no snapshot available",
|
|
93
|
+
details={"query": _query_to_dict(query)},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if isinstance(query, ElementQuery):
|
|
97
|
+
matches = query.find_all(snap)
|
|
98
|
+
ok = len(matches) > 0
|
|
99
|
+
return AssertOutcome(
|
|
100
|
+
passed=ok,
|
|
101
|
+
reason="" if ok else f"no elements matched query: {_query_to_dict(query)}",
|
|
102
|
+
details={"query": _query_to_dict(query), "matched": len(matches)},
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
return AssertOutcome(
|
|
106
|
+
passed=False,
|
|
107
|
+
reason="to_exist() requires ElementQuery",
|
|
108
|
+
details={},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return _pred
|
|
112
|
+
|
|
113
|
+
def not_to_exist(self) -> Predicate:
|
|
114
|
+
"""
|
|
115
|
+
Assert that NO elements match the query.
|
|
116
|
+
|
|
117
|
+
Useful for asserting absence of error messages, loading indicators, etc.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Predicate function for use with runtime.assert_()
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
runtime.assert_(
|
|
124
|
+
expect(E(text_contains="Error")).not_to_exist(),
|
|
125
|
+
label="no_error_message"
|
|
126
|
+
)
|
|
127
|
+
"""
|
|
128
|
+
query = self._query
|
|
129
|
+
|
|
130
|
+
def _pred(ctx: AssertContext) -> AssertOutcome:
|
|
131
|
+
snap = ctx.snapshot
|
|
132
|
+
if snap is None:
|
|
133
|
+
return AssertOutcome(
|
|
134
|
+
passed=False,
|
|
135
|
+
reason="no snapshot available",
|
|
136
|
+
details={"query": _query_to_dict(query)},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if isinstance(query, ElementQuery):
|
|
140
|
+
matches = query.find_all(snap)
|
|
141
|
+
ok = len(matches) == 0
|
|
142
|
+
return AssertOutcome(
|
|
143
|
+
passed=ok,
|
|
144
|
+
reason=(
|
|
145
|
+
""
|
|
146
|
+
if ok
|
|
147
|
+
else f"found {len(matches)} elements matching: {_query_to_dict(query)}"
|
|
148
|
+
),
|
|
149
|
+
details={"query": _query_to_dict(query), "matched": len(matches)},
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
return AssertOutcome(
|
|
153
|
+
passed=False,
|
|
154
|
+
reason="not_to_exist() requires ElementQuery",
|
|
155
|
+
details={},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return _pred
|
|
159
|
+
|
|
160
|
+
def to_be_visible(self) -> Predicate:
|
|
161
|
+
"""
|
|
162
|
+
Assert that element exists AND is visible (in_viewport=True, occluded=False).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Predicate function for use with runtime.assert_()
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
runtime.assert_(
|
|
169
|
+
expect(E(text_contains="Checkout")).to_be_visible(),
|
|
170
|
+
label="checkout_button_visible"
|
|
171
|
+
)
|
|
172
|
+
"""
|
|
173
|
+
query = self._query
|
|
174
|
+
|
|
175
|
+
def _pred(ctx: AssertContext) -> AssertOutcome:
|
|
176
|
+
snap = ctx.snapshot
|
|
177
|
+
if snap is None:
|
|
178
|
+
return AssertOutcome(
|
|
179
|
+
passed=False,
|
|
180
|
+
reason="no snapshot available",
|
|
181
|
+
details={"query": _query_to_dict(query)},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if isinstance(query, ElementQuery):
|
|
185
|
+
matches = query.find_all(snap)
|
|
186
|
+
if len(matches) == 0:
|
|
187
|
+
return AssertOutcome(
|
|
188
|
+
passed=False,
|
|
189
|
+
reason=f"no elements matched query: {_query_to_dict(query)}",
|
|
190
|
+
details={"query": _query_to_dict(query), "matched": 0},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Check visibility of first match
|
|
194
|
+
el = matches[0]
|
|
195
|
+
is_visible = el.in_viewport and not el.is_occluded
|
|
196
|
+
return AssertOutcome(
|
|
197
|
+
passed=is_visible,
|
|
198
|
+
reason=(
|
|
199
|
+
""
|
|
200
|
+
if is_visible
|
|
201
|
+
else f"element found but not visible (in_viewport={el.in_viewport}, is_occluded={el.is_occluded})"
|
|
202
|
+
),
|
|
203
|
+
details={
|
|
204
|
+
"query": _query_to_dict(query),
|
|
205
|
+
"element_id": el.id,
|
|
206
|
+
"in_viewport": el.in_viewport,
|
|
207
|
+
"is_occluded": el.is_occluded,
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
return AssertOutcome(
|
|
212
|
+
passed=False,
|
|
213
|
+
reason="to_be_visible() requires ElementQuery",
|
|
214
|
+
details={},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return _pred
|
|
218
|
+
|
|
219
|
+
def to_have_text_contains(self, text: str) -> Predicate:
|
|
220
|
+
"""
|
|
221
|
+
Assert that element's text contains the specified substring.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
text: Substring to search for (case-insensitive)
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Predicate function for use with runtime.assert_()
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
runtime.assert_(
|
|
231
|
+
expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN"),
|
|
232
|
+
label="first_item_is_show_hn"
|
|
233
|
+
)
|
|
234
|
+
"""
|
|
235
|
+
query = self._query
|
|
236
|
+
|
|
237
|
+
def _pred(ctx: AssertContext) -> AssertOutcome:
|
|
238
|
+
snap = ctx.snapshot
|
|
239
|
+
if snap is None:
|
|
240
|
+
return AssertOutcome(
|
|
241
|
+
passed=False,
|
|
242
|
+
reason="no snapshot available",
|
|
243
|
+
details={"query": _query_to_dict(query), "expected_text": text},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if isinstance(query, ElementQuery):
|
|
247
|
+
matches = query.find_all(snap)
|
|
248
|
+
if len(matches) == 0:
|
|
249
|
+
return AssertOutcome(
|
|
250
|
+
passed=False,
|
|
251
|
+
reason=f"no elements matched query: {_query_to_dict(query)}",
|
|
252
|
+
details={
|
|
253
|
+
"query": _query_to_dict(query),
|
|
254
|
+
"matched": 0,
|
|
255
|
+
"expected_text": text,
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Check text of first match
|
|
260
|
+
el = matches[0]
|
|
261
|
+
el_text = el.text or ""
|
|
262
|
+
ok = text.lower() in el_text.lower()
|
|
263
|
+
return AssertOutcome(
|
|
264
|
+
passed=ok,
|
|
265
|
+
reason=(
|
|
266
|
+
"" if ok else f"element text '{el_text[:100]}' does not contain '{text}'"
|
|
267
|
+
),
|
|
268
|
+
details={
|
|
269
|
+
"query": _query_to_dict(query),
|
|
270
|
+
"element_id": el.id,
|
|
271
|
+
"element_text": el_text[:200],
|
|
272
|
+
"expected_text": text,
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
elif isinstance(query, _MultiTextPredicate):
|
|
276
|
+
# This is from MultiQuery.any_text_contains()
|
|
277
|
+
# Already handled by that method
|
|
278
|
+
return AssertOutcome(
|
|
279
|
+
passed=False,
|
|
280
|
+
reason="use any_text_contains() for MultiQuery",
|
|
281
|
+
details={},
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
return AssertOutcome(
|
|
285
|
+
passed=False,
|
|
286
|
+
reason="to_have_text_contains() requires ElementQuery",
|
|
287
|
+
details={},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return _pred
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class _ExpectFactory:
|
|
294
|
+
"""
|
|
295
|
+
Factory for creating ExpectBuilder instances and global assertions.
|
|
296
|
+
|
|
297
|
+
This is the main entry point for the assertion DSL.
|
|
298
|
+
|
|
299
|
+
Usage:
|
|
300
|
+
from sentience.asserts import expect, E
|
|
301
|
+
|
|
302
|
+
# Element-based assertions
|
|
303
|
+
expect(E(role="button")).to_exist()
|
|
304
|
+
expect(E(text_contains="Error")).not_to_exist()
|
|
305
|
+
|
|
306
|
+
# Global text assertions
|
|
307
|
+
expect.text_present("Welcome back")
|
|
308
|
+
expect.no_text("Error")
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def __call__(
|
|
312
|
+
self,
|
|
313
|
+
query: ElementQuery | ListQuery | MultiQuery | _MultiTextPredicate,
|
|
314
|
+
) -> ExpectBuilder:
|
|
315
|
+
"""
|
|
316
|
+
Create an expectation builder for the given query.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
query: ElementQuery, ListQuery.nth() result, MultiQuery, or _MultiTextPredicate
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
ExpectBuilder for chaining matchers
|
|
323
|
+
|
|
324
|
+
Example:
|
|
325
|
+
expect(E(role="button")).to_exist()
|
|
326
|
+
expect(in_dominant_list().nth(0)).to_have_text_contains("Show HN")
|
|
327
|
+
"""
|
|
328
|
+
if isinstance(query, (ElementQuery, MultiQuery, _MultiTextPredicate)):
|
|
329
|
+
return ExpectBuilder(query)
|
|
330
|
+
else:
|
|
331
|
+
raise TypeError(
|
|
332
|
+
f"expect() requires ElementQuery, MultiQuery, or _MultiTextPredicate, got {type(query)}"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def text_present(self, text: str) -> Predicate:
|
|
336
|
+
"""
|
|
337
|
+
Global assertion: check if text is present anywhere on the page.
|
|
338
|
+
|
|
339
|
+
Searches across all element text_norm fields.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
text: Text to search for (case-insensitive substring)
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Predicate function for use with runtime.assert_()
|
|
346
|
+
|
|
347
|
+
Example:
|
|
348
|
+
runtime.assert_(
|
|
349
|
+
expect.text_present("Welcome back"),
|
|
350
|
+
label="user_logged_in"
|
|
351
|
+
)
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def _pred(ctx: AssertContext) -> AssertOutcome:
|
|
355
|
+
snap = ctx.snapshot
|
|
356
|
+
if snap is None:
|
|
357
|
+
return AssertOutcome(
|
|
358
|
+
passed=False,
|
|
359
|
+
reason="no snapshot available",
|
|
360
|
+
details={"search_text": text},
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Search all element texts
|
|
364
|
+
text_lower = text.lower()
|
|
365
|
+
for el in snap.elements:
|
|
366
|
+
el_text = el.text or ""
|
|
367
|
+
if text_lower in el_text.lower():
|
|
368
|
+
return AssertOutcome(
|
|
369
|
+
passed=True,
|
|
370
|
+
reason="",
|
|
371
|
+
details={"search_text": text, "found_in_element": el.id},
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return AssertOutcome(
|
|
375
|
+
passed=False,
|
|
376
|
+
reason=f"text '{text}' not found on page",
|
|
377
|
+
details={"search_text": text, "elements_searched": len(snap.elements)},
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return _pred
|
|
381
|
+
|
|
382
|
+
def no_text(self, text: str) -> Predicate:
|
|
383
|
+
"""
|
|
384
|
+
Global assertion: check that text is NOT present anywhere on the page.
|
|
385
|
+
|
|
386
|
+
Searches across all element text_norm fields.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
text: Text that should not be present (case-insensitive substring)
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Predicate function for use with runtime.assert_()
|
|
393
|
+
|
|
394
|
+
Example:
|
|
395
|
+
runtime.assert_(
|
|
396
|
+
expect.no_text("Error"),
|
|
397
|
+
label="no_error_message"
|
|
398
|
+
)
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def _pred(ctx: AssertContext) -> AssertOutcome:
|
|
402
|
+
snap = ctx.snapshot
|
|
403
|
+
if snap is None:
|
|
404
|
+
return AssertOutcome(
|
|
405
|
+
passed=False,
|
|
406
|
+
reason="no snapshot available",
|
|
407
|
+
details={"search_text": text},
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Search all element texts
|
|
411
|
+
text_lower = text.lower()
|
|
412
|
+
for el in snap.elements:
|
|
413
|
+
el_text = el.text or ""
|
|
414
|
+
if text_lower in el_text.lower():
|
|
415
|
+
return AssertOutcome(
|
|
416
|
+
passed=False,
|
|
417
|
+
reason=f"text '{text}' found in element id={el.id}",
|
|
418
|
+
details={
|
|
419
|
+
"search_text": text,
|
|
420
|
+
"found_in_element": el.id,
|
|
421
|
+
"element_text": el_text[:200],
|
|
422
|
+
},
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return AssertOutcome(
|
|
426
|
+
passed=True,
|
|
427
|
+
reason="",
|
|
428
|
+
details={"search_text": text, "elements_searched": len(snap.elements)},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return _pred
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# Create the singleton factory
|
|
435
|
+
expect = _ExpectFactory()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _query_to_dict(query: ElementQuery | MultiQuery | _MultiTextPredicate | Any) -> dict[str, Any]:
|
|
439
|
+
"""Convert query to a serializable dict for debugging."""
|
|
440
|
+
if isinstance(query, ElementQuery):
|
|
441
|
+
result = {}
|
|
442
|
+
if query.role:
|
|
443
|
+
result["role"] = query.role
|
|
444
|
+
if query.name:
|
|
445
|
+
result["name"] = query.name
|
|
446
|
+
if query.text:
|
|
447
|
+
result["text"] = query.text
|
|
448
|
+
if query.text_contains:
|
|
449
|
+
result["text_contains"] = query.text_contains
|
|
450
|
+
if query.href_contains:
|
|
451
|
+
result["href_contains"] = query.href_contains
|
|
452
|
+
if query.in_viewport is not None:
|
|
453
|
+
result["in_viewport"] = query.in_viewport
|
|
454
|
+
if query.occluded is not None:
|
|
455
|
+
result["occluded"] = query.occluded
|
|
456
|
+
if query.group:
|
|
457
|
+
result["group"] = query.group
|
|
458
|
+
if query.in_dominant_group is not None:
|
|
459
|
+
result["in_dominant_group"] = query.in_dominant_group
|
|
460
|
+
if query._group_index is not None:
|
|
461
|
+
result["group_index"] = query._group_index
|
|
462
|
+
if query._from_dominant_list:
|
|
463
|
+
result["from_dominant_list"] = True
|
|
464
|
+
return result
|
|
465
|
+
elif isinstance(query, MultiQuery):
|
|
466
|
+
return {"type": "multi", "limit": query.limit}
|
|
467
|
+
elif isinstance(query, _MultiTextPredicate):
|
|
468
|
+
return {
|
|
469
|
+
"type": "multi_text",
|
|
470
|
+
"text": query.text,
|
|
471
|
+
"check_type": query.check_type,
|
|
472
|
+
}
|
|
473
|
+
else:
|
|
474
|
+
return {"type": str(type(query))}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class EventuallyWrapper:
|
|
478
|
+
"""
|
|
479
|
+
Wrapper that adds retry logic to a predicate.
|
|
480
|
+
|
|
481
|
+
Created by calling .eventually() on an ExpectBuilder method result.
|
|
482
|
+
This is a helper that executes retries by taking fresh snapshots.
|
|
483
|
+
|
|
484
|
+
Note: .eventually() returns an async function that must be awaited.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
def __init__(
|
|
488
|
+
self,
|
|
489
|
+
predicate: Predicate,
|
|
490
|
+
config: EventuallyConfig,
|
|
491
|
+
):
|
|
492
|
+
"""
|
|
493
|
+
Initialize eventually wrapper.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
predicate: The predicate to retry
|
|
497
|
+
config: Retry configuration
|
|
498
|
+
"""
|
|
499
|
+
self._predicate = predicate
|
|
500
|
+
self._config = config
|
|
501
|
+
|
|
502
|
+
async def evaluate(self, ctx: AssertContext, snapshot_fn) -> AssertOutcome:
|
|
503
|
+
"""
|
|
504
|
+
Evaluate predicate with retry logic.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
ctx: Initial assertion context
|
|
508
|
+
snapshot_fn: Async function to take fresh snapshots
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
AssertOutcome from successful evaluation or last failed attempt
|
|
512
|
+
"""
|
|
513
|
+
start_time = time.monotonic()
|
|
514
|
+
last_outcome: AssertOutcome | None = None
|
|
515
|
+
attempts = 0
|
|
516
|
+
|
|
517
|
+
while True:
|
|
518
|
+
# Check timeout (higher precedence than max_retries)
|
|
519
|
+
elapsed = time.monotonic() - start_time
|
|
520
|
+
if elapsed >= self._config.timeout:
|
|
521
|
+
if last_outcome:
|
|
522
|
+
last_outcome.reason = f"timeout after {elapsed:.1f}s: {last_outcome.reason}"
|
|
523
|
+
return last_outcome
|
|
524
|
+
return AssertOutcome(
|
|
525
|
+
passed=False,
|
|
526
|
+
reason=f"timeout after {elapsed:.1f}s",
|
|
527
|
+
details={"attempts": attempts},
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
# Check max retries
|
|
531
|
+
if attempts >= self._config.max_retries:
|
|
532
|
+
if last_outcome:
|
|
533
|
+
last_outcome.reason = (
|
|
534
|
+
f"max retries ({self._config.max_retries}) exceeded: {last_outcome.reason}"
|
|
535
|
+
)
|
|
536
|
+
return last_outcome
|
|
537
|
+
return AssertOutcome(
|
|
538
|
+
passed=False,
|
|
539
|
+
reason=f"max retries ({self._config.max_retries}) exceeded",
|
|
540
|
+
details={"attempts": attempts},
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Take fresh snapshot if not first attempt
|
|
544
|
+
if attempts > 0:
|
|
545
|
+
try:
|
|
546
|
+
fresh_snapshot = await snapshot_fn()
|
|
547
|
+
ctx = AssertContext(
|
|
548
|
+
snapshot=fresh_snapshot,
|
|
549
|
+
url=fresh_snapshot.url if fresh_snapshot else ctx.url,
|
|
550
|
+
step_id=ctx.step_id,
|
|
551
|
+
)
|
|
552
|
+
except Exception as e:
|
|
553
|
+
last_outcome = AssertOutcome(
|
|
554
|
+
passed=False,
|
|
555
|
+
reason=f"failed to take snapshot: {e}",
|
|
556
|
+
details={"attempts": attempts, "error": str(e)},
|
|
557
|
+
)
|
|
558
|
+
attempts += 1
|
|
559
|
+
await asyncio.sleep(self._config.poll)
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
# Evaluate predicate
|
|
563
|
+
outcome = self._predicate(ctx)
|
|
564
|
+
if outcome.passed:
|
|
565
|
+
outcome.details["attempts"] = attempts + 1
|
|
566
|
+
return outcome
|
|
567
|
+
|
|
568
|
+
last_outcome = outcome
|
|
569
|
+
attempts += 1
|
|
570
|
+
|
|
571
|
+
# Wait before next retry
|
|
572
|
+
if attempts < self._config.max_retries:
|
|
573
|
+
# Check if we'd exceed timeout with the poll delay
|
|
574
|
+
if (time.monotonic() - start_time + self._config.poll) < self._config.timeout:
|
|
575
|
+
await asyncio.sleep(self._config.poll)
|
|
576
|
+
else:
|
|
577
|
+
# No point waiting, we'll timeout anyway
|
|
578
|
+
last_outcome.reason = (
|
|
579
|
+
f"timeout after {time.monotonic() - start_time:.1f}s: {last_outcome.reason}"
|
|
580
|
+
)
|
|
581
|
+
return last_outcome
|
|
582
|
+
|
|
583
|
+
return last_outcome or AssertOutcome(passed=False, reason="unexpected state")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def with_eventually(
|
|
587
|
+
predicate: Predicate,
|
|
588
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
589
|
+
poll: float = DEFAULT_POLL,
|
|
590
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
591
|
+
) -> EventuallyWrapper:
|
|
592
|
+
"""
|
|
593
|
+
Wrap a predicate with retry logic.
|
|
594
|
+
|
|
595
|
+
This is the Python API for .eventually(). Since Python predicates
|
|
596
|
+
are synchronous, this returns a wrapper that provides an async
|
|
597
|
+
evaluate() method for use with the runtime.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
predicate: Predicate to wrap
|
|
601
|
+
timeout: Max time to wait (seconds, default 10)
|
|
602
|
+
poll: Interval between retries (seconds, default 0.2)
|
|
603
|
+
max_retries: Max number of retry attempts (default 3)
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
EventuallyWrapper with async evaluate() method
|
|
607
|
+
|
|
608
|
+
Example:
|
|
609
|
+
wrapper = with_eventually(
|
|
610
|
+
expect(E(role="button")).to_exist(),
|
|
611
|
+
timeout=5,
|
|
612
|
+
max_retries=10
|
|
613
|
+
)
|
|
614
|
+
result = await wrapper.evaluate(ctx, runtime.snapshot)
|
|
615
|
+
"""
|
|
616
|
+
config = EventuallyConfig(
|
|
617
|
+
timeout=timeout,
|
|
618
|
+
poll=poll,
|
|
619
|
+
max_retries=max_retries,
|
|
620
|
+
)
|
|
621
|
+
return EventuallyWrapper(predicate, config)
|