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.
- browser_cli/__init__.py +7 -0
- browser_cli/actions/__init__.py +5 -0
- browser_cli/actions/cli_specs.py +868 -0
- browser_cli/agent_scope/__init__.py +14 -0
- browser_cli/automation/__init__.py +11 -0
- browser_cli/automation/api/__init__.py +5 -0
- browser_cli/automation/api/server.py +321 -0
- browser_cli/automation/hooks.py +42 -0
- browser_cli/automation/loader.py +134 -0
- browser_cli/automation/models.py +127 -0
- browser_cli/automation/persistence/__init__.py +5 -0
- browser_cli/automation/persistence/store.py +682 -0
- browser_cli/automation/projections.py +279 -0
- browser_cli/automation/publisher.py +153 -0
- browser_cli/automation/scheduler/__init__.py +5 -0
- browser_cli/automation/scheduler/schedule.py +113 -0
- browser_cli/automation/service/__init__.py +8 -0
- browser_cli/automation/service/__main__.py +64 -0
- browser_cli/automation/service/client.py +232 -0
- browser_cli/automation/service/runtime.py +388 -0
- browser_cli/automation/toml.py +35 -0
- browser_cli/automation/web/__init__.py +5 -0
- browser_cli/automation/web/assets.py +377 -0
- browser_cli/browser/__init__.py +1 -0
- browser_cli/browser/models.py +44 -0
- browser_cli/browser/network_capture.py +392 -0
- browser_cli/browser/service.py +1519 -0
- browser_cli/browser/session.py +158 -0
- browser_cli/browser/snapshot.py +35 -0
- browser_cli/browser/stealth.py +450 -0
- browser_cli/cli/__init__.py +1 -0
- browser_cli/cli/error_hints.py +26 -0
- browser_cli/cli/main.py +298 -0
- browser_cli/commands/__init__.py +1 -0
- browser_cli/commands/action.py +25 -0
- browser_cli/commands/automation.py +304 -0
- browser_cli/commands/doctor.py +336 -0
- browser_cli/commands/install_skills.py +138 -0
- browser_cli/commands/paths.py +31 -0
- browser_cli/commands/read.py +33 -0
- browser_cli/commands/reload.py +51 -0
- browser_cli/commands/status.py +444 -0
- browser_cli/commands/task.py +71 -0
- browser_cli/constants.py +97 -0
- browser_cli/daemon/__init__.py +1 -0
- browser_cli/daemon/__main__.py +34 -0
- browser_cli/daemon/app.py +941 -0
- browser_cli/daemon/browser_service.py +998 -0
- browser_cli/daemon/client.py +280 -0
- browser_cli/daemon/models.py +63 -0
- browser_cli/daemon/runtime_presentation.py +167 -0
- browser_cli/daemon/server.py +103 -0
- browser_cli/daemon/state.py +15 -0
- browser_cli/daemon/transport.py +77 -0
- browser_cli/drivers/__init__.py +6 -0
- browser_cli/drivers/_extension/__init__.py +1 -0
- browser_cli/drivers/_extension/artifact_actions.py +154 -0
- browser_cli/drivers/_extension/dialog_actions.py +35 -0
- browser_cli/drivers/_extension/input_actions.py +63 -0
- browser_cli/drivers/_extension/locator_actions.py +100 -0
- browser_cli/drivers/_extension/observe_actions.py +285 -0
- browser_cli/drivers/_extension/page_actions.py +210 -0
- browser_cli/drivers/_extension/state_actions.py +134 -0
- browser_cli/drivers/base.py +391 -0
- browser_cli/drivers/extension_driver.py +33 -0
- browser_cli/drivers/models.py +21 -0
- browser_cli/drivers/playwright_driver.py +499 -0
- browser_cli/error_codes.py +27 -0
- browser_cli/errors.py +146 -0
- browser_cli/exit_codes.py +9 -0
- browser_cli/extension/__init__.py +32 -0
- browser_cli/extension/protocol.py +222 -0
- browser_cli/extension/session.py +393 -0
- browser_cli/network.py +147 -0
- browser_cli/outputs/__init__.py +1 -0
- browser_cli/outputs/json.py +10 -0
- browser_cli/outputs/render.py +7 -0
- browser_cli/packaged_skills/__init__.py +1 -0
- browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
- browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
- browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
- browser_cli/profiles/__init__.py +1 -0
- browser_cli/profiles/discovery.py +185 -0
- browser_cli/refs/__init__.py +17 -0
- browser_cli/refs/generator.py +386 -0
- browser_cli/refs/models.py +127 -0
- browser_cli/refs/registry.py +51 -0
- browser_cli/refs/resolver.py +214 -0
- browser_cli/tabs/__init__.py +5 -0
- browser_cli/tabs/registry.py +283 -0
- browser_cli/task_runtime/__init__.py +31 -0
- browser_cli/task_runtime/client.py +107 -0
- browser_cli/task_runtime/entrypoint.py +110 -0
- browser_cli/task_runtime/errors.py +17 -0
- browser_cli/task_runtime/flow.py +171 -0
- browser_cli/task_runtime/models.py +97 -0
- browser_cli/task_runtime/read.py +71 -0
- browser_cli/task_runtime/templates.py +58 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
- 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)
|