Skip to content

Commit 9de198a

Browse files
committed
clean up & hardening
1 parent 517e3f6 commit 9de198a

26 files changed

+580
-285
lines changed

sentience/actions.py

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
from typing import Optional
2+
13
"""
24
Actions v1 - click, type, press
35
"""
46

57
import time
68

79
from .browser import AsyncSentienceBrowser, SentienceBrowser
10+
from .browser_evaluator import BrowserEvaluator
811
from .models import ActionResult, BBox, Snapshot
912
from .snapshot import snapshot, snapshot_async
1013

@@ -59,41 +62,22 @@ def click( # noqa: C901
5962
else:
6063
# Fallback to JS click if element not found in snapshot
6164
try:
62-
success = browser.page.evaluate(
63-
"""
64-
(id) => {
65-
return window.sentience.click(id);
66-
}
67-
""",
68-
element_id,
65+
success = BrowserEvaluator.call_sentience_method(
66+
browser.page, "click", element_id
6967
)
7068
except Exception:
7169
# Navigation might have destroyed context, assume success if URL changed
7270
success = True
7371
except Exception:
7472
# Fallback to JS click on error
7573
try:
76-
success = browser.page.evaluate(
77-
"""
78-
(id) => {
79-
return window.sentience.click(id);
80-
}
81-
""",
82-
element_id,
83-
)
74+
success = BrowserEvaluator.call_sentience_method(browser.page, "click", element_id)
8475
except Exception:
8576
# Navigation might have destroyed context, assume success if URL changed
8677
success = True
8778
else:
8879
# Legacy JS-based click
89-
success = browser.page.evaluate(
90-
"""
91-
(id) => {
92-
return window.sentience.click(id);
93-
}
94-
""",
95-
element_id,
96-
)
80+
success = BrowserEvaluator.call_sentience_method(browser.page, "click", element_id)
9781

9882
# Wait a bit for navigation/DOM updates
9983
try:

sentience/agent.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ def _compute_hash(self, text: str) -> str:
100100
"""Compute SHA256 hash of text."""
101101
return hashlib.sha256(text.encode("utf-8")).hexdigest()
102102

103-
def _get_element_bbox(self, element_id: int | None, snap: Snapshot) -> dict[str, float] | None:
103+
def _get_element_bbox(
104+
self, element_id: int | None, snap: Snapshot
105+
) -> dict[str, float] | None:
104106
"""Get bounding box for an element from snapshot."""
105107
if element_id is None:
106108
return None
@@ -872,7 +874,9 @@ def _compute_hash(self, text: str) -> str:
872874
"""Compute SHA256 hash of text."""
873875
return hashlib.sha256(text.encode("utf-8")).hexdigest()
874876

875-
def _get_element_bbox(self, element_id: int | None, snap: Snapshot) -> dict[str, float] | None:
877+
def _get_element_bbox(
878+
self, element_id: int | None, snap: Snapshot
879+
) -> dict[str, float] | None:
876880
"""Get bounding box for an element from snapshot."""
877881
if element_id is None:
878882
return None

sentience/base_agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Optional
2+
13
"""
24
BaseAgent: Abstract base class for all Sentience agents
35
Defines the interface that all agent implementations must follow

sentience/browser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import tempfile
99
import time
1010
from pathlib import Path
11+
from typing import Optional, Union
1112
from urllib.parse import urlparse
1213

1314
from playwright.async_api import BrowserContext as AsyncBrowserContext

sentience/browser_evaluator.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
"""
2+
Browser evaluation helper for common window.sentience API patterns.
3+
4+
Consolidates repeated patterns for:
5+
- Waiting for extension injection
6+
- Calling window.sentience methods
7+
- Error handling with diagnostics
8+
"""
9+
10+
from typing import Any, Optional, Union
11+
12+
from playwright.async_api import Page as AsyncPage
13+
from playwright.sync_api import Page
14+
15+
from .browser import AsyncSentienceBrowser, SentienceBrowser
16+
17+
18+
class BrowserEvaluator:
19+
"""Helper class for common browser evaluation patterns"""
20+
21+
@staticmethod
22+
def wait_for_extension(
23+
page: Union[Page, AsyncPage],
24+
timeout_ms: int = 5000,
25+
) -> None:
26+
"""
27+
Wait for window.sentience API to be available.
28+
29+
Args:
30+
page: Playwright Page instance (sync or async)
31+
timeout_ms: Timeout in milliseconds (default: 5000)
32+
33+
Raises:
34+
RuntimeError: If extension fails to inject within timeout
35+
"""
36+
if hasattr(page, "wait_for_function"):
37+
# Sync page
38+
try:
39+
page.wait_for_function(
40+
"typeof window.sentience !== 'undefined'",
41+
timeout=timeout_ms,
42+
)
43+
except Exception as e:
44+
diag = BrowserEvaluator._gather_diagnostics(page)
45+
raise RuntimeError(
46+
f"Sentience extension failed to inject window.sentience API. "
47+
f"Is the extension loaded? Diagnostics: {diag}"
48+
) from e
49+
else:
50+
# Async page - should use async version
51+
raise TypeError("Use wait_for_extension_async for async pages")
52+
53+
@staticmethod
54+
async def wait_for_extension_async(
55+
page: AsyncPage,
56+
timeout_ms: int = 5000,
57+
) -> None:
58+
"""
59+
Wait for window.sentience API to be available (async).
60+
61+
Args:
62+
page: Playwright AsyncPage instance
63+
timeout_ms: Timeout in milliseconds (default: 5000)
64+
65+
Raises:
66+
RuntimeError: If extension fails to inject within timeout
67+
"""
68+
try:
69+
await page.wait_for_function(
70+
"typeof window.sentience !== 'undefined'",
71+
timeout=timeout_ms,
72+
)
73+
except Exception as e:
74+
diag = await BrowserEvaluator._gather_diagnostics_async(page)
75+
raise RuntimeError(
76+
f"Sentience extension failed to inject window.sentience API. "
77+
f"Is the extension loaded? Diagnostics: {diag}"
78+
) from e
79+
80+
@staticmethod
81+
def _gather_diagnostics(page: Union[Page, AsyncPage]) -> dict[str, Any]:
82+
"""
83+
Gather diagnostics about extension state.
84+
85+
Args:
86+
page: Playwright Page instance
87+
88+
Returns:
89+
Dictionary with diagnostic information
90+
"""
91+
try:
92+
if hasattr(page, "evaluate"):
93+
# Sync page
94+
return page.evaluate(
95+
"""() => ({
96+
sentience_defined: typeof window.sentience !== 'undefined',
97+
extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
98+
url: window.location.href
99+
})"""
100+
)
101+
else:
102+
return {"error": "Could not gather diagnostics - invalid page type"}
103+
except Exception:
104+
return {"error": "Could not gather diagnostics"}
105+
106+
@staticmethod
107+
async def _gather_diagnostics_async(page: AsyncPage) -> dict[str, Any]:
108+
"""
109+
Gather diagnostics about extension state (async).
110+
111+
Args:
112+
page: Playwright AsyncPage instance
113+
114+
Returns:
115+
Dictionary with diagnostic information
116+
"""
117+
try:
118+
return await page.evaluate(
119+
"""() => ({
120+
sentience_defined: typeof window.sentience !== 'undefined',
121+
extension_id: document.documentElement.dataset.sentienceExtensionId || 'not set',
122+
url: window.location.href
123+
})"""
124+
)
125+
except Exception:
126+
return {"error": "Could not gather diagnostics"}
127+
128+
@staticmethod
129+
def call_sentience_method(
130+
page: Page,
131+
method_name: str,
132+
*args: Any,
133+
**kwargs: Any,
134+
) -> Any:
135+
"""
136+
Call a window.sentience method with error handling.
137+
138+
Args:
139+
page: Playwright Page instance (sync)
140+
method_name: Name of the method (e.g., "snapshot", "click")
141+
*args: Positional arguments to pass to the method
142+
**kwargs: Keyword arguments to pass to the method
143+
144+
Returns:
145+
Result from the method call
146+
147+
Raises:
148+
RuntimeError: If method is not available or call fails
149+
"""
150+
# Build JavaScript call
151+
if args and kwargs:
152+
# Both args and kwargs - use object spread
153+
js_code = f"""
154+
(args, kwargs) => {{
155+
return window.sentience.{method_name}(...args, kwargs);
156+
}}
157+
"""
158+
result = page.evaluate(js_code, list(args), kwargs)
159+
elif args:
160+
# Only args
161+
js_code = f"""
162+
(args) => {{
163+
return window.sentience.{method_name}(...args);
164+
}}
165+
"""
166+
result = page.evaluate(js_code, list(args))
167+
elif kwargs:
168+
# Only kwargs - pass as single object
169+
js_code = f"""
170+
(options) => {{
171+
return window.sentience.{method_name}(options);
172+
}}
173+
"""
174+
result = page.evaluate(js_code, kwargs)
175+
else:
176+
# No arguments
177+
js_code = f"""
178+
() => {{
179+
return window.sentience.{method_name}();
180+
}}
181+
"""
182+
result = page.evaluate(js_code)
183+
184+
return result
185+
186+
@staticmethod
187+
async def call_sentience_method_async(
188+
page: AsyncPage,
189+
method_name: str,
190+
*args: Any,
191+
**kwargs: Any,
192+
) -> Any:
193+
"""
194+
Call a window.sentience method with error handling (async).
195+
196+
Args:
197+
page: Playwright AsyncPage instance
198+
method_name: Name of the method (e.g., "snapshot", "click")
199+
*args: Positional arguments to pass to the method
200+
**kwargs: Keyword arguments to pass to the method
201+
202+
Returns:
203+
Result from the method call
204+
205+
Raises:
206+
RuntimeError: If method is not available or call fails
207+
"""
208+
# Build JavaScript call
209+
if args and kwargs:
210+
js_code = f"""
211+
(args, kwargs) => {{
212+
return window.sentience.{method_name}(...args, kwargs);
213+
}}
214+
"""
215+
result = await page.evaluate(js_code, list(args), kwargs)
216+
elif args:
217+
js_code = f"""
218+
(args) => {{
219+
return window.sentience.{method_name}(...args);
220+
}}
221+
"""
222+
result = await page.evaluate(js_code, list(args))
223+
elif kwargs:
224+
js_code = f"""
225+
(options) => {{
226+
return window.sentience.{method_name}(options);
227+
}}
228+
"""
229+
result = await page.evaluate(js_code, kwargs)
230+
else:
231+
js_code = f"""
232+
() => {{
233+
return window.sentience.{method_name}();
234+
}}
235+
"""
236+
result = await page.evaluate(js_code)
237+
238+
return result
239+
240+
@staticmethod
241+
def verify_method_exists(
242+
page: Page,
243+
method_name: str,
244+
) -> bool:
245+
"""
246+
Verify that a window.sentience method exists.
247+
248+
Args:
249+
page: Playwright Page instance (sync)
250+
method_name: Name of the method to check
251+
252+
Returns:
253+
True if method exists, False otherwise
254+
"""
255+
try:
256+
return page.evaluate(f"typeof window.sentience.{method_name} !== 'undefined'")
257+
except Exception:
258+
return False
259+
260+
@staticmethod
261+
async def verify_method_exists_async(
262+
page: AsyncPage,
263+
method_name: str,
264+
) -> bool:
265+
"""
266+
Verify that a window.sentience method exists (async).
267+
268+
Args:
269+
page: Playwright AsyncPage instance
270+
method_name: Name of the method to check
271+
272+
Returns:
273+
True if method exists, False otherwise
274+
"""
275+
try:
276+
return await page.evaluate(f"typeof window.sentience.{method_name} !== 'undefined'")
277+
except Exception:
278+
return False
279+

0 commit comments

Comments
 (0)