browser-control-and-automation-cli 0.1.1.dev3__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.
Files changed (104) hide show
  1. browser_cli/__init__.py +7 -0
  2. browser_cli/actions/__init__.py +5 -0
  3. browser_cli/actions/cli_specs.py +868 -0
  4. browser_cli/agent_scope/__init__.py +14 -0
  5. browser_cli/automation/__init__.py +11 -0
  6. browser_cli/automation/api/__init__.py +5 -0
  7. browser_cli/automation/api/server.py +321 -0
  8. browser_cli/automation/hooks.py +42 -0
  9. browser_cli/automation/loader.py +134 -0
  10. browser_cli/automation/models.py +127 -0
  11. browser_cli/automation/persistence/__init__.py +5 -0
  12. browser_cli/automation/persistence/store.py +682 -0
  13. browser_cli/automation/projections.py +279 -0
  14. browser_cli/automation/publisher.py +153 -0
  15. browser_cli/automation/scheduler/__init__.py +5 -0
  16. browser_cli/automation/scheduler/schedule.py +113 -0
  17. browser_cli/automation/service/__init__.py +8 -0
  18. browser_cli/automation/service/__main__.py +64 -0
  19. browser_cli/automation/service/client.py +232 -0
  20. browser_cli/automation/service/runtime.py +388 -0
  21. browser_cli/automation/toml.py +35 -0
  22. browser_cli/automation/web/__init__.py +5 -0
  23. browser_cli/automation/web/assets.py +377 -0
  24. browser_cli/browser/__init__.py +1 -0
  25. browser_cli/browser/models.py +44 -0
  26. browser_cli/browser/network_capture.py +392 -0
  27. browser_cli/browser/service.py +1519 -0
  28. browser_cli/browser/session.py +158 -0
  29. browser_cli/browser/snapshot.py +35 -0
  30. browser_cli/browser/stealth.py +450 -0
  31. browser_cli/cli/__init__.py +1 -0
  32. browser_cli/cli/error_hints.py +26 -0
  33. browser_cli/cli/main.py +298 -0
  34. browser_cli/commands/__init__.py +1 -0
  35. browser_cli/commands/action.py +25 -0
  36. browser_cli/commands/automation.py +304 -0
  37. browser_cli/commands/doctor.py +336 -0
  38. browser_cli/commands/install_skills.py +138 -0
  39. browser_cli/commands/paths.py +31 -0
  40. browser_cli/commands/read.py +33 -0
  41. browser_cli/commands/reload.py +51 -0
  42. browser_cli/commands/status.py +444 -0
  43. browser_cli/commands/task.py +71 -0
  44. browser_cli/constants.py +97 -0
  45. browser_cli/daemon/__init__.py +1 -0
  46. browser_cli/daemon/__main__.py +34 -0
  47. browser_cli/daemon/app.py +941 -0
  48. browser_cli/daemon/browser_service.py +998 -0
  49. browser_cli/daemon/client.py +280 -0
  50. browser_cli/daemon/models.py +63 -0
  51. browser_cli/daemon/runtime_presentation.py +167 -0
  52. browser_cli/daemon/server.py +103 -0
  53. browser_cli/daemon/state.py +15 -0
  54. browser_cli/daemon/transport.py +77 -0
  55. browser_cli/drivers/__init__.py +6 -0
  56. browser_cli/drivers/_extension/__init__.py +1 -0
  57. browser_cli/drivers/_extension/artifact_actions.py +154 -0
  58. browser_cli/drivers/_extension/dialog_actions.py +35 -0
  59. browser_cli/drivers/_extension/input_actions.py +63 -0
  60. browser_cli/drivers/_extension/locator_actions.py +100 -0
  61. browser_cli/drivers/_extension/observe_actions.py +285 -0
  62. browser_cli/drivers/_extension/page_actions.py +210 -0
  63. browser_cli/drivers/_extension/state_actions.py +134 -0
  64. browser_cli/drivers/base.py +391 -0
  65. browser_cli/drivers/extension_driver.py +33 -0
  66. browser_cli/drivers/models.py +21 -0
  67. browser_cli/drivers/playwright_driver.py +499 -0
  68. browser_cli/error_codes.py +27 -0
  69. browser_cli/errors.py +146 -0
  70. browser_cli/exit_codes.py +9 -0
  71. browser_cli/extension/__init__.py +32 -0
  72. browser_cli/extension/protocol.py +222 -0
  73. browser_cli/extension/session.py +393 -0
  74. browser_cli/network.py +147 -0
  75. browser_cli/outputs/__init__.py +1 -0
  76. browser_cli/outputs/json.py +10 -0
  77. browser_cli/outputs/render.py +7 -0
  78. browser_cli/packaged_skills/__init__.py +1 -0
  79. browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
  80. browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
  81. browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
  82. browser_cli/profiles/__init__.py +1 -0
  83. browser_cli/profiles/discovery.py +185 -0
  84. browser_cli/refs/__init__.py +17 -0
  85. browser_cli/refs/generator.py +386 -0
  86. browser_cli/refs/models.py +127 -0
  87. browser_cli/refs/registry.py +51 -0
  88. browser_cli/refs/resolver.py +214 -0
  89. browser_cli/tabs/__init__.py +5 -0
  90. browser_cli/tabs/registry.py +283 -0
  91. browser_cli/task_runtime/__init__.py +31 -0
  92. browser_cli/task_runtime/client.py +107 -0
  93. browser_cli/task_runtime/entrypoint.py +110 -0
  94. browser_cli/task_runtime/errors.py +17 -0
  95. browser_cli/task_runtime/flow.py +171 -0
  96. browser_cli/task_runtime/models.py +97 -0
  97. browser_cli/task_runtime/read.py +71 -0
  98. browser_cli/task_runtime/templates.py +58 -0
  99. browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
  100. browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
  101. browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
  102. browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
  103. browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
  104. browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1519 @@
