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
@@ -0,0 +1,241 @@
1
+ """
2
+ Browser-use adapter for Sentience SDK.
3
+
4
+ This module provides BrowserUseAdapter which wraps browser-use's BrowserSession
5
+ and provides a CDPBackendV0 for Sentience operations.
6
+
7
+ Usage:
8
+ from browser_use import BrowserSession, BrowserProfile
9
+ from sentience import get_extension_dir
10
+ from sentience.backends import BrowserUseAdapter
11
+
12
+ # Create browser-use session with Sentience extension
13
+ profile = BrowserProfile(args=[f"--load-extension={get_extension_dir()}"])
14
+ session = BrowserSession(browser_profile=profile)
15
+ await session.start()
16
+
17
+ # Create Sentience adapter
18
+ adapter = BrowserUseAdapter(session)
19
+ backend = await adapter.create_backend()
20
+
21
+ # Use backend for Sentience operations
22
+ viewport = await backend.refresh_page_info()
23
+ await backend.mouse_click(100, 200)
24
+ """
25
+
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from .cdp_backend import CDPBackendV0, CDPTransport
29
+
30
+ if TYPE_CHECKING:
31
+ # Import browser-use types only for type checking
32
+ # This avoids requiring browser-use as a hard dependency
33
+ pass
34
+
35
+
36
+ class BrowserUseCDPTransport(CDPTransport):
37
+ """
38
+ CDP transport implementation for browser-use.
39
+
40
+ Wraps browser-use's CDP client to provide the CDPTransport interface.
41
+ Uses cdp-use library pattern: cdp_client.send.Domain.method(params={}, session_id=)
42
+ """
43
+
44
+ def __init__(self, cdp_client: Any, session_id: str) -> None:
45
+ """
46
+ Initialize transport with browser-use CDP client.
47
+
48
+ Args:
49
+ cdp_client: browser-use's CDP client (from cdp_session.cdp_client)
50
+ session_id: CDP session ID (from cdp_session.session_id)
51
+ """
52
+ self._client = cdp_client
53
+ self._session_id = session_id
54
+
55
+ async def send(self, method: str, params: dict | None = None) -> dict:
56
+ """
57
+ Send CDP command using browser-use's cdp-use client.
58
+
59
+ Translates method name like "Runtime.evaluate" to
60
+ cdp_client.send.Runtime.evaluate(params={...}, session_id=...).
61
+
62
+ Args:
63
+ method: CDP method name, e.g., "Runtime.evaluate"
64
+ params: Method parameters
65
+
66
+ Returns:
67
+ CDP response dict
68
+ """
69
+ # Split method into domain and method name
70
+ # e.g., "Runtime.evaluate" -> ("Runtime", "evaluate")
71
+ parts = method.split(".", 1)
72
+ if len(parts) != 2:
73
+ raise ValueError(f"Invalid CDP method format: {method}")
74
+
75
+ domain_name, method_name = parts
76
+
77
+ # Get the domain object from cdp_client.send
78
+ domain = getattr(self._client.send, domain_name, None)
79
+ if domain is None:
80
+ raise ValueError(f"Unknown CDP domain: {domain_name}")
81
+
82
+ # Get the method from the domain
83
+ method_func = getattr(domain, method_name, None)
84
+ if method_func is None:
85
+ raise ValueError(f"Unknown CDP method: {method}")
86
+
87
+ # Call the method with params and session_id
88
+ result = await method_func(
89
+ params=params or {},
90
+ session_id=self._session_id,
91
+ )
92
+
93
+ # cdp-use returns the result directly or None
94
+ return result if result is not None else {}
95
+
96
+
97
+ class BrowserUseAdapter:
98
+ """
99
+ Adapter to use Sentience with browser-use's BrowserSession.
100
+
101
+ This adapter:
102
+ 1. Wraps browser-use's CDP client with BrowserUseCDPTransport
103
+ 2. Creates CDPBackendV0 for Sentience operations
104
+ 3. Provides access to the underlying page for extension calls
105
+
106
+ Example:
107
+ from browser_use import BrowserSession, BrowserProfile
108
+ from sentience import get_extension_dir, snapshot_async, SnapshotOptions
109
+ from sentience.backends import BrowserUseAdapter
110
+
111
+ # Setup browser-use with Sentience extension
112
+ profile = BrowserProfile(args=[f"--load-extension={get_extension_dir()}"])
113
+ session = BrowserSession(browser_profile=profile)
114
+ await session.start()
115
+
116
+ # Create adapter and backend
117
+ adapter = BrowserUseAdapter(session)
118
+ backend = await adapter.create_backend()
119
+
120
+ # Navigate (using browser-use)
121
+ page = await session.get_current_page()
122
+ await page.goto("https://example.com")
123
+
124
+ # Take Sentience snapshot (uses extension)
125
+ snap = await snapshot_async(adapter, SnapshotOptions())
126
+
127
+ # Use backend for precise clicking
128
+ await backend.mouse_click(snap.elements[0].bbox.x, snap.elements[0].bbox.y)
129
+ """
130
+
131
+ def __init__(self, session: Any) -> None:
132
+ """
133
+ Initialize adapter with browser-use BrowserSession.
134
+
135
+ Args:
136
+ session: browser-use BrowserSession instance
137
+ """
138
+ self._session = session
139
+ self._backend: CDPBackendV0 | None = None
140
+ self._transport: BrowserUseCDPTransport | None = None
141
+
142
+ @property
143
+ def page(self) -> Any:
144
+ """
145
+ Get the current Playwright page from browser-use.
146
+
147
+ This is needed for Sentience snapshot() which calls window.sentience.snapshot().
148
+
149
+ Returns:
150
+ Playwright Page object
151
+ """
152
+ # browser-use stores page in session
153
+ # Access pattern may vary by browser-use version
154
+ if hasattr(self._session, "page"):
155
+ return self._session.page
156
+ if hasattr(self._session, "_page"):
157
+ return self._session._page
158
+ if hasattr(self._session, "get_current_page"):
159
+ # This is async, but we need sync access for property
160
+ # Caller should use get_page_async() instead
161
+ raise RuntimeError("Use await adapter.get_page_async() to get the page")
162
+ raise RuntimeError("Could not find page in browser-use session")
163
+
164
+ async def get_page_async(self) -> Any:
165
+ """
166
+ Get the current Playwright page (async).
167
+
168
+ Returns:
169
+ Playwright Page object
170
+ """
171
+ if hasattr(self._session, "get_current_page"):
172
+ return await self._session.get_current_page()
173
+ return self.page
174
+
175
+ @property
176
+ def api_key(self) -> str | None:
177
+ """
178
+ API key for Sentience API (for snapshot compatibility).
179
+
180
+ Returns None since browser-use users pass api_key via SnapshotOptions.
181
+ """
182
+ return None
183
+
184
+ @property
185
+ def api_url(self) -> str | None:
186
+ """
187
+ API URL for Sentience API (for snapshot compatibility).
188
+
189
+ Returns None to use default.
190
+ """
191
+ return None
192
+
193
+ async def create_backend(self) -> CDPBackendV0:
194
+ """
195
+ Create CDP backend for Sentience operations.
196
+
197
+ This method:
198
+ 1. Gets or creates a CDP session from browser-use
199
+ 2. Creates BrowserUseCDPTransport to wrap the CDP client
200
+ 3. Creates CDPBackendV0 with the transport
201
+
202
+ Returns:
203
+ CDPBackendV0 instance ready for use
204
+
205
+ Raises:
206
+ RuntimeError: If CDP session cannot be created
207
+ """
208
+ if self._backend is not None:
209
+ return self._backend
210
+
211
+ # Get CDP session from browser-use
212
+ # browser-use uses: cdp_session = await session.get_or_create_cdp_session()
213
+ if not hasattr(self._session, "get_or_create_cdp_session"):
214
+ raise RuntimeError(
215
+ "browser-use session does not have get_or_create_cdp_session method. "
216
+ "Make sure you're using a compatible version of browser-use."
217
+ )
218
+
219
+ cdp_session = await self._session.get_or_create_cdp_session()
220
+
221
+ # Extract CDP client and session ID
222
+ cdp_client = cdp_session.cdp_client
223
+ session_id = cdp_session.session_id
224
+
225
+ # Create transport and backend
226
+ self._transport = BrowserUseCDPTransport(cdp_client, session_id)
227
+ self._backend = CDPBackendV0(self._transport)
228
+
229
+ return self._backend
230
+
231
+ async def get_transport(self) -> BrowserUseCDPTransport:
232
+ """
233
+ Get the CDP transport (creates backend if needed).
234
+
235
+ Returns:
236
+ BrowserUseCDPTransport instance
237
+ """
238
+ if self._transport is None:
239
+ await self.create_backend()
240
+ assert self._transport is not None
241
+ return self._transport
@@ -0,0 +1,393 @@
1
+ """
2
+ CDP Backend implementation for browser-use integration.
3
+
4
+ This module provides CDPBackendV0, which implements BrowserBackend protocol
5
+ using Chrome DevTools Protocol (CDP) commands.
6
+
7
+ Usage with browser-use:
8
+ from browser_use import BrowserSession
9
+ from sentience.backends import CDPBackendV0
10
+ from sentience.backends.browser_use_adapter import BrowserUseAdapter
11
+
12
+ session = BrowserSession(...)
13
+ await session.start()
14
+
15
+ adapter = BrowserUseAdapter(session)
16
+ backend = await adapter.create_backend()
17
+
18
+ # Now use backend for Sentience operations
19
+ viewport = await backend.refresh_page_info()
20
+ await backend.mouse_click(100, 200)
21
+ """
22
+
23
+ import asyncio
24
+ import base64
25
+ import time
26
+ from typing import Any, Literal, Protocol, runtime_checkable
27
+
28
+ from .protocol import BrowserBackend, LayoutMetrics, ViewportInfo
29
+
30
+
31
+ @runtime_checkable
32
+ class CDPTransport(Protocol):
33
+ """
34
+ Protocol for CDP transport layer.
35
+
36
+ This abstracts the actual CDP communication, allowing different
37
+ implementations (browser-use, Playwright CDP, raw WebSocket).
38
+ """
39
+
40
+ async def send(self, method: str, params: dict | None = None) -> dict:
41
+ """
42
+ Send a CDP command and return the result.
43
+
44
+ Args:
45
+ method: CDP method name, e.g., "Runtime.evaluate"
46
+ params: Method parameters
47
+
48
+ Returns:
49
+ CDP response dict
50
+ """
51
+ ...
52
+
53
+
54
+ class CDPBackendV0:
55
+ """
56
+ CDP-based implementation of BrowserBackend.
57
+
58
+ This backend uses CDP commands to interact with the browser,
59
+ making it compatible with browser-use's CDP client.
60
+ """
61
+
62
+ def __init__(self, transport: CDPTransport) -> None:
63
+ """
64
+ Initialize CDP backend.
65
+
66
+ Args:
67
+ transport: CDP transport for sending commands
68
+ """
69
+ self._transport = transport
70
+ self._cached_viewport: ViewportInfo | None = None
71
+ self._execution_context_id: int | None = None
72
+
73
+ async def _get_execution_context(self) -> int:
74
+ """Get or create execution context ID for Runtime.callFunctionOn."""
75
+ if self._execution_context_id is not None:
76
+ return self._execution_context_id
77
+
78
+ # Enable Runtime domain if not already enabled
79
+ try:
80
+ await self._transport.send("Runtime.enable")
81
+ except Exception:
82
+ pass # May already be enabled
83
+
84
+ # Get the main frame's execution context
85
+ result = await self._transport.send(
86
+ "Runtime.evaluate",
87
+ {
88
+ "expression": "1",
89
+ "returnByValue": True,
90
+ },
91
+ )
92
+
93
+ # Extract context ID from the result
94
+ if "executionContextId" in result:
95
+ self._execution_context_id = result["executionContextId"]
96
+ else:
97
+ # Fallback: use context ID 1 (main frame)
98
+ self._execution_context_id = 1
99
+
100
+ return self._execution_context_id
101
+
102
+ async def refresh_page_info(self) -> ViewportInfo:
103
+ """Cache viewport + scroll offsets; cheap & safe to call often."""
104
+ result = await self.eval(
105
+ """(() => ({
106
+ width: window.innerWidth,
107
+ height: window.innerHeight,
108
+ scroll_x: window.scrollX,
109
+ scroll_y: window.scrollY,
110
+ content_width: document.documentElement.scrollWidth,
111
+ content_height: document.documentElement.scrollHeight
112
+ }))()"""
113
+ )
114
+
115
+ self._cached_viewport = ViewportInfo(
116
+ width=result.get("width", 0),
117
+ height=result.get("height", 0),
118
+ scroll_x=result.get("scroll_x", 0),
119
+ scroll_y=result.get("scroll_y", 0),
120
+ content_width=result.get("content_width"),
121
+ content_height=result.get("content_height"),
122
+ )
123
+ return self._cached_viewport
124
+
125
+ async def eval(self, expression: str) -> Any:
126
+ """Evaluate JavaScript expression using Runtime.evaluate."""
127
+ result = await self._transport.send(
128
+ "Runtime.evaluate",
129
+ {
130
+ "expression": expression,
131
+ "returnByValue": True,
132
+ "awaitPromise": True,
133
+ },
134
+ )
135
+
136
+ # Check for exceptions
137
+ if "exceptionDetails" in result:
138
+ exc = result["exceptionDetails"]
139
+ text = exc.get("text", "Unknown error")
140
+ raise RuntimeError(f"JavaScript evaluation failed: {text}")
141
+
142
+ # Extract value from result
143
+ if "result" in result:
144
+ res = result["result"]
145
+ if res.get("type") == "undefined":
146
+ return None
147
+ return res.get("value")
148
+
149
+ return None
150
+
151
+ async def call(
152
+ self,
153
+ function_declaration: str,
154
+ args: list[Any] | None = None,
155
+ ) -> Any:
156
+ """Call JavaScript function using Runtime.callFunctionOn."""
157
+ # Build call arguments
158
+ call_args = []
159
+ if args:
160
+ for arg in args:
161
+ if arg is None:
162
+ call_args.append({"value": None})
163
+ elif isinstance(arg, bool):
164
+ call_args.append({"value": arg})
165
+ elif isinstance(arg, (int, float)):
166
+ call_args.append({"value": arg})
167
+ elif isinstance(arg, str):
168
+ call_args.append({"value": arg})
169
+ elif isinstance(arg, dict):
170
+ call_args.append({"value": arg})
171
+ elif isinstance(arg, list):
172
+ call_args.append({"value": arg})
173
+ else:
174
+ # Serialize complex objects to JSON
175
+ call_args.append({"value": str(arg)})
176
+
177
+ # We need an object ID to call function on
178
+ # Use globalThis (window) as the target
179
+ global_result = await self._transport.send(
180
+ "Runtime.evaluate",
181
+ {
182
+ "expression": "globalThis",
183
+ "returnByValue": False,
184
+ },
185
+ )
186
+
187
+ object_id = global_result.get("result", {}).get("objectId")
188
+ if not object_id:
189
+ # Fallback: evaluate the function directly
190
+ if args:
191
+ args_json = ", ".join(repr(a) if isinstance(a, str) else str(a) for a in args)
192
+ expression = f"({function_declaration})({args_json})"
193
+ else:
194
+ expression = f"({function_declaration})()"
195
+ return await self.eval(expression)
196
+
197
+ result = await self._transport.send(
198
+ "Runtime.callFunctionOn",
199
+ {
200
+ "functionDeclaration": function_declaration,
201
+ "objectId": object_id,
202
+ "arguments": call_args,
203
+ "returnByValue": True,
204
+ "awaitPromise": True,
205
+ },
206
+ )
207
+
208
+ # Check for exceptions
209
+ if "exceptionDetails" in result:
210
+ exc = result["exceptionDetails"]
211
+ text = exc.get("text", "Unknown error")
212
+ raise RuntimeError(f"JavaScript call failed: {text}")
213
+
214
+ # Extract value from result
215
+ if "result" in result:
216
+ res = result["result"]
217
+ if res.get("type") == "undefined":
218
+ return None
219
+ return res.get("value")
220
+
221
+ return None
222
+
223
+ async def get_layout_metrics(self) -> LayoutMetrics:
224
+ """Get page layout metrics using Page.getLayoutMetrics."""
225
+ result = await self._transport.send("Page.getLayoutMetrics")
226
+
227
+ # Extract metrics from result
228
+ layout_viewport = result.get("layoutViewport", {})
229
+ content_size = result.get("contentSize", {})
230
+ visual_viewport = result.get("visualViewport", {})
231
+
232
+ return LayoutMetrics(
233
+ viewport_x=visual_viewport.get("pageX", 0),
234
+ viewport_y=visual_viewport.get("pageY", 0),
235
+ viewport_width=visual_viewport.get(
236
+ "clientWidth", layout_viewport.get("clientWidth", 0)
237
+ ),
238
+ viewport_height=visual_viewport.get(
239
+ "clientHeight", layout_viewport.get("clientHeight", 0)
240
+ ),
241
+ content_width=content_size.get("width", 0),
242
+ content_height=content_size.get("height", 0),
243
+ device_scale_factor=visual_viewport.get("scale", 1.0),
244
+ )
245
+
246
+ async def screenshot_png(self) -> bytes:
247
+ """Capture viewport screenshot as PNG bytes."""
248
+ result = await self._transport.send(
249
+ "Page.captureScreenshot",
250
+ {
251
+ "format": "png",
252
+ "captureBeyondViewport": False,
253
+ },
254
+ )
255
+
256
+ data = result.get("data", "")
257
+ return base64.b64decode(data)
258
+
259
+ async def mouse_move(self, x: float, y: float) -> None:
260
+ """Move mouse to viewport coordinates."""
261
+ await self._transport.send(
262
+ "Input.dispatchMouseEvent",
263
+ {
264
+ "type": "mouseMoved",
265
+ "x": x,
266
+ "y": y,
267
+ },
268
+ )
269
+
270
+ async def mouse_click(
271
+ self,
272
+ x: float,
273
+ y: float,
274
+ button: Literal["left", "right", "middle"] = "left",
275
+ click_count: int = 1,
276
+ ) -> None:
277
+ """Click at viewport coordinates."""
278
+ # Mouse down
279
+ await self._transport.send(
280
+ "Input.dispatchMouseEvent",
281
+ {
282
+ "type": "mousePressed",
283
+ "x": x,
284
+ "y": y,
285
+ "button": button,
286
+ "clickCount": click_count,
287
+ },
288
+ )
289
+
290
+ # Small delay between press and release
291
+ await asyncio.sleep(0.05)
292
+
293
+ # Mouse up
294
+ await self._transport.send(
295
+ "Input.dispatchMouseEvent",
296
+ {
297
+ "type": "mouseReleased",
298
+ "x": x,
299
+ "y": y,
300
+ "button": button,
301
+ "clickCount": click_count,
302
+ },
303
+ )
304
+
305
+ async def wheel(
306
+ self,
307
+ delta_y: float,
308
+ x: float | None = None,
309
+ y: float | None = None,
310
+ ) -> None:
311
+ """Scroll using mouse wheel."""
312
+ # Get viewport center if coordinates not provided
313
+ if x is None or y is None:
314
+ if self._cached_viewport is None:
315
+ await self.refresh_page_info()
316
+ assert self._cached_viewport is not None
317
+ x = x if x is not None else self._cached_viewport.width / 2
318
+ y = y if y is not None else self._cached_viewport.height / 2
319
+
320
+ await self._transport.send(
321
+ "Input.dispatchMouseEvent",
322
+ {
323
+ "type": "mouseWheel",
324
+ "x": x,
325
+ "y": y,
326
+ "deltaX": 0,
327
+ "deltaY": delta_y,
328
+ },
329
+ )
330
+
331
+ async def type_text(self, text: str) -> None:
332
+ """Type text using keyboard input."""
333
+ for char in text:
334
+ # Key down
335
+ await self._transport.send(
336
+ "Input.dispatchKeyEvent",
337
+ {
338
+ "type": "keyDown",
339
+ "text": char,
340
+ },
341
+ )
342
+
343
+ # Char event (for text input)
344
+ await self._transport.send(
345
+ "Input.dispatchKeyEvent",
346
+ {
347
+ "type": "char",
348
+ "text": char,
349
+ },
350
+ )
351
+
352
+ # Key up
353
+ await self._transport.send(
354
+ "Input.dispatchKeyEvent",
355
+ {
356
+ "type": "keyUp",
357
+ "text": char,
358
+ },
359
+ )
360
+
361
+ # Small delay between characters
362
+ await asyncio.sleep(0.01)
363
+
364
+ async def wait_ready_state(
365
+ self,
366
+ state: Literal["interactive", "complete"] = "interactive",
367
+ timeout_ms: int = 15000,
368
+ ) -> None:
369
+ """Wait for document.readyState using polling."""
370
+ start = time.monotonic()
371
+ timeout_sec = timeout_ms / 1000.0
372
+
373
+ # Map state to acceptable states
374
+ acceptable_states = {"complete"} if state == "complete" else {"interactive", "complete"}
375
+
376
+ while True:
377
+ elapsed = time.monotonic() - start
378
+ if elapsed >= timeout_sec:
379
+ raise TimeoutError(
380
+ f"Timed out waiting for document.readyState='{state}' " f"after {timeout_ms}ms"
381
+ )
382
+
383
+ current_state = await self.eval("document.readyState")
384
+ if current_state in acceptable_states:
385
+ return
386
+
387
+ # Poll every 100ms
388
+ await asyncio.sleep(0.1)
389
+
390
+ async def get_url(self) -> str:
391
+ """Get current page URL."""
392
+ result = await self.eval("window.location.href")
393
+ return result if result else ""