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,285 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ class ExtensionDriverObserveActionsMixin:
8
+ async def wait(
9
+ self,
10
+ page_id: str,
11
+ *,
12
+ seconds: float | None = None,
13
+ text: str | None = None,
14
+ gone: bool = False,
15
+ exact: bool = False,
16
+ ) -> dict[str, object]:
17
+ payload = await self._page_command(
18
+ page_id,
19
+ "wait",
20
+ {
21
+ "seconds": seconds,
22
+ "text": text,
23
+ "gone": gone,
24
+ "exact": exact,
25
+ },
26
+ )
27
+ return {"page_id": page_id, **payload}
28
+
29
+ async def wait_for_network_idle(
30
+ self, page_id: str, *, timeout_seconds: float = 30.0
31
+ ) -> dict[str, object]:
32
+ payload = await self._page_command(
33
+ page_id, "wait-network", {"timeout_seconds": timeout_seconds}
34
+ )
35
+ return {"page_id": page_id, **payload}
36
+
37
+ async def start_console_capture(self, page_id: str) -> dict[str, object]:
38
+ payload = await self._page_command(page_id, "console-start", {})
39
+ return {"page_id": page_id, **payload}
40
+
41
+ async def get_console_messages(
42
+ self,
43
+ page_id: str,
44
+ *,
45
+ message_type: str | None = None,
46
+ clear: bool = True,
47
+ ) -> dict[str, object]:
48
+ payload = await self._page_command(
49
+ page_id,
50
+ "console-get",
51
+ {"message_type": message_type, "clear": clear},
52
+ )
53
+ return {"page_id": page_id, **payload}
54
+
55
+ async def stop_console_capture(self, page_id: str) -> dict[str, object]:
56
+ payload = await self._page_command(page_id, "console-stop", {})
57
+ return {"page_id": page_id, **payload}
58
+
59
+ async def start_network_capture(self, page_id: str) -> dict[str, object]:
60
+ payload = await self._page_command(page_id, "network-start", {})
61
+ return {"page_id": page_id, **payload}
62
+
63
+ async def wait_for_network_record(
64
+ self,
65
+ page_id: str,
66
+ *,
67
+ url_contains: str | None = None,
68
+ url_regex: str | None = None,
69
+ method: str | None = None,
70
+ status: int | None = None,
71
+ resource_type: str | None = None,
72
+ mime_contains: str | None = None,
73
+ include_static: bool = False,
74
+ timeout_seconds: float = 30.0,
75
+ ) -> dict[str, object]:
76
+ payload = await self._page_command(
77
+ page_id,
78
+ "network-wait",
79
+ {
80
+ "url_contains": url_contains,
81
+ "url_regex": url_regex,
82
+ "method": method,
83
+ "status": status,
84
+ "resource_type": resource_type,
85
+ "mime_contains": mime_contains,
86
+ "include_static": include_static,
87
+ "timeout_seconds": timeout_seconds,
88
+ },
89
+ )
90
+ await self._materialize_network_body_artifacts(payload, page_id=page_id)
91
+ return {"page_id": page_id, **payload}
92
+
93
+ async def get_network_records(
94
+ self,
95
+ page_id: str,
96
+ *,
97
+ url_contains: str | None = None,
98
+ url_regex: str | None = None,
99
+ method: str | None = None,
100
+ status: int | None = None,
101
+ resource_type: str | None = None,
102
+ mime_contains: str | None = None,
103
+ include_static: bool = False,
104
+ clear: bool = True,
105
+ ) -> dict[str, object]:
106
+ payload = await self._page_command(
107
+ page_id,
108
+ "network",
109
+ {
110
+ "url_contains": url_contains,
111
+ "url_regex": url_regex,
112
+ "method": method,
113
+ "status": status,
114
+ "resource_type": resource_type,
115
+ "mime_contains": mime_contains,
116
+ "include_static": include_static,
117
+ "clear": clear,
118
+ },
119
+ )
120
+ await self._materialize_network_body_artifacts(payload, page_id=page_id)
121
+ return {"page_id": page_id, **payload}
122
+
123
+ async def stop_network_capture(self, page_id: str) -> dict[str, object]:
124
+ payload = await self._page_command(page_id, "network-stop", {})
125
+ return {"page_id": page_id, **payload}
126
+
127
+ async def _materialize_network_body_artifacts(
128
+ self, payload: dict[str, object], *, page_id: str
129
+ ) -> None:
130
+ artifacts = list(payload.get("_artifacts") or [])
131
+ artifact_paths: dict[str, str] = {}
132
+ for artifact in artifacts:
133
+ if str(artifact.get("artifact_kind") or "") != "network-body":
134
+ continue
135
+ artifact_id = str(artifact.get("artifact_id") or "").strip()
136
+ if not artifact_id:
137
+ continue
138
+ resolved = self._resolve_artifact_output_path(
139
+ artifact,
140
+ page_id=page_id,
141
+ path=None,
142
+ suffix="",
143
+ )
144
+ self._write_artifact_bytes(artifact, resolved)
145
+ artifact_paths[artifact_id] = str(resolved)
146
+ if "record" in payload and isinstance(payload.get("record"), dict):
147
+ self._rewrite_network_body_path(payload["record"], artifact_paths)
148
+ records = payload.get("records")
149
+ if isinstance(records, list):
150
+ for record in records:
151
+ if isinstance(record, dict):
152
+ self._rewrite_network_body_path(record, artifact_paths)
153
+
154
+ @staticmethod
155
+ def _rewrite_network_body_path(
156
+ record: dict[str, object], artifact_paths: dict[str, str]
157
+ ) -> None:
158
+ body = record.get("body")
159
+ if not isinstance(body, dict):
160
+ return
161
+ raw_path = str(body.get("path") or "")
162
+ if not raw_path.startswith("artifact://"):
163
+ return
164
+ artifact_id = raw_path.removeprefix("artifact://")
165
+ resolved_path = artifact_paths.get(artifact_id)
166
+ if resolved_path:
167
+ body["path"] = resolved_path
168
+
169
+ async def get_cookies(
170
+ self,
171
+ page_id: str,
172
+ *,
173
+ name: str | None = None,
174
+ domain: str | None = None,
175
+ path: str | None = None,
176
+ ) -> dict[str, object]:
177
+ payload = await self._page_command(
178
+ page_id,
179
+ "cookies-get",
180
+ {"name": name, "domain": domain, "path": path},
181
+ )
182
+ return {"page_id": page_id, **payload}
183
+
184
+ async def set_cookie(
185
+ self,
186
+ page_id: str,
187
+ *,
188
+ name: str,
189
+ value: str,
190
+ domain: str | None = None,
191
+ path: str = "/",
192
+ expires: float | None = None,
193
+ http_only: bool = False,
194
+ secure: bool = False,
195
+ same_site: str | None = None,
196
+ ) -> dict[str, object]:
197
+ payload = await self._page_command(
198
+ page_id,
199
+ "cookie-set",
200
+ {
201
+ "name": name,
202
+ "value": value,
203
+ "domain": domain,
204
+ "path": path,
205
+ "expires": expires,
206
+ "http_only": http_only,
207
+ "secure": secure,
208
+ "same_site": same_site,
209
+ },
210
+ )
211
+ return {"page_id": page_id, **payload}
212
+
213
+ async def clear_cookies(
214
+ self,
215
+ page_id: str,
216
+ *,
217
+ name: str | None = None,
218
+ domain: str | None = None,
219
+ path: str | None = None,
220
+ ) -> dict[str, object]:
221
+ payload = await self._page_command(
222
+ page_id,
223
+ "cookies-clear",
224
+ {"name": name, "domain": domain, "path": path},
225
+ )
226
+ return {"page_id": page_id, **payload}
227
+
228
+ async def save_storage_state(
229
+ self, page_id: str, *, path: str | None = None
230
+ ) -> dict[str, object]:
231
+ payload = await self._page_command(page_id, "storage-get", {})
232
+ output_path = self._resolve_output_path(path, page_id=page_id, suffix=".json")
233
+ output_path.write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8")
234
+ return {"page_id": page_id, "path": str(output_path)}
235
+
236
+ async def load_storage_state(self, page_id: str, *, path: str) -> dict[str, object]:
237
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
238
+ result = await self._page_command(page_id, "storage-set", payload)
239
+ return {"page_id": page_id, **result}
240
+
241
+ async def verify_text(
242
+ self,
243
+ page_id: str,
244
+ *,
245
+ text: str,
246
+ exact: bool = False,
247
+ timeout_seconds: float = 5.0,
248
+ ) -> dict[str, object]:
249
+ payload = await self._page_command(
250
+ page_id,
251
+ "verify-text",
252
+ {"text": text, "exact": exact, "timeout_seconds": timeout_seconds},
253
+ )
254
+ return {"page_id": page_id, **payload}
255
+
256
+ async def verify_url(
257
+ self, page_id: str, *, expected: str, exact: bool = False
258
+ ) -> dict[str, object]:
259
+ payload = await self._page_command(
260
+ page_id, "verify-url", {"expected": expected, "exact": exact}
261
+ )
262
+ return {"page_id": page_id, **payload}
263
+
264
+ async def verify_title(
265
+ self, page_id: str, *, expected: str, exact: bool = False
266
+ ) -> dict[str, object]:
267
+ payload = await self._page_command(
268
+ page_id, "verify-title", {"expected": expected, "exact": exact}
269
+ )
270
+ return {"page_id": page_id, **payload}
271
+
272
+ async def verify_visible(
273
+ self,
274
+ page_id: str,
275
+ *,
276
+ role: str,
277
+ name: str,
278
+ timeout_seconds: float = 5.0,
279
+ ) -> dict[str, object]:
280
+ payload = await self._page_command(
281
+ page_id,
282
+ "verify-visible",
283
+ {"role": role, "name": name, "timeout_seconds": timeout_seconds},
284
+ )
285
+ return {"page_id": page_id, **payload}
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from browser_cli.refs.models import SnapshotInput
7
+
8
+ from ..models import TabState
9
+
10
+
11
+ class ExtensionDriverPageActionsMixin:
12
+ async def new_tab(
13
+ self,
14
+ *,
15
+ page_id: str,
16
+ url: str | None = None,
17
+ wait_until: str = "load",
18
+ timeout_seconds: float | None = None,
19
+ ) -> dict[str, Any]:
20
+ session = await self._require_session()
21
+ payload = await session.send_request(
22
+ "open-tab",
23
+ {
24
+ "page_id": page_id,
25
+ "url": url,
26
+ "wait_until": wait_until,
27
+ "timeout_seconds": timeout_seconds,
28
+ },
29
+ )
30
+ tab_id = int(payload["tab_id"])
31
+ self._page_to_tab[page_id] = tab_id
32
+ self._tab_to_page[tab_id] = page_id
33
+ self._active_page_id = page_id
34
+ return {
35
+ "page_id": page_id,
36
+ "url": str(payload.get("url") or url or ""),
37
+ "title": str(payload.get("title") or ""),
38
+ }
39
+
40
+ async def close_tab(self, page_id: str) -> dict[str, Any]:
41
+ tab_id = self._require_tab_id(page_id)
42
+ session = await self._require_session()
43
+ payload = await session.send_request("close-tab", {"tab_id": tab_id})
44
+ self._page_to_tab.pop(page_id, None)
45
+ self._tab_to_page.pop(tab_id, None)
46
+ if self._active_page_id == page_id:
47
+ self._active_page_id = next(iter(self._page_to_tab.keys()), None)
48
+ result = {
49
+ "page_id": page_id,
50
+ "closed": True,
51
+ "url": str(payload.get("url") or ""),
52
+ "title": str(payload.get("title") or ""),
53
+ }
54
+ video_paths = await self._materialize_video_artifacts(payload.get("_artifacts") or [])
55
+ if video_paths:
56
+ result["video_path"] = video_paths[0]
57
+ return result
58
+
59
+ async def list_tabs(self) -> list[TabState]:
60
+ session = await self._require_session()
61
+ payload = await session.send_request("list-tabs", {})
62
+ results: list[TabState] = []
63
+ for item in payload.get("tabs", []):
64
+ try:
65
+ tab_id = int(item["tab_id"])
66
+ except Exception:
67
+ continue
68
+ page_id = self._tab_to_page.get(tab_id)
69
+ if not page_id:
70
+ continue
71
+ results.append(
72
+ TabState(
73
+ page_id=page_id,
74
+ url=str(item.get("url") or ""),
75
+ title=str(item.get("title") or ""),
76
+ active=bool(item.get("active")),
77
+ )
78
+ )
79
+ if item.get("active"):
80
+ self._active_page_id = page_id
81
+ return results
82
+
83
+ async def switch_tab(self, page_id: str) -> dict[str, Any]:
84
+ tab_id = self._require_tab_id(page_id)
85
+ session = await self._require_session()
86
+ payload = await session.send_request("activate-tab", {"tab_id": tab_id})
87
+ self._active_page_id = page_id
88
+ return {
89
+ "page_id": page_id,
90
+ "url": str(payload.get("url") or ""),
91
+ "title": str(payload.get("title") or ""),
92
+ }
93
+
94
+ async def get_page_summary(self, page_id: str) -> dict[str, Any]:
95
+ tab_id = self._require_tab_id(page_id)
96
+ session = await self._require_session()
97
+ payload = await session.send_request("page-summary", {"tab_id": tab_id})
98
+ return {
99
+ "page_id": page_id,
100
+ "url": str(payload.get("url") or ""),
101
+ "title": str(payload.get("title") or ""),
102
+ }
103
+
104
+ async def get_page_info(self, page_id: str) -> dict[str, Any]:
105
+ tab_id = self._require_tab_id(page_id)
106
+ session = await self._require_session()
107
+ payload = await session.send_request("page-info", {"tab_id": tab_id})
108
+ return {"page_id": page_id, **payload}
109
+
110
+ async def capture_html(self, page_id: str) -> dict[str, Any]:
111
+ tab_id = self._require_tab_id(page_id)
112
+ session = await self._require_session()
113
+ payload = await session.send_request("capture-html", {"tab_id": tab_id})
114
+ return {"page_id": page_id, "html": str(payload.get("html") or "")}
115
+
116
+ async def capture_snapshot_input(
117
+ self,
118
+ page_id: str,
119
+ *,
120
+ interactive: bool = False,
121
+ full_page: bool = True,
122
+ ) -> SnapshotInput:
123
+ tab_id = self._require_tab_id(page_id)
124
+ session = await self._require_session()
125
+ payload = await session.send_request(
126
+ "capture-snapshot-input",
127
+ {
128
+ "tab_id": tab_id,
129
+ "interactive": interactive,
130
+ "full_page": full_page,
131
+ },
132
+ )
133
+ return SnapshotInput(
134
+ raw_snapshot=str(payload.get("raw_snapshot") or ""),
135
+ captured_url=str(payload.get("captured_url") or ""),
136
+ captured_at=float(payload.get("captured_at") or time.time()),
137
+ )
138
+
139
+ async def navigate(
140
+ self,
141
+ page_id: str,
142
+ url: str,
143
+ *,
144
+ wait_until: str = "load",
145
+ timeout_seconds: float = 30.0,
146
+ ) -> dict[str, Any]:
147
+ tab_id = self._require_tab_id(page_id)
148
+ session = await self._require_session()
149
+ payload = await session.send_request(
150
+ "navigate",
151
+ {
152
+ "tab_id": tab_id,
153
+ "url": url,
154
+ "wait_until": wait_until,
155
+ "timeout_seconds": timeout_seconds,
156
+ },
157
+ )
158
+ return {
159
+ "page_id": page_id,
160
+ "url": str(payload.get("url") or url),
161
+ "title": str(payload.get("title") or ""),
162
+ }
163
+
164
+ async def reload(
165
+ self,
166
+ page_id: str,
167
+ *,
168
+ wait_until: str = "load",
169
+ timeout_seconds: float = 30.0,
170
+ ) -> dict[str, Any]:
171
+ return await self._simple_page_command(
172
+ page_id,
173
+ "reload",
174
+ {"wait_until": wait_until, "timeout_seconds": timeout_seconds},
175
+ )
176
+
177
+ async def go_back(self, page_id: str) -> dict[str, Any]:
178
+ return await self._simple_page_command(page_id, "go-back", {})
179
+
180
+ async def go_forward(self, page_id: str) -> dict[str, Any]:
181
+ return await self._simple_page_command(page_id, "go-forward", {})
182
+
183
+ async def resize(self, page_id: str, *, width: int, height: int) -> dict[str, Any]:
184
+ payload = await self._page_command(page_id, "resize", {"width": width, "height": height})
185
+ return {
186
+ "page_id": page_id,
187
+ "width": int(payload.get("width") or width),
188
+ "height": int(payload.get("height") or height),
189
+ }
190
+
191
+ async def evaluate(self, page_id: str, code: str) -> dict[str, Any]:
192
+ payload = await self._page_command(page_id, "eval", {"code": code})
193
+ return {"page_id": page_id, "result": payload.get("result")}
194
+
195
+ async def _simple_page_command(
196
+ self, page_id: str, action: str, payload: dict[str, Any]
197
+ ) -> dict[str, Any]:
198
+ payload = await self._page_command(page_id, action, payload)
199
+ return {
200
+ "page_id": page_id,
201
+ "url": str(payload.get("url") or ""),
202
+ "title": str(payload.get("title") or ""),
203
+ }
204
+
205
+ async def _page_command(
206
+ self, page_id: str, action: str, payload: dict[str, Any]
207
+ ) -> dict[str, Any]:
208
+ tab_id = self._require_tab_id(page_id)
209
+ session = await self._require_session()
210
+ return await session.send_request(action, {"tab_id": tab_id, **payload})
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from browser_cli.errors import OperationFailedError, TabNotFoundError
7
+
8
+ if TYPE_CHECKING:
9
+ from browser_cli.extension import ExtensionHub
10
+
11
+
12
+ class ExtensionDriverStateMixin:
13
+ _hub: ExtensionHub
14
+ _page_to_tab: dict[str, int]
15
+ _tab_to_page: dict[int, str]
16
+ _active_page_id: str | None
17
+
18
+ async def ensure_started(self) -> None:
19
+ await self._hub.ensure_started()
20
+ if self._hub.session is None:
21
+ raise OperationFailedError(
22
+ "Browser CLI extension is not connected.",
23
+ error_code="EXTENSION_UNAVAILABLE",
24
+ )
25
+
26
+ async def stop(self) -> dict[str, Any]:
27
+ closed_pages = sorted(self._page_to_tab.keys())
28
+ session = self._hub.session
29
+ payload: dict[str, Any] = {}
30
+ cleanup_error: str | None = None
31
+ if session is not None:
32
+ try:
33
+ payload = await session.send_request("workspace-close", {})
34
+ except Exception as exc:
35
+ cleanup_error = str(exc)
36
+ payload = {}
37
+ video_paths = await self._materialize_video_artifacts(payload.get("_artifacts") or [])
38
+ self._page_to_tab.clear()
39
+ self._tab_to_page.clear()
40
+ self._active_page_id = None
41
+ result = {
42
+ "closed_pages": closed_pages,
43
+ "extension_connected": session is not None,
44
+ "video_paths": video_paths,
45
+ }
46
+ if cleanup_error:
47
+ result["cleanup_error"] = cleanup_error
48
+ return result
49
+
50
+ async def workspace_status(self) -> dict[str, Any]:
51
+ session = await self._require_session()
52
+ payload = await session.send_request("workspace-status", {})
53
+ return {
54
+ "window_id": payload.get("window_id"),
55
+ "tab_count": int(payload.get("tab_count") or 0),
56
+ "managed_tab_count": int(payload.get("managed_tab_count") or 0),
57
+ "binding_state": str(payload.get("binding_state") or "absent"),
58
+ }
59
+
60
+ async def rebuild_workspace_binding(self) -> dict[str, Any]:
61
+ session = await self._require_session()
62
+ payload = await session.send_request("workspace-rebuild-binding", {})
63
+ video_paths = await self._materialize_video_artifacts(payload.get("_artifacts") or [])
64
+ self._page_to_tab.clear()
65
+ self._tab_to_page.clear()
66
+ self._active_page_id = None
67
+ return {
68
+ "rebuilt": bool(payload.get("rebuilt")),
69
+ "workspace_window_state": {
70
+ "window_id": payload.get("window_id"),
71
+ "tab_count": int(payload.get("tab_count") or 0),
72
+ "managed_tab_count": int(payload.get("managed_tab_count") or 0),
73
+ "binding_state": str(payload.get("binding_state") or "absent"),
74
+ },
75
+ "video_paths": video_paths,
76
+ }
77
+
78
+ async def health(self):
79
+ session = self._hub.session
80
+ if session is None:
81
+ from ..models import DriverHealth
82
+
83
+ return DriverHealth(name=self.name, available=False, details={"connected": False})
84
+ hello = session.hello
85
+ from ..models import DriverHealth
86
+
87
+ return DriverHealth(
88
+ name=self.name,
89
+ available=hello.has_required_capabilities(),
90
+ details={
91
+ "connected": True,
92
+ "extension_version": hello.extension_version,
93
+ "browser_name": hello.browser_name,
94
+ "browser_version": hello.browser_version,
95
+ "capabilities": sorted(hello.capabilities),
96
+ "capability_complete": hello.has_required_capabilities(),
97
+ "missing_capabilities": hello.missing_required_capabilities(),
98
+ "workspace_window_state": dict(hello.workspace_window_state),
99
+ "extension_instance_id": hello.extension_instance_id,
100
+ },
101
+ )
102
+
103
+ async def _require_session(self):
104
+ await self.ensure_started()
105
+ session = self._hub.session
106
+ if session is None:
107
+ raise OperationFailedError(
108
+ "Browser CLI extension is not connected.", error_code="EXTENSION_UNAVAILABLE"
109
+ )
110
+ return session
111
+
112
+ def _require_tab_id(self, page_id: str) -> int:
113
+ tab_id = self._page_to_tab.get(page_id)
114
+ if tab_id is None:
115
+ raise TabNotFoundError()
116
+ return tab_id
117
+
118
+ @staticmethod
119
+ def _resolve_output_path(path: str | None, *, page_id: str, suffix: str) -> Path:
120
+ if not path:
121
+ from browser_cli.constants import get_app_paths
122
+
123
+ output = get_app_paths().artifacts_dir / f"{page_id}{suffix}"
124
+ output.parent.mkdir(parents=True, exist_ok=True)
125
+ return output.resolve()
126
+ raw = Path(path).expanduser()
127
+ if not raw.is_absolute():
128
+ from browser_cli.constants import get_app_paths
129
+
130
+ raw = (get_app_paths().artifacts_dir / raw).resolve()
131
+ if raw.suffix.lower() != suffix:
132
+ raw = raw.with_suffix(suffix)
133
+ raw.parent.mkdir(parents=True, exist_ok=True)
134
+ return raw