1
+ """Long-lived browser service owned by the daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import time
11
+ import urllib.parse
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from browser_cli.browser.models import BrowserLaunchConfig, default_headless
16
+ from browser_cli.browser.network_capture import PlaywrightNetworkObserver
17
+ from browser_cli.browser.snapshot import SnapshotCapture, capture_snapshot
18
+ from browser_cli.browser.stealth import (
19
+ build_context_options,
20
+ build_ignore_default_args,
21
+ build_init_script,
22
+ build_launch_args,
23
+ )
24
+ from browser_cli.constants import get_app_paths
25
+ from browser_cli.errors import (
26
+ AmbiguousRefError,
27
+ BrowserUnavailableError,
28
+ EmptyContentError,
29
+ InvalidInputError,
30
+ NoSnapshotContextError,
31
+ OperationFailedError,
32
+ ProfileUnavailableError,
33
+ RefNotFoundError,
34
+ StaleSnapshotError,
35
+ TemporaryReadError,
36
+ )
37
+ from browser_cli.network import NetworkRecordFilter
38
+ from browser_cli.profiles.discovery import ChromeEnvironment, discover_chrome_environment
39
+ from browser_cli.refs import SemanticRefResolver, SnapshotRegistry
40
+ from browser_cli.refs.generator import SemanticSnapshotGenerator
41
+ from browser_cli.refs.models import (
42
+ LocatorSpec,
43
+ RefData,
44
+ SemanticSnapshot,
45
+ SnapshotInput,
46
+ SnapshotMetadata,
47
+ )
48
+
49
+
50
+ class BrowserService:
51
+ READ_NAVIGATION_TIMEOUT_SECONDS = 30.0
52
+ READ_SETTLE_TIMEOUT_MS = 1_200
53
+ READ_SCROLL_PAUSE_MS = 450
54
+ READ_SCROLL_MAX_ROUNDS = 8
55
+ READ_SCROLL_STABLE_ROUNDS = 2
56
+
57
+ def __init__(
58
+ self,
59
+ chrome_environment: ChromeEnvironment | None = None,
60
+ *,
61
+ headless: bool | None = None,
62
+ ) -> None:
63
+ self._chrome_environment = chrome_environment
64
+ self._headless = default_headless() if headless is None else headless
65
+ self._playwright: Any | None = None
66
+ self._context: Any | None = None
67
+ self._pages: dict[str, Any] = {}
68
+ self._page_counter = 0
69
+ self._reusable_startup_pages: list[Any] = []
70
+ self._snapshot_registry = SnapshotRegistry()
71
+ self._ref_resolver = SemanticRefResolver()
72
+ self._console_messages: dict[str, list[dict[str, Any]]] = {}
73
+ self._console_handlers: dict[str, Any] = {}
74
+ self._network_observers: dict[str, PlaywrightNetworkObserver] = {}
75
+ self._dialog_handlers: dict[str, Any] = {}
76
+ self._tracing_active = False
77
+ self._video_started: set[str] = set()
78
+ self._pending_video_save_paths: dict[str, str | None] = {}
79
+ self._start_lock = asyncio.Lock()
80
+ # Serialize page acquisition with last-page replacement/close so
81
+ # concurrent closers cannot strand the persistent context with zero pages.
82
+ self._page_create_lock = asyncio.Lock()
83
+
84
+ @property
85
+ def chrome_environment(self) -> ChromeEnvironment | None:
86
+ return self._chrome_environment
87
+
88
+ def configure_environment(self, chrome_environment: ChromeEnvironment) -> None:
89
+ if self._context is not None and self._chrome_environment not in (None, chrome_environment):
90
+ raise OperationFailedError(
91
+ "Browser daemon is already running with a different Chrome profile."
92
+ )
93
+ self._chrome_environment = chrome_environment
94
+
95
+ async def ensure_started(self) -> None:
96
+ async with self._start_lock:
97
+ if self._context is not None:
98
+ return
99
+ await self._start()
100
+
101
+ async def _start(self) -> None:
102
+ try:
103
+ from playwright.async_api import async_playwright
104
+ except ImportError as exc: # pragma: no cover
105
+ raise BrowserUnavailableError(
106
+ "Playwright is not installed in this repo environment. Run: uv sync --dev"
107
+ ) from exc
108
+
109
+ chrome_environment = self._chrome_environment or discover_chrome_environment()
110
+ launch_config = BrowserLaunchConfig(
111
+ executable_path=chrome_environment.executable_path,
112
+ user_data_dir=chrome_environment.user_data_dir,
113
+ profile_directory=chrome_environment.profile_directory,
114
+ headless=self._headless,
115
+ )
116
+ self._chrome_environment = chrome_environment
117
+
118
+ try:
119
+ self._playwright = await async_playwright().start()
120
+ chromium = self._playwright.chromium
121
+ video_dir = get_app_paths().artifacts_dir / "playwright-video"
122
+ video_dir.mkdir(parents=True, exist_ok=True)
123
+ context_options = build_context_options(
124
+ viewport_width=launch_config.viewport_width,
125
+ viewport_height=launch_config.viewport_height,
126
+ locale=launch_config.locale,
127
+ )
128
+ self._context = await chromium.launch_persistent_context(
129
+ user_data_dir=str(launch_config.user_data_dir),
130
+ executable_path=str(launch_config.executable_path)
131
+ if launch_config.executable_path
132
+ else None,
133
+ headless=launch_config.headless,
134
+ viewport={
135
+ "width": launch_config.viewport_width,
136
+ "height": launch_config.viewport_height,
137
+ },
138
+ record_video_dir=str(video_dir),
139
+ record_video_size={
140
+ "width": launch_config.viewport_width,
141
+ "height": launch_config.viewport_height,
142
+ },
143
+ ignore_default_args=build_ignore_default_args(),
144
+ args=[
145
+ *build_launch_args(
146
+ headless=launch_config.headless,
147
+ viewport_width=launch_config.viewport_width,
148
+ viewport_height=launch_config.viewport_height,
149
+ locale=launch_config.locale,
150
+ ),
151
+ f"--profile-directory={launch_config.profile_directory}",
152
+ ],
153
+ **context_options,
154
+ )
155
+ init_script = build_init_script(
156
+ headless=launch_config.headless, locale=launch_config.locale
157
+ )
158
+ if init_script:
159
+ await self._context.add_init_script(init_script)
160
+ self._reusable_startup_pages = list(self._context.pages)
161
+ except Exception as exc:
162
+ await self.stop()
163
+ self._raise_launch_error(exc)
164
+
165
+ async def stop(self) -> dict[str, Any]:
166
+ trace_path: str | None = None
167
+ if self._tracing_active and self._context is not None:
168
+ try:
169
+ trace_path = await self._stop_tracing_impl(path=None)
170
+ except Exception:
171
+ trace_path = None
172
+ closed_pages: list[str] = []
173
+ video_paths: list[str] = []
174
+ for page_id in list(self._pages.keys()):
175
+ try:
176
+ result = await self._close_page(page_id)
177
+ closed_pages.append(page_id)
178
+ if result.get("video_path"):
179
+ video_paths.append(str(result["video_path"]))
180
+ except Exception:
181
+ pass
182
+ self._pages.clear()
183
+ self._snapshot_registry.clear()
184
+ self._console_messages.clear()
185
+ self._console_handlers.clear()
186
+ self._network_observers.clear()
187
+ self._dialog_handlers.clear()
188
+ self._video_started.clear()
189
+ self._pending_video_save_paths.clear()
190
+ self._reusable_startup_pages.clear()
191
+ self._tracing_active = False
192
+ if self._context is not None:
193
+ await self._context.close()
194
+ self._context = None
195
+ if self._playwright is not None:
196
+ await self._playwright.stop()
197
+ self._playwright = None
198
+ return {
199
+ "closed_pages": closed_pages,
200
+ "video_paths": video_paths,
201
+ "trace_path": trace_path,
202
+ }
203
+
204
+ async def new_tab(
205
+ self,
206
+ *,
207
+ page_id: str | None = None,
208
+ url: str | None = None,
209
+ wait_until: str = "load",
210
+ timeout_seconds: float | None = None,
211
+ ) -> dict[str, Any]:
212
+ await self.ensure_started()
213
+ async with self._page_create_lock:
214
+ page = await self._acquire_page()
215
+ page_id = self._register_page(page, page_id=page_id)
216
+ if url:
217
+ await page.goto(
218
+ self._normalize_url(url),
219
+ wait_until=wait_until,
220
+ timeout=(timeout_seconds or 30.0) * 1000.0,
221
+ )
222
+ return await self.get_page_summary(page_id)
223
+
224
+ async def capture_snapshot_input(
225
+ self,
226
+ page_id: str,
227
+ *,
228
+ interactive: bool = False,
229
+ full_page: bool = True,
230
+ ) -> SnapshotInput:
231
+ _ = interactive
232
+ _ = full_page
233
+ page = self._require_page(page_id)
234
+ raw_snapshot = await SemanticSnapshotGenerator().page_snapshot_for_ai(page)
235
+ return SnapshotInput(
236
+ raw_snapshot=raw_snapshot,
237
+ captured_url=str(page.url),
238
+ captured_at=time.time(),
239
+ )
240
+
241
+ async def capture_semantic_snapshot(
242
+ self,
243
+ page_id: str,
244
+ *,
245
+ interactive: bool = False,
246
+ full_page: bool = True,
247
+ ) -> SemanticSnapshot:
248
+ page = self._require_page(page_id)
249
+ raw_snapshot = await SemanticSnapshotGenerator().page_snapshot_for_ai(page)
250
+ return SemanticSnapshotGenerator().snapshot_from_raw_text(
251
+ raw_snapshot,
252
+ page_id=page_id,
253
+ captured_url=str(page.url),
254
+ interactive=interactive,
255
+ full_page=full_page,
256
+ captured_at=time.time(),
257
+ )
258
+
259
+ async def read_page(
260
+ self,
261
+ *,
262
+ url: str,
263
+ output_mode: str = "html",
264
+ scroll_bottom: bool = False,
265
+ ) -> dict[str, Any]:
266
+ if output_mode not in {"html", "snapshot"}:
267
+ raise InvalidInputError(f"Unsupported read output mode: {output_mode}")
268
+ await self.ensure_started()
269
+ page_id: str | None = None
270
+ page: Any | None = None
271
+ try:
272
+ async with self._page_create_lock:
273
+ page = await self._acquire_page()
274
+ page_id = self._register_page(page)
275
+ await page.goto(
276
+ self._normalize_url(url),
277
+ wait_until="load",
278
+ timeout=self.READ_NAVIGATION_TIMEOUT_SECONDS * 1000.0,
279
+ )
280
+ await self._settle_page(page)
281
+ if scroll_bottom:
282
+ await self._scroll_page_to_bottom(page)
283
+ await self._settle_page(page)
284
+ if output_mode == "snapshot":
285
+ snapshot = await capture_snapshot(
286
+ page, page_id=page_id, interactive=False, full_page=True
287
+ )
288
+ body = snapshot.tree
289
+ else:
290
+ body = await self._capture_html_from_page(page)
291
+ if not body.strip():
292
+ raise EmptyContentError()
293
+ payload = {
294
+ "page_id": page_id,
295
+ "body": body,
296
+ "output_mode": output_mode,
297
+ "url": page.url,
298
+ }
299
+ chrome_environment = self._chrome_environment
300
+ if chrome_environment is not None:
301
+ payload["used_fallback_profile"] = chrome_environment.source == "fallback"
302
+ if chrome_environment.source == "fallback":
303
+ payload["fallback_profile_dir"] = str(chrome_environment.user_data_dir)
304
+ payload["fallback_reason"] = chrome_environment.fallback_reason
305
+ if chrome_environment.profile_name:
306
+ payload["profile_name"] = chrome_environment.profile_name
307
+ payload["profile_directory"] = chrome_environment.profile_directory
308
+ return payload
309
+ finally:
310
+ if page_id is not None and page_id in self._pages:
311
+ with contextlib.suppress(Exception):
312
+ await self._close_page(page_id)
313
+
314
+ async def close_tab(self, page_id: str) -> dict[str, Any]:
315
+ return await self._close_page(page_id)
316
+
317
+ async def get_page_summary(self, page_id: str) -> dict[str, Any]:
318
+ page = self._require_page(page_id)
319
+ title = await page.title()
320
+ return {
321
+ "page_id": page_id,
322
+ "url": page.url,
323
+ "title": title,
324
+ }
325
+
326
+ async def get_page_info(self, page_id: str) -> dict[str, Any]:
327
+ page = self._require_page(page_id)
328
+ title = await page.title()
329
+ page_metrics = await page.evaluate(
330
+ """() => ({
331
+ viewport_width: window.innerWidth,
332
+ viewport_height: window.innerHeight,
333
+ page_width: Math.max(document.body.scrollWidth, document.documentElement.scrollWidth),
334
+ page_height: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight),
335
+ scroll_x: window.scrollX,
336
+ scroll_y: window.scrollY,
337
+ })"""
338
+ )
339
+ return {
340
+ "page_id": page_id,
341
+ "url": page.url,
342
+ "title": title,
343
+ **page_metrics,
344
+ }
345
+
346
+ async def capture_html(self, page_id: str) -> dict[str, Any]:
347
+ page = self._require_page(page_id)
348
+ html = await self._capture_html_from_page(page)
349
+ return {
350
+ "page_id": page_id,
351
+ "html": html,
352
+ }
353
+
354
+ async def capture_snapshot(
355
+ self,
356
+ page_id: str,
357
+ *,
358
+ interactive: bool = False,
359
+ full_page: bool = True,
360
+ ) -> dict[str, Any]:
361
+ page = self._require_page(page_id)
362
+ snapshot = await capture_snapshot(
363
+ page, page_id=page_id, interactive=interactive, full_page=full_page
364
+ )
365
+ semantic_snapshot = self._semantic_snapshot_from_capture(
366
+ page_id, snapshot, interactive=interactive, full_page=full_page
367
+ )
368
+ state = self._snapshot_registry.store(semantic_snapshot)
369
+ return {
370
+ "page_id": page_id,
371
+ "tree": snapshot.tree,
372
+ "snapshot_id": snapshot.snapshot_id,
373
+ "captured_url": state.captured_url,
374
+ "captured_at": state.captured_at,
375
+ "refs_summary": [
376
+ {
377
+ "ref": ref,
378
+ **metadata,
379
+ }
380
+ for ref, metadata in snapshot.refs.items()
381
+ ],
382
+ }
383
+
384
+ async def navigate(
385
+ self, page_id: str, url: str, *, wait_until: str = "load", timeout_seconds: float = 30.0
386
+ ) -> dict[str, Any]:
387
+ page = self._require_page(page_id)
388
+ await page.goto(
389
+ self._normalize_url(url), wait_until=wait_until, timeout=timeout_seconds * 1000.0
390
+ )
391
+ return await self.get_page_summary(page_id)
392
+
393
+ async def reload(
394
+ self, page_id: str, *, wait_until: str = "load", timeout_seconds: float = 30.0
395
+ ) -> dict[str, Any]:
396
+ page = self._require_page(page_id)
397
+ await page.reload(wait_until=wait_until, timeout=timeout_seconds * 1000.0)
398
+ return await self.get_page_summary(page_id)
399
+
400
+ async def go_back(self, page_id: str) -> dict[str, Any]:
401
+ page = self._require_page(page_id)
402
+ await page.go_back()
403
+ return await self.get_page_summary(page_id)
404
+
405
+ async def go_forward(self, page_id: str) -> dict[str, Any]:
406
+ page = self._require_page(page_id)
407
+ await page.go_forward()
408
+ return await self.get_page_summary(page_id)
409
+
410
+ async def resize(self, page_id: str, *, width: int, height: int) -> dict[str, Any]:
411
+ page = self._require_page(page_id)
412
+ await page.set_viewport_size({"width": width, "height": height})
413
+ return {"page_id": page_id, "width": width, "height": height}
414
+
415
+ async def click_locator(self, page_id: str, locator_spec: LocatorSpec) -> dict[str, Any]:
416
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
417
+ await locator.click()
418
+ return {"page_id": page_id, "ref": locator_spec.ref, "action": "click"}
419
+
420
+ async def double_click_locator(self, page_id: str, locator_spec: LocatorSpec) -> dict[str, Any]:
421
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
422
+ await locator.dblclick()
423
+ return {"page_id": page_id, "ref": locator_spec.ref, "action": "double-click"}
424
+
425
+ async def hover_locator(self, page_id: str, locator_spec: LocatorSpec) -> dict[str, Any]:
426
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
427
+ await locator.hover()
428
+ return {"page_id": page_id, "ref": locator_spec.ref, "action": "hover"}
429
+
430
+ async def focus_locator(self, page_id: str, locator_spec: LocatorSpec) -> dict[str, Any]:
431
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
432
+ await locator.focus()
433
+ return {"page_id": page_id, "ref": locator_spec.ref, "action": "focus"}
434
+
435
+ async def fill_locator(
436
+ self,
437
+ page_id: str,
438
+ locator_spec: LocatorSpec,
439
+ text: str,
440
+ *,
441
+ submit: bool = False,
442
+ ) -> dict[str, Any]:
443
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
444
+ await locator.fill(text)
445
+ if submit:
446
+ await locator.press("Enter")
447
+ return {"page_id": page_id, "ref": locator_spec.ref, "filled": True, "submitted": submit}
448
+
449
+ async def select_locator(
450
+ self, page_id: str, locator_spec: LocatorSpec, text: str
451
+ ) -> dict[str, Any]:
452
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
453
+ await locator.select_option(label=text)
454
+ return {"page_id": page_id, "ref": locator_spec.ref, "selected": text}
455
+
456
+ async def list_locator_options(self, page_id: str, locator_spec: LocatorSpec) -> dict[str, Any]:
457
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
458
+ options = await locator.locator("option").all_inner_texts()
459
+ normalized = [item.strip() for item in options if item.strip()]
460
+ return {"page_id": page_id, "ref": locator_spec.ref, "options": normalized}
461
+
462
+ async def set_checked_locator(
463
+ self,
464
+ page_id: str,
465
+ locator_spec: LocatorSpec,
466
+ *,
467
+ checked: bool,
468
+ ) -> dict[str, Any]:
469
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
470
+ if checked:
471
+ await locator.check()
472
+ else:
473
+ await locator.uncheck()
474
+ return {"page_id": page_id, "ref": locator_spec.ref, "checked": checked}
475
+
476
+ async def scroll_to_locator(self, page_id: str, locator_spec: LocatorSpec) -> dict[str, Any]:
477
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
478
+ await locator.scroll_into_view_if_needed()
479
+ return {"page_id": page_id, "ref": locator_spec.ref, "scrolled": True}
480
+
481
+ async def drag_between_locators(
482
+ self,
483
+ page_id: str,
484
+ start_locator_spec: LocatorSpec,
485
+ end_locator_spec: LocatorSpec,
486
+ ) -> dict[str, Any]:
487
+ source = await self._get_locator_by_spec(page_id, start_locator_spec)
488
+ target = await self._get_locator_by_spec(page_id, end_locator_spec)
489
+ await source.drag_to(target)
490
+ return {
491
+ "page_id": page_id,
492
+ "start_ref": start_locator_spec.ref,
493
+ "end_ref": end_locator_spec.ref,
494
+ "dragged": True,
495
+ }
496
+
497
+ async def upload_to_locator(
498
+ self,
499
+ page_id: str,
500
+ locator_spec: LocatorSpec,
501
+ file_path: str,
502
+ ) -> dict[str, Any]:
503
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
504
+ await locator.set_input_files(file_path)
505
+ return {
506
+ "page_id": page_id,
507
+ "ref": locator_spec.ref,
508
+ "file_path": str(Path(file_path).resolve()),
509
+ }
510
+
511
+ async def evaluate_on_locator(
512
+ self, page_id: str, locator_spec: LocatorSpec, code: str
513
+ ) -> dict[str, Any]:
514
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
515
+ result = await locator.evaluate(code)
516
+ return {
517
+ "page_id": page_id,
518
+ "ref": locator_spec.ref,
519
+ "result": self._normalize_json_value(result),
520
+ }
521
+
522
+ async def verify_state_locator(
523
+ self, page_id: str, locator_spec: LocatorSpec, *, state: str
524
+ ) -> dict[str, Any]:
525
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
526
+ normalized = state.strip().lower()
527
+ if normalized in {"checked", "unchecked"}:
528
+ actual = await locator.is_checked()
529
+ expected = normalized == "checked"
530
+ elif normalized == "disabled":
531
+ actual = await locator.is_disabled()
532
+ expected = True
533
+ elif normalized == "enabled":
534
+ actual = await locator.is_enabled()
535
+ expected = True
536
+ elif normalized == "editable":
537
+ actual = await locator.is_editable()
538
+ expected = True
539
+ elif normalized == "visible":
540
+ actual = await locator.is_visible()
541
+ expected = True
542
+ elif normalized == "hidden":
543
+ actual = not await locator.is_visible()
544
+ expected = True
545
+ else:
546
+ raise InvalidInputError(f"Unsupported verify-state value: {state}")
547
+ return {
548
+ "page_id": page_id,
549
+ "ref": locator_spec.ref,
550
+ "state": normalized,
551
+ "passed": actual == expected,
552
+ }
553
+
554
+ async def verify_value_locator(
555
+ self,
556
+ page_id: str,
557
+ locator_spec: LocatorSpec,
558
+ *,
559
+ expected: str,
560
+ ) -> dict[str, Any]:
561
+ locator = await self._get_locator_by_spec(page_id, locator_spec)
562
+ actual = await locator.input_value()
563
+ return {
564
+ "page_id": page_id,
565
+ "ref": locator_spec.ref,
566
+ "expected": expected,
567
+ "actual": actual,
568
+ "passed": actual == expected,
569
+ }
570
+
571
+ async def click_ref(self, page_id: str, ref: str) -> dict[str, Any]:
572
+ locator = await self._get_locator_by_ref(page_id, ref)
573
+ await locator.click()
574
+ return {"page_id": page_id, "ref": ref, "action": "click"}
575
+
576
+ async def double_click_ref(self, page_id: str, ref: str) -> dict[str, Any]:
577
+ locator = await self._get_locator_by_ref(page_id, ref)
578
+ await locator.dblclick()
579
+ return {"page_id": page_id, "ref": ref, "action": "double-click"}
580
+
581
+ async def hover_ref(self, page_id: str, ref: str) -> dict[str, Any]:
582
+ locator = await self._get_locator_by_ref(page_id, ref)
583
+ await locator.hover()
584
+ return {"page_id": page_id, "ref": ref, "action": "hover"}
585
+
586
+ async def focus_ref(self, page_id: str, ref: str) -> dict[str, Any]:
587
+ locator = await self._get_locator_by_ref(page_id, ref)
588
+ await locator.focus()
589
+ return {"page_id": page_id, "ref": ref, "action": "focus"}
590
+
591
+ async def fill_ref(
592
+ self, page_id: str, ref: str, text: str, *, submit: bool = False
593
+ ) -> dict[str, Any]:
594
+ locator = await self._get_locator_by_ref(page_id, ref)
595
+ await locator.fill(text)
596
+ if submit:
597
+ await locator.press("Enter")
598
+ return {"page_id": page_id, "ref": ref, "filled": True, "submitted": submit}
599
+
600
+ async def fill_form(
601
+ self, page_id: str, fields: list[dict[str, Any]], *, submit: bool = False
602
+ ) -> dict[str, Any]:
603
+ for field in fields:
604
+ ref = str(field.get("ref") or "").strip()
605
+ text = str(field.get("text") or "")
606
+ if not ref:
607
+ raise InvalidInputError("Each fill-form field must include a ref.")
608
+ await self.fill_ref(page_id, ref, text, submit=False)
609
+ if submit and fields:
610
+ await self.press_key(page_id, "Enter")
611
+ return {"page_id": page_id, "filled_fields": len(fields), "submitted": submit}
612
+
613
+ async def select_option(self, page_id: str, ref: str, text: str) -> dict[str, Any]:
614
+ locator = await self._get_locator_by_ref(page_id, ref)
615
+ await locator.select_option(label=text)
616
+ return {"page_id": page_id, "ref": ref, "selected": text}
617
+
618
+ async def list_options(self, page_id: str, ref: str) -> dict[str, Any]:
619
+ locator = await self._get_locator_by_ref(page_id, ref)
620
+ options = await locator.locator("option").all_inner_texts()
621
+ normalized = [item.strip() for item in options if item.strip()]
622
+ return {"page_id": page_id, "ref": ref, "options": normalized}
623
+
624
+ async def check_ref(self, page_id: str, ref: str) -> dict[str, Any]:
625
+ locator = await self._get_locator_by_ref(page_id, ref)
626
+ await locator.check()
627
+ return {"page_id": page_id, "ref": ref, "checked": True}
628
+
629
+ async def uncheck_ref(self, page_id: str, ref: str) -> dict[str, Any]:
630
+ locator = await self._get_locator_by_ref(page_id, ref)
631
+ await locator.uncheck()
632
+ return {"page_id": page_id, "ref": ref, "checked": False}
633
+
634
+ async def scroll_to_ref(self, page_id: str, ref: str) -> dict[str, Any]:
635
+ locator = await self._get_locator_by_ref(page_id, ref)
636
+ await locator.scroll_into_view_if_needed()
637
+ return {"page_id": page_id, "ref": ref, "scrolled": True}
638
+
639
+ async def drag_ref(self, page_id: str, start_ref: str, end_ref: str) -> dict[str, Any]:
640
+ source = await self._get_locator_by_ref(page_id, start_ref)
641
+ target = await self._get_locator_by_ref(page_id, end_ref)
642
+ await source.drag_to(target)
643
+ return {"page_id": page_id, "start_ref": start_ref, "end_ref": end_ref, "dragged": True}
644
+
645
+ async def upload_file(self, page_id: str, ref: str, file_path: str) -> dict[str, Any]:
646
+ locator = await self._get_locator_by_ref(page_id, ref)
647
+ await locator.set_input_files(file_path)
648
+ return {"page_id": page_id, "ref": ref, "file_path": str(Path(file_path).resolve())}
649
+
650
+ async def type_text(self, page_id: str, text: str, *, submit: bool = False) -> dict[str, Any]:
651
+ page = self._require_page(page_id)
652
+ await page.keyboard.type(text)
653
+ if submit:
654
+ await page.keyboard.press("Enter")
655
+ return {"page_id": page_id, "typed": True, "submitted": submit}
656
+
657
+ async def press_key(self, page_id: str, key: str) -> dict[str, Any]:
658
+ page = self._require_page(page_id)
659
+ await page.keyboard.press(key)
660
+ return {"page_id": page_id, "key": key}
661
+
662
+ async def key_down(self, page_id: str, key: str) -> dict[str, Any]:
663
+ page = self._require_page(page_id)
664
+ await page.keyboard.down(key)
665
+ return {"page_id": page_id, "key": key}
666
+
667
+ async def key_up(self, page_id: str, key: str) -> dict[str, Any]:
668
+ page = self._require_page(page_id)
669
+ await page.keyboard.up(key)
670
+ return {"page_id": page_id, "key": key}
671
+
672
+ async def wheel(self, page_id: str, *, dx: int = 0, dy: int = 700) -> dict[str, Any]:
673
+ page = self._require_page(page_id)
674
+ before = await page.evaluate(
675
+ """() => ({
676
+ scrollX: window.scrollX,
677
+ scrollY: window.scrollY
678
+ })"""
679
+ )
680
+ await page.mouse.wheel(dx, dy)
681
+ await page.wait_for_timeout(50)
682
+ after = await page.evaluate(
683
+ """() => ({
684
+ scrollX: window.scrollX,
685
+ scrollY: window.scrollY
686
+ })"""
687
+ )
688
+ if (after["scrollX"], after["scrollY"]) == (before["scrollX"], before["scrollY"]) and (
689
+ dx or dy
690
+ ):
691
+ after = await page.evaluate(
692
+ """({ dx, dy }) => {
693
+ window.scrollBy(dx, dy);
694
+ return {
695
+ scrollX: window.scrollX,
696
+ scrollY: window.scrollY
697
+ };
698
+ }""",
699
+ {"dx": dx, "dy": dy},
700
+ )
701
+ return {
702
+ "page_id": page_id,
703
+ "dx": dx,
704
+ "dy": dy,
705
+ "scroll_x": after["scrollX"],
706
+ "scroll_y": after["scrollY"],
707
+ }
708
+
709
+ async def mouse_move(self, page_id: str, *, x: int, y: int) -> dict[str, Any]:
710
+ page = self._require_page(page_id)
711
+ await page.mouse.move(x, y)
712
+ return {"page_id": page_id, "x": x, "y": y}
713
+
714
+ async def mouse_click(
715
+ self, page_id: str, *, x: int, y: int, button: str = "left", count: int = 1
716
+ ) -> dict[str, Any]:
717
+ page = self._require_page(page_id)
718
+ await page.mouse.click(x, y, button=button, click_count=count)
719
+ return {"page_id": page_id, "x": x, "y": y, "button": button, "count": count}
720
+
721
+ async def mouse_drag(
722
+ self, page_id: str, *, x1: int, y1: int, x2: int, y2: int
723
+ ) -> dict[str, Any]:
724
+ page = self._require_page(page_id)
725
+ await page.mouse.move(x1, y1)
726
+ await page.mouse.down()
727
+ await page.mouse.move(x2, y2)
728
+ await page.mouse.up()
729
+ return {"page_id": page_id, "from": {"x": x1, "y": y1}, "to": {"x": x2, "y": y2}}
730
+
731
+ async def mouse_down(self, page_id: str, *, button: str = "left") -> dict[str, Any]:
732
+ page = self._require_page(page_id)
733
+ await page.mouse.down(button=button)
734
+ return {"page_id": page_id, "button": button}
735
+
736
+ async def mouse_up(self, page_id: str, *, button: str = "left") -> dict[str, Any]:
737
+ page = self._require_page(page_id)
738
+ await page.mouse.up(button=button)
739
+ return {"page_id": page_id, "button": button}
740
+
741
+ async def evaluate(self, page_id: str, code: str) -> dict[str, Any]:
742
+ page = self._require_page(page_id)
743
+ result = await page.evaluate(code)
744
+ return {"page_id": page_id, "result": self._normalize_json_value(result)}
745
+
746
+ async def evaluate_on_ref(self, page_id: str, ref: str, code: str) -> dict[str, Any]:
747
+ locator = await self._get_locator_by_ref(page_id, ref)
748
+ result = await locator.evaluate(code)
749
+ return {"page_id": page_id, "ref": ref, "result": self._normalize_json_value(result)}
750
+
751
+ async def wait(
752
+ self,
753
+ page_id: str,
754
+ *,
755
+ seconds: float | None = None,
756
+ text: str | None = None,
757
+ gone: bool = False,
758
+ exact: bool = False,
759
+ ) -> dict[str, Any]:
760
+ page = self._require_page(page_id)
761
+ if text:
762
+ locator = page.get_by_text(text, exact=exact).first
763
+ state = "hidden" if gone else "visible"
764
+ await locator.wait_for(state=state, timeout=((seconds or 30.0) * 1000.0))
765
+ return {"page_id": page_id, "text": text, "state": state}
766
+ await page.wait_for_timeout((seconds or 1.0) * 1000.0)
767
+ return {"page_id": page_id, "seconds": seconds or 1.0}
768
+
769
+ async def wait_for_network_idle(
770
+ self, page_id: str, *, timeout_seconds: float = 30.0
771
+ ) -> dict[str, Any]:
772
+ page = self._require_page(page_id)
773
+ await page.wait_for_load_state("networkidle", timeout=timeout_seconds * 1000.0)
774
+ return {"page_id": page_id, "network_idle": True}
775
+
776
+ async def screenshot(
777
+ self, page_id: str, *, path: str, full_page: bool = False
778
+ ) -> dict[str, Any]:
779
+ page = self._require_page(page_id)
780
+ output_path = self._resolve_output_path(path)
781
+ await page.screenshot(path=str(output_path), full_page=full_page)
782
+ return {"page_id": page_id, "path": str(output_path), "full_page": full_page}
783
+
784
+ async def save_pdf(self, page_id: str, *, path: str) -> dict[str, Any]:
785
+ page = self._require_page(page_id)
786
+ output_path = self._resolve_output_path(path)
787
+ await page.pdf(path=str(output_path))
788
+ return {"page_id": page_id, "path": str(output_path)}
789
+
790
+ async def start_console_capture(self, page_id: str) -> dict[str, Any]:
791
+ page = self._require_page(page_id)
792
+ if page_id in self._console_handlers:
793
+ with contextlib.suppress(Exception):
794
+ page.remove_listener("console", self._console_handlers[page_id])
795
+ self._console_messages[page_id] = []
796
+
797
+ def _handle_console(message: Any) -> None:
798
+ location = message.location or {}
799
+ location_text = None
800
+ if location:
801
+ location_text = f"{location.get('url', '')}:{location.get('lineNumber', 0)}:{location.get('columnNumber', 0)}"
802
+ self._console_messages.setdefault(page_id, []).append(
803
+ {
804
+ "type": message.type,
805
+ "text": message.text,
806
+ "location": location_text,
807
+ }
808
+ )
809
+
810
+ page.on("console", _handle_console)
811
+ self._console_handlers[page_id] = _handle_console
812
+ return {"page_id": page_id, "capturing": True}
813
+
814
+ async def get_console_messages(
815
+ self, page_id: str, *, message_type: str | None = None, clear: bool = True
816
+ ) -> dict[str, Any]:
817
+ messages = list(self._console_messages.get(page_id, []))
818
+ if message_type:
819
+ messages = [item for item in messages if item.get("type") == message_type]
820
+ if clear:
821
+ self._console_messages[page_id] = []
822
+ return {"page_id": page_id, "messages": messages}
823
+
824
+ async def stop_console_capture(self, page_id: str) -> dict[str, Any]:
825
+ page = self._require_page(page_id)
826
+ handler = self._console_handlers.pop(page_id, None)
827
+ if handler is not None:
828
+ with contextlib.suppress(Exception):
829
+ page.remove_listener("console", handler)
830
+ return {"page_id": page_id, "capturing": False}
831
+
832
+ async def start_network_capture(self, page_id: str) -> dict[str, Any]:
833
+ _ = self._require_page(page_id)
834
+ observer = self._require_network_observer(page_id)
835
+ observer.start_capture()
836
+ return {"page_id": page_id, "capturing": True}
837
+
838
+ async def wait_for_network_record(
839
+ self,
840
+ page_id: str,
841
+ *,
842
+ url_contains: str | None = None,
843
+ url_regex: str | None = None,
844
+ method: str | None = None,
845
+ status: int | None = None,
846
+ resource_type: str | None = None,
847
+ mime_contains: str | None = None,
848
+ include_static: bool = False,
849
+ timeout_seconds: float = 30.0,
850
+ ) -> dict[str, Any]:
851
+ _ = self._require_page(page_id)
852
+ observer = self._require_network_observer(page_id)
853
+ try:
854
+ record = await observer.wait_for_record(
855
+ record_filter=NetworkRecordFilter(
856
+ url_contains=url_contains,
857
+ url_regex=url_regex,
858
+ method=method,
859
+ status=status,
860
+ resource_type=resource_type,
861
+ mime_contains=mime_contains,
862
+ include_static=include_static,
863
+ ),
864
+ timeout_seconds=timeout_seconds,
865
+ )
866
+ except TimeoutError as exc:
867
+ raise OperationFailedError(
868
+ f"Timed out waiting for a matching network record after {timeout_seconds:.1f}s."
869
+ ) from exc
870
+ return {"page_id": page_id, "record": record}
871
+
872
+ async def get_network_records(
873
+ self,
874
+ page_id: str,
875
+ *,
876
+ url_contains: str | None = None,
877
+ url_regex: str | None = None,
878
+ method: str | None = None,
879
+ status: int | None = None,
880
+ resource_type: str | None = None,
881
+ mime_contains: str | None = None,
882
+ include_static: bool = False,
883
+ clear: bool = True,
884
+ ) -> dict[str, Any]:
885
+ _ = self._require_page(page_id)
886
+ observer = self._require_network_observer(page_id)
887
+ records = observer.get_records(
888
+ record_filter=NetworkRecordFilter(
889
+ url_contains=url_contains,
890
+ url_regex=url_regex,
891
+ method=method,
892
+ status=status,
893
+ resource_type=resource_type,
894
+ mime_contains=mime_contains,
895
+ include_static=include_static,
896
+ ),
897
+ clear=clear,
898
+ )
899
+ return {"page_id": page_id, "records": records}
900
+
901
+ async def stop_network_capture(self, page_id: str) -> dict[str, Any]:
902
+ _ = self._require_page(page_id)
903
+ observer = self._require_network_observer(page_id)
904
+ observer.stop_capture()
905
+ return {"page_id": page_id, "capturing": False}
906
+
907
+ async def setup_dialog_handler(
908
+ self,
909
+ page_id: str,
910
+ *,
911
+ default_action: str = "accept",
912
+ default_prompt_text: str | None = None,
913
+ ) -> dict[str, Any]:
914
+ page = self._require_page(page_id)
915
+ existing = self._dialog_handlers.pop(page_id, None)
916
+ if existing is not None:
917
+ with contextlib.suppress(Exception):
918
+ page.remove_listener("dialog", existing)
919
+
920
+ async def _handle_dialog(dialog: Any) -> None:
921
+ if default_action == "accept":
922
+ if dialog.type == "prompt" and default_prompt_text is not None:
923
+ await dialog.accept(default_prompt_text)
924
+ else:
925
+ await dialog.accept()
926
+ else:
927
+ await dialog.dismiss()
928
+
929
+ page.on("dialog", _handle_dialog)
930
+ self._dialog_handlers[page_id] = _handle_dialog
931
+ return {
932
+ "page_id": page_id,
933
+ "action": default_action,
934
+ "text": default_prompt_text,
935
+ "configured": True,
936
+ }
937
+
938
+ async def handle_dialog(
939
+ self,
940
+ page_id: str,
941
+ *,
942
+ accept: bool,
943
+ prompt_text: str | None = None,
944
+ ) -> dict[str, Any]:
945
+ page = self._require_page(page_id)
946
+ existing = self._dialog_handlers.pop(page_id, None)
947
+ if existing is not None:
948
+ with contextlib.suppress(Exception):
949
+ page.remove_listener("dialog", existing)
950
+
951
+ async def _handle_next_dialog(dialog: Any) -> None:
952
+ if accept:
953
+ if dialog.type == "prompt" and prompt_text is not None:
954
+ await dialog.accept(prompt_text)
955
+ else:
956
+ await dialog.accept()
957
+ else:
958
+ await dialog.dismiss()
959
+
960
+ page.once("dialog", _handle_next_dialog)
961
+ return {
962
+ "page_id": page_id,
963
+ "accept": accept,
964
+ "text": prompt_text,
965
+ "armed": True,
966
+ }
967
+
968
+ async def remove_dialog_handler(self, page_id: str) -> dict[str, Any]:
969
+ page = self._require_page(page_id)
970
+ existing = self._dialog_handlers.pop(page_id, None)
971
+ removed = existing is not None
972
+ if existing is not None:
973
+ with contextlib.suppress(Exception):
974
+ page.remove_listener("dialog", existing)
975
+ return {"page_id": page_id, "removed": removed}
976
+
977
+ async def start_tracing(
978
+ self,
979
+ page_id: str,
980
+ *,
981
+ screenshots: bool = True,
982
+ snapshots: bool = True,
983
+ sources: bool = False,
984
+ ) -> dict[str, Any]:
985
+ _ = self._require_page(page_id)
986
+ if self._context is None:
987
+ raise OperationFailedError("Browser context is not available.")
988
+ if self._tracing_active:
989
+ raise OperationFailedError("Tracing is already active. Stop the current trace first.")
990
+ await self._context.tracing.start(
991
+ screenshots=screenshots,
992
+ snapshots=snapshots,
993
+ sources=sources,
994
+ )
995
+ self._tracing_active = True
996
+ return {
997
+ "page_id": page_id,
998
+ "tracing": True,
999
+ "screenshots": screenshots,
1000
+ "snapshots": snapshots,
1001
+ "sources": sources,
1002
+ }
1003
+
1004
+ async def add_trace_chunk(self, page_id: str, *, title: str | None = None) -> dict[str, Any]:
1005
+ _ = self._require_page(page_id)
1006
+ if self._context is None or not self._tracing_active:
1007
+ raise OperationFailedError("No active tracing session. Start tracing first.")
1008
+ await self._context.tracing.start_chunk(title=title)
1009
+ return {"page_id": page_id, "chunk_started": True, "title": title}
1010
+
1011
+ async def stop_tracing(self, page_id: str, *, path: str | None = None) -> dict[str, Any]:
1012
+ _ = self._require_page(page_id)
1013
+ saved_path = await self._stop_tracing_impl(path=path)
1014
+ return {"page_id": page_id, "path": saved_path}
1015
+
1016
+ async def start_video(
1017
+ self, page_id: str, *, width: int | None = None, height: int | None = None
1018
+ ) -> dict[str, Any]:
1019
+ page = self._require_page(page_id)
1020
+ if getattr(page, "video", None) is None:
1021
+ raise OperationFailedError("No video recording is available for the active tab.")
1022
+ self._video_started.add(page_id)
1023
+ self._pending_video_save_paths.pop(page_id, None)
1024
+ return {
1025
+ "page_id": page_id,
1026
+ "recording": True,
1027
+ "width": width,
1028
+ "height": height,
1029
+ }
1030
+
1031
+ async def stop_video(self, page_id: str, *, path: str | None = None) -> dict[str, Any]:
1032
+ page = self._require_page(page_id)
1033
+ if getattr(page, "video", None) is None:
1034
+ raise OperationFailedError("No video recording is available for the active tab.")
1035
+ if page_id not in self._video_started:
1036
+ raise OperationFailedError("No active video recording. Use video-start first.")
1037
+ resolved_path = self._resolve_video_output_path(path, page_id=page_id)
1038
+ self._video_started.discard(page_id)
1039
+ self._pending_video_save_paths[page_id] = str(resolved_path) if resolved_path else None
1040
+ return {
1041
+ "page_id": page_id,
1042
+ "recording": False,
1043
+ "path": str(resolved_path) if resolved_path else None,
1044
+ "deferred": True,
1045
+ }
1046
+
1047
+ async def get_cookies(
1048
+ self,
1049
+ page_id: str,
1050
+ *,
1051
+ name: str | None = None,
1052
+ domain: str | None = None,
1053
+ path: str | None = None,
1054
+ ) -> dict[str, Any]:
1055
+ _ = self._require_page(page_id)
1056
+ cookies = await self._context.cookies()
1057
+ if name:
1058
+ cookies = [cookie for cookie in cookies if cookie.get("name") == name]
1059
+ if domain:
1060
+ cookies = [cookie for cookie in cookies if domain in (cookie.get("domain") or "")]
1061
+ if path:
1062
+ cookies = [cookie for cookie in cookies if (cookie.get("path") or "").startswith(path)]
1063
+ return {"page_id": page_id, "cookies": cookies}
1064
+
1065
+ async def set_cookie(
1066
+ self,
1067
+ page_id: str,
1068
+ *,
1069
+ name: str,
1070
+ value: str,
1071
+ domain: str | None = None,
1072
+ path: str = "/",
1073
+ expires: float | None = None,
1074
+ http_only: bool = False,
1075
+ secure: bool = False,
1076
+ same_site: str | None = None,
1077
+ ) -> dict[str, Any]:
1078
+ page = self._require_page(page_id)
1079
+ cookie_domain = domain or urllib.parse.urlparse(page.url).hostname
1080
+ if not cookie_domain:
1081
+ raise InvalidInputError(
1082
+ "Cookie domain is required when the current page has no hostname."
1083
+ )
1084
+ payload: dict[str, Any] = {
1085
+ "name": name,
1086
+ "value": value,
1087
+ "domain": cookie_domain,
1088
+ "path": path,
1089
+ "httpOnly": http_only,
1090
+ "secure": secure,
1091
+ }
1092
+ if expires is not None:
1093
+ payload["expires"] = expires
1094
+ if same_site:
1095
+ payload["sameSite"] = same_site
1096
+ await self._context.add_cookies([payload])
1097
+ return {"page_id": page_id, "cookie": payload}
1098
+
1099
+ async def clear_cookies(
1100
+ self,
1101
+ page_id: str,
1102
+ *,
1103
+ name: str | None = None,
1104
+ domain: str | None = None,
1105
+ path: str | None = None,
1106
+ ) -> dict[str, Any]:
1107
+ _ = self._require_page(page_id)
1108
+ await self._context.clear_cookies(name=name, domain=domain, path=path)
1109
+ return {
1110
+ "page_id": page_id,
1111
+ "cleared": True,
1112
+ "filters": {"name": name, "domain": domain, "path": path},
1113
+ }
1114
+
1115
+ async def save_storage_state(self, page_id: str, *, path: str | None = None) -> dict[str, Any]:
1116
+ _ = self._require_page(page_id)
1117
+ if path:
1118
+ output_path = self._resolve_output_path(path)
1119
+ else:
1120
+ output_path = Path(tempfile.mkstemp(suffix=".json", prefix="browser-cli-state-")[1])
1121
+ await self._context.storage_state(path=str(output_path))
1122
+ return {"page_id": page_id, "path": str(output_path)}
1123
+
1124
+ async def load_storage_state(self, page_id: str, *, path: str) -> dict[str, Any]:
1125
+ page = self._require_page(page_id)
1126
+ input_path = Path(path).expanduser().resolve()
1127
+ if not input_path.exists():
1128
+ raise InvalidInputError(f"Storage state file does not exist: {input_path}")
1129
+ payload = json.loads(input_path.read_text(encoding="utf-8"))
1130
+ cookies = payload.get("cookies", [])
1131
+ if cookies:
1132
+ await self._context.add_cookies(cookies)
1133
+ for origin_payload in payload.get("origins", []):
1134
+ for entry in origin_payload.get("localStorage", []):
1135
+ name = entry.get("name")
1136
+ value = entry.get("value")
1137
+ if name is None:
1138
+ continue
1139
+ await page.evaluate(
1140
+ "(args) => localStorage.setItem(args.name, args.value)",
1141
+ {"name": name, "value": value},
1142
+ )
1143
+ return {"page_id": page_id, "path": str(input_path), "cookies_loaded": len(cookies)}
1144
+
1145
+ async def verify_text(
1146
+ self, page_id: str, *, text: str, exact: bool = False, timeout_seconds: float = 5.0
1147
+ ) -> dict[str, Any]:
1148
+ page = self._require_page(page_id)
1149
+ locator = page.get_by_text(text, exact=exact).first
1150
+ try:
1151
+ await locator.wait_for(state="visible", timeout=timeout_seconds * 1000.0)
1152
+ return {"page_id": page_id, "passed": True, "text": text}
1153
+ except Exception:
1154
+ return {"page_id": page_id, "passed": False, "text": text}
1155
+
1156
+ async def verify_visible(
1157
+ self, page_id: str, *, role: str, name: str, timeout_seconds: float = 5.0
1158
+ ) -> dict[str, Any]:
1159
+ page = self._require_page(page_id)
1160
+ locator = page.get_by_role(role, name=name).first
1161
+ try:
1162
+ await locator.wait_for(state="visible", timeout=timeout_seconds * 1000.0)
1163
+ return {"page_id": page_id, "passed": True, "role": role, "name": name}
1164
+ except Exception:
1165
+ return {"page_id": page_id, "passed": False, "role": role, "name": name}
1166
+
1167
+ async def verify_url(
1168
+ self, page_id: str, *, expected: str, exact: bool = False
1169
+ ) -> dict[str, Any]:
1170
+ page = self._require_page(page_id)
1171
+ actual = page.url
1172
+ passed = actual == expected if exact else expected in actual
1173
+ return {"page_id": page_id, "passed": passed, "expected": expected, "actual": actual}
1174
+
1175
+ async def verify_title(
1176
+ self, page_id: str, *, expected: str, exact: bool = False
1177
+ ) -> dict[str, Any]:
1178
+ page = self._require_page(page_id)
1179
+ actual = await page.title()
1180
+ passed = actual == expected if exact else expected in actual
1181
+ return {"page_id": page_id, "passed": passed, "expected": expected, "actual": actual}
1182
+
1183
+ async def verify_state(self, page_id: str, *, ref: str, state: str) -> dict[str, Any]:
1184
+ locator = await self._get_locator_by_ref(page_id, ref)
1185
+ state = state.lower()
1186
+ passed: bool
1187
+ if state == "visible":
1188
+ passed = await locator.is_visible()
1189
+ elif state == "hidden":
1190
+ passed = not await locator.is_visible()
1191
+ elif state == "enabled":
1192
+ passed = await locator.is_enabled()
1193
+ elif state == "disabled":
1194
+ passed = not await locator.is_enabled()
1195
+ elif state == "checked":
1196
+ passed = await locator.is_checked()
1197
+ elif state == "unchecked":
1198
+ passed = not await locator.is_checked()
1199
+ elif state == "editable":
1200
+ passed = await locator.is_editable()
1201
+ else:
1202
+ raise InvalidInputError(f"Unsupported state: {state}")
1203
+ return {"page_id": page_id, "ref": ref, "state": state, "passed": passed}
1204
+
1205
+ async def verify_value(self, page_id: str, *, ref: str, expected: str) -> dict[str, Any]:
1206
+ locator = await self._get_locator_by_ref(page_id, ref)
1207
+ actual = await locator.input_value()
1208
+ return {
1209
+ "page_id": page_id,
1210
+ "ref": ref,
1211
+ "expected": expected,
1212
+ "actual": actual,
1213
+ "passed": actual == expected,
1214
+ }
1215
+
1216
+ async def search(self, *, query: str, engine: str = "duckduckgo") -> dict[str, Any]:
1217
+ search_url = self._build_search_url(query, engine)
1218
+ return await self.new_tab(url=search_url, wait_until="load")
1219
+
1220
+ async def _get_locator_by_ref(self, page_id: str, ref: str) -> Any:
1221
+ page = self._require_page(page_id)
1222
+ state = self._snapshot_registry.get(page_id)
1223
+ if state is None:
1224
+ raise NoSnapshotContextError()
1225
+ normalized = self._ref_resolver.parse_ref(ref)
1226
+ if normalized is None:
1227
+ raise InvalidInputError(f"Invalid ref: {ref}")
1228
+ if normalized not in state.refs:
1229
+ raise RefNotFoundError()
1230
+ locator = self._ref_resolver.get_locator(page, normalized, state.refs)
1231
+ if locator is None:
1232
+ raise RefNotFoundError()
1233
+ count = await locator.count()
1234
+ if count == 0:
1235
+ raise StaleSnapshotError()
1236
+ if count > 1:
1237
+ raise AmbiguousRefError()
1238
+ return locator.first
1239
+
1240
+ async def _get_locator_by_spec(self, page_id: str, locator_spec: LocatorSpec) -> Any:
1241
+ page = self._require_page(page_id)
1242
+ locator = self._ref_resolver.get_locator_from_spec(page, locator_spec)
1243
+ if locator is None:
1244
+ raise RefNotFoundError()
1245
+ count = await locator.count()
1246
+ if count == 0:
1247
+ raise StaleSnapshotError()
1248
+ if count > 1:
1249
+ raise AmbiguousRefError()
1250
+ return locator.first
1251
+
1252
+ async def _remove_page_handlers(self, page_id: str) -> None:
1253
+ page = self._pages.get(page_id)
1254
+ self._console_messages.pop(page_id, None)
1255
+ console_handler = self._console_handlers.pop(page_id, None)
1256
+ if page is not None and console_handler is not None:
1257
+ with contextlib.suppress(Exception):
1258
+ page.remove_listener("console", console_handler)
1259
+ network_observer = self._network_observers.pop(page_id, None)
1260
+ if network_observer is not None:
1261
+ await network_observer.close()
1262
+ dialog_handler = self._dialog_handlers.pop(page_id, None)
1263
+ if page is not None and dialog_handler is not None:
1264
+ with contextlib.suppress(Exception):
1265
+ page.remove_listener("dialog", dialog_handler)
1266
+ self._video_started.discard(page_id)
1267
+ self._pending_video_save_paths.pop(page_id, None)
1268
+
1269
+ def _require_page(self, page_id: str) -> Any:
1270
+ page = self._pages.get(page_id)
1271
+ if page is None:
1272
+ raise OperationFailedError(f"Page {page_id} is not available anymore.")
1273
+ return page
1274
+
1275
+ def _require_network_observer(self, page_id: str) -> PlaywrightNetworkObserver:
1276
+ observer = self._network_observers.get(page_id)
1277
+ if observer is None:
1278
+ raise OperationFailedError(f"Network capture is not available for page {page_id}.")
1279
+ return observer
1280
+
1281
+ def _next_page_id(self) -> str:
1282
+ self._page_counter += 1
1283
+ return f"page_{self._page_counter:04d}"
1284
+
1285
+ def _is_reusable_startup_page(self, page: Any) -> bool:
1286
+ if page.is_closed():
1287
+ return False
1288
+ return str(getattr(page, "url", "")) in {"", "about:blank"}
1289
+
1290
+ async def _acquire_page(self) -> Any:
1291
+ while self._reusable_startup_pages:
1292
+ page = self._reusable_startup_pages.pop(0)
1293
+ if self._is_reusable_startup_page(page):
1294
+ return page
1295
+ return await self._context.new_page()
1296
+
1297
+ def _register_page(self, page: Any, *, page_id: str | None = None) -> str:
1298
+ resolved_page_id = page_id or self._next_page_id()
1299
+ self._pages[resolved_page_id] = page
1300
+ self._network_observers[resolved_page_id] = PlaywrightNetworkObserver(
1301
+ page_id=resolved_page_id,
1302
+ page=page,
1303
+ )
1304
+ return resolved_page_id
1305
+
1306
+ def _count_open_context_pages(self) -> int:
1307
+ if self._context is None:
1308
+ return 0
1309
+ return sum(1 for page in self._context.pages if not page.is_closed())
1310
+
1311
+ async def _ensure_reusable_page_before_last_close(self, page: Any) -> None:
1312
+ if self._context is None or page.is_closed() or self._count_open_context_pages() > 1:
1313
+ return
1314
+ replacement = await self._context.new_page()
1315
+ if self._is_reusable_startup_page(replacement):
1316
+ self._reusable_startup_pages.append(replacement)
1317
+
1318
+ @staticmethod
1319
+ async def _capture_html_from_page(page: Any) -> str:
1320
+ return await page.evaluate(
1321
+ """() => {
1322
+ const doctype = document.doctype
1323
+ ? `<!DOCTYPE ${document.doctype.name}>`
1324
+ : '';
1325
+ return `${doctype}${document.documentElement.outerHTML}`;
1326
+ }"""
1327
+ )
1328
+
1329
+ async def _settle_page(self, page: Any) -> None:
1330
+ await page.wait_for_timeout(self.READ_SETTLE_TIMEOUT_MS)
1331
+
1332
+ async def _scroll_page_to_bottom(self, page: Any) -> None:
1333
+ stable_rounds = 0
1334
+ previous_height = -1
1335
+ for _ in range(self.READ_SCROLL_MAX_ROUNDS):
1336
+ current_height = await page.evaluate(
1337
+ "() => Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)"
1338
+ )
1339
+ await page.evaluate(
1340
+ "() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' })"
1341
+ )
1342
+ await page.wait_for_timeout(self.READ_SCROLL_PAUSE_MS)
1343
+ next_height = await page.evaluate(
1344
+ "() => Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)"
1345
+ )
1346
+ if next_height == current_height == previous_height:
1347
+ stable_rounds += 1
1348
+ else:
1349
+ stable_rounds = 0
1350
+ previous_height = next_height
1351
+ if stable_rounds >= self.READ_SCROLL_STABLE_ROUNDS - 1:
1352
+ break
1353
+
1354
+ @staticmethod
1355
+ def _normalize_json_value(value: Any) -> Any:
1356
+ if value is None or isinstance(value, bool | int | float | str):
1357
+ return value
1358
+ if isinstance(value, list):
1359
+ return [BrowserService._normalize_json_value(item) for item in value]
1360
+ if isinstance(value, dict):
1361
+ return {
1362
+ str(key): BrowserService._normalize_json_value(item) for key, item in value.items()
1363
+ }
1364
+ return str(value)
1365
+
1366
+ @staticmethod
1367
+ def _normalize_url(url: str) -> str:
1368
+ lowered = url.lower()
1369
+ if "://" in url or lowered.startswith(("data:", "about:")):
1370
+ return url
1371
+ return f"https://{url}"
1372
+
1373
+ @staticmethod
1374
+ def _build_search_url(query: str, engine: str) -> str:
1375
+ encoded = urllib.parse.quote_plus(query)
1376
+ template = os.environ.get("BROWSER_CLI_SEARCH_URL_TEMPLATE")
1377
+ if template:
1378
+ return template.format(query=encoded, raw_query=query, engine=engine)
1379
+ if engine == "google":
1380
+ return f"https://www.google.com/search?q={encoded}&udm=14"
1381
+ if engine == "bing":
1382
+ return f"https://www.bing.com/search?q={encoded}"
1383
+ return f"https://duckduckgo.com/?q={encoded}"
1384
+
1385
+ @staticmethod
1386
+ def _resolve_output_path(path: str) -> Path:
1387
+ candidate = Path(path).expanduser()
1388
+ if not candidate.is_absolute():
1389
+ candidate = (get_app_paths().artifacts_dir / candidate).resolve()
1390
+ candidate.parent.mkdir(parents=True, exist_ok=True)
1391
+ return candidate
1392
+
1393
+ async def _close_page(self, page_id: str) -> dict[str, Any]:
1394
+ page = self._require_page(page_id)
1395
+ video = getattr(page, "video", None)
1396
+ sentinel = object()
1397
+ pending = self._pending_video_save_paths.pop(page_id, sentinel)
1398
+ has_pending = pending is not sentinel
1399
+ video_requested = page_id in self._video_started or has_pending
1400
+ self._video_started.discard(page_id)
1401
+ async with self._page_create_lock:
1402
+ await self._ensure_reusable_page_before_last_close(page)
1403
+ await self._remove_page_handlers(page_id)
1404
+ await page.close()
1405
+ self._pages.pop(page_id, None)
1406
+ self._snapshot_registry.clear_page(page_id)
1407
+ result: dict[str, Any] = {"page_id": page_id, "closed": True}
1408
+ if video is not None and video_requested:
1409
+ result["video_path"] = await self._save_video_artifact(
1410
+ video,
1411
+ None if pending is sentinel else pending,
1412
+ )
1413
+ return result
1414
+
1415
+ async def _stop_tracing_impl(self, *, path: str | None) -> str:
1416
+ if self._context is None or not self._tracing_active:
1417
+ raise OperationFailedError("No active tracing session. Start tracing first.")
1418
+ if path:
1419
+ output_path = self._resolve_output_path(path)
1420
+ if output_path.suffix.lower() != ".zip":
1421
+ output_path = output_path.with_suffix(".zip")
1422
+ else:
1423
+ artifacts_dir = get_app_paths().artifacts_dir
1424
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
1425
+ fd, raw_path = tempfile.mkstemp(
1426
+ suffix=".zip",
1427
+ prefix="browser-cli-trace-",
1428
+ dir=str(artifacts_dir),
1429
+ )
1430
+ os.close(fd)
1431
+ output_path = Path(raw_path)
1432
+ await self._context.tracing.stop(path=str(output_path))
1433
+ self._tracing_active = False
1434
+ return str(output_path)
1435
+
1436
+ async def _save_video_artifact(self, video: Any, requested_path: str | None) -> str:
1437
+ if requested_path:
1438
+ output_path = Path(requested_path)
1439
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1440
+ await video.save_as(str(output_path))
1441
+ return str(output_path)
1442
+ raw_path = await video.path()
1443
+ return str(Path(str(raw_path)).resolve())
1444
+
1445
+ @staticmethod
1446
+ def _resolve_video_output_path(path: str | None, *, page_id: str) -> Path | None:
1447
+ if not path:
1448
+ return None
1449
+ raw = Path(path).expanduser()
1450
+ if path.endswith(os.sep) or path.endswith("/") or raw.is_dir():
1451
+ base_dir = raw if raw.is_absolute() else (get_app_paths().artifacts_dir / raw)
1452
+ base_dir.mkdir(parents=True, exist_ok=True)
1453
+ return (base_dir / f"{page_id}.webm").resolve()
1454
+ if not raw.is_absolute():
1455
+ raw = (get_app_paths().artifacts_dir / raw).resolve()
1456
+ if raw.suffix.lower() != ".webm":
1457
+ raw = raw.with_suffix(".webm")
1458
+ raw.parent.mkdir(parents=True, exist_ok=True)
1459
+ return raw
1460
+
1461
+ @staticmethod
1462
+ def _raise_launch_error(exc: Exception) -> None:
1463
+ message = str(exc)
1464
+ lowered = message.lower()
1465
+ if (
1466
+ "singleton" in lowered
1467
+ or "profile" in lowered
1468
+ or "user data directory is already in use" in lowered
1469
+ ):
1470
+ raise ProfileUnavailableError(message) from exc
1471
+ if "executable" in lowered or "browser" in lowered or "failed to launch" in lowered:
1472
+ raise BrowserUnavailableError(message) from exc
1473
+ raise TemporaryReadError(message) from exc
1474
+
1475
+ def _semantic_snapshot_from_capture(
1476
+ self,
1477
+ page_id: str,
1478
+ snapshot: SnapshotCapture,
1479
+ *,
1480
+ interactive: bool,
1481
+ full_page: bool,
1482
+ ) -> SemanticSnapshot:
1483
+ page = self._require_page(page_id)
1484
+ captured_at = time.time()
1485
+ metadata = SnapshotMetadata(
1486
+ snapshot_id=snapshot.snapshot_id,
1487
+ page_id=page_id,
1488
+ captured_url=str(page.url),
1489
+ captured_at=captured_at,
1490
+ interactive=interactive,
1491
+ full_page=full_page,
1492
+ )
1493
+ refs = {
1494
+ ref: RefData(
1495
+ ref=str(ref),
1496
+ role=str(data.get("role") or ""),
1497
+ name=str(data["name"]) if data.get("name") is not None else None,
1498
+ nth=int(data["nth"]) if data.get("nth") is not None else None,
1499
+ text_content=str(data["text_content"])
1500
+ if data.get("text_content") is not None
1501
+ else None,
1502
+ tag=str(data["tag"]) if data.get("tag") is not None else None,
1503
+ interactive=bool(data.get("interactive")),
1504
+ parent_ref=str(data["parent_ref"]) if data.get("parent_ref") is not None else None,
1505
+ frame_path=tuple(int(item) for item in (data.get("frame_path") or [])),
1506
+ playwright_ref=str(data["playwright_ref"])
1507
+ if data.get("playwright_ref") is not None
1508
+ else None,
1509
+ selector_recipe=str(data["selector_recipe"])
1510
+ if data.get("selector_recipe") is not None
1511
+ else None,
1512
+ snapshot_id=snapshot.snapshot_id,
1513
+ page_id=page_id,
1514
+ captured_url=str(page.url),
1515
+ captured_at=captured_at,
1516
+ )
1517
+ for ref, data in snapshot.refs.items()
1518
+ }
1519
+ return SemanticSnapshot(tree=snapshot.tree, refs=refs, metadata=metadata)