main
py 4,891 lines 179 KB
Raw
1 import asyncio
2 import concurrent.futures
3 import json
4 import re
5 import subprocess
6 import sys
7 import threading
8 import zipfile
9 from pathlib import Path
10 from types import ModuleType, SimpleNamespace
11
12 import pytest
13
14
15 PROJECT_ROOT = Path(__file__).resolve().parents[1]
16 if str(PROJECT_ROOT) not in sys.path:
17 sys.path.insert(0, str(PROJECT_ROOT))
18
19
20 class _TestAgentContext:
21 @staticmethod
22 def get(context_id):
23 return None
24
25
26 class _TestAgent:
27 pass
28
29
30 class _TestAgentConfig:
31 pass
32
33
34 class _TestAgentContextType:
35 BACKGROUND = "background"
36 USER = SimpleNamespace(value="user")
37
38
39 class _TestResponse(SimpleNamespace):
40 def __init__(self, message="", break_loop=False, additional=None, **kwargs):
41 super().__init__(
42 message=message,
43 break_loop=break_loop,
44 additional=additional,
45 **kwargs,
46 )
47
48
49 class _TestTool:
50 def __init__(
51 self,
52 agent=None,
53 name="",
54 method=None,
55 args=None,
56 message="",
57 loop_data=None,
58 **kwargs,
59 ):
60 self.agent = agent
61 self.name = name
62 self.method = method
63 self.args = args or {}
64 self.message = message
65 self.loop_data = loop_data
66
67 async def after_execution(self, response, **kwargs):
68 self.agent.hist_add_tool_result(
69 self.name,
70 response.message.strip(),
71 id=self.log.id,
72 **(response.additional or {}),
73 )
74 self.log.update(content=response.message.strip())
75
76
77 class _TestWsHandler:
78 def __init__(self, *args, **kwargs):
79 self.emitted = []
80
81 async def emit_to(self, sid, event, data, correlation_id=None):
82 self.emitted.append((sid, event, data, correlation_id))
83
84
85 class _TestWsResult(dict):
86 @staticmethod
87 def error(code="", message="", correlation_id=None):
88 return _TestWsResult(
89 {
90 "ok": False,
91 "code": code,
92 "error": message,
93 "correlation_id": correlation_id,
94 }
95 )
96
97
98 sys.modules.setdefault(
99 "agent",
100 SimpleNamespace(
101 Agent=_TestAgent,
102 AgentConfig=_TestAgentConfig,
103 AgentContext=_TestAgentContext,
104 AgentContextType=_TestAgentContextType,
105 ),
106 )
107 sys.modules.setdefault("helpers.tool", SimpleNamespace(Response=_TestResponse, Tool=_TestTool))
108 sys.modules.setdefault("helpers.ws", SimpleNamespace(WsHandler=_TestWsHandler))
109 sys.modules.setdefault("helpers.ws_manager", SimpleNamespace(WsResult=_TestWsResult))
110 _model_config_stub = ModuleType("plugins._model_config.helpers.model_config")
111 _model_config_stub.get_presets = lambda: []
112 _model_config_stub.get_preset_by_name = lambda name: None
113 _model_config_stub.get_chat_model_config = lambda agent=None: {}
114 _model_config_stub.get_vision_model_config = lambda agent=None: {}
115 _model_config_stub.build_vision_model = lambda agent=None: None
116 sys.modules.setdefault("plugins._model_config.helpers.model_config", _model_config_stub)
117
118
119 @pytest.fixture
120 def anyio_backend():
121 return "asyncio"
122
123 from helpers import ephemeral_images, virtual_desktop
124 from helpers.errors import RepairableException
125 from plugins._browser.helpers.config import (
126 build_browser_launch_config,
127 get_browser_main_model_summary,
128 get_browser_model_preset_options,
129 normalize_browser_config,
130 resolve_browser_model_selection,
131 )
132 from plugins._browser.helpers.extension_manager import (
133 _build_web_store_download_url,
134 _crx_zip_payload,
135 _detect_chrome_prodversion,
136 _normalize_chrome_prodversion,
137 get_extensions_root,
138 parse_chrome_web_store_extension_id,
139 uninstall_browser_extension,
140 )
141 import plugins._browser.helpers.extension_manager as browser_extension_manager_module
142 from plugins._browser.helpers.runtime import (
143 BrowserPage,
144 _BrowserRuntimeCore,
145 _BrowserScreencast,
146 list_runtime_sessions,
147 normalize_url,
148 )
149 import plugins._browser.helpers.runtime as browser_runtime_module
150 from plugins._browser.helpers.interactive_view import BrowserInteractiveView
151 import plugins._browser.helpers.interactive_view as browser_interactive_view_module
152 from plugins._browser.helpers.playwright import (
153 ensure_playwright_binary,
154 get_playwright_binary,
155 get_playwright_cache_dir,
156 )
157 import plugins._browser.helpers.playwright as browser_playwright_module
158 import plugins._browser.hooks as browser_hooks_module
159 import plugins._browser.tools.browser as browser_tool_module
160 import plugins._browser.api.ws_browser as ws_browser_module
161
162
163 SMALL_JPEG_10X10 = (
164 "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL"
165 "DBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/"
166 "2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy"
167 "MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAKAAoDASIAAhEBAxEB/8QAFQAB"
168 "AAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADE"
169 "AAAAKf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAEFAqf/xAAUEQEAAAAAAAA"
170 "AAAAAAAAAAAAA/9oACAEDAQE/ASP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECA"
171 "QE/ASP/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAY/Aqf/xAAUEAEAAAAAAA"
172 "AAAAAAAAAAAAAA/9oACAEBAAE/ISf/2gAMAwEAAgADAAAAEP/EABQRAQAAAAAAAAA"
173 "AAAAAAAAAAP/aAAgBAwEBPxAk/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEB"
174 "PxAk/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxAn/9k="
175 )
176
177
178 def test_browser_url_normalization_matches_address_bar_hosts():
179 assert normalize_url("localhost:3000") == "http://localhost:3000/"
180 assert normalize_url("127.0.0.1:8000/path") == "http://127.0.0.1:8000/path"
181 assert normalize_url("novinky.cz") == "https://novinky.cz/"
182 assert normalize_url("https://example.com") == "https://example.com/"
183 assert normalize_url("about:blank") == "about:blank"
184 with pytest.raises(RepairableException, match="relative"):
185 normalize_url("/docs")
186
187
188 def test_browser_config_normalizes_extension_paths(tmp_path):
189 extension_dir = tmp_path / "extension"
190 extension_dir.mkdir()
191
192 config = normalize_browser_config(
193 {
194 "extension_paths": [str(extension_dir), "", " ", str(extension_dir)],
195 }
196 )
197
198 assert config == {
199 "extension_paths": [str(extension_dir)],
200 "default_homepage": "about:blank",
201 "autofocus_active_page": True,
202 "browser_tab_scope": "per_context",
203 "max_open_tabs": 32,
204 "runtime_backend": "container",
205 "host_browser_privacy_policy": "allow",
206 "host_browser_profile_mode": "existing",
207 "host_browser_selection": "",
208 "proxy_server": "",
209 "proxy_bypass": "",
210 "proxy_username": "",
211 "proxy_password": "",
212 "keyboard_layout": "",
213 "keyboard_variant": "",
214 "model_preset": "",
215 }
216
217
218 def test_browser_config_normalizes_keyboard_layout():
219 config = normalize_browser_config(
220 {
221 "keyboard_layout": " DE ",
222 "keyboard_variant": " Mac ",
223 }
224 )
225
226 assert config["keyboard_layout"] == "de"
227 assert config["keyboard_variant"] == "mac"
228
229 cleared = normalize_browser_config(
230 {
231 "keyboard_layout": "",
232 "keyboard_variant": "mac",
233 }
234 )
235
236 assert cleared["keyboard_layout"] == ""
237 assert cleared["keyboard_variant"] == ""
238
239 unsafe = normalize_browser_config(
240 {
241 "keyboard_layout": "de; rm -rf /",
242 "keyboard_variant": "mac & echo pwned",
243 }
244 )
245
246 for token in (unsafe["keyboard_layout"], unsafe["keyboard_variant"]):
247 assert token == token.lower()
248 for forbidden in (" ", ";", "&", "|", "$", "`", "'", '"'):
249 assert forbidden not in token
250
251
252 def test_browser_keyboard_layout_restarts_runtime():
253 from plugins._browser.helpers.config import browser_runtime_config
254
255 base = browser_runtime_config({"keyboard_layout": "de", "keyboard_variant": "mac"})
256
257 assert base["keyboard_layout"] == "de"
258 assert base["keyboard_variant"] == "mac"
259
260 changed = browser_runtime_config({"keyboard_layout": "us", "keyboard_variant": "mac"})
261 assert changed != base
262
263
264 def test_browser_config_normalizes_model_preset():
265 assert normalize_browser_config({"model_preset": " Research "})["model_preset"] == "Research"
266 assert "model" not in normalize_browser_config({"model": "main"})
267
268
269 def test_browser_config_normalizes_host_backend_and_privacy_policy():
270 config = normalize_browser_config(
271 {
272 "runtime_backend": "host-required",
273 "host_browser_privacy_policy": "warn",
274 "host_browser_profile_mode": "agent",
275 }
276 )
277
278 assert config["runtime_backend"] == "host_required"
279 assert config["host_browser_privacy_policy"] == "warn"
280 assert config["host_browser_profile_mode"] == "agent"
281
282
283 def test_browser_config_builds_authenticated_internal_proxy():
284 config = normalize_browser_config(
285 {
286 "proxy_server": " socks5://proxy.example:1080 ",
287 "proxy_bypass": " localhost, .example.com ",
288 "proxy_username": "researcher",
289 "proxy_password": "secret",
290 }
291 )
292
293 assert build_browser_launch_config(config)["proxy"] == {
294 "server": "socks5://proxy.example:1080",
295 "bypass": "localhost, .example.com",
296 "username": "researcher",
297 "password": "secret",
298 }
299
300
301 def test_browser_config_normalizes_host_browser_selection():
302 assert normalize_browser_config({})["host_browser_selection"] == ""
303 assert (
304 normalize_browser_config({"host_browser_selection": " Edge Dev "})["host_browser_selection"]
305 == "edge_dev"
306 )
307 assert (
308 normalize_browser_config({"host_browser_choice": "chrome"})["host_browser_selection"]
309 == "chrome"
310 )
311 assert (
312 normalize_browser_config({"host_browser_selection": "ws://127.0.0.1:9222/devtools/Browser/AbC?token=XyZ"})[
313 "host_browser_selection"
314 ]
315 == "ws://127.0.0.1:9222/devtools/Browser/AbC?token=XyZ"
316 )
317 assert normalize_browser_config({"host_browser_selection": "localhost:9222"})[
318 "host_browser_selection"
319 ] == "localhost:9222"
320 assert normalize_browser_config({"host_browser_selection": "bad\x00value"})[
321 "host_browser_selection"
322 ] == "badvalue"
323 assert (
324 normalize_browser_config({"runtime_backend": "host_when_available"})["runtime_backend"]
325 == "host_required"
326 )
327
328
329 def test_browser_config_store_migrates_advertised_endpoint_to_stable_id():
330 path = PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-config-store.js"
331 source = path.read_text(encoding="utf-8")
332 source = re.sub(r"^import .*;\n", "", source, flags=re.M)
333 source = source.replace("export const store = createStore", "const store = createStore")
334 endpoint = "ws://localhost:9222/devtools/browser/old-guid"
335 browser_status = {
336 "connectors": [
337 {
338 "browser_id": "chrome-cdp",
339 "cdp_endpoint": endpoint,
340 "available_browsers": [
341 {
342 "id": "chrome-cdp",
343 "family": "chrome-cdp",
344 "label": "Chrome (allowed)",
345 "cdp_endpoint": endpoint,
346 },
347 {
348 "id": "chrome:default",
349 "family": "chrome",
350 "label": "Chrome profile",
351 "cdp_endpoint": "",
352 },
353 ],
354 }
355 ]
356 }
357 script = (
358 "const browserStatus = "
359 + json.dumps(browser_status)
360 + ";\n"
361 + "const createStore = (_name, value) => value;\n"
362 + "const callJsonApi = async () => ({ host_browser: browserStatus });\n"
363 + "const showConfirmDialog = async () => false;\n"
364 + source
365 + f"\nstore.config = ensureConfig({{ host_browser_selection: {json.dumps(endpoint)} }});\n"
366 + "await store.loadHostBrowserStatus();\n"
367 + "if (store.config.host_browser_selection !== 'chrome-cdp') throw new Error('legacy endpoint was not migrated');\n"
368 + "const values = store.hostBrowserOptions().map((option) => option.value);\n"
369 + "if (!values.includes('chrome-cdp')) throw new Error('stable browser id is missing');\n"
370 + f"if (values.includes({json.dumps(endpoint)})) throw new Error('volatile endpoint was advertised');\n"
371 + "if (values.includes('chrome:default')) throw new Error('local profiles leaked into the dropdown');\n"
372 + "const custom = 'ws://localhost:9333/devtools/browser/custom';\n"
373 + "if (stableHostBrowserSelection(custom, browserStatus) !== custom) throw new Error('custom endpoint changed');\n"
374 )
375
376 subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)
377
378
379 def test_browser_config_normalizes_max_open_tabs():
380 assert normalize_browser_config({"max_open_tabs": "12"})["max_open_tabs"] == 12
381 assert normalize_browser_config({"max_open_tabs": "0"})["max_open_tabs"] == 1
382 assert normalize_browser_config({"max_open_tabs": "200"})["max_open_tabs"] == 50
383 assert normalize_browser_config({"max_open_tabs": "oops"})["max_open_tabs"] == 32
384
385
386 def test_browser_config_normalizes_tab_scope():
387 assert normalize_browser_config({})["browser_tab_scope"] == "per_context"
388 assert normalize_browser_config({"browser_tab_scope": "shared"})["browser_tab_scope"] == "shared"
389 assert normalize_browser_config({"browser_tab_scope": "per-context"})["browser_tab_scope"] == "per_context"
390 assert normalize_browser_config({"browser_tab_scope": "everything"})["browser_tab_scope"] == "per_context"
391
392
393 def test_browser_model_selection_uses_presets(monkeypatch):
394 import plugins._browser.helpers.config as browser_config_module
395 from plugins._model_config.helpers import model_config
396
397 monkeypatch.setattr(
398 browser_config_module,
399 "get_browser_config",
400 lambda agent=None: {"model_preset": "Research", "extension_paths": []},
401 )
402 monkeypatch.setattr(
403 model_config,
404 "get_preset_by_name",
405 lambda name: {
406 "name": "Research",
407 "chat": {"provider": "openrouter", "name": "example/model"},
408 } if name == "Research" else None,
409 )
410
411 selection = resolve_browser_model_selection(SimpleNamespace())
412
413 assert selection["source_kind"] == "preset"
414 assert selection["config"] == {"provider": "openrouter", "name": "example/model"}
415
416
417 def test_browser_model_selection_falls_back_to_main_for_missing_preset(monkeypatch):
418 from plugins._model_config.helpers import model_config
419
420 monkeypatch.setattr(model_config, "get_preset_by_name", lambda name: None)
421 monkeypatch.setattr(
422 model_config,
423 "get_chat_model_config",
424 lambda agent=None: {"provider": "openrouter", "name": "main/model"},
425 )
426
427 selection = resolve_browser_model_selection(SimpleNamespace(), {"model_preset": "Missing"})
428
429 assert selection["source_kind"] == "main"
430 assert selection["preset_status"] == "missing"
431 assert selection["config"] == {"provider": "openrouter", "name": "main/model"}
432
433
434 def test_browser_model_preset_controls_followup_turn_and_clears(monkeypatch):
435 import importlib
436 import plugins._browser.helpers.config as browser_config_module
437
438 state = {}
439 agent = SimpleNamespace(
440 set_data=lambda key, value: state.__setitem__(key, value),
441 get_data=lambda key: state.get(key),
442 )
443 monkeypatch.setattr(
444 browser_config_module,
445 "resolve_browser_model_selection",
446 lambda agent=None, settings=None: {
447 "source_kind": "preset",
448 "selected_preset_name": "Browser",
449 },
450 )
451
452 browser_config_module.activate_browser_model(agent)
453 assert browser_config_module.browser_model_is_active(agent)
454
455 provider_module = importlib.import_module(
456 "plugins._browser.extensions.python._functions.agent.Agent.get_chat_model.start._20_browser_model"
457 )
458 cleanup_module = importlib.import_module(
459 "plugins._browser.extensions.python.monologue_end._20_browser_model"
460 )
461 selected_model = object()
462 monkeypatch.setattr(
463 provider_module,
464 "resolve_browser_model",
465 lambda agent, fallback=None: selected_model,
466 )
467 model_data = {"result": object()}
468 provider_module.BrowserModelProvider(agent).execute(data=model_data)
469 assert model_data["result"] is selected_model
470
471 cleanup_module.BrowserModelCleanup(agent).execute()
472 assert not browser_config_module.browser_model_is_active(agent)
473
474
475 def test_browser_model_preset_options_include_missing_selected(monkeypatch):
476 from plugins._model_config.helpers import model_config
477
478 monkeypatch.setattr(
479 model_config,
480 "get_presets",
481 lambda: [{"name": "Balance", "chat": {"provider": "openrouter", "name": "model"}}],
482 )
483
484 options = get_browser_model_preset_options(settings={"model_preset": "Deleted"})
485
486 assert options[-1]["name"] == "Deleted"
487 assert options[-1]["missing"] is True
488
489
490 def test_browser_main_model_summary_shows_current_model(monkeypatch):
491 from plugins._model_config.helpers import model_config
492
493 monkeypatch.setattr(
494 model_config,
495 "get_chat_model_config",
496 lambda agent=None: {"provider": "openrouter", "name": "example/main"},
497 )
498
499 assert get_browser_main_model_summary() == "openrouter / example/main"
500
501
502 def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
503 default_launch = build_browser_launch_config(
504 {
505 "extension_paths": [],
506 }
507 )
508
509 assert default_launch["browser_mode"] == "chromium"
510 assert default_launch["channel"] is None
511 assert default_launch["requires_full_browser"] is True
512 assert default_launch["proxy"] is None
513 assert "--hide-crash-restore-bubble" in default_launch["args"]
514 assert not any(arg.startswith("--load-extension=") for arg in default_launch["args"])
515 assert "--no-sandbox" not in default_launch["args"]
516 assert "--disable-dev-shm-usage" not in default_launch["args"]
517 assert "--disable-gpu" not in default_launch["args"]
518 assert "--headless=new" not in default_launch["args"]
519
520 extension_dir = tmp_path / "extension"
521 extension_dir.mkdir()
522
523 launch = build_browser_launch_config(
524 {
525 "extension_paths": [str(extension_dir)],
526 }
527 )
528
529 assert launch["browser_mode"] == "chromium"
530 assert launch["channel"] is None
531 assert launch["requires_full_browser"] is True
532 assert launch["extensions"]["active"] is True
533 assert any(arg.startswith("--load-extension=") for arg in launch["args"])
534 assert "--headless=new" not in launch["args"]
535
536
537 def _patch_playwright_cache_root(monkeypatch, tmp_path):
538 monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False)
539 monkeypatch.setattr(
540 browser_playwright_module.files,
541 "get_abs_path",
542 lambda *parts: str(tmp_path.joinpath(*parts)),
543 )
544 monkeypatch.setattr(
545 browser_playwright_module,
546 "get_playwright_chromium_revision",
547 lambda: "1169",
548 )
549
550
551 def test_browser_uses_patchright_revision_when_newer_playwright_cache_exists(
552 monkeypatch, tmp_path
553 ):
554 _patch_playwright_cache_root(monkeypatch, tmp_path)
555 monkeypatch.setattr(
556 browser_playwright_module,
557 "get_playwright_chromium_revision",
558 lambda: "1228",
559 )
560 cache_dir = Path(get_playwright_cache_dir())
561 expected = cache_dir / "chromium-1228" / "chrome-linux64" / "chrome"
562 other = cache_dir / "chromium-1234" / "chrome-linux64" / "chrome"
563 expected.parent.mkdir(parents=True)
564 other.parent.mkdir(parents=True)
565 expected.touch()
566 other.touch()
567
568 assert get_playwright_binary() == expected
569
570
571 @pytest.mark.parametrize("platform_dir", ["chrome-linux", "chrome-linux64"])
572 def test_browser_installs_current_patchright_chromium_for_host_architecture(
573 monkeypatch, tmp_path, platform_dir
574 ):
575 _patch_playwright_cache_root(monkeypatch, tmp_path)
576 monkeypatch.setattr(
577 browser_playwright_module,
578 "get_playwright_chromium_revision",
579 lambda: "1234",
580 )
581 old_binary = (
582 tmp_path / "tmp" / "playwright" / "chromium-1169" / "chrome-linux" / "chrome"
583 )
584 old_binary.parent.mkdir(parents=True)
585 old_binary.touch()
586 expected = (
587 tmp_path / "tmp" / "playwright" / "chromium-1234" / platform_dir / "chrome"
588 )
589
590 def install(command, *, env):
591 assert command == [
592 sys.executable,
593 "-m",
594 "patchright",
595 "install",
596 "chromium",
597 "--no-shell",
598 ]
599 assert env["PLAYWRIGHT_BROWSERS_PATH"] == str(tmp_path / "tmp" / "playwright")
600 expected.parent.mkdir(parents=True)
601 expected.touch()
602
603 monkeypatch.setattr(browser_playwright_module.subprocess, "check_call", install)
604
605 assert ensure_playwright_binary() == expected
606
607
608 def test_browser_hook_installs_patchright_for_existing_self_updated_runtime(monkeypatch):
609 current = False
610
611 def is_current(requirement):
612 assert requirement == "patchright==1.61.2"
613 return current
614
615 def install(command, *, cwd):
616 nonlocal current
617 assert command == [
618 "/usr/local/bin/uv",
619 "pip",
620 "install",
621 "--python",
622 sys.executable,
623 "patchright==1.61.2",
624 ]
625 assert cwd == str(PROJECT_ROOT / "plugins" / "_browser")
626 current = True
627
628 monkeypatch.setattr(
629 browser_hooks_module,
630 "_patchright_requirement",
631 lambda: "patchright==1.61.2",
632 )
633 monkeypatch.setattr(browser_hooks_module, "_patchright_is_current", is_current)
634 monkeypatch.setattr(browser_hooks_module.shutil, "which", lambda name: "/usr/local/bin/uv")
635 monkeypatch.setattr(browser_hooks_module.subprocess, "check_call", install)
636
637 browser_hooks_module._ensure_patchright_dependency()
638
639 assert current is True
640
641
642 def _write_playwright_binary(cache_dir: Path) -> Path:
643 browser_binary = cache_dir / "chromium-1169" / "chrome-linux" / "chrome"
644 browser_binary.parent.mkdir(parents=True)
645 browser_binary.write_text("#!/bin/sh\n", encoding="utf-8")
646 return browser_binary
647
648
649 def test_browser_playwright_cache_uses_tmp_path(monkeypatch, tmp_path):
650 _patch_playwright_cache_root(monkeypatch, tmp_path)
651 primary_cache = tmp_path / "tmp" / "playwright"
652 browser_binary = _write_playwright_binary(primary_cache)
653
654 assert get_playwright_cache_dir() == str(
655 tmp_path / "tmp" / "playwright"
656 )
657 assert browser_playwright_module.get_playwright_cache_dirs() == [
658 tmp_path / "tmp" / "playwright",
659 tmp_path / "usr" / "plugins" / "_browser" / "playwright",
660 tmp_path / "usr" / "browser" / "playwright",
661 ]
662 assert get_playwright_binary() == browser_binary
663
664
665 def test_browser_playwright_binary_ignores_retired_usr_cache(monkeypatch, tmp_path):
666 _patch_playwright_cache_root(monkeypatch, tmp_path)
667 _write_playwright_binary(
668 tmp_path / "usr" / "plugins" / "_browser" / "playwright"
669 )
670
671 assert get_playwright_binary() is None
672
673
674 def test_browser_playwright_cache_migrates_valid_retired_usr_cache(monkeypatch, tmp_path):
675 _patch_playwright_cache_root(monkeypatch, tmp_path)
676 retired_cache = tmp_path / "usr" / "plugins" / "_browser" / "playwright"
677 _write_playwright_binary(retired_cache)
678
679 result = browser_hooks_module.cleanup_playwright_cache()
680
681 assert result["errors"] == []
682 assert result["migrated"] == str(retired_cache)
683 assert get_playwright_binary() == (
684 tmp_path / "tmp" / "playwright" / "chromium-1169" / "chrome-linux" / "chrome"
685 )
686 assert not retired_cache.exists()
687
688
689 def test_browser_playwright_cache_removes_retired_usr_cache_when_tmp_valid(
690 monkeypatch, tmp_path
691 ):
692 _patch_playwright_cache_root(monkeypatch, tmp_path)
693 primary_cache = tmp_path / "tmp" / "playwright"
694 retired_cache = tmp_path / "usr" / "plugins" / "_browser" / "playwright"
695 _write_playwright_binary(primary_cache)
696 _write_playwright_binary(retired_cache)
697
698 result = browser_hooks_module.cleanup_playwright_cache()
699
700 assert result["errors"] == []
701 assert result["migrated"] == ""
702 assert str(retired_cache) in result["removed"]
703 assert get_playwright_binary() == (
704 primary_cache / "chromium-1169" / "chrome-linux" / "chrome"
705 )
706 assert not retired_cache.exists()
707
708
709 def test_browser_playwright_cache_missing_dirs_do_not_raise(monkeypatch, tmp_path):
710 _patch_playwright_cache_root(monkeypatch, tmp_path)
711
712 result = browser_hooks_module.cleanup_playwright_cache()
713
714 assert result["errors"] == []
715 assert result["migrated"] == ""
716 assert result["removed"] == []
717 assert not (tmp_path / "tmp" / "playwright").exists()
718 assert browser_playwright_module.get_playwright_cache_dirs() == [
719 tmp_path / "tmp" / "playwright",
720 tmp_path / "usr" / "plugins" / "_browser" / "playwright",
721 tmp_path / "usr" / "browser" / "playwright",
722 ]
723 assert get_playwright_binary() is None
724
725
726 def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path):
727 monkeypatch.setattr(
728 browser_extension_manager_module.files,
729 "get_abs_path",
730 lambda *parts: str(tmp_path.joinpath(*parts)),
731 )
732
733 assert get_extensions_root() == tmp_path / "usr" / "_browser" / "extensions"
734
735
736 def test_browser_extension_manager_uninstalls_only_managed_extensions(monkeypatch, tmp_path):
737 monkeypatch.setattr(
738 browser_extension_manager_module.files,
739 "get_abs_path",
740 lambda *parts: str(tmp_path.joinpath(*parts)),
741 )
742 managed_extension = get_extensions_root() / "chrome-web-store" / ("a" * 32)
743 external_extension = tmp_path / "external-extension"
744 for extension_dir, name in (
745 (managed_extension, "Managed Extension"),
746 (external_extension, "External Extension"),
747 ):
748 extension_dir.mkdir(parents=True)
749 (extension_dir / "manifest.json").write_text(
750 json.dumps({"name": name, "version": "1.0.0"}),
751 encoding="utf-8",
752 )
753
754 saved_configs = []
755 monkeypatch.setattr(
756 browser_extension_manager_module,
757 "get_browser_config",
758 lambda: {
759 "extension_paths": [str(managed_extension), str(external_extension)],
760 },
761 )
762 monkeypatch.setattr(
763 browser_extension_manager_module.plugins,
764 "save_plugin_config",
765 lambda _plugin, _project, _agent, config: saved_configs.append(config.copy()),
766 )
767
768 entries = browser_extension_manager_module.list_browser_extensions()
769 managed_entry = next(item for item in entries if item["path"] == str(managed_extension))
770 external_entry = next(item for item in entries if item["path"] == str(external_extension))
771
772 assert managed_entry["can_delete"] is True
773 assert managed_entry["managed"] is True
774 assert external_entry["can_delete"] is False
775
776 result = uninstall_browser_extension(str(managed_extension))
777
778 assert result["name"] == "Managed Extension"
779 assert result["extension_paths"] == [str(external_extension)]
780 assert not managed_extension.exists()
781 assert external_extension.exists()
782 assert saved_configs[-1]["extension_paths"] == [str(external_extension)]
783
784 with pytest.raises(ValueError, match="Only Browser-managed"):
785 uninstall_browser_extension(str(external_extension))
786 assert external_extension.exists()
787
788
789 def test_browser_extension_manager_exposes_openable_manifest_ui(monkeypatch, tmp_path):
790 monkeypatch.setattr(
791 browser_extension_manager_module.files,
792 "get_abs_path",
793 lambda *parts: str(tmp_path.joinpath(*parts)),
794 )
795 extension_dir = get_extensions_root() / "local-options"
796 extension_dir.mkdir(parents=True)
797 (extension_dir / "manifest.json").write_text(
798 json.dumps(
799 {
800 "manifest_version": 3,
801 "name": "Openable Extension",
802 "version": "1.0.0",
803 "options_ui": {"page": "options/index.html"},
804 "action": {"default_popup": "popup.html"},
805 }
806 ),
807 encoding="utf-8",
808 )
809
810 monkeypatch.setattr(
811 browser_extension_manager_module,
812 "get_browser_config",
813 lambda: {"extension_paths": [str(extension_dir)]},
814 )
815
816 entry = browser_extension_manager_module.list_browser_extensions()[0]
817 expected_id = browser_extension_manager_module._extension_id_from_path(extension_dir)
818
819 assert entry["id"] == expected_id
820 assert entry["has_ui"] is True
821 assert entry["open_label"] == "Options"
822 assert entry["open_url"] == f"chrome-extension://{expected_id}/options/index.html"
823 assert entry["ui"]["targets"][1]["url"] == f"chrome-extension://{expected_id}/popup.html"
824
825
826 def test_browser_extension_manager_parses_web_store_urls():
827 extension_id = "a" * 32
828
829 assert parse_chrome_web_store_extension_id(extension_id) == extension_id
830 assert (
831 parse_chrome_web_store_extension_id(
832 f"https://chromewebstore.google.com/detail/example/{extension_id}"
833 )
834 == extension_id
835 )
836 assert (
837 parse_chrome_web_store_extension_id(
838 f"https://chrome.google.com/webstore/detail/example/{extension_id}?hl=en"
839 )
840 == extension_id
841 )
842
843
844 def test_browser_extension_manager_extracts_crx3_zip_payload():
845 payload = b"PK\x03\x04zip-payload"
846 header = b"metadata"
847 crx = b"Cr24" + (3).to_bytes(4, "little") + len(header).to_bytes(4, "little") + header + payload
848
849 assert _crx_zip_payload(crx) == payload
850
851
852 def test_browser_extension_manager_skips_same_version_reinstall(monkeypatch, tmp_path):
853 extension_id = "a" * 32
854 monkeypatch.setattr(
855 browser_extension_manager_module.files,
856 "get_abs_path",
857 lambda *parts: str(tmp_path.joinpath(*parts)),
858 )
859 target = get_extensions_root() / "chrome-web-store" / extension_id
860 target.mkdir(parents=True)
861 (target / "manifest.json").write_text(
862 json.dumps({"name": "Current", "version": "1.0.0"}),
863 encoding="utf-8",
864 )
865 (target / "keep.txt").write_text("current", encoding="utf-8")
866
867 def download(_extension_id, archive_path):
868 with zipfile.ZipFile(archive_path, "w") as archive:
869 archive.writestr("manifest.json", json.dumps({"name": "Current", "version": "1.0.0"}))
870 archive.writestr("replacement.txt", "should not be extracted")
871
872 monkeypatch.setattr(browser_extension_manager_module, "_download_crx", download)
873 monkeypatch.setattr(
874 browser_extension_manager_module,
875 "get_browser_config",
876 lambda: {"extension_paths": [str(target)]},
877 )
878 monkeypatch.setattr(
879 browser_extension_manager_module.plugins,
880 "save_plugin_config",
881 lambda *_args, **_kwargs: None,
882 )
883 monkeypatch.setattr(
884 browser_extension_manager_module,
885 "close_all_runtimes_sync",
886 lambda: pytest.fail("same-version reinstall restarted Browser runtimes"),
887 )
888 monkeypatch.setattr(
889 browser_extension_manager_module,
890 "_safe_extract_zip",
891 lambda *_args: pytest.fail("same-version reinstall extracted the package"),
892 )
893
894 result = browser_extension_manager_module.install_chrome_web_store_extension(extension_id)
895
896 assert result["version"] == "1.0.0"
897 assert (target / "keep.txt").read_text(encoding="utf-8") == "current"
898 assert not (target / "replacement.txt").exists()
899
900
901 def test_browser_extension_manager_stages_updates_and_restarts_runtime(monkeypatch, tmp_path):
902 extension_id = "a" * 32
903 monkeypatch.setattr(
904 browser_extension_manager_module.files,
905 "get_abs_path",
906 lambda *parts: str(tmp_path.joinpath(*parts)),
907 )
908 target = get_extensions_root() / "chrome-web-store" / extension_id
909 target.mkdir(parents=True)
910 (target / "manifest.json").write_text(
911 json.dumps({"name": "Old", "version": "1.0.0"}),
912 encoding="utf-8",
913 )
914 (target / "old.txt").write_text("old", encoding="utf-8")
915
916 def download(_extension_id, archive_path):
917 with zipfile.ZipFile(archive_path, "w") as archive:
918 archive.writestr("manifest.json", json.dumps({"name": "Updated", "version": "2.0.0"}))
919 archive.writestr("new.txt", "new")
920
921 saved_configs = []
922 restarts = []
923 monkeypatch.setattr(browser_extension_manager_module, "_download_crx", download)
924 monkeypatch.setattr(
925 browser_extension_manager_module,
926 "get_browser_config",
927 lambda: {"extension_paths": [str(target)]},
928 )
929 monkeypatch.setattr(
930 browser_extension_manager_module.plugins,
931 "save_plugin_config",
932 lambda _plugin, _project, _agent, config: saved_configs.append(config.copy()),
933 )
934 def restart():
935 listed_paths = [
936 extension["path"]
937 for extension in browser_extension_manager_module.list_browser_extensions()
938 ]
939 restarts.append(((target / "old.txt").exists(), listed_paths))
940
941 monkeypatch.setattr(browser_extension_manager_module, "close_all_runtimes_sync", restart)
942
943 result = browser_extension_manager_module.install_chrome_web_store_extension(extension_id)
944
945 assert result["name"] == "Updated"
946 assert result["version"] == "2.0.0"
947 assert restarts == [(True, [str(target)])]
948 assert saved_configs[-1]["extension_paths"] == [str(target)]
949 assert (target / "new.txt").read_text(encoding="utf-8") == "new"
950 assert not (target / "old.txt").exists()
951 assert list(target.parent.iterdir()) == [target]
952
953
954 def test_browser_extension_manager_uses_modern_chrome_prodversion(monkeypatch):
955 extension_id = "a" * 32
956
957 assert _normalize_chrome_prodversion("Google Chrome 147.0.7727.55") == "147.0.7727.55"
958 assert _normalize_chrome_prodversion("Chromium 124") == "124.0.0.0"
959
960 monkeypatch.setenv("A0_BROWSER_EXTENSION_PRODVERSION", "147.0.7727.55")
961 assert _detect_chrome_prodversion() == "147.0.7727.55"
962
963 url = _build_web_store_download_url(extension_id, prodversion=_detect_chrome_prodversion())
964 assert "prod=chromecrx" in url
965 assert "prodversion=147.0.7727.55" in url
966 assert "prodversion=120.0.0.0" not in url
967
968
969 def test_browser_extension_menu_exposes_agent_and_url_paths():
970 html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
971 encoding="utf-8"
972 )
973 skill = (
974 PROJECT_ROOT
975 / "plugins"
976 / "_browser"
977 / "skills"
978 / "browser-extension-control"
979 / "SKILL.md"
980 )
981
982 assert "Create New Extension with A0" in html
983 assert "+ Create New with A0" not in html
984 assert "Input a Chrome Web Store URL" in html
985 assert "My Browser Extensions" not in html
986 assert "Browser LLM Preset" in html
987 assert "Chrome Extensions" in html
988 assert "Installed extensions" in html
989 assert "deleteExtension(extension)" not in html
990 assert "No extensions installed yet." not in html
991 assert "Browser Extension Settings" not in html
992 assert "<span>Settings</span>" in html
993 assert "extensionHasOpenUi(extension)" in html
994 assert "openExtensionUi(extension)" in html
995 assert "<span>Open</span>" in html
996 assert "hasExtensionInstallUrl()" in html
997 assert "malicious or buggy extensions" in html
998 assert "'Installing…' : 'Install URL'" in html
999 assert ':aria-busy="$store.browserPage.extensionActionLoading.toString()"' in html
1000 assert "Large packages may take a few minutes." in (
1001 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
1002 ).read_text(encoding="utf-8")
1003 assert skill.exists()
1004
1005
1006 def test_browser_viewer_allows_slow_extension_startup():
1007 js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1008 encoding="utf-8"
1009 )
1010
1011 assert "const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;" in js
1012 assert "const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;" in js
1013 assert "const BROWSER_COMMAND_TIMEOUT_MS = 45000;" in js
1014 assert "? BROWSER_FIRST_INSTALL_TIMEOUT_MS" in js
1015 assert ": BROWSER_SUBSCRIBE_TIMEOUT_MS" in js
1016 assert "browserInstallExpected" in js
1017 assert "Installing Chromium for the first Browser run" not in js
1018
1019
1020 def test_browser_viewer_creates_chat_when_no_context_is_selected():
1021 js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1022 encoding="utf-8"
1023 )
1024
1025 assert "async ensureContextId()" in js
1026 assert "async createChatContextForBrowser()" in js
1027 assert 'callJsonApi("/chat_create"' in js
1028 assert 'import { getContext, setContext } from "/index.js";' in js
1029 assert "setContext(contextId)" in js
1030 assert "chatsStore.setSelected?.(contextId)" in js
1031 assert "this.contextId = existingContextId;" in js
1032 assert "this.contextId = contextId;" in js
1033 assert "let targetContextId = requestedContextId" in js
1034 assert "|| this.resolveContextId();" in js
1035 assert "targetContextId = await this.ensureContextId();" in js
1036 assert "contextId: targetContextId" in js
1037 assert "No active chat context is selected." not in js
1038
1039
1040 def test_browser_canvas_startup_waits_for_raw_viewport_settle():
1041 js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1042 encoding="utf-8"
1043 )
1044
1045 assert "const CANVAS_VIEWPORT_SETTLE_MS = 520;" in js
1046 assert "const SURFACE_VIEWPORT_STABLE_FRAMES = 4;" in js
1047 assert "const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200;" in js
1048 assert "surfaceViewportMeasurement()" in js
1049 assert "rawWidth" in js
1050 assert "rawHeight" in js
1051 assert "const key = `${viewport.rawWidth}x${viewport.rawHeight}`;" in js
1052 assert "Date.now() - this._surfaceOpenedAt >= CANVAS_VIEWPORT_SETTLE_MS" in js
1053 assert "while (Date.now() - startedAt <= SURFACE_VIEWPORT_MAX_WAIT_MS)" in js
1054 assert "this._openPromise && this._openSignature === openSignature" in js
1055 assert "isCurrentSurfaceOpen(surfaceSequence)" in js
1056 assert "isCanvasSurfaceVisible(element)" in js
1057 assert "scheduleViewportSyncForSurface" in js
1058 assert "const targetChanged = Boolean(" in js
1059 assert "if (this.hasFrame() && !targetChanged)" in js
1060 assert "this.cancelFrameRender();" in js
1061 assert "this.resetRenderedFrame();" in js
1062
1063
1064 def test_browser_interactive_modal_handoff_reconciles_after_xpra_resize():
1065 js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1066 encoding="utf-8"
1067 )
1068
1069 assert "const INTERACTIVE_VIEWPORT_SETTLE_MS = 320;" in js
1070 assert "const surfaceMode = this._mode;" in js
1071 assert 'surfaceMode === "modal" && this.usesInteractiveTransport()' in js
1072 assert "this.scheduleViewportSyncForSurface(sequence, INTERACTIVE_VIEWPORT_SETTLE_MS, surfaceMode);" in js
1073 assert "scheduleViewportSyncForSurface(sequence, delayMs = 0, mode = this._mode)" in js
1074 assert "this._mode !== mode" in js
1075 assert "(!force && !restartStream && this._lastViewportKey === key)" in js
1076
1077
1078 def test_browser_surface_handoffs_keep_existing_frame_until_replacement_arrives():
1079 js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1080 encoding="utf-8"
1081 )
1082 prepare_start = js.index("prepareSurfaceOpen(nextMode")
1083 prepare_block = js[prepare_start: js.index("resetViewportTracking()", prepare_start)]
1084 viewport_start = js.index("resetRenderedFrameIfViewportChanged(viewport =")
1085 viewport_block = js[viewport_start: js.index("async waitForSurfaceViewport", viewport_start)]
1086 clear_start = js.index("clearRenderedFrameIfViewportChanged()")
1087 clear_block = js[clear_start: js.index("beginCommand()", clear_start)]
1088
1089 assert "modeChanged" not in prepare_block
1090 assert "if (this.hasFrame() && !targetChanged)" in prepare_block
1091 assert "this.resetRenderedFrame();" in prepare_block
1092 assert "this.cancelFrameRender();" in viewport_block
1093 assert "this.resetRenderedFrame();" not in viewport_block
1094 assert "this.cancelFrameRender();" in clear_block
1095 assert "this.resetRenderedFrame();" not in clear_block
1096
1097
1098 def test_browser_canvas_surface_open_waits_for_visible_panel():
1099 js = (
1100 PROJECT_ROOT
1101 / "plugins"
1102 / "_browser"
1103 / "extensions"
1104 / "webui"
1105 / "right_canvas_register_surfaces"
1106 / "register-browser.js"
1107 ).read_text(encoding="utf-8")
1108
1109 assert "waitForVisibleCanvasPanel" in js
1110 assert "isVisibleCanvasPanel(panel)" in js
1111 assert 'panel.closest(".browser-canvas-surface")' in js
1112 assert 'panel.querySelector(".browser-stage")' in js
1113 assert "getBoundingClientRect" in js
1114 assert "stableCount >= 2" in js
1115 assert "forceCanvasWidthNudgeAfterBrowserMount" not in js
1116
1117
1118 def test_browser_canvas_does_not_resize_after_first_accepted_frame():
1119 js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1120 encoding="utf-8"
1121 )
1122
1123 assert "_canvasSurfaceReadySequence" not in js
1124 assert "_canvasFirstFrameAcceptedSequence" not in js
1125 assert "_canvasFirstFrameNudgeSequence" not in js
1126 assert "scheduleCanvasWidthNudgeAfterFirstFrame" not in js
1127 assert "forceRightCanvasWidthNudge" not in js
1128 assert "canvas.setWidth?.(nudgedWidth" not in js
1129
1130
1131 def test_browser_canvas_restarts_stream_after_page_navigation():
1132 js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1133 encoding="utf-8"
1134 )
1135
1136 assert "async restartCanvasStreamAfterPageChange()" in js
1137 assert "if (!this.usesScreencastTransport())" in js
1138 assert '["navigate", "back", "forward", "reload"].includes(commandName)' in js
1139 assert "await this.restartCanvasStreamAfterPageChange();" in js
1140 assert "await this.waitForSurfaceViewport({ sequence: surfaceSequence });" in js
1141 assert "await this.syncViewport(true, { restartStream: true });" in js
1142 reconnect_index = js.index("contextId: this.activeBrowserContextId")
1143 restart_index = js.index("await this.restartCanvasStreamAfterPageChange();")
1144 assert reconnect_index < restart_index
1145
1146
1147 def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff():
1148 browser_button_path = (
1149 PROJECT_ROOT
1150 / "plugins"
1151 / "_browser"
1152 / "extensions"
1153 / "webui"
1154 / "chat-input-bottom-actions-start"
1155 / "browser-button.html"
1156 )
1157 tool_handler = (
1158 PROJECT_ROOT
1159 / "plugins"
1160 / "_browser"
1161 / "extensions"
1162 / "webui"
1163 / "get_tool_message_handler"
1164 / "browser-tool-handler.js"
1165 ).read_text(encoding="utf-8")
1166 after_loop_handler = (
1167 PROJECT_ROOT
1168 / "plugins"
1169 / "_browser"
1170 / "extensions"
1171 / "webui"
1172 / "set_messages_after_loop"
1173 / "auto-open-browser-results.js"
1174 ).read_text(encoding="utf-8")
1175 register_js = (
1176 PROJECT_ROOT
1177 / "plugins"
1178 / "_browser"
1179 / "extensions"
1180 / "webui"
1181 / "surfaces_register"
1182 / "register-browser.js"
1183 ).read_text(encoding="utf-8")
1184 browser_store = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1185 encoding="utf-8"
1186 )
1187 canvas_store = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js").read_text(
1188 encoding="utf-8"
1189 )
1190 modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
1191 surfaces_js = (PROJECT_ROOT / "webui" / "js" / "surfaces.js").read_text(encoding="utf-8")
1192
1193 assert not browser_button_path.exists()
1194 assert 'defaultOpenMode: "modal"' not in register_js
1195 assert "beginDockHandoff()" in register_js
1196 assert "beginSurfaceHandoff" in register_js
1197 assert "finishDockHandoff()" in register_js
1198 assert "cancelDockHandoff()" in register_js
1199 assert "defaultOpenMode" not in canvas_store
1200 assert "await surface.beginDockHandoff?.(payload)" in canvas_store
1201 assert "await this.closeDockSourceModal(payload, modalPath)" in canvas_store
1202 assert "delete openPayload.closeSourceModal" in canvas_store
1203 assert "await surface.finishDockHandoff?.({ ...openPayload, opened })" in canvas_store
1204 assert "await surface.cancelDockHandoff?.(payload)" in canvas_store
1205 assert "async closeDockSourceModal" in canvas_store
1206
1207 assert "dock(metadata.surfaceId" in surfaces_js
1208 assert "button.disabled = true" in surfaces_js
1209 assert "dockSurface(metadata.surfaceId" not in modals_js
1210
1211 assert "beginSurfaceHandoff()" in browser_store
1212 assert "finishSurfaceHandoff()" in browser_store
1213 assert "cancelSurfaceHandoff()" in browser_store
1214 assert "releaseSurfaceBindings()" in browser_store
1215 assert "this.releaseSurfaceBindings();" in browser_store
1216
1217 assert "async function openBrowserSurface" in tool_handler
1218 assert 'await openSurface("browser", payload);' in tool_handler
1219 assert "rightCanvasStore" not in tool_handler
1220 assert "window.ensureModalOpen" not in tool_handler
1221 assert "window.openModal" not in tool_handler
1222 assert "function syncOpenBrowserCanvas" in tool_handler
1223 assert "async function syncOpenBrowserCanvas" in after_loop_handler
1224 assert "syncBrowserResultsIntoOpenCanvas" in after_loop_handler
1225 assert '${contextId || ""}' in tool_handler
1226 assert "contextId || \"\"" in after_loop_handler
1227 assert '"context_id"' in after_loop_handler
1228 assert '"contextId"' in after_loop_handler
1229 assert "rightCanvasStore" not in after_loop_handler
1230 assert "window.ensureModalOpen" not in after_loop_handler
1231 assert "window.openModal" not in after_loop_handler
1232
1233 for js in (tool_handler, after_loop_handler):
1234 assert "openBrowserModal" not in js
1235 assert "isBrowserCanvasAlreadyOpen" in js
1236 assert '[data-surface-id="browser"].is-active .browser-panel' in js
1237 assert "autoOpenBrowserCanvas" not in js
1238 assert "autoOpenedBrowsers" not in js
1239 assert "syncedBrowserCanvases" in js
1240 assert "const FOCUS_ACTIONS = new Set" in js
1241 assert "FOCUS_ACTIONS.has(action)" in js
1242
1243 assert 'id: "browser"' in surfaces_js
1244 assert "/plugins/_browser/webui/main.html" in surfaces_js
1245
1246 for js in (tool_handler, after_loop_handler, register_js, browser_store, modals_js, surfaces_js):
1247 assert "globalThis.Alpine" not in js
1248 assert "Alpine?.store" not in js
1249 assert "Alpine.store" not in js
1250
1251
1252 def test_surface_buttons_keep_modal_and_canvas_entry_points_separate():
1253 canvas_store = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js").read_text(
1254 encoding="utf-8"
1255 )
1256 canvas_html = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.html").read_text(
1257 encoding="utf-8"
1258 )
1259 canvas_css = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.css").read_text(
1260 encoding="utf-8"
1261 )
1262 modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
1263 modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(encoding="utf-8")
1264 surfaces_js = (PROJECT_ROOT / "webui" / "js" / "surfaces.js").read_text(encoding="utf-8")
1265 surfaces_css = (PROJECT_ROOT / "webui" / "css" / "surfaces.css").read_text(encoding="utf-8")
1266 close_block = canvas_store[
1267 canvas_store.index("async close()"):
1268 canvas_store.index("async dockSurface")
1269 ]
1270 undock_block = canvas_store[
1271 canvas_store.index("async undockSurface"):
1272 canvas_store.index("async openModalSurface")
1273 ]
1274 open_modal_block = canvas_store[
1275 canvas_store.index("async openModalSurface"):
1276 canvas_store.index("async undockActiveSurface")
1277 ]
1278 should_render_block = canvas_store[
1279 canvas_store.index("\n shouldRender()"):
1280 canvas_store.index("};\n\nexport const store")
1281 ]
1282 surface_button_block = surfaces_js[
1283 surfaces_js.index("function createModalSurfaceButton"):
1284 surfaces_js.index("function configureModalSurfaceSwitcher")
1285 ]
1286
1287 assert "surfaceModes: {}" in canvas_store
1288 assert "mountedSurfaces: {}" in canvas_store
1289 assert "recordSurfaceMode(surfaceId" in canvas_store
1290 assert "latestSurfaceMode(surfaceId)" in canvas_store
1291 assert "markSurfaceMounted(targetId)" in canvas_store
1292 assert "isSurfaceRendered(id)" in canvas_store
1293 assert "isSurfaceVisible(id)" in canvas_store
1294 assert 'import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";' in canvas_store
1295 assert "async openLatest(surfaceId" in canvas_store
1296 assert "async openModalSurface(surfaceId" in canvas_store
1297 assert "this.recordSurfaceMode(targetId, SURFACE_MODE_DOCKED" in canvas_store
1298 assert "this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING)" in canvas_store
1299 assert "surfaceModes: this.surfaceModes" in canvas_store
1300 assert "normalizeSurfaceMode(mode)" in canvas_store
1301 assert "migratePersistedSurfaceState" in canvas_store
1302 assert "if (this.isMobileMode && !surface.actionOnly)" not in canvas_store
1303 assert "return await this.openModalSurface(targetId, payload);" in canvas_store
1304 assert "return await this.open(targetId, payload);" in canvas_store
1305 assert 'return await this.open(this.activeSurfaceId || this.panelSurfaces[0]?.id || "", { source: "mobile-toggle" });' in canvas_store
1306 assert "isWelcomeVisible()" in canvas_store
1307 assert "return !this.isWelcomeVisible();" in should_render_block
1308 assert "isMobileMode" not in should_render_block
1309 assert 'document.body.classList.toggle("right-canvas-open", this.isOpen && !this.isMobileMode && this.shouldRender());' in canvas_store
1310 assert "this.mountedSurfaces = {}" not in close_block
1311 assert "surface?.close" not in close_block
1312 assert "this.mountedSurfaces = {}" not in undock_block
1313 assert "failed to close while undocking" not in undock_block
1314 assert "this.mountedSurfaces = {}" not in open_modal_block
1315 assert "failed to close before modal open" not in open_modal_block
1316
1317 assert '@click="$store.rightCanvas.openLatest(surface.id)"' not in canvas_html
1318 assert canvas_html.count('@click="$store.rightCanvas.open(surface.id)"') == 2
1319 assert "body.right-canvas-mobile-mode .right-canvas-rail" in canvas_css
1320 assert "display: flex !important;" in canvas_css
1321 assert "body.right-canvas-mobile-mode .right-canvas-shell" in canvas_css
1322 assert "body.right-canvas-mobile-mode .right-canvas,\nbody.right-canvas-mobile-mode .right-canvas-rail" not in canvas_css
1323
1324 assert "recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING)" in surfaces_js
1325 assert "configureModalSurfaceSwitcher" in surfaces_js
1326 assert "surface-switcher" in surfaces_js
1327 assert "surface-button" in surfaces_js
1328 assert "SINGLE_VISIBLE_MODAL_SURFACE_PATHS" not in modals_js
1329 assert "modal-surface-parked" in surfaces_js
1330 assert "parkSiblingSurfaceModals(activeModal)" in surfaces_js
1331 assert "activateModal(modal)" in modals_js
1332 assert "closeSurfaceGroupModals" not in modals_js
1333 assert "closeSurfaceGroupModals" in surfaces_js
1334 assert "const closed = await closeSurfaceGroupModals()" in surfaces_js
1335 assert "globalThis.closeSurfaceGroupModals = closeSurfaceGroupModals" in surfaces_js
1336 assert "button.title = title" not in modals_js
1337 assert "button.title = metadata.title" not in modals_js
1338 assert "rightCanvasStore.panelSurfaces" not in modals_js
1339 assert 'await recordMode(normalizedId, SURFACE_MODE_FLOATING)' in surfaces_js
1340 assert "const openPromise = ensureModalOpen(targetModalPath)" in surface_button_block
1341 assert "await closeModal(modal.path)" not in surface_button_block
1342 assert "modalRequiresExplicitClose" in modals_js
1343 assert "modalSurfaceMetadata" not in modals_js
1344 assert "modal-content-loaded" in modals_js
1345 assert '"plugins/_browser/webui/main.html"' not in modals_js
1346 assert '"plugins/_office/webui/main.html"' not in modals_js
1347 assert "&& !modalRequiresExplicitClose(newModal)" in modals_js
1348 assert "if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;" in modals_js
1349 assert ".modal-surface-switcher" not in modals_css
1350
1351 modal_stack_z = int(re.search(r"const baseZIndex = (\d+);", modals_js).group(1))
1352 modal_css_z = int(re.search(r"\.modal \{[^}]*z-index: (\d+);", modals_css, re.S).group(1))
1353 legacy_overlay_z = int(re.search(r"\.modal-overlay \{[^}]*z-index: (\d+);", modals_css, re.S).group(1))
1354 mobile_canvas_z = int(
1355 re.search(
1356 r"body\.right-canvas-mobile-mode \.right-canvas \{[^}]*z-index: (\d+);",
1357 canvas_css,
1358 re.S,
1359 ).group(1)
1360 )
1361 assert mobile_canvas_z == 3400
1362 assert modal_stack_z > mobile_canvas_z
1363 assert modal_css_z > mobile_canvas_z
1364 assert legacy_overlay_z > mobile_canvas_z
1365 assert "@media (max-width: 420px)" in canvas_css
1366 assert "body.right-canvas-mobile-mode .right-canvas-rail {\n gap: 5px;" in canvas_css
1367 assert "transform: translateY(-50%) scale(0.92);" in canvas_css
1368 assert ".surface-switcher" in surfaces_css
1369 assert ".surface-button" in surfaces_css
1370 assert ".modal-surface-button.is-active" in surfaces_css
1371 assert ".modal-surface-image" in surfaces_css
1372 assert ".modal.modal-surface-parked" in surfaces_css
1373 assert "@media (max-width: 768px)" in surfaces_css
1374 assert ".modal-inner.surface-modal .surface-modal-action-group-surfaces" in surfaces_css
1375 assert ".modal-inner.surface-modal .surface-modal-action-group-window" in surfaces_css
1376 assert ".modal-inner.surface-modal .surface-modal-action-group-new" in surfaces_css
1377 assert ".modal-inner.editor-modal .surface-modal-action-group-new" in surfaces_css
1378 assert ".surface-modal-new-action:not(.editor-header-actions)" in surfaces_css
1379 assert "const safeMinWidth = Math.min(minWidth, maxWidth)" in surfaces_js
1380 assert "grid-auto-flow: column" in surfaces_css
1381 assert 'id: "browser"' in surfaces_js
1382 assert 'id: "desktop"' in surfaces_js
1383 assert "/plugins/_browser/webui/main.html" in surfaces_js
1384 assert "/plugins/_desktop/webui/main.html" in surfaces_js
1385
1386
1387 def test_transient_ui_layers_above_modals_below_confirm_dialogs():
1388 index_css = (PROJECT_ROOT / "webui" / "index.css").read_text(encoding="utf-8")
1389 modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
1390 modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(encoding="utf-8")
1391 toast_html = (
1392 PROJECT_ROOT / "webui" / "components" / "notifications" / "notification-toast-stack.html"
1393 ).read_text(encoding="utf-8")
1394
1395 modal_base_z = int(re.search(r"const baseZIndex = (\d+);", modals_js).group(1))
1396 legacy_overlay_z = int(re.search(r"\.modal-overlay \{[^}]*z-index: (\d+);", modals_css, re.S).group(1))
1397 confirm_z = int(re.search(r"\.confirm-dialog-backdrop \{[^}]*z-index: (\d+);", modals_css, re.S).group(1))
1398 toast_z = int(re.search(r"\.toast-stack-container \{[^}]*z-index: (\d+);", toast_html, re.S).group(1))
1399 tooltip_z_indexes = [
1400 int(z)
1401 for z in re.findall(r"\.tooltip \{[^}]*z-index: (\d+);", index_css, re.S)
1402 ]
1403
1404 assert tooltip_z_indexes
1405 assert toast_z > max(modal_base_z + 20, legacy_overlay_z)
1406 assert all(z > toast_z for z in tooltip_z_indexes)
1407 assert all(z < confirm_z for z in [toast_z, *tooltip_z_indexes])
1408
1409
1410 def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
1411 prompt = (
1412 PROJECT_ROOT / "plugins" / "_browser" / "prompts" / "agent.system.tool.browser.md"
1413 ).read_text(encoding="utf-8")
1414 from helpers import tokens
1415
1416 config = (PROJECT_ROOT / "plugins" / "_browser" / "default_config.yaml").read_text(
1417 encoding="utf-8"
1418 )
1419 config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
1420 encoding="utf-8"
1421 )
1422 config_store_js = (
1423 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-config-store.js"
1424 ).read_text(encoding="utf-8")
1425
1426 assert "must not open a Browser surface automatically" in prompt
1427 assert "Use the tool headlessly unless the user opens the Browser surface" in prompt
1428 assert "optional visible WebUI viewer" in prompt
1429 assert "screenshot" in prompt
1430 assert "vision_load" in prompt
1431 assert "select_option" in prompt
1432 assert "set_checked" in prompt
1433 assert "upload_file" in prompt
1434 assert "browser-form-workflows" in prompt
1435 assert "first load `browser-automation` with `skills_tool:load`" in prompt
1436 assert "`browser-automation` links to `browser-form-workflows`" in prompt
1437 assert "does not automatically load screenshots" in prompt
1438 assert "chrome://inspect/#remote-debugging" in prompt
1439 assert "opera://inspect/#remote-debugging" in prompt
1440 assert tokens.approximate_tokens(prompt) <= 650
1441 assert "already open" in config
1442 assert "already-open Browser surface" in config_html
1443 assert "chrome://inspect/#remote-debugging" in config_html
1444 assert "opera://inspect/#remote-debugging" in config_html
1445 assert "A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS" in config_html
1446 assert "Custom endpoint" in config_html
1447 assert "localhost:9222" in config_html
1448 assert "customHostBrowserEndpointDiagnostic" in config_store_js
1449 assert "http(s):// discovery address" in config_store_js
1450 assert "HOST_BROWSER_STATUS_REFRESH_MS = 1000" in config_store_js
1451
1452
1453 def test_browser_skills_are_plugin_owned_and_progressively_linked():
1454 automation_path = (
1455 PROJECT_ROOT / "plugins" / "_browser" / "skills" / "browser-automation" / "SKILL.md"
1456 )
1457 skill_path = PROJECT_ROOT / "plugins" / "_browser" / "skills" / "browser-form-workflows" / "SKILL.md"
1458 assert automation_path.exists()
1459 assert skill_path.exists()
1460 automation = automation_path.read_text(encoding="utf-8")
1461 skill = skill_path.read_text(encoding="utf-8")
1462 assert skill.startswith("---\n")
1463 automation_frontmatter = automation.split("---", 2)[1]
1464 frontmatter = skill.split("---", 2)[1]
1465 assert "name: browser-automation" in automation_frontmatter
1466 assert "triggers:" in automation_frontmatter
1467 assert "browser screenshot" in automation_frontmatter
1468 assert "host browser" in automation_frontmatter
1469 assert "name: browser-form-workflows" in frontmatter
1470 assert "description:" in frontmatter
1471 assert "triggers:" in frontmatter
1472 assert "fill web form" in frontmatter
1473 assert "file upload" in frontmatter
1474 assert "progressive-disclosure workflow guide" in automation
1475 assert "`browser-form-workflows` with `skills_tool:load`" in automation
1476 assert 'skill_name: "browser-form-workflows"' in automation
1477 assert "form-specific extension of `browser-automation`" in skill
1478 assert "select_option" in skill
1479 assert "set_checked" in skill
1480 assert "upload_file" in skill
1481 assert "browser:screenshot" in skill
1482 assert "vision_load" in skill
1483
1484
1485 def test_browser_canvas_uses_plain_panel_without_debug_probe():
1486 panel_html = (
1487 PROJECT_ROOT
1488 / "plugins"
1489 / "_browser"
1490 / "extensions"
1491 / "webui"
1492 / "right-canvas-panels"
1493 / "browser-panel.html"
1494 ).read_text(encoding="utf-8")
1495 browser_store = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text(
1496 encoding="utf-8"
1497 )
1498 register_js = (
1499 PROJECT_ROOT
1500 / "plugins"
1501 / "_browser"
1502 / "extensions"
1503 / "webui"
1504 / "right_canvas_register_surfaces"
1505 / "register-browser.js"
1506 ).read_text(encoding="utf-8")
1507 canvas_store = (PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js").read_text(
1508 encoding="utf-8"
1509 )
1510 modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
1511
1512 assert 'x-component path="/plugins/_browser/webui/browser-panel.html" mode="canvas"' in panel_html
1513 assert "browserCanvasRemountKey" not in panel_html
1514 assert "remountBrowserCanvasOnce" not in panel_html
1515 assert "data-remount-slot" not in panel_html
1516
1517 for js in (browser_store, register_js, canvas_store, modals_js):
1518 assert "__a0BrowserDebug" not in js
1519 assert "browserDebug" not in js
1520 assert "emitBrowserDebug" not in js
1521 assert ".debug(" not in js
1522
1523
1524 def test_browser_ui_spinners_have_browser_local_animation():
1525 main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
1526 encoding="utf-8"
1527 )
1528 config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
1529 encoding="utf-8"
1530 )
1531
1532 assert ":class=\"{ spinning: $store.browserPage.extensionActionLoading }\"" in main_html
1533 assert "@keyframes browser-spin" in main_html
1534 assert "@keyframes browser-config-spin" in config_html
1535
1536
1537 def test_browser_extension_settings_stay_user_facing():
1538 config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
1539 encoding="utf-8"
1540 )
1541 config_store = (
1542 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-config-store.js"
1543 ).read_text(encoding="utf-8")
1544
1545 assert "Choose which installed Chrome extensions Browser loads." in config_html
1546 assert "Installed extensions" in config_html
1547 assert "Browser tabs" in config_html
1548 assert "Separate per chat" in config_html
1549 assert "Shared across chats" in config_html
1550 assert "Maximum tabs per chat" in config_html
1551 assert "Each chat shows only its own tabs. Browser sign-ins are shared across chats." in config_html
1552 assert "The tab strip shows tabs from every active chat. Browser sign-ins are shared across chats." in config_html
1553 assert 'x-model="$store.browserConfig.config.browser_tab_scope"' in config_html
1554 assert 'x-model.number="$store.browserConfig.config.max_open_tabs"' in config_html
1555 assert 'x-model="$store.browserConfig.config.proxy_server"' in config_html
1556 assert 'x-model="$store.browserConfig.config.proxy_bypass"' in config_html
1557 assert 'x-model="$store.browserConfig.config.proxy_username"' in config_html
1558 assert 'x-model="$store.browserConfig.config.proxy_password"' in config_html
1559 assert '<details\n class="browser-config-proxy"' in config_html
1560 assert "<summary>Proxy server</summary>" in config_html
1561 assert 'type="password"' in config_html
1562 assert "normalizeMaxOpenTabs()" in config_html
1563 assert "BROWSER_TAB_SCOPES" in config_store
1564 assert "HARD_MAX_OPEN_TABS = 50" in config_store
1565 assert "extensionDeleteTitle(extension)" in config_html
1566 assert "deleteExtension(extension)" in config_html
1567 assert "Delete extension" in config_store
1568 assert 'import { showConfirmDialog } from "/js/confirmDialog.js";' in config_store
1569 assert "const safeName = name.replace" in config_store
1570 assert "const confirmed = await showConfirmDialog({" in config_store
1571 assert "globalThis.confirm" not in config_store
1572 assert "<textarea" not in config_html
1573 assert "Enabled extension directories" not in config_html
1574 assert "Chrome Web Store URL installs" not in config_html
1575 assert "Browser caches Playwright Chromium" not in config_html
1576
1577
1578 def test_browser_viewer_uses_tabs_for_session_switching():
1579 main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
1580 encoding="utf-8"
1581 )
1582 browser_store = (
1583 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
1584 ).read_text(encoding="utf-8")
1585
1586 assert 'class="browser-session-tabs" role="tablist"' in main_html
1587 assert 'class="browser-tab"' in main_html
1588 assert 'class="browser-new-tab"' in main_html
1589 assert 'browser in $store.browserPage.visibleBrowsers()' in main_html
1590 assert ':key="$store.browserPage.browserTabKey(browser)"' in main_html
1591 assert "browser.context_id" in main_html
1592 assert ':title="$store.browserPage.browserTabTooltip(browser)"' in main_html
1593 assert ':aria-busy="$store.browserPage.isBrowserLoading(browser).toString()"' in main_html
1594 assert 'class="browser-tab-loading"' in main_html
1595 assert "'is-visible': $store.browserPage.isBrowserLoading(browser)" in main_html
1596 tab_button = main_html.index('class="browser-tab"')
1597 tab_button_end = main_html.index("</button>", tab_button)
1598 tab_spinner = main_html.index('class="browser-tab-loading"', tab_button_end)
1599 tab_close = main_html.index('class="browser-tab-close"', tab_spinner)
1600 assert tab_button_end < tab_spinner < tab_close
1601 assert "<span x-text=\"$store.browserPage.loadingMessage()\">Loading</span>" not in main_html
1602 assert "isBrowserLoading(browser)" in browser_store
1603 assert "loadingMessage()" not in browser_store
1604 assert 'class="browser-empty browser-starting" role="status" aria-live="polite"' in main_html
1605 assert "browserLoadingLabel()" in browser_store
1606 assert "Starting Browser…" in browser_store
1607 assert "browser-tab-context" not in main_html
1608 assert 'handleSelectedContextChange($store.chats?.selected)' in main_html
1609 assert "activeBrowserContextId" in browser_store
1610 assert "sameBrowserTab" in browser_store
1611 assert "applyBrowserListing" in browser_store
1612 assert "syncViewerToSelectedContext(selectedContextId)" in browser_store
1613 assert "async syncViewerToSelectedContext" in browser_store
1614 assert "const selectedContextId = this.normalizeContextId(chatsStore.selected);" in browser_store
1615 assert "this.contextId = selectedContextId;" in browser_store
1616 assert "isVisibleBrowserSurface()" in browser_store
1617 assert "firstBrowserInContext(selectedContextId)" in browser_store
1618 assert "visibleBrowsers()" in browser_store
1619 assert 'tabScope: "per_context"' in browser_store
1620 assert "applyTabScope(data)" in browser_store
1621 assert 'if (this.tabScope === "shared") return browsers;' in browser_store
1622 assert "requestedContextId && requestedContextId !== inFlightContextId" in browser_store
1623 assert "create_browser: Boolean(options.createBrowser || options.create_browser)" in browser_store
1624 assert "browserTabTooltip(browser)" in browser_store
1625 assert "browserChatTitle(browser = {})" in browser_store
1626 assert "contextId.slice" not in browser_store
1627 assert '"browser_viewer_sessions"' in browser_store
1628 assert "$store.browserPage.openNewBrowser()" in main_html
1629 assert "browser-select" not in main_html
1630 assert "browser-live-dot" not in main_html
1631 assert "async openNewBrowser()" in browser_store
1632 assert "browserTabTitle(browser)" in browser_store
1633 assert "Scan with A0" in browser_store
1634 assert "Review with A0" not in browser_store
1635 assert "Using ${this.mainModelSummary}" in browser_store
1636
1637
1638 def test_browser_tabs_close_without_confirmation_or_busy_lock():
1639 main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
1640 encoding="utf-8"
1641 )
1642 browser_store = (
1643 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
1644 ).read_text(encoding="utf-8")
1645 close_start = main_html.index('class="browser-tab-close"')
1646 close_end = main_html.index("</button>", close_start)
1647 close_markup = main_html[close_start:close_end]
1648
1649 assert '@click.stop="$store.browserPage.closeBrowser(browser.id, browser.context_id)"' in main_html
1650 assert "@pointerdown.stop" in close_markup
1651 assert "$confirmClick" not in main_html
1652 assert "isBusy()" not in close_markup
1653 assert ':disabled="$store.browserPage.isClosingBrowser(browser.id, browser.context_id)"' in close_markup
1654 assert "--browser-tab-close-size: 32px;" in main_html
1655 assert "async closeBrowser(id, contextId = \"\")" in browser_store
1656 assert "isClosingBrowser(id, contextId = \"\")" in browser_store
1657 assert "_closingBrowserIds" in browser_store
1658 assert "_commandInFlightCount" in browser_store
1659
1660
1661 def test_browser_viewer_defaults_to_interactive_with_screencast_and_snapshot_fallbacks():
1662 ws_browser = (PROJECT_ROOT / "plugins" / "_browser" / "api" / "ws_browser.py").read_text(
1663 encoding="utf-8"
1664 )
1665 main_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html").read_text(
1666 encoding="utf-8"
1667 )
1668 runtime = (
1669 PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
1670 ).read_text(encoding="utf-8")
1671 browser_store = (
1672 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
1673 ).read_text(encoding="utf-8")
1674 browser_tool_handler = (
1675 PROJECT_ROOT / "plugins" / "_browser" / "extensions" / "webui" / "get_tool_message_handler" / "browser-tool-handler.js"
1676 ).read_text(encoding="utf-8")
1677
1678 assert 'runtime.call("screenshot"' in ws_browser
1679 assert 'VIEWER_TRANSPORT_SNAPSHOT = "snapshot"' in ws_browser
1680 assert 'VIEWER_TRANSPORT_SCREENCAST = "screencast"' in ws_browser
1681 assert 'VIEWER_TRANSPORT_INTERACTIVE = "interactive"' in ws_browser
1682 assert "async def _effective_viewer(" in ws_browser
1683 assert "return VIEWER_TRANSPORT_SCREENCAST, viewer" in ws_browser
1684 assert "def _viewer_transport(data: dict[str, Any])" in ws_browser
1685 assert "return VIEWER_TRANSPORT_SNAPSHOT" in ws_browser
1686 assert "self._stream_state" in ws_browser
1687 assert "SCREENCAST_STREAM_QUALITY = 80" in ws_browser
1688 assert "SCREENSHOT_QUALITY = 92" in ws_browser
1689 assert "initial_viewport = self._viewport_from_data(data)" in ws_browser
1690 assert '"set_viewport"' in ws_browser
1691 assert "start_screencast" in ws_browser
1692 assert '"attach_screencast_consumer"' in ws_browser
1693 assert "asyncio.run_coroutine_threadsafe" in ws_browser
1694 assert 'runtime.call(\n "read_screencast_frame"' not in ws_browser
1695 assert "read_screencast_frame" in runtime
1696 assert "FRAME_READ_TIMEOUT_SECONDS" in ws_browser
1697 assert "FRAME_IDLE_POLL_SECONDS" not in ws_browser
1698 assert "except TimeoutError:" in ws_browser
1699 assert "stop_screencast" in ws_browser
1700 assert "viewer_transport == VIEWER_TRANSPORT_SCREENCAST" in ws_browser
1701 assert "viewer_transport != VIEWER_TRANSPORT_INTERACTIVE" in ws_browser
1702 assert "if viewer_transport == VIEWER_TRANSPORT_INTERACTIVE" in ws_browser
1703 assert '"viewer_transport": viewer_transport' in ws_browser
1704 assert "viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT" in ws_browser
1705 assert '"Page.startScreencast"' in runtime
1706 assert '"Page.screencastFrame"' in runtime
1707 assert '"Page.screencastFrameAck"' in runtime
1708 assert "async def attach_screencast_consumer" in runtime
1709 assert "async def attach_consumer" in runtime
1710 assert "stop_callback: Any | None = None" in runtime
1711 assert "def _notify_stopped(self) -> None:" in runtime
1712 assert "server_loop.call_soon_threadsafe(stop_event.set)" in ws_browser
1713 assert "await self._deliver_frame(" in runtime
1714 assert "await asyncio.wrap_future(future)" in runtime
1715 assert '"Page.stopScreencast"' in runtime
1716 assert '"Emulation.setDeviceMetricsOverride"' in runtime
1717 assert '"Emulation.setVisibleSize"' in runtime
1718 assert "asyncio.Queue(maxsize=1)" in runtime
1719 assert "await self._stop_screencasts_for_browser(resolved_id)" in runtime
1720 assert "queueFrameRender" in browser_store
1721 assert "requestAnimationFrame" in browser_store
1722 assert "function frameImageSource(data = {})" in browser_store
1723 assert "const BROWSER_BINARY_FRAME_REQUESTS_ENABLED = false;" in browser_store
1724 assert "const BROWSER_BINARY_PAYLOADS_SUPPORTED = typeof Blob" in browser_store
1725 assert "const BROWSER_CANVAS_FRAMES_SUPPORTED = typeof globalThis.createImageBitmap" in browser_store
1726 assert "if (data.encoding === \"binary\") return null;" in browser_store
1727 assert "async function loadFrameBitmap(src, options = {})" in browser_store
1728 assert "return await globalThis.createImageBitmap(blob);" in browser_store
1729 assert 'const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";' in browser_store
1730 assert 'const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";' in browser_store
1731 assert 'const BROWSER_VIEWER_TRANSPORT_INTERACTIVE = "interactive";' in browser_store
1732 assert "const VIEWPORT_SYNC_INTERVAL_MS = 50;" in browser_store
1733 assert "viewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE" in browser_store
1734 assert "return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;" in browser_store
1735 assert "usesInteractiveTransport()" in browser_store
1736 assert "isInteractiveSurface(stage = null)" in browser_store
1737 assert "prepareInteractiveViewFrame(frame = null)" in browser_store
1738 assert 'style.id = "a0-xpra-browser-frame-css";' in browser_store
1739 assert "#shadow_pointer" in browser_store
1740 assert "xpraWindow._set_decorated?.(false);" in browser_store
1741 assert "xpraWindow.updateCSSGeometry?.();" in browser_store
1742 assert "syncInteractiveViewSize()" in browser_store
1743 assert "frame?.contentWindow?.client?._screen_resized?.();" in browser_store
1744 assert "applyViewer(data = {})" in browser_store
1745 assert "requestedViewerTransport()" in browser_store
1746 assert "normalizeViewerTransport(value = \"\")" in browser_store
1747 assert "usesScreencastTransport()" in browser_store
1748 assert "supportsBinaryFrames()" in browser_store
1749 assert "captureDevicePixelRatio()" in browser_store
1750 assert "frameDimensionsFromData(data = null)" in browser_store
1751 assert "frameDimensionsFromMetadata(metadata = null)" in browser_store
1752 assert "metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth" in browser_store
1753 assert "dimensions: this.frameDimensionsFromData(data)" in browser_store
1754 assert "useCanvas: true" in browser_store
1755 assert "let dimensions = options?.dimensions || null" in browser_store
1756 assert "dimensions ||= await loadFrameDimensions(frameSrc)" in browser_store
1757 assert "paintFrameBitmap(bitmap)" in browser_store
1758 assert "if (canvas.width !== bitmap.width) canvas.width = bitmap.width;" in browser_store
1759 assert "freezeCanvasFrameToImage()" in browser_store
1760 assert "currentFrameCanvas()" in browser_store
1761 assert 'this._stageElement?.querySelector?.(".browser-frame-canvas")' in browser_store
1762 assert 'canvas.toDataURL("image/jpeg", 0.86)' in browser_store
1763 assert "this.frameSrc = frameSrc;" in browser_store
1764 assert "hasFrame()" in browser_store
1765 assert "frameCanvasReady: false" in browser_store
1766 assert "attachFrameCanvas(canvas = null)" in browser_store
1767 assert "viewer_transport: this.requestedViewerTransport()" in browser_store
1768 assert "binary_frames: this.supportsBinaryFrames()" in browser_store
1769 assert "slim_frames: true" in browser_store
1770 assert "device_pixel_ratio: this.captureDevicePixelRatio()" in browser_store
1771 assert "viewport_width: initialViewport?.width" in browser_store
1772 assert "viewport_height: initialViewport?.height" in browser_store
1773 assert "restart_stream: restartStream && this.usesScreencastTransport()" in browser_store
1774 assert 'restart_screencast=bool(data.get("restart_stream"))' in ws_browser
1775 assert "restart_screencast: bool = False" in runtime
1776 assert "should_restart_screencast = changed or restart_screencast" in runtime
1777 assert "await self._apply_cdp_viewport({\"width\": width, \"height\": height})" in runtime
1778 assert "await page.set_viewport_size(viewport)" in runtime
1779 assert "VIEWPORT_REMOUNT_PAUSE_SECONDS" not in runtime
1780 assert "_nudged_viewport" not in runtime
1781 assert 'wait_until="commit"' in ws_browser
1782 assert 'restartStream: this._mode === "canvas" && this.usesScreencastTransport()' in browser_store
1783 assert "this.frameState = data.state || null" not in browser_store
1784 assert "function loadFrameDimensions(src)" in browser_store
1785 assert "frameMatchesViewport(dimensions = null, viewport = null)" in browser_store
1786 assert "shouldAcceptMismatchedFrame" not in browser_store
1787 assert "requestViewportSyncAfterRejectedFrame()" in browser_store
1788 assert "this.applySnapshot(data.snapshot);" in browser_store
1789 assert "if (!this.frameCanvasReady || !this.usesScreencastTransport())" in browser_store
1790 assert "else if (!data.state)" in browser_store
1791 assert '"snapshot": snapshot' in ws_browser
1792 assert '"binary_frames": binary_frames' in ws_browser
1793 assert '"slim_frames": slim_frames' in ws_browser
1794 assert "capture_scale=capture_scale" in ws_browser
1795 assert "base64.b64decode(image, validate=False)" in ws_browser
1796 assert '"encoding"] = "binary"' in ws_browser
1797 assert "def _frame_payload(" in ws_browser
1798 assert "def _emit_viewer_state(" in ws_browser
1799 assert 'const BROWSER_SNAPSHOT_META_KEY = "browser_snapshot";' in browser_tool_handler
1800 assert "staticScreenshotUri(kvps)" in browser_tool_handler
1801 assert "/components/modals/image-viewer/image-viewer-store.js" in browser_tool_handler
1802 assert "function openStaticScreenshot(uri = \"\")" in browser_tool_handler
1803 assert "imageViewerStore.open(src, { name: \"Browser screenshot\" });" in browser_tool_handler
1804 assert "if (staticUri) {" in browser_tool_handler
1805 assert "openStaticScreenshot(staticUri);" in browser_tool_handler
1806 assert "delete displayKvps[BROWSER_SNAPSHOT_META_KEY];" in browser_tool_handler
1807 assert "startBrowserScreenshotPreview(button, image, resolveBrowserPayload)" in browser_tool_handler
1808 assert "FRAME_FALLBACK_SCREENSHOT_SECONDS" not in ws_browser
1809 assert '"browser_viewer_state"' in ws_browser
1810 assert '"frame_source": VIEWER_TRANSPORT_SCREENCAST' in ws_browser
1811 assert '"viewer_transport": VIEWER_TRANSPORT_SCREENCAST' in ws_browser
1812 assert "fallback_screenshot" not in ws_browser
1813 assert "canvas_wheel_screenshot" not in ws_browser
1814 assert "surface_mode: this._mode" not in browser_store
1815 assert '<canvas class="browser-frame browser-frame-canvas"' in main_html
1816 assert '<iframe class="browser-interactive-frame"' in main_html
1817 assert 'x-if="$store.browserPage.isInteractiveSurface($el.parentElement)"' in main_html
1818 assert 'aria-label="Browser viewport"' in main_html
1819 assert 'title="Interactive Browser viewport"' not in main_html
1820 assert 'allow="clipboard-read; clipboard-write"' in main_html
1821 assert 'x-init="$store.browserPage.attachFrameCanvas($el)"' in main_html
1822 assert '<img class="browser-frame browser-frame-image"' in main_html
1823 assert "$store.browserPage.hasFrame()" in main_html
1824 assert "overflow: hidden;" in main_html
1825 assert "object-fit: contain;" in main_html
1826 assert "image-rendering: auto;" in main_html
1827
1828
1829 def test_browser_viewer_frame_payload_supports_binary_slim_frames():
1830 payload = ws_browser_module.WsBrowser._frame_payload(
1831 {
1832 "image": SMALL_JPEG_10X10,
1833 "mime": "image/jpeg",
1834 "metadata": {"expectedWidth": 900, "expectedHeight": 600},
1835 },
1836 context_id="ctx",
1837 viewer_id="viewer",
1838 browser_id=1,
1839 sequence=3,
1840 binary_frames=True,
1841 )
1842
1843 assert payload["encoding"] == "binary"
1844 assert payload["image"] == __import__("base64").b64decode(SMALL_JPEG_10X10)
1845 assert payload["width"] == 900
1846 assert payload["height"] == 600
1847 assert payload["seq"] == 3
1848 assert "browsers" not in payload
1849 assert "state" not in payload
1850
1851
1852 def test_browser_viewer_frame_dimensions_reject_crops_but_allow_uniform_scaling():
1853 dimensions = ws_browser_module.WsBrowser._frame_dimensions
1854
1855 assert dimensions(
1856 {
1857 "expectedWidth": 900,
1858 "expectedHeight": 600,
1859 "jpegWidth": 1800,
1860 "jpegHeight": 1200,
1861 }
1862 ) == {"width": 900, "height": 600}
1863 assert dimensions(
1864 {
1865 "expectedWidth": 900,
1866 "expectedHeight": 600,
1867 "jpegWidth": 900,
1868 "jpegHeight": 500,
1869 }
1870 ) == {"width": 900, "height": 500}
1871
1872
1873 def test_browser_navigation_errors_stay_inside_native_browser_page():
1874 runtime = (
1875 PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
1876 ).read_text(encoding="utf-8")
1877
1878 assert "Error as PlaywrightError" in runtime
1879 assert "except PlaywrightError as exc:" in runtime
1880 assert "Browser navigation showed a native error page" in runtime
1881 assert "await self._settle(page)" in runtime
1882 assert "except (PlaywrightError, PlaywrightTimeoutError):" in runtime
1883
1884
1885 def test_browser_annotate_mode_ui_and_prompt_hooks():
1886 panel_html = (
1887 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html"
1888 ).read_text(encoding="utf-8")
1889 browser_store = (
1890 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
1891 ).read_text(encoding="utf-8")
1892
1893 assert "Annotate" in panel_html
1894 assert "Annotating" in panel_html
1895 assert "browser-annotation-layer" in panel_html
1896 assert "browser-annotation-tray" in panel_html
1897 assert "annotationTrayStyle()" in panel_html
1898 assert "startAnnotationTrayDrag($event)" in panel_html
1899 assert "Draft to chat" not in panel_html
1900 assert "Send now" not in panel_html
1901 assert 'class="browser-annotation-popover-close"' in panel_html
1902 assert panel_html.count('class="browser-annotation-mic mic-inactive"') == 2
1903 assert 'class="browser-annotation-send"' in panel_html
1904 assert 'name="arrow_forward"' in panel_html
1905 assert 'name="add_comment"' not in panel_html
1906 assert "data-whisper-microphone" in panel_html
1907 assert "syncAnnotationMicrophoneUI()" in panel_html
1908 assert "startAnnotationVoice(true)" in panel_html
1909 assert "@pointerdown.stop.prevent=\"$store.browserPage.startAnnotationSelection($event)\"" in panel_html
1910 assert "clearAnnotationHover()" in panel_html
1911 assert "@keydown.window=\"$store.browserPage.handleKeydown($event)\"" in panel_html
1912 assert "annotationComments: []" in browser_store
1913 assert "annotationHover: null" in browser_store
1914 assert "annotationTrayPosition: null" in browser_store
1915 assert "pendingAnnotations()" in browser_store
1916 assert "annotationBatchLabel()" in browser_store
1917 assert "updateAnnotationHover(event)" in browser_store
1918 assert "startAnnotationVoice(draftComment = false)" in browser_store
1919 assert "this.annotationDraftText = existing" in browser_store
1920 assert "options.sendImmediately" in browser_store
1921 assert "clampAnnotationTrayPosition" in browser_store
1922 assert '"browser_viewer_annotation"' in browser_store
1923 assert 'event?.key === "." && (event.metaKey || event.ctrlKey)' in browser_store
1924 assert "Browser annotations" in browser_store
1925 assert "Instruction:" in browser_store
1926 assert "Page ${pageIndex + 1}" in browser_store
1927 assert "Comment:" in browser_store
1928 assert "Coordinates:" in browser_store
1929 assert "Selector:" in browser_store
1930 assert "DOM:" in browser_store
1931 assert "value=\\\"[redacted]\\\"" in browser_store
1932
1933 whisper_store = (
1934 PROJECT_ROOT / "plugins" / "_whisper_stt" / "webui" / "whisper-stt-store.js"
1935 ).read_text(encoding="utf-8")
1936 assert "finalTranscriptHandler" in whisper_store
1937 assert "deliverVoiceMessage(text)" in whisper_store
1938 assert '"[data-whisper-microphone], #microphone-button"' in whisper_store
1939
1940
1941 def test_browser_visual_mode_bridges_clipboard_shortcuts():
1942 browser_store = (
1943 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
1944 ).read_text(encoding="utf-8")
1945 runtime = (
1946 PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
1947 ).read_text(encoding="utf-8")
1948 ws_browser = (
1949 PROJECT_ROOT / "plugins" / "_browser" / "api" / "ws_browser.py"
1950 ).read_text(encoding="utf-8")
1951
1952 assert 'import { copyToClipboard } from "/components/messages/action-buttons/simple-action-buttons.js";' in browser_store
1953 assert "BROWSER_VISUAL_SHORTCUT_KEYS" in browser_store
1954 assert "handleVisualBrowserShortcut(event)" in browser_store
1955 assert "visualBrowserStageForEvent(event)" in browser_store
1956 assert "isLocalEditableTarget(event?.target)" in browser_store
1957 assert 'return { action: "paste" };' in browser_store
1958 assert 'return { action: "copy" };' in browser_store
1959 assert 'return { action: "cut" };' in browser_store
1960 assert 'return { key: "Control+A" };' in browser_store
1961 assert 'return { key: shift ? "Control+Shift+Z" : "Control+Z" };' in browser_store
1962 assert 'input_type: "clipboard"' in browser_store
1963 assert "pasteHostClipboardToBrowser()" in browser_store
1964 assert "copyBrowserClipboardToHost" in browser_store
1965 assert "Browser paste needs clipboard permission in this tab." in browser_store
1966
1967 assert "CLIPBOARD_BRIDGE_SCRIPT" in runtime
1968 assert "async def clipboard" in runtime
1969 assert "insertFromPaste" in runtime
1970 assert "deleteByCut" in runtime
1971 assert "keyboard.insert_text" in runtime
1972 assert 'input_type == "clipboard"' in ws_browser
1973 assert 'runtime.call(\n "clipboard"' in ws_browser
1974
1975
1976 def test_browser_visual_mode_only_forwards_text_producing_alt_keys():
1977 browser_store = (
1978 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
1979 ).read_text(encoding="utf-8")
1980 start = browser_store.index("function isAltTextInput")
1981 end = browser_store.index("\n}\n", start) + 3
1982 helper = browser_store[start:end]
1983 script = helper + """
1984 const check = (condition, message) => {
1985 if (!condition) throw new Error(message);
1986 };
1987 check(isAltTextInput({ key: "@", altKey: true, ctrlKey: true }, "Windows"), "AltGr text was blocked");
1988 check(isAltTextInput({ key: "", altKey: true, getModifierState: (name) => name === "AltGraph" }, "Linux"), "AltGraph text was blocked");
1989 check(isAltTextInput({ key: "@", altKey: true }, "MacIntel"), "Option text was blocked");
1990 check(!isAltTextInput({ key: "f", altKey: true }, "Windows"), "Windows Alt shortcut became text");
1991 check(!isAltTextInput({ key: "d", altKey: true }, "Linux"), "Linux Alt shortcut became text");
1992 check(!isAltTextInput({ key: "@", altKey: true, metaKey: true }, "MacIntel"), "Command shortcut became text");
1993 """
1994
1995 subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)
1996
1997
1998 def test_browser_runtime_and_content_helper_expose_annotation_target():
1999 runtime = (
2000 PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
2001 ).read_text(encoding="utf-8")
2002 helper = (
2003 PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-page-content.js"
2004 ).read_text(encoding="utf-8")
2005 dom_helper = (
2006 PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-dom-helper.js"
2007 ).read_text(encoding="utf-8")
2008
2009 assert "DOM_HELPER_PATH" in runtime
2010 assert "browser-dom-helper.js" in runtime
2011 assert "globalThis.__spaceBrowserDomHelper__?.captureDocument" in runtime
2012 assert "async def annotation_target" in runtime
2013 assert "globalThis.__spaceBrowserPageContent__.annotate(payload || null)" in runtime
2014 assert "function annotate(payload = null)" in helper
2015 assert "annotate," in helper
2016 assert "boundingBoxFor," in helper
2017 assert "pointFor," in helper
2018 assert "select(referenceId, valueOrValues)" in helper
2019 assert "setChecked(referenceId, checked)" in helper
2020 assert "fileInputFor," in helper
2021 assert "sanitizeAnnotationDom" in helper
2022 assert "password" in helper
2023 assert "__spaceBrowserDomHelper__" in dom_helper
2024 assert "captureDocument(payload)" in dom_helper
2025 assert "clickNode(frameChain, nodeId)" in dom_helper
2026 assert "requestChildFrameOperation" in dom_helper
2027 assert "data-space-browser-frame-chain" in dom_helper
2028
2029
2030 def test_browser_content_helper_keeps_label_wrapped_controls_referenceable():
2031 helper = (
2032 PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-page-content.js"
2033 ).read_text(encoding="utf-8")
2034
2035 assert 'const VERSION = "13"' in helper
2036 assert "function patchOpenShadowDom" in helper
2037 assert "Element.prototype.attachShadow = patched" in helper
2038 assert "const REQUIRED_API_NAMES = Object.freeze([" in helper
2039 assert "requiredApis: REQUIRED_API_NAMES.slice()" in helper
2040 assert "ready()" in helper
2041 for api_name in ("click", "scroll", "submit", "type", "typeSubmit"):
2042 assert f'"{api_name}"' in helper
2043 assert "function renderControlLabelReferences" in helper
2044 assert "getLabelElementText(labelElement, element)" in helper
2045 assert "return renderControlLabelReferences(node, context);" in helper
2046 assert "return renderControlLabelReferences(element, context);" in helper
2047 assert "function isGlobalOrDelegatedEventBinding" in helper
2048 assert 'parts.includes("window")' in helper
2049 assert 'parts.includes("outside")' in helper
2050 assert 'actionStrategy: entry.helperBacked ? "frame_chain_ref" : "dom_ref"' in helper
2051 assert 'actionStrategy: "frame_chain_ref"' in helper
2052 assert "frameChain: Array.isArray(entry?.frameChain)" in helper
2053
2054
2055 def test_browser_panel_exposes_agent_friendly_address_input():
2056 panel = (
2057 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html"
2058 ).read_text(encoding="utf-8")
2059
2060 assert 'class="browser-address-form" aria-label="Browser navigation"' in panel
2061 assert 'class="browser-address" aria-label="Browser address" name="browser_address"' in panel
2062
2063
2064 def test_browser_panel_groups_mobile_toolbar_controls_above_address():
2065 panel = (
2066 PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-panel.html"
2067 ).read_text(encoding="utf-8")
2068
2069 toolbar_index = panel.index('<div class="browser-toolbar">')
2070 nav_index = panel.index('<div class="browser-navigation">', toolbar_index)
2071 controls_index = panel.index('<div class="browser-session-controls">', toolbar_index)
2072 address_index = panel.index('<form class="browser-address-form"', toolbar_index)
2073
2074 assert nav_index < controls_index < address_index
2075 assert ".browser-panel {\n --browser-chrome-surface:" in panel
2076 assert "container-type: inline-size;" in panel
2077 assert "@container (max-width: 460px)" in panel
2078 assert ".browser-toolbar {\n grid-template-columns: auto minmax(0, 1fr) auto;" in panel
2079 assert ' "nav . controls"\n "address address address";' in panel
2080 assert ".browser-session-controls {\n width: auto;" in panel
2081 assert ".browser-address-form {\n width: 100%;" in panel
2082
2083
2084 def test_browser_runtime_requires_current_content_helper_for_modifier_clicks():
2085 runtime = (
2086 PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
2087 ).read_text(encoding="utf-8")
2088
2089 assert "__spaceBrowserPageContent__?.ready?.()" in runtime
2090 assert "context.add_init_script" not in runtime
2091 assert "isolated_context=False" in runtime
2092
2093
2094 @pytest.mark.anyio
2095 async def test_browser_dom_helper_clicks_content_ref_inside_iframe():
2096 pytest.importorskip("patchright.async_api")
2097 from patchright.async_api import async_playwright
2098
2099 browser_binary = get_playwright_binary()
2100 if not browser_binary:
2101 pytest.skip("Patchright Chromium binary is not installed")
2102
2103 async with async_playwright() as playwright:
2104 try:
2105 browser = await playwright.chromium.launch(
2106 executable_path=str(browser_binary),
2107 headless=True,
2108 args=["--no-sandbox"],
2109 )
2110 except Exception as exc:
2111 pytest.skip(f"Patchright Chromium could not launch: {exc}")
2112
2113 try:
2114 context = await browser.new_context()
2115 page = await context.new_page()
2116 await page.set_content(
2117 """
2118 <html>
2119 <body>
2120 <iframe srcdoc="
2121 <html>
2122 <body>
2123 <button id='inside' onclick=&quot;document.body.dataset.clicked = 'yes'; this.textContent = 'Clicked inside frame'&quot;>
2124 Frame Launch
2125 </button>
2126 </body>
2127 </html>
2128 "></iframe>
2129 </body>
2130 </html>
2131 """
2132 )
2133 core = _BrowserRuntimeCore("patchright-helper")
2134 await core._ensure_content_helper(page)
2135
2136 captured = await page.evaluate(
2137 "(payload) => globalThis.__spaceBrowserPageContent__.capture(payload || null)",
2138 None,
2139 )
2140 document_content = str(captured.get("document") or "")
2141 match = re.search(r"\[button (\d+)\]\s*Frame Launch", document_content)
2142 assert match, document_content
2143
2144 action = await page.evaluate(
2145 "(ref) => globalThis.__spaceBrowserPageContent__.click(ref)",
2146 match.group(1),
2147 )
2148 frame = next(frame for frame in page.frames if frame != page.main_frame)
2149 assert await frame.evaluate("() => document.body.dataset.clicked") == "yes"
2150 assert action["status"]["reacted"] is True
2151 finally:
2152 await browser.close()
2153
2154
2155 @pytest.mark.anyio
2156 async def test_browser_screencast_acknowledges_and_drops_stale_frames():
2157 first_image = SMALL_JPEG_10X10
2158
2159 class FakeSession:
2160 def __init__(self):
2161 self.handlers = {}
2162 self.sent = []
2163 self.detached = False
2164
2165 def on(self, event, handler):
2166 self.handlers[event] = handler
2167
2168 async def send(self, method, params=None):
2169 self.sent.append((method, params or {}))
2170
2171 async def detach(self):
2172 self.detached = True
2173
2174 session = FakeSession()
2175 screencast = _BrowserScreencast(
2176 stream_id="stream",
2177 browser_id=7,
2178 session=session,
2179 mime="image/jpeg",
2180 )
2181
2182 await screencast.start(
2183 quality=92,
2184 every_nth_frame=1,
2185 viewport={"width": 1118, "height": 662},
2186 capture_scale=2,
2187 )
2188 session.handlers["Page.screencastFrame"](
2189 {"data": first_image, "metadata": {"deviceWidth": 10}, "sessionId": 1}
2190 )
2191 session.handlers["Page.screencastFrame"](
2192 {"data": "second", "metadata": {"deviceWidth": 200}, "sessionId": 2}
2193 )
2194 await asyncio.sleep(0)
2195
2196 frame = await screencast.next_frame(timeout=0.1)
2197
2198 assert frame["browser_id"] == 7
2199 assert frame["image"] == "second"
2200 assert frame["metadata"]["deviceWidth"] == 200
2201 assert frame["metadata"]["expectedWidth"] == 1118
2202 assert frame["metadata"]["expectedHeight"] == 662
2203 metrics_calls = [
2204 params
2205 for method, params in session.sent
2206 if method == "Emulation.setDeviceMetricsOverride"
2207 ]
2208 visible_calls = [
2209 params
2210 for method, params in session.sent
2211 if method == "Emulation.setVisibleSize"
2212 ]
2213 assert metrics_calls == [
2214 {
2215 "width": 1118,
2216 "height": 662,
2217 "deviceScaleFactor": 1,
2218 "mobile": False,
2219 "dontSetVisibleSize": True,
2220 },
2221 ]
2222 assert visible_calls == [{"width": 1118, "height": 662}]
2223 start_index = next(
2224 index
2225 for index, (method, _params) in enumerate(session.sent)
2226 if method == "Page.startScreencast"
2227 )
2228 start_params = session.sent[start_index][1]
2229 assert start_params["quality"] == 92
2230 assert start_params["maxWidth"] == 2236
2231 assert start_params["maxHeight"] == 1324
2232 cdp_viewport_indices = [
2233 index
2234 for index, (method, _params) in enumerate(session.sent)
2235 if method.startswith("Emulation.")
2236 ]
2237 assert max(cdp_viewport_indices) < start_index
2238 assert ("Page.screencastFrameAck", {"sessionId": 1}) in session.sent
2239 assert ("Page.screencastFrameAck", {"sessionId": 2}) in session.sent
2240
2241 await screencast.stop()
2242
2243 assert ("Page.stopScreencast", {}) in session.sent
2244 assert session.detached is True
2245
2246
2247 @pytest.mark.anyio
2248 async def test_browser_screencast_acks_after_consumer_settles():
2249 class FakeSession:
2250 def __init__(self):
2251 self.handlers = {}
2252 self.sent = []
2253
2254 def on(self, event, handler):
2255 self.handlers[event] = handler
2256
2257 async def send(self, method, params=None):
2258 self.sent.append((method, params or {}))
2259
2260 async def detach(self):
2261 pass
2262
2263 session = FakeSession()
2264 delivered = []
2265 delivery = concurrent.futures.Future()
2266 screencast = _BrowserScreencast(
2267 stream_id="stream",
2268 browser_id=7,
2269 session=session,
2270 mime="image/jpeg",
2271 )
2272 screencast.frame_consumer = lambda frame: delivered.append(frame) or delivery
2273
2274 await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 640, "height": 480})
2275 session.handlers["Page.screencastFrame"](
2276 {"data": SMALL_JPEG_10X10, "metadata": {}, "sessionId": 21}
2277 )
2278 await asyncio.sleep(0)
2279
2280 assert delivered
2281 assert ("Page.screencastFrameAck", {"sessionId": 21}) not in session.sent
2282
2283 delivery.set_result(None)
2284 await asyncio.sleep(0)
2285 await asyncio.sleep(0)
2286
2287 assert ("Page.screencastFrameAck", {"sessionId": 21}) in session.sent
2288
2289 await screencast.stop()
2290
2291
2292 @pytest.mark.anyio
2293 async def test_browser_screencast_notifies_consumer_when_frame_task_stops_before_delivery():
2294 class FakeSession:
2295 def __init__(self):
2296 self.handlers = {}
2297 self.sent = []
2298 self.detached = False
2299
2300 def on(self, event, handler):
2301 self.handlers[event] = handler
2302
2303 async def send(self, method, params=None):
2304 self.sent.append((method, params or {}))
2305
2306 async def detach(self):
2307 self.detached = True
2308
2309 session = FakeSession()
2310 delivered = []
2311 stopped = []
2312 screencast = _BrowserScreencast(
2313 stream_id="stream",
2314 browser_id=7,
2315 session=session,
2316 mime="image/jpeg",
2317 )
2318
2319 await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 640, "height": 480})
2320 await screencast.attach_consumer(
2321 lambda frame: delivered.append(frame),
2322 lambda: stopped.append(True),
2323 )
2324
2325 def fail_jpeg_probe(_data):
2326 raise RuntimeError("jpeg probe failed")
2327
2328 screencast._jpeg_size = fail_jpeg_probe
2329 session.handlers["Page.screencastFrame"](
2330 {"data": SMALL_JPEG_10X10, "metadata": {}, "sessionId": 29}
2331 )
2332 await asyncio.sleep(0)
2333 await asyncio.sleep(0)
2334
2335 assert delivered == []
2336 assert stopped == [True]
2337 assert screencast.stopped is True
2338 assert ("Page.screencastFrameAck", {"sessionId": 29}) in session.sent
2339
2340 await screencast.stop()
2341
2342 assert ("Page.stopScreencast", {}) in session.sent
2343 assert session.detached is True
2344
2345
2346 @pytest.mark.anyio
2347 async def test_browser_screencast_stop_notifies_consumer_once():
2348 class FakeSession:
2349 def __init__(self):
2350 self.handlers = {}
2351 self.sent = []
2352
2353 def on(self, event, handler):
2354 self.handlers[event] = handler
2355
2356 async def send(self, method, params=None):
2357 self.sent.append((method, params or {}))
2358
2359 async def detach(self):
2360 pass
2361
2362 session = FakeSession()
2363 stopped = []
2364 screencast = _BrowserScreencast(
2365 stream_id="stream",
2366 browser_id=7,
2367 session=session,
2368 mime="image/jpeg",
2369 )
2370
2371 await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 640, "height": 480})
2372 await screencast.attach_consumer(lambda frame: None, lambda: stopped.append(True))
2373 await screencast.stop()
2374 await screencast.stop()
2375
2376 assert stopped == [True]
2377 assert ("Page.stopScreencast", {}) in session.sent
2378
2379
2380 @pytest.mark.anyio
2381 async def test_browser_screencast_attach_consumer_flushes_queued_frame():
2382 class FakeSession:
2383 def __init__(self):
2384 self.handlers = {}
2385 self.sent = []
2386
2387 def on(self, event, handler):
2388 self.handlers[event] = handler
2389
2390 async def send(self, method, params=None):
2391 self.sent.append((method, params or {}))
2392
2393 async def detach(self):
2394 pass
2395
2396 session = FakeSession()
2397 delivered = []
2398 delivery = concurrent.futures.Future()
2399 delivery.set_result(None)
2400 screencast = _BrowserScreencast(
2401 stream_id="stream",
2402 browser_id=7,
2403 session=session,
2404 mime="image/jpeg",
2405 )
2406
2407 await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 640, "height": 480})
2408 session.handlers["Page.screencastFrame"](
2409 {"data": SMALL_JPEG_10X10, "metadata": {}, "sessionId": 31}
2410 )
2411 await asyncio.sleep(0)
2412
2413 await screencast.attach_consumer(lambda frame: delivered.append(frame) or delivery)
2414
2415 assert delivered
2416 assert delivered[0]["image"] == SMALL_JPEG_10X10
2417 assert await screencast.pop_frame() is None
2418
2419 await screencast.stop()
2420
2421
2422 @pytest.mark.anyio
2423 async def test_browser_screencast_passes_wrong_viewport_frames_to_frontend_validator():
2424 class FakeSession:
2425 def __init__(self):
2426 self.handlers = {}
2427 self.sent = []
2428
2429 def on(self, event, handler):
2430 self.handlers[event] = handler
2431
2432 async def send(self, method, params=None):
2433 self.sent.append((method, params or {}))
2434
2435 async def detach(self):
2436 pass
2437
2438 session = FakeSession()
2439 screencast = _BrowserScreencast(
2440 stream_id="stream",
2441 browser_id=7,
2442 session=session,
2443 mime="image/jpeg",
2444 )
2445
2446 await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 1118, "height": 662})
2447 for session_id in range(1, 14):
2448 session.handlers["Page.screencastFrame"](
2449 {"data": SMALL_JPEG_10X10, "metadata": {}, "sessionId": session_id}
2450 )
2451 await asyncio.sleep(0)
2452
2453 frame = await screencast.pop_frame()
2454
2455 assert frame is not None
2456 assert frame["image"] == SMALL_JPEG_10X10
2457 assert frame["metadata"]["jpegWidth"] == 10
2458 assert frame["metadata"]["jpegHeight"] == 10
2459 assert frame["metadata"]["expectedWidth"] == 1118
2460 assert frame["metadata"]["expectedHeight"] == 662
2461 assert ("Page.screencastFrameAck", {"sessionId": 13}) in session.sent
2462
2463 await screencast.stop()
2464
2465
2466 def test_browser_docker_installs_full_chromium_to_tmp_cache():
2467 script = (
2468 PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_playwright.sh"
2469 ).read_text(encoding="utf-8")
2470 requirements = (PROJECT_ROOT / "requirements.txt").read_text(encoding="utf-8")
2471
2472 assert "PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright" in script
2473 assert "patchright install chromium --no-shell" in script
2474 assert "playwright install chromium" not in script
2475 assert "uv pip install" not in script
2476 assert "patchright==1.61.2" in requirements
2477 assert "--only-shell" not in script
2478
2479 runtime = (PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py").read_text(
2480 encoding="utf-8"
2481 )
2482 assert "from patchright.async_api import async_playwright" in runtime
2483 assert "from playwright.async_api import async_playwright" not in runtime
2484 assert runtime.index("hooks.prepare_playwright_cache()") < runtime.index(
2485 "from patchright.async_api import async_playwright"
2486 )
2487 install_additional = (
2488 PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh"
2489 ).read_text(encoding="utf-8")
2490 assert '"headless": not bool(browser_display)' in runtime
2491 assert 'launch_kwargs["env"] = {**os.environ, "DISPLAY": browser_display}' in runtime
2492 assert 'launch_kwargs["no_viewport"] = True' in runtime
2493 assert '"windowState": "fullscreen"' not in runtime
2494 assert "self.interactive_view.ensure_display()" in runtime
2495 assert " xvfb \\" in install_additional
2496 assert " xdotool \\" in install_additional
2497 assert " xclip \\" in install_additional
2498
2499
2500 def test_browser_docker_pins_python_313_compatible_desktop_packages():
2501 install_additional = (
2502 PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh"
2503 ).read_text(encoding="utf-8")
2504
2505 assert 'KALI_SUITE="kali-last-snapshot"' in install_additional
2506 assert 'LIBREOFFICE_VERSION="4:26.2.4.2-1"' in install_additional
2507 assert 'XPRA_VERSION="6.5.2-r0-1"' in install_additional
2508 assert 'XPRA_HTML5_VERSION="19-r1-1"' in install_additional
2509 assert 'XPRA_HTML5_VERSION="21-r1-1"' in install_additional
2510 assert '"python3-uno=$LIBREOFFICE_VERSION"' in install_additional
2511 assert 'apt-get download "gir1.2-atk-1.0=$ATK_VERSION"' in install_additional
2512 assert ' "$ATK_GIR_PACKAGE" \\' in install_additional
2513 assert " gir1.2-gtk-3.0 \\" in install_additional
2514 assert '"xpra-client=$XPRA_VERSION"' in install_additional
2515 assert '"xpra-client-gtk3=$XPRA_VERSION"' in install_additional
2516 assert '"xpra-server=$XPRA_VERSION"' in install_additional
2517 assert '"xpra-html5=$XPRA_HTML5_VERSION"' in install_additional
2518 assert install_additional.index("apt-get download") < install_additional.index('s/kali-rolling/$KALI_SUITE')
2519 assert "https://xpra.org/beta" not in install_additional
2520
2521
2522 def test_browser_startup_migration_prepares_current_playwright_binary():
2523 extension = (
2524 PROJECT_ROOT
2525 / "plugins"
2526 / "_browser"
2527 / "extensions"
2528 / "python"
2529 / "startup_migration"
2530 / "_20_browser_playwright_cache.py"
2531 ).read_text(encoding="utf-8")
2532
2533 assert "class BrowserPlaywrightCacheMigration(Extension)" in extension
2534 assert "virtual_desktop_routes.install_route_hooks()" in extension
2535 assert "hooks.prepare_playwright_cache()" in extension
2536 assert "PrintStyle.warning" in extension
2537
2538
2539
2540
2541 def test_browser_interactive_view_keyboard_options(monkeypatch):
2542 import plugins._browser.helpers.interactive_view as iv_module
2543
2544 view = BrowserInteractiveView("ctx-kb")
2545
2546 # no layout -> no xpra keyboard args
2547 monkeypatch.setattr(
2548 iv_module, "keyboard_options", lambda: {"layout": "", "variant": ""}
2549 )
2550 assert view._keyboard_xpra_args() == []
2551
2552 # layout + variant -> pinned server layout, sync disabled
2553 monkeypatch.setattr(
2554 iv_module,
2555 "keyboard_options",
2556 lambda: {"layout": "de", "variant": "mac"},
2557 )
2558 assert view._keyboard_xpra_args() == [
2559 "--keyboard-sync=no",
2560 "--keyboard-layout", "de",
2561 "--keyboard-variant", "mac",
2562 ]
2563
2564
2565 def test_browser_interactive_view_applies_keyboard_layout(monkeypatch):
2566 commands = []
2567
2568 def fake_run(command, **kwargs):
2569 commands.append(list(command))
2570 return SimpleNamespace(returncode=0)
2571
2572 monkeypatch.setattr(browser_interactive_view_module.subprocess, "run", fake_run)
2573 monkeypatch.setattr(
2574 browser_interactive_view_module.shutil,
2575 "which",
2576 lambda name: "/usr/bin/setxkbmap" if name == "setxkbmap" else None,
2577 )
2578 monkeypatch.setattr(
2579 browser_interactive_view_module,
2580 "keyboard_options",
2581 lambda: {"layout": "de", "variant": "mac"},
2582 )
2583
2584 view = BrowserInteractiveView("ctx-kb-apply")
2585 view.display = 42
2586 view._apply_keyboard_layout()
2587
2588 assert commands == [["/usr/bin/setxkbmap", "-display", ":42", "-layout", "de", "-variant", "mac"]]
2589
2590 # no configured layout -> no setxkbmap call
2591 monkeypatch.setattr(
2592 browser_interactive_view_module,
2593 "keyboard_options",
2594 lambda: {"layout": "", "variant": ""},
2595 )
2596 view._apply_keyboard_layout()
2597 assert len(commands) == 1
2598
2599
2600 def test_browser_interactive_views_use_isolated_loopback_sessions(monkeypatch, tmp_path):
2601 class FakeProcess:
2602 def __init__(self):
2603 self.returncode = None
2604
2605 def poll(self):
2606 return self.returncode
2607
2608 def terminate(self):
2609 self.returncode = 0
2610
2611 def wait(self, timeout=None):
2612 return self.returncode
2613
2614 next_display = iter((71, 72))
2615 next_port = iter((44001, 44002))
2616
2617 def fake_ensure_display(view):
2618 view.display = next(next_display)
2619 view._xvfb = FakeProcess()
2620 return view.display_name
2621
2622 def fake_resize(view, width, height):
2623 view.width, view.height = virtual_desktop.normalize_size(width, height)
2624 return {"ok": True, "width": view.width, "height": view.height}
2625
2626 def fake_start_xpra(view, xpra):
2627 assert xpra == "/usr/bin/xpra"
2628 view.port = next(next_port)
2629 view._xpra = FakeProcess()
2630
2631 monkeypatch.setattr(
2632 browser_interactive_view_module.files,
2633 "get_abs_path",
2634 lambda *parts: str(tmp_path.joinpath(*parts)),
2635 )
2636 monkeypatch.setattr(BrowserInteractiveView, "ensure_display", fake_ensure_display)
2637 monkeypatch.setattr(BrowserInteractiveView, "resize", fake_resize)
2638 monkeypatch.setattr(BrowserInteractiveView, "_start_xpra", fake_start_xpra)
2639 monkeypatch.setattr(
2640 browser_interactive_view_module,
2641 "collect_status",
2642 lambda: {
2643 "available": True,
2644 "missing": [],
2645 "binaries": {"xpra": "/usr/bin/xpra"},
2646 "xpra_html_root": "/usr/share/xpra/www",
2647 },
2648 )
2649
2650 views = [BrowserInteractiveView("ctx-a"), BrowserInteractiveView("ctx-b")]
2651 try:
2652 viewers = [view.ensure_viewer(1200, 700) for view in views]
2653
2654 assert views[0].token != views[1].token
2655 assert views[0].display != views[1].display
2656 assert all(viewer["available"] for viewer in viewers)
2657 for view, viewer in zip(views, viewers, strict=True):
2658 endpoint = virtual_desktop.proxy_for_token(view.token)
2659 assert endpoint is not None
2660 assert endpoint.host == "127.0.0.1"
2661 assert endpoint.owner == "browser"
2662 assert endpoint.port == view.port
2663 assert viewer["token"] == view.token
2664 assert "file_transfer=false" in viewer["url"]
2665 assert "printing=false" in viewer["url"]
2666 finally:
2667 for view in views:
2668 view.close()
2669
2670 assert all(virtual_desktop.proxy_for_token(view.token) is None for view in views)
2671
2672
2673 @pytest.mark.anyio
2674 async def test_browser_interactive_viewer_reuses_the_automated_page():
2675 class FakePage:
2676 viewport_size = {"width": 1024, "height": 768}
2677
2678 class FakeInteractiveView:
2679 def __init__(self):
2680 self.ensure_calls = []
2681
2682 def ensure_viewer(self, width, height):
2683 self.ensure_calls.append((width, height))
2684 return {
2685 "available": True,
2686 "token": "browser-token",
2687 "url": "/desktop/session/browser-token/",
2688 "width": width,
2689 "height": height,
2690 }
2691
2692 page = FakePage()
2693 interactive_view = FakeInteractiveView()
2694 viewport_calls = []
2695 stopped = []
2696 core = _BrowserRuntimeCore("ctx")
2697 core.context = object()
2698 core.pages[7] = BrowserPage(id=7, page=page)
2699 core.interactive_view = interactive_view
2700
2701 async def ensure_started():
2702 return None
2703
2704 async def stop_screencasts(browser_id):
2705 stopped.append(browser_id)
2706
2707 async def set_viewport(browser_id, width, height, **kwargs):
2708 viewport_calls.append((browser_id, width, height, kwargs))
2709 return {"state": {"id": browser_id}, "viewport": {"width": width, "height": height}}
2710
2711 core.ensure_started = ensure_started
2712 core._stop_screencasts_for_browser = stop_screencasts
2713 core.set_viewport = set_viewport
2714
2715 result = await core.interactive_viewer(7, width=1200, height=700)
2716
2717 assert core.pages[7].page is page
2718 assert interactive_view.ensure_calls == [(1200, 700)]
2719 assert stopped == [7]
2720 assert viewport_calls == [(7, 1200, 700, {"resize_interactive": True})]
2721 assert core.last_interacted_browser_id == 7
2722 assert result["url"] == "/desktop/session/browser-token/"
2723
2724
2725 @pytest.mark.anyio
2726 async def test_browser_interactive_window_clips_chrome_without_fullscreen():
2727 commands = []
2728
2729 class FakeSession:
2730 async def send(self, method, params=None):
2731 commands.append((method, params))
2732 if method == "Browser.getWindowForTarget":
2733 return {"windowId": 7}
2734 if method == "Browser.getWindowBounds":
2735 return {
2736 "bounds": {
2737 "windowState": "normal",
2738 "left": 0,
2739 "top": 0,
2740 "width": 1024,
2741 "height": 768,
2742 }
2743 }
2744 return {}
2745
2746 async def detach(self):
2747 commands.append(("detach", None))
2748
2749 class FakeContext:
2750 async def new_cdp_session(self, page):
2751 assert page is fake_page
2752 return FakeSession()
2753
2754 class FakeInteractiveView:
2755 display = 0
2756 width = 1200
2757 height = 700
2758
2759 class FakePage:
2760 async def evaluate(self, script, **kwargs):
2761 commands.append(("evaluate", script))
2762 return 87
2763
2764 fake_page = FakePage()
2765 core = _BrowserRuntimeCore("ctx")
2766 core.context = FakeContext()
2767 core.interactive_view = FakeInteractiveView()
2768
2769 await core._fit_browser_window(fake_page)
2770 core.interactive_view.width = 1280
2771 core.interactive_view.height = 720
2772 await core._fit_browser_window(fake_page)
2773 await core._reset_browser_window_session()
2774
2775 assert commands == [
2776 ("Browser.getWindowForTarget", None),
2777 ("Browser.getWindowBounds", {"windowId": 7}),
2778 (
2779 "evaluate",
2780 "() => Math.max(0, globalThis.outerHeight - globalThis.innerHeight)",
2781 ),
2782 (
2783 "Browser.setWindowBounds",
2784 {
2785 "windowId": 7,
2786 "bounds": {
2787 "windowState": "normal",
2788 "left": 0,
2789 "top": -87,
2790 "width": 1200,
2791 "height": 787,
2792 },
2793 },
2794 ),
2795 (
2796 "Browser.setWindowBounds",
2797 {
2798 "windowId": 7,
2799 "bounds": {
2800 "windowState": "normal",
2801 "left": 0,
2802 "top": -87,
2803 "width": 1280,
2804 "height": 807,
2805 },
2806 },
2807 ),
2808 ("detach", None),
2809 ]
2810
2811
2812 @pytest.mark.anyio
2813 async def test_browser_viewer_falls_back_when_interactive_runtime_is_unavailable():
2814 calls = []
2815
2816 class FakeRuntime:
2817 async def call(self, method, *args, **kwargs):
2818 calls.append((method, args, kwargs))
2819 return {"available": False, "error": "xpra unavailable"}
2820
2821 handler = ws_browser_module.WsBrowser(SimpleNamespace(), threading.RLock(), manager=None)
2822 transport, viewer = await handler._effective_viewer(
2823 FakeRuntime(),
2824 7,
2825 {
2826 "viewer_transport": "interactive",
2827 "viewport_width": 1200,
2828 "viewport_height": 700,
2829 },
2830 )
2831
2832 assert transport == ws_browser_module.VIEWER_TRANSPORT_SCREENCAST
2833 assert viewer == {"available": False, "error": "xpra unavailable"}
2834 assert calls == [("interactive_viewer", (7,), {"width": 1200, "height": 700})]
2835
2836
2837 def test_browser_runtime_removes_stale_profile_singletons(monkeypatch, tmp_path):
2838 monkeypatch.setattr(
2839 browser_runtime_module.files,
2840 "get_abs_path",
2841 lambda *parts: str(tmp_path.joinpath(*parts)),
2842 )
2843 core = _BrowserRuntimeCore("stale-profile")
2844 core.profile_dir.mkdir(parents=True)
2845
2846 for name in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
2847 (core.profile_dir / name).symlink_to("missing-host-999999")
2848
2849 core._release_orphaned_profile_singleton()
2850
2851 assert not any(
2852 (core.profile_dir / name).exists() or (core.profile_dir / name).is_symlink()
2853 for name in ("SingletonLock", "SingletonCookie", "SingletonSocket")
2854 )
2855
2856
2857 @pytest.mark.anyio
2858 async def test_browser_first_open_reuses_headful_bootstrap_page(monkeypatch):
2859 class BootstrapPage:
2860 url = "about:blank"
2861
2862 @staticmethod
2863 def is_closed():
2864 return False
2865
2866 page = BootstrapPage()
2867
2868 class Context:
2869 pages = [page]
2870
2871 @staticmethod
2872 async def new_page():
2873 raise AssertionError("The first headful tab must reuse Chrome's bootstrap page")
2874
2875 core = _BrowserRuntimeCore("headful")
2876 core.context = Context()
2877 core._bootstrap_page = page
2878
2879 async def register(registered_page, context_id=None):
2880 assert registered_page is page
2881 assert context_id == "headful"
2882 browser_page = BrowserPage(id=1, page=registered_page, context_id=context_id or "")
2883 core.pages[1] = browser_page
2884 return browser_page
2885
2886 async def settle(_page, short=False):
2887 return None
2888
2889 async def state(browser_id):
2890 return {"id": browser_id, "currentUrl": "about:blank"}
2891
2892 core._register_page = register
2893 core._settle = settle
2894 core._state = state
2895 monkeypatch.setattr(
2896 browser_runtime_module,
2897 "get_browser_config",
2898 lambda: {"default_homepage": "about:blank", "max_open_tabs": 8},
2899 )
2900
2901 result = await core.open()
2902
2903 assert result == {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}}
2904 assert core._bootstrap_page is None
2905
2906
2907 @pytest.mark.anyio
2908 async def test_browser_runtime_restarts_when_cached_context_is_stale():
2909 starts = []
2910 stopped = []
2911
2912 class StaleContext:
2913 @property
2914 def pages(self):
2915 raise RuntimeError("Target page, context or browser has been closed")
2916
2917 class LiveContext:
2918 pages = []
2919
2920 class FakePlaywright:
2921 async def stop(self):
2922 stopped.append(True)
2923
2924 core = _BrowserRuntimeCore("ctx")
2925 core.context = StaleContext()
2926 core.playwright = FakePlaywright()
2927 core.pages[4] = browser_runtime_module.BrowserPage(id=4, page=object())
2928 core.last_interacted_browser_id = 4
2929
2930 async def fake_start():
2931 starts.append(True)
2932 core.context = LiveContext()
2933
2934 core._start = fake_start
2935
2936 await core.ensure_started()
2937
2938 assert starts == [True]
2939 assert stopped == [True]
2940 assert isinstance(core.context, LiveContext)
2941 assert core.pages == {}
2942 assert core.last_interacted_browser_id is None
2943
2944
2945 def test_browser_runtime_context_close_event_clears_cached_state():
2946 core = _BrowserRuntimeCore("ctx")
2947 core.context = object()
2948 core.pages[4] = browser_runtime_module.BrowserPage(id=4, page=object())
2949 core.last_interacted_browser_id = 4
2950
2951 core._on_context_closed()
2952
2953 assert core.context is None
2954 assert core.pages == {}
2955 assert core.last_interacted_browser_id is None
2956
2957
2958 def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch):
2959 restarted = []
2960
2961 monkeypatch.setattr(
2962 browser_hooks_module,
2963 "_load_saved_browser_config",
2964 lambda project_name="", agent_profile="": {
2965 "extension_paths": [],
2966 "proxy_server": "",
2967 },
2968 )
2969 monkeypatch.setattr(
2970 browser_hooks_module,
2971 "close_all_runtimes_sync",
2972 lambda: restarted.append(True),
2973 )
2974
2975 result = browser_hooks_module.save_plugin_config(
2976 {
2977 "extension_paths": [],
2978 "proxy_server": "http://proxy.example:3128",
2979 },
2980 project_name="",
2981 agent_profile="",
2982 )
2983
2984 assert result["proxy_server"] == "http://proxy.example:3128"
2985 assert result["model_preset"] == ""
2986 assert restarted == [True]
2987
2988
2989 def test_browser_save_plugin_config_does_not_restart_runtimes_for_preset_only(monkeypatch):
2990 restarted = []
2991
2992 monkeypatch.setattr(
2993 browser_hooks_module,
2994 "_load_saved_browser_config",
2995 lambda project_name="", agent_profile="": {
2996 "extension_paths": [],
2997 "model_preset": "",
2998 },
2999 )
3000 monkeypatch.setattr(
3001 browser_hooks_module,
3002 "close_all_runtimes_sync",
3003 lambda: restarted.append(True),
3004 )
3005
3006 result = browser_hooks_module.save_plugin_config(
3007 {
3008 "extension_paths": [],
3009 "model_preset": "Research",
3010 },
3011 project_name="",
3012 agent_profile="",
3013 )
3014
3015 assert result["model_preset"] == "Research"
3016 assert restarted == []
3017
3018
3019 def test_browser_save_plugin_config_does_not_restart_runtimes_for_viewer_settings(monkeypatch):
3020 restarted = []
3021
3022 monkeypatch.setattr(
3023 browser_hooks_module,
3024 "_load_saved_browser_config",
3025 lambda project_name="", agent_profile="": {
3026 "extension_paths": [],
3027 "browser_tab_scope": "per_context",
3028 "max_open_tabs": 32,
3029 },
3030 )
3031 monkeypatch.setattr(
3032 browser_hooks_module,
3033 "close_all_runtimes_sync",
3034 lambda: restarted.append(True),
3035 )
3036
3037 result = browser_hooks_module.save_plugin_config(
3038 {
3039 "extension_paths": [],
3040 "browser_tab_scope": "shared",
3041 "max_open_tabs": 12,
3042 },
3043 project_name="",
3044 agent_profile="",
3045 )
3046
3047 assert result["browser_tab_scope"] == "shared"
3048 assert result["max_open_tabs"] == 12
3049 assert restarted == []
3050
3051
3052 @pytest.mark.anyio
3053 async def test_browser_tool_dispatches_direct_actions(monkeypatch):
3054 calls = []
3055
3056 class FakeRuntime:
3057 async def call(self, method, *args):
3058 calls.append((method, args))
3059 if method == "content":
3060 return {"document": "[link 1] Example"}
3061 return {"ok": True, "method": method, "args": args}
3062
3063 async def fake_get_runtime(context_id, create=True, agent=None):
3064 del create, agent
3065 assert context_id == "ctx"
3066 return FakeRuntime()
3067
3068 monkeypatch.setattr(browser_tool_module, "get_runtime", fake_get_runtime)
3069 agent = SimpleNamespace(context=SimpleNamespace(id="ctx"))
3070 tool = browser_tool_module.Browser(
3071 agent=agent,
3072 name="browser",
3073 method=None,
3074 args={},
3075 message="",
3076 loop_data=None,
3077 )
3078
3079 response = await tool.execute(action="content", browser_id=1)
3080
3081 assert response.message == "[link 1] Example"
3082 assert calls == [("content", (1, None))]
3083
3084
3085 @pytest.mark.anyio
3086 async def test_browser_tool_dispatches_v1_agent_actions(monkeypatch):
3087 calls = []
3088
3089 class FakeRuntime:
3090 async def call(self, method, *args, **kwargs):
3091 calls.append((method, args, kwargs))
3092 return {"ok": True, "method": method, "args": args, "kwargs": kwargs}
3093
3094 async def fake_get_runtime(context_id, create=True, agent=None):
3095 del create, agent
3096 assert context_id == "ctx"
3097 return FakeRuntime()
3098
3099 monkeypatch.setattr(browser_tool_module, "get_runtime", fake_get_runtime)
3100 agent = SimpleNamespace(context=SimpleNamespace(id="ctx"))
3101
3102 async def execute(**kwargs):
3103 tool = browser_tool_module.Browser(
3104 agent=agent,
3105 name="browser",
3106 method=kwargs.pop("_method", None),
3107 args={},
3108 message="",
3109 loop_data=None,
3110 )
3111 response = await tool.execute(**kwargs)
3112 assert response.break_loop is False
3113
3114 await execute(action="screenshot", browser_id=1, quality=91, full_page=True, path="/tmp/a.jpg")
3115 await execute(action="hover", browser_id=1, ref=2, offset_x=3, offset_y=4)
3116 await execute(action="double_click", browser_id=1, x=10, y=20, button="left", modifiers=["Shift"])
3117 await execute(action="right_click", browser_id=1, ref=3, modifiers="Control")
3118 await execute(action="drag", browser_id=1, ref=4, target_ref=5, target_offset_x=6, target_offset_y=7)
3119 await execute(action="wheel", browser_id=1, x=8, y=9, delta_x=1, delta_y=2)
3120 await execute(action="keyboard", browser_id=1, key="Enter")
3121 await execute(_method="clipboard", action="paste", browser_id=1, text="hello")
3122 await execute(action="copy", browser_id=1)
3123 await execute(action="set_viewport", browser_id=1, width=1280, height=720)
3124 await execute(action="select_option", browser_id=1, ref=6, value="CA")
3125 await execute(action="set_checked", browser_id=1, ref=7, checked=False)
3126 await execute(action="upload_file", browser_id=1, ref=8, paths=["/tmp/a.txt"])
3127 await execute(action="key_chord", browser_id=1, keys="CTRL+A")
3128 await execute(action="click", browser_id=1, x=10, y=20)
3129 await execute(action="type", browser_id=1, text="agent-zero.ai")
3130
3131 assert calls == [
3132 ("screenshot_file", (1,), {"quality": 91, "full_page": True, "path": "/tmp/a.jpg"}),
3133 ("hover", (1,), {"ref": 2, "x": 0.0, "y": 0.0, "offset_x": 3, "offset_y": 4}),
3134 (
3135 "double_click",
3136 (1,),
3137 {
3138 "ref": None,
3139 "x": 10,
3140 "y": 20,
3141 "button": "left",
3142 "modifiers": ["Shift"],
3143 "offset_x": 0.0,
3144 "offset_y": 0.0,
3145 },
3146 ),
3147 (
3148 "right_click",
3149 (1,),
3150 {
3151 "ref": 3,
3152 "x": 0.0,
3153 "y": 0.0,
3154 "modifiers": ["Control"],
3155 "offset_x": 0.0,
3156 "offset_y": 0.0,
3157 },
3158 ),
3159 (
3160 "drag",
3161 (1,),
3162 {
3163 "ref": 4,
3164 "target_ref": 5,
3165 "x": 0.0,
3166 "y": 0.0,
3167 "to_x": 0.0,
3168 "to_y": 0.0,
3169 "offset_x": 0.0,
3170 "offset_y": 0.0,
3171 "target_offset_x": 6,
3172 "target_offset_y": 7,
3173 },
3174 ),
3175 ("wheel", (1, 8, 9, 1, 2), {}),
3176 ("keyboard", (1,), {"key": "Enter", "text": ""}),
3177 ("clipboard", (1,), {"action": "paste", "text": "hello"}),
3178 ("clipboard", (1,), {"action": "copy", "text": ""}),
3179 ("set_viewport", (1, 1280, 720), {}),
3180 ("select_option", (1, 6), {"value": "CA", "values": None}),
3181 ("set_checked", (1, 7), {"checked": False}),
3182 ("upload_file", (1, 8), {"path": "", "paths": ["/tmp/a.txt"]}),
3183 ("key_chord", (1, ["Control", "A"]), {}),
3184 ("mouse", (1, "click", 10, 20), {"button": "left", "modifiers": None}),
3185 ("keyboard", (1,), {"key": "", "text": "agent-zero.ai"}),
3186 ]
3187
3188
3189 @pytest.mark.anyio
3190 async def test_browser_tool_resolves_selector_for_reference_actions(monkeypatch):
3191 calls = []
3192
3193 class FakeRuntime:
3194 async def call(self, method, *args, **kwargs):
3195 calls.append((method, args, kwargs))
3196 if method == "content":
3197 return {"input.browser-address": "[input text 31] Browser address"}
3198 return {"ok": True, "method": method, "args": args, "kwargs": kwargs}
3199
3200 async def fake_get_runtime(context_id, create=True, agent=None):
3201 del create, agent
3202 assert context_id == "ctx"
3203 return FakeRuntime()
3204
3205 monkeypatch.setattr(browser_tool_module, "get_runtime", fake_get_runtime)
3206 tool = browser_tool_module.Browser(
3207 agent=SimpleNamespace(context=SimpleNamespace(id="ctx")),
3208 name="browser",
3209 method=None,
3210 args={},
3211 message="",
3212 loop_data=None,
3213 )
3214
3215 response = await tool.execute(
3216 action="type_submit",
3217 browser_id=1,
3218 selector="input.browser-address",
3219 text="agent-zero.ai",
3220 )
3221
3222 assert response.break_loop is False
3223 assert calls == [
3224 ("content", (1, {"selector": "input.browser-address"}), {}),
3225 ("type_submit", (1, "31", "agent-zero.ai"), {}),
3226 ]
3227
3228
3229 @pytest.mark.anyio
3230 async def test_browser_runtime_multi_accepts_human_shaped_input_calls():
3231 calls = []
3232 core = _BrowserRuntimeCore("ctx")
3233
3234 async def fake_mouse(browser_id, event_type, x, y, *, button="left", modifiers=None):
3235 calls.append(("mouse", browser_id, event_type, x, y, button, modifiers))
3236 return {"ok": True}
3237
3238 async def fake_keyboard(browser_id, *, key="", text=""):
3239 calls.append(("keyboard", browser_id, key, text))
3240 return {"ok": True}
3241
3242 async def fake_key_chord(browser_id, keys):
3243 calls.append(("key_chord", browser_id, keys))
3244 return {"ok": True}
3245
3246 core.mouse = fake_mouse
3247 core.keyboard = fake_keyboard
3248 core.key_chord = fake_key_chord
3249
3250 assert await core._dispatch_call({"action": "click", "browser_id": 1, "x": 10, "y": 20}) == {"ok": True}
3251 assert await core._dispatch_call({"action": "type", "browser_id": 1, "text": "agent-zero.ai"}) == {"ok": True}
3252 assert await core._dispatch_call({"action": "key_chord", "browser_id": 1, "keys": "CTRL+A"}) == {"ok": True}
3253
3254 assert calls == [
3255 ("mouse", 1, "click", 10.0, 20.0, "left", None),
3256 ("keyboard", 1, "", "agent-zero.ai"),
3257 ("key_chord", 1, ["Control", "A"]),
3258 ]
3259
3260
3261 @pytest.mark.anyio
3262 async def test_browser_tool_records_static_history_screenshot(monkeypatch, tmp_path):
3263 calls = []
3264
3265 class FakeRuntime:
3266 async def call(self, method, *args, **kwargs):
3267 calls.append((method, args, kwargs))
3268 if method == "open":
3269 return {
3270 "id": 1,
3271 "state": {
3272 "id": 1,
3273 "context_id": "browser-context",
3274 "currentUrl": "https://example.com/",
3275 "title": "Example Domain",
3276 },
3277 }
3278 if method == "screenshot_file":
3279 return {
3280 "browser_id": args[0],
3281 "ephemeral": True,
3282 "ephemeral_ref": "a0-ephemeral-image://fake",
3283 "mime": "image/jpeg",
3284 "state": {"id": args[0], "context_id": "browser-context"},
3285 }
3286 raise AssertionError(method)
3287
3288 async def fake_get_runtime(context_id, create=True, agent=None):
3289 del create, agent
3290 assert context_id == "chat"
3291 return FakeRuntime()
3292
3293 class FakeLog:
3294 id = "tool-log-id"
3295
3296 def __init__(self):
3297 self.updates = []
3298
3299 def update(self, **kwargs):
3300 self.updates.append(kwargs)
3301
3302 monkeypatch.setattr(browser_tool_module, "get_runtime", fake_get_runtime)
3303 monkeypatch.setitem(
3304 sys.modules,
3305 "helpers.persist_chat",
3306 SimpleNamespace(
3307 get_chat_folder_path=lambda context_id: str(tmp_path / "usr" / "chats" / context_id)
3308 ),
3309 )
3310
3311 log = FakeLog()
3312 tool = browser_tool_module.Browser(
3313 agent=SimpleNamespace(context=SimpleNamespace(id="chat")),
3314 name="browser",
3315 method=None,
3316 args={"action": "open", "url": "https://example.com"},
3317 message="",
3318 loop_data=None,
3319 )
3320 tool.log = log
3321
3322 response = await tool.execute(action="open", url="https://example.com")
3323
3324 assert response.break_loop is False
3325 assert calls[0] == ("open", ("https://example.com",), {})
3326 assert calls[1][0] == "screenshot_file"
3327 assert calls[1][1] == (1,)
3328 assert calls[1][2]["quality"] == browser_tool_module.HISTORY_SCREENSHOT_QUALITY
3329 assert calls[1][2]["full_page"] is False
3330 assert calls[1][2]["path"] == ""
3331 assert "Screenshot" not in log.updates[-1]
3332 assert log.updates[-1]["browser_snapshot"]["browser_id"] == 1
3333 assert log.updates[-1]["browser_snapshot"]["context_id"] == "chat"
3334 assert log.updates[-1]["browser_snapshot"]["browser_context_id"] == "browser-context"
3335 assert log.updates[-1]["browser_snapshot"]["ephemeral"] is True
3336 assert log.updates[-1]["browser_snapshot"]["ephemeral_ref"] == "a0-ephemeral-image://fake"
3337
3338
3339 @pytest.mark.anyio
3340 async def test_browser_multi_dispatch_accepts_v1_actions():
3341 calls = []
3342 core = _BrowserRuntimeCore("ctx")
3343
3344 async def record(method):
3345 async def inner(*args, **kwargs):
3346 calls.append((method, args, kwargs))
3347 return {"method": method}
3348 return inner
3349
3350 for method in (
3351 "screenshot_file",
3352 "hover",
3353 "double_click",
3354 "right_click",
3355 "drag",
3356 "wheel",
3357 "keyboard",
3358 "clipboard",
3359 "set_viewport",
3360 "select_option",
3361 "set_checked",
3362 "upload_file",
3363 ):
3364 setattr(core, method, await record(method))
3365
3366 results = await core.multi(
3367 [
3368 {"action": "screenshot", "browser_id": 1, "quality": 5, "full_page": True},
3369 {"action": "hover", "browser_id": 1, "ref": 2},
3370 {"action": "double_click", "browser_id": 1, "x": 1, "y": 2},
3371 {"action": "right_click", "browser_id": 1, "ref": 3},
3372 {"action": "drag", "browser_id": 1, "ref": 4, "target_ref": 5},
3373 {"action": "wheel", "browser_id": 1, "delta_y": 100},
3374 {"action": "keyboard", "browser_id": 1, "key": "Enter"},
3375 {"action": "paste", "browser_id": 1, "text": "x"},
3376 {"action": "set_viewport", "browser_id": 1, "width": 640, "height": 480},
3377 {"action": "select_option", "browser_id": 1, "ref": 6, "values": ["a", "b"]},
3378 {"action": "set_checked", "browser_id": 1, "ref": 7, "checked": False},
3379 {"action": "upload_file", "browser_id": 1, "ref": 8, "path": "/tmp/file.txt"},
3380 ]
3381 )
3382
3383 assert all(result["ok"] for result in results)
3384 assert [call[0] for call in calls] == [
3385 "screenshot_file",
3386 "hover",
3387 "double_click",
3388 "right_click",
3389 "drag",
3390 "wheel",
3391 "keyboard",
3392 "clipboard",
3393 "set_viewport",
3394 "select_option",
3395 "set_checked",
3396 "upload_file",
3397 ]
3398
3399
3400 @pytest.mark.anyio
3401 async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch):
3402 class FakeRuntime:
3403 def __init__(self) -> None:
3404 self.opened = False
3405
3406 async def call(self, method, *args):
3407 if method == "list":
3408 if self.opened:
3409 return {
3410 "browsers": [{"id": 1, "currentUrl": "about:blank", "title": ""}],
3411 "last_interacted_browser_id": 1,
3412 }
3413 return {"browsers": [], "last_interacted_browser_id": None}
3414 if method == "open":
3415 self.opened = True
3416 return {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}}
3417 raise AssertionError(method)
3418
3419 fake_runtime = FakeRuntime()
3420
3421 async def fake_get_runtime(context_id, create=True):
3422 assert context_id == "ctx"
3423 assert create is False
3424 return fake_runtime
3425
3426 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3427 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3428 monkeypatch.setattr(
3429 ws_browser_module.AgentContext,
3430 "get",
3431 staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
3432 )
3433
3434 handler = ws_browser_module.WsBrowser(
3435 SimpleNamespace(),
3436 threading.RLock(),
3437 manager=None,
3438 )
3439
3440 result = await handler.process(
3441 "browser_viewer_subscribe",
3442 {"context_id": "ctx", "correlationId": "c1"},
3443 "sid-1",
3444 )
3445
3446 assert result["context_id"] == "ctx"
3447 assert result["active_browser_id"] is None
3448 assert fake_runtime.opened is False
3449 assert ("sid-1", "ctx") in ws_browser_module.WsBrowser._streams
3450
3451 await handler.on_disconnect("sid-1")
3452
3453 assert ("sid-1", "ctx") not in ws_browser_module.WsBrowser._streams
3454
3455
3456 @pytest.mark.anyio
3457 async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monkeypatch):
3458 class FakeRuntime:
3459 def __init__(self) -> None:
3460 self.opened = False
3461
3462 async def call(self, method, *args):
3463 if method == "list":
3464 if self.opened:
3465 return {
3466 "browsers": [{"id": 1, "currentUrl": "about:blank", "title": ""}],
3467 "last_interacted_browser_id": 1,
3468 }
3469 return {"browsers": [], "last_interacted_browser_id": None}
3470 if method == "open":
3471 self.opened = True
3472 return {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}}
3473 raise AssertionError(method)
3474
3475 fake_runtime = FakeRuntime()
3476
3477 async def fake_get_runtime(context_id, create=True):
3478 assert context_id == "ctx"
3479 assert create is True
3480 return fake_runtime
3481
3482 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3483 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3484 monkeypatch.setattr(
3485 ws_browser_module.AgentContext,
3486 "get",
3487 staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
3488 )
3489
3490 handler = ws_browser_module.WsBrowser(
3491 SimpleNamespace(),
3492 threading.RLock(),
3493 manager=None,
3494 )
3495
3496 result = await handler.process(
3497 "browser_viewer_subscribe",
3498 {"context_id": "ctx", "create_browser": True},
3499 "sid-create",
3500 )
3501
3502 assert result["active_browser_id"] == 1
3503 assert fake_runtime.opened is True
3504
3505 await handler.on_disconnect("sid-create")
3506
3507
3508 @pytest.mark.anyio
3509 async def test_browser_viewer_subscribe_returns_initial_snapshot(monkeypatch):
3510 calls = []
3511
3512 class FakeRuntime:
3513 async def call(self, method, *args, **kwargs):
3514 calls.append((method, args, kwargs))
3515 if method == "list":
3516 return {
3517 "browsers": [{"id": 1, "context_id": "ctx", "currentUrl": "https://example.com/"}],
3518 "last_interacted_browser_id": 1,
3519 }
3520 if method == "set_viewport":
3521 return {"state": {"id": args[0], "currentUrl": "https://example.com/"}}
3522 if method == "screenshot":
3523 return {
3524 "browser_id": args[0],
3525 "mime": "image/jpeg",
3526 "image": "jpeg-data",
3527 "state": {"id": args[0], "context_id": "ctx", "currentUrl": "https://example.com/"},
3528 }
3529 raise AssertionError(method)
3530
3531 async def fake_get_runtime(context_id, create=True):
3532 assert context_id == "ctx"
3533 assert create is False
3534 return FakeRuntime()
3535
3536 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3537 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3538 monkeypatch.setattr(
3539 ws_browser_module.AgentContext,
3540 "get",
3541 staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
3542 )
3543
3544 handler = ws_browser_module.WsBrowser(
3545 SimpleNamespace(),
3546 threading.RLock(),
3547 manager=None,
3548 )
3549
3550 result = await handler.process(
3551 "browser_viewer_subscribe",
3552 {
3553 "context_id": "ctx",
3554 "browser_id": 1,
3555 "viewport_width": 900,
3556 "viewport_height": 600,
3557 },
3558 "sid-snapshot",
3559 )
3560
3561 assert result["active_browser_id"] == 1
3562 assert result["snapshot"]["image"] == "jpeg-data"
3563 assert result["browsers"] == [{"id": 1, "context_id": "ctx", "currentUrl": "https://example.com/"}]
3564 assert result["all_browsers"] is False
3565 assert result["tab_scope"] == "per_context"
3566 assert ("screenshot", (1,), {"quality": ws_browser_module.SCREENSHOT_QUALITY}) in calls
3567
3568 await handler.on_disconnect("sid-snapshot")
3569
3570
3571 @pytest.mark.anyio
3572 async def test_browser_viewer_subscribe_skips_snapshot_for_interactive(monkeypatch):
3573 calls = []
3574
3575 class FakeRuntime:
3576 async def call(self, method, *args, **kwargs):
3577 calls.append(method)
3578 if method == "list":
3579 return {
3580 "browsers": [{"id": 1, "context_id": "ctx", "currentUrl": "about:blank"}],
3581 "last_interacted_browser_id": 1,
3582 }
3583 if method == "interactive_viewer":
3584 return {"available": True, "browser_id": 1, "url": "/desktop/session/test/"}
3585 if method == "screenshot":
3586 raise AssertionError("Interactive Browser must not wait for a redundant screenshot")
3587 raise AssertionError(method)
3588
3589 async def fake_get_runtime(context_id, create=True):
3590 assert context_id == "ctx"
3591 assert create is False
3592 return FakeRuntime()
3593
3594 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3595 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3596 monkeypatch.setattr(
3597 ws_browser_module.AgentContext,
3598 "get",
3599 staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
3600 )
3601
3602 handler = ws_browser_module.WsBrowser(SimpleNamespace(), threading.RLock(), manager=None)
3603 result = await handler.process(
3604 "browser_viewer_subscribe",
3605 {"context_id": "ctx", "browser_id": 1, "viewer_transport": "interactive"},
3606 "sid-interactive",
3607 )
3608
3609 assert result["viewer_transport"] == "interactive"
3610 assert result["snapshot"] is None
3611 assert "screenshot" not in calls
3612 await handler.on_disconnect("sid-interactive")
3613
3614
3615 @pytest.mark.anyio
3616 async def test_browser_viewer_subscribe_without_runtime_does_not_create_runtime(monkeypatch):
3617 async def fake_get_runtime(context_id, create=True):
3618 assert context_id == "ctx"
3619 assert create is False
3620 return None
3621
3622 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3623 monkeypatch.setattr(ws_browser_module, "has_restorable_browser_tabs", lambda context_id: False)
3624 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3625 monkeypatch.setattr(
3626 ws_browser_module.AgentContext,
3627 "get",
3628 staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
3629 )
3630
3631 handler = ws_browser_module.WsBrowser(
3632 SimpleNamespace(),
3633 threading.RLock(),
3634 manager=None,
3635 )
3636
3637 result = await handler.process(
3638 "browser_viewer_subscribe",
3639 {"context_id": "ctx"},
3640 "sid-empty",
3641 )
3642
3643 assert result["active_browser_id"] is None
3644 assert result["browsers"] == []
3645 assert ("sid-empty", "ctx") not in ws_browser_module.WsBrowser._streams
3646
3647
3648 @pytest.mark.anyio
3649 async def test_browser_viewer_subscribe_starts_runtime_for_saved_tabs(monkeypatch):
3650 calls = []
3651
3652 class FakeRuntime:
3653 async def call(self, method, *args, **kwargs):
3654 if method == "list":
3655 return {
3656 "browsers": [
3657 {"id": 4, "context_id": "ctx", "currentUrl": "https://example.com/"}
3658 ],
3659 "last_interacted_browser_id": 4,
3660 }
3661 if method == "interactive_viewer":
3662 return {"available": True, "browser_id": 4, "url": "/desktop/session/test/"}
3663 raise AssertionError(method)
3664
3665 async def fake_get_runtime(context_id, create=True):
3666 calls.append(create)
3667 return FakeRuntime() if create else None
3668
3669 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
3670 monkeypatch.setattr(ws_browser_module, "has_restorable_browser_tabs", lambda context_id: True)
3671 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
3672 monkeypatch.setattr(
3673 ws_browser_module.AgentContext,
3674 "get",
3675 staticmethod(lambda context_id: SimpleNamespace(id=context_id)),
3676 )
3677
3678 handler = ws_browser_module.WsBrowser(SimpleNamespace(), threading.RLock(), manager=None)
3679 result = await handler.process(
3680 "browser_viewer_subscribe",
3681 {"context_id": "ctx", "viewer_transport": "interactive"},
3682 "sid-restore",
3683 )
3684
3685 assert calls[:2] == [False, True]
3686 assert result["active_browser_id"] == 4
3687 assert result["browsers"][0]["currentUrl"] == "https://example.com/"
3688 await handler.on_disconnect("sid-restore")
3689
3690
3691 @pytest.mark.anyio
3692 async def test_browser_runtime_sessions_are_context_qualified(monkeypatch):
3693 class FakeRuntime:
3694 async def call(self, method, *args, **kwargs):
3695 assert method == "list"
3696 return {
3697 "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "https://example.com/"}],
3698 "last_interacted_browser_id": 1,
3699 }
3700
3701 with browser_runtime_module._runtime_lock:
3702 previous_runtimes = dict(browser_runtime_module._runtimes)
3703 browser_runtime_module._runtimes.clear()
3704 browser_runtime_module._runtimes["ctx-a"] = FakeRuntime()
3705 try:
3706 sessions = await list_runtime_sessions()
3707 finally:
3708 with browser_runtime_module._runtime_lock:
3709 browser_runtime_module._runtimes.clear()
3710 browser_runtime_module._runtimes.update(previous_runtimes)
3711
3712 assert sessions == [
3713 {
3714 "context_id": "ctx-a",
3715 "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "https://example.com/"}],
3716 "last_interacted_browser_id": 1,
3717 }
3718 ]
3719
3720
3721 @pytest.mark.anyio
3722 async def test_shared_runtime_session_list_includes_restored_inactive_chats(monkeypatch):
3723 class FakeSharedRuntime:
3724 async def call_for(self, context_id, method):
3725 assert context_id == browser_runtime_module.SHARED_RUNTIME_ID
3726 assert method == "list_all"
3727 return {
3728 "browsers": [
3729 {"id": 1, "context_id": "ctx-a", "currentUrl": "https://example.com/"},
3730 {"id": 2, "context_id": "ctx-b", "currentUrl": "https://example.org/"},
3731 ],
3732 "last_interacted_browser_ids": {"ctx-a": 1, "ctx-b": 2},
3733 }
3734
3735 monkeypatch.setattr(
3736 browser_runtime_module,
3737 "get_browser_config",
3738 lambda: {"browser_tab_scope": "shared"},
3739 )
3740 with browser_runtime_module._runtime_lock:
3741 previous_runtimes = dict(browser_runtime_module._runtimes)
3742 previous_shared = browser_runtime_module._shared_runtime
3743 browser_runtime_module._runtimes.clear()
3744 browser_runtime_module._shared_runtime = FakeSharedRuntime()
3745 try:
3746 sessions = await list_runtime_sessions()
3747 finally:
3748 with browser_runtime_module._runtime_lock:
3749 browser_runtime_module._runtimes.clear()
3750 browser_runtime_module._runtimes.update(previous_runtimes)
3751 browser_runtime_module._shared_runtime = previous_shared
3752
3753 assert [session["context_id"] for session in sessions] == ["ctx-a", "ctx-b"]
3754 assert [session["last_interacted_browser_id"] for session in sessions] == [1, 2]
3755
3756
3757 @pytest.mark.anyio
3758 async def test_browser_context_handles_share_one_runtime(monkeypatch):
3759 class FakeSharedRuntime:
3760 instances = []
3761
3762 def __init__(self, context_id):
3763 self.context_id = context_id
3764 self.calls = []
3765 self.closed = []
3766 self.instances.append(self)
3767
3768 async def call_for(self, context_id, method, *args, **kwargs):
3769 self.calls.append((context_id, method, args, kwargs))
3770 return {"context_id": context_id, "method": method}
3771
3772 async def close(self, delete_profile=False):
3773 self.closed.append(delete_profile)
3774
3775 monkeypatch.setattr(browser_runtime_module, "BrowserRuntime", FakeSharedRuntime)
3776 with browser_runtime_module._runtime_lock:
3777 previous_runtimes = dict(browser_runtime_module._runtimes)
3778 previous_shared = browser_runtime_module._shared_runtime
3779 browser_runtime_module._runtimes.clear()
3780 browser_runtime_module._shared_runtime = None
3781 try:
3782 first = await browser_runtime_module.get_runtime("ctx-a")
3783 second = await browser_runtime_module.get_runtime("ctx-b")
3784
3785 assert first is not second
3786 assert first._runtime is second._runtime
3787 assert len(FakeSharedRuntime.instances) == 1
3788 assert FakeSharedRuntime.instances[0].context_id == browser_runtime_module.SHARED_RUNTIME_ID
3789 assert await first.call("list") == {"context_id": "ctx-a", "method": "list"}
3790 assert await second.call("open", "https://example.org/") == {
3791 "context_id": "ctx-b",
3792 "method": "open",
3793 }
3794
3795 await browser_runtime_module.close_runtime("ctx-a", delete_profile=True)
3796 assert browser_runtime_module._shared_runtime is FakeSharedRuntime.instances[0]
3797 assert FakeSharedRuntime.instances[0].calls[-1][:2] == ("ctx-a", "close_context")
3798
3799 await browser_runtime_module.close_all_runtimes()
3800 assert FakeSharedRuntime.instances[0].closed == [False]
3801 finally:
3802 with browser_runtime_module._runtime_lock:
3803 browser_runtime_module._runtimes.clear()
3804 browser_runtime_module._runtimes.update(previous_runtimes)
3805 browser_runtime_module._shared_runtime = previous_shared
3806
3807
3808 @pytest.mark.anyio
3809 async def test_shared_browser_runtime_keeps_tabs_context_scoped():
3810 class FakePage:
3811 def __init__(self, url):
3812 self.url = url
3813 self.closed = False
3814
3815 async def title(self):
3816 return self.url
3817
3818 async def evaluate(self, script, **kwargs):
3819 return 1
3820
3821 async def close(self):
3822 self.closed = True
3823
3824 core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3825 page_a = FakePage("https://example.com/")
3826 page_b = FakePage("https://example.org/")
3827 core.context = object()
3828 core.pages = {
3829 1: BrowserPage(1, page_a, "ctx-a"),
3830 2: BrowserPage(2, page_b, "ctx-b"),
3831 }
3832 core._set_last_interacted("ctx-a", 1)
3833 core._set_last_interacted("ctx-b", 2)
3834
3835 token = core.request_context_id.set("ctx-a")
3836 try:
3837 listing = await core.list()
3838 assert [browser["id"] for browser in listing["browsers"]] == [1]
3839 assert listing["last_interacted_browser_id"] == 1
3840 with pytest.raises(KeyError, match="Browser 2 is not open"):
3841 await core.state(2)
3842 await core.close_context()
3843 finally:
3844 core.request_context_id.reset(token)
3845
3846 assert page_a.closed is True
3847 assert page_b.closed is False
3848 token = core.request_context_id.set("ctx-b")
3849 try:
3850 listing = await core.list()
3851 assert [browser["id"] for browser in listing["browsers"]] == [2]
3852 assert listing["browsers"][0]["context_id"] == "ctx-b"
3853 finally:
3854 core.request_context_id.reset(token)
3855
3856
3857 @pytest.mark.anyio
3858 async def test_shared_browser_runtime_matches_popup_to_its_opener_context():
3859 core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3860 opener_a = object()
3861 opener_b = object()
3862 core.pages = {
3863 1: BrowserPage(1, opener_a, "ctx-a"),
3864 2: BrowserPage(2, opener_b, "ctx-b"),
3865 }
3866 loop = asyncio.get_running_loop()
3867 waiter_a = loop.create_future()
3868 waiter_b = loop.create_future()
3869 core._pending_popups = [waiter_a, waiter_b]
3870 core._pending_popup_contexts = {
3871 waiter_a: "ctx-a",
3872 waiter_b: "ctx-b",
3873 }
3874
3875 class Popup:
3876 async def opener(self):
3877 return opener_b
3878
3879 context_id = await core._new_page_context_id(Popup())
3880
3881 assert context_id == "ctx-b"
3882 assert core._pop_pending_popup(context_id) is waiter_b
3883 assert core._pending_popups == [waiter_a]
3884
3885
3886 def test_explicit_open_claims_page_registered_by_playwright_event():
3887 class Page:
3888 @staticmethod
3889 def on(event, callback):
3890 assert event in {"close", "framenavigated"}
3891
3892 core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3893 page = Page()
3894 registered = core._register_page_locked(
3895 page,
3896 "ctx-a",
3897 )
3898 core._set_last_interacted("ctx-a", registered.id)
3899
3900 token = core.request_context_id.set("ctx-b")
3901 try:
3902 claimed = core._register_page_locked(page, "ctx-b")
3903 finally:
3904 core.request_context_id.reset(token)
3905
3906 assert claimed is registered
3907 assert claimed.context_id == "ctx-b"
3908 assert core._last_interacted_browser_ids.get("ctx-a") is None
3909
3910
3911 @pytest.mark.anyio
3912 async def test_browser_restores_per_context_then_all_shared_tabs(monkeypatch):
3913 saved = []
3914 browser_config = {"browser_tab_scope": "per_context", "max_open_tabs": 32}
3915
3916 class Page:
3917 def __init__(self):
3918 self.url = "about:blank"
3919
3920 @staticmethod
3921 def is_closed():
3922 return False
3923
3924 @staticmethod
3925 def on(event, callback):
3926 assert event in {"close", "framenavigated"}
3927
3928 class Context:
3929 async def new_page(self):
3930 return Page()
3931
3932 async def goto(page, url, **kwargs):
3933 page.url = url
3934
3935 monkeypatch.setattr(
3936 browser_runtime_module,
3937 "get_browser_config",
3938 lambda: browser_config,
3939 )
3940 monkeypatch.setattr(
3941 browser_runtime_module,
3942 "_save_browser_tabs",
3943 lambda tabs: saved.append(tabs),
3944 )
3945
3946 core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3947 core.context = Context()
3948 core._restore_state_loaded = True
3949 core._restore_state_exists = True
3950 core._restore_entries = [
3951 {"context_id": "ctx-a", "url": "https://example.com/one", "active": True},
3952 {"context_id": "ctx-b", "url": "https://example.org/two", "active": True},
3953 ]
3954 monkeypatch.setattr(core, "_goto", goto)
3955
3956 token = core.request_context_id.set("ctx-a")
3957 try:
3958 await core._restore_tabs_for_scope()
3959 finally:
3960 core.request_context_id.reset(token)
3961
3962 assert [page.context_id for page in core.pages.values()] == ["ctx-a"]
3963 assert [entry["context_id"] for entry in saved[-1]] == ["ctx-b", "ctx-a"]
3964
3965 browser_config["browser_tab_scope"] = "shared"
3966 token = core.request_context_id.set("ctx-a")
3967 try:
3968 await core._restore_tabs_for_scope()
3969 finally:
3970 core.request_context_id.reset(token)
3971
3972 assert [page.context_id for page in core.pages.values()] == ["ctx-a", "ctx-b"]
3973 assert {entry["url"] for entry in saved[-1]} == {
3974 "https://example.com/one",
3975 "https://example.org/two",
3976 }
3977 assert core._restored_all is True
3978
3979
3980 def test_unexpected_browser_exit_keeps_last_saved_tabs(monkeypatch):
3981 saved = []
3982 restore_entries = [
3983 {"context_id": "ctx-a", "url": "https://example.com/", "active": True}
3984 ]
3985 monkeypatch.setattr(
3986 browser_runtime_module,
3987 "_save_browser_tabs",
3988 lambda tabs: saved.append(tabs),
3989 )
3990
3991 core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
3992 core.context = object()
3993 core._restore_state_loaded = True
3994 core._restore_entries = restore_entries
3995 core.pages = {1: BrowserPage(1, SimpleNamespace(url="https://example.com/"), "ctx-a")}
3996
3997 core._on_context_closed()
3998
3999 assert saved == []
4000 assert core._restore_entries == restore_entries
4001 assert core.pages == {}
4002
4003
4004 def test_shared_browser_runtime_adopts_first_requesting_legacy_profile(monkeypatch, tmp_path):
4005 monkeypatch.setattr(
4006 browser_runtime_module.files,
4007 "get_abs_path",
4008 lambda *parts: str(tmp_path.joinpath(*parts)),
4009 )
4010 legacy_profile = tmp_path / "tmp" / "browser" / "sessions" / "ctx-a"
4011 (legacy_profile / "Default").mkdir(parents=True)
4012 (legacy_profile / "Default" / "Cookies").write_bytes(b"legacy-session")
4013
4014 core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
4015 core._adopt_legacy_profile("ctx-a")
4016
4017 assert not legacy_profile.exists()
4018 assert (core.profile_dir / "Default" / "Cookies").read_bytes() == b"legacy-session"
4019
4020
4021 @pytest.mark.anyio
4022 async def test_browser_runtime_refuses_new_tabs_when_context_limit_is_reached(monkeypatch):
4023 core = _BrowserRuntimeCore("ctx-limit")
4024 core.pages = {
4025 1: BrowserPage(1, SimpleNamespace()),
4026 2: BrowserPage(2, SimpleNamespace()),
4027 }
4028
4029 async def fake_ensure_started():
4030 return None
4031
4032 monkeypatch.setattr(core, "ensure_started", fake_ensure_started)
4033 monkeypatch.setattr(
4034 browser_runtime_module,
4035 "get_browser_config",
4036 lambda: {"max_open_tabs": 2, "default_homepage": "about:blank"},
4037 )
4038
4039 with pytest.raises(RepairableException, match="Browser tab limit reached"):
4040 await core.open("https://example.com/")
4041
4042
4043 @pytest.mark.anyio
4044 async def test_browser_viewer_command_returns_only_requested_context_tabs(monkeypatch):
4045 class FakeRuntime:
4046 async def call(self, method, *args, **kwargs):
4047 if method == "list":
4048 return {
4049 "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
4050 "last_interacted_browser_id": 1,
4051 }
4052 raise AssertionError(method)
4053
4054 async def fake_get_runtime(context_id, create=True):
4055 assert context_id == "ctx-a"
4056 return FakeRuntime()
4057
4058 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
4059 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
4060
4061 handler = ws_browser_module.WsBrowser(
4062 SimpleNamespace(),
4063 threading.RLock(),
4064 manager=None,
4065 )
4066
4067 result = await handler.process(
4068 "browser_viewer_command",
4069 {"context_id": "ctx-a", "command": "list"},
4070 "sid-1",
4071 )
4072
4073 assert result["all_browsers"] is False
4074 assert result["tab_scope"] == "per_context"
4075 assert result["active_browser_context_id"] == "ctx-a"
4076 assert result["browsers"] == [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}]
4077
4078
4079 @pytest.mark.anyio
4080 async def test_browser_viewer_command_can_return_shared_context_tabs(monkeypatch):
4081 class FakeRuntime:
4082 async def call(self, method, *args, **kwargs):
4083 if method == "list":
4084 return {
4085 "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
4086 "last_interacted_browser_id": 1,
4087 }
4088 raise AssertionError(method)
4089
4090 async def fake_get_runtime(context_id, create=True):
4091 assert context_id == "ctx-a"
4092 return FakeRuntime()
4093
4094 async def fake_list_runtime_sessions():
4095 return [
4096 {
4097 "context_id": "ctx-a",
4098 "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
4099 "last_interacted_browser_id": 1,
4100 },
4101 {
4102 "context_id": "ctx-b",
4103 "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"}],
4104 "last_interacted_browser_id": 1,
4105 },
4106 ]
4107
4108 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "shared"})
4109 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
4110 monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
4111
4112 handler = ws_browser_module.WsBrowser(
4113 SimpleNamespace(),
4114 threading.RLock(),
4115 manager=None,
4116 )
4117
4118 result = await handler.process(
4119 "browser_viewer_command",
4120 {"context_id": "ctx-a", "command": "list"},
4121 "sid-1",
4122 )
4123
4124 assert result["all_browsers"] is True
4125 assert result["tab_scope"] == "shared"
4126 assert result["active_browser_context_id"] == "ctx-a"
4127 assert [browser["context_id"] for browser in result["browsers"]] == ["ctx-a", "ctx-b"]
4128
4129
4130 @pytest.mark.anyio
4131 async def test_browser_viewer_sessions_lists_only_requested_context_without_creating(monkeypatch):
4132 class FakeRuntime:
4133 async def call(self, method, *args, **kwargs):
4134 assert method == "list"
4135 return {
4136 "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "about:blank"}],
4137 "last_interacted_browser_id": 1,
4138 }
4139
4140 async def fake_get_runtime(context_id, create=True):
4141 assert context_id == "ctx-b"
4142 assert create is False
4143 return FakeRuntime()
4144
4145 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
4146 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "per_context"})
4147
4148 handler = ws_browser_module.WsBrowser(
4149 SimpleNamespace(),
4150 threading.RLock(),
4151 manager=None,
4152 )
4153
4154 result = await handler.process(
4155 "browser_viewer_sessions",
4156 {"context_id": "ctx-b"},
4157 "sid-1",
4158 )
4159
4160 assert result == {
4161 "context_id": "ctx-b",
4162 "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "about:blank"}],
4163 "all_browsers": False,
4164 "tab_scope": "per_context",
4165 }
4166
4167
4168 @pytest.mark.anyio
4169 async def test_browser_viewer_sessions_can_list_shared_context_tabs(monkeypatch):
4170 async def fake_list_runtime_sessions():
4171 return [
4172 {
4173 "context_id": "ctx-a",
4174 "browsers": [{"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"}],
4175 "last_interacted_browser_id": 1,
4176 },
4177 {
4178 "context_id": "ctx-b",
4179 "browsers": [{"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"}],
4180 "last_interacted_browser_id": 1,
4181 },
4182 ]
4183
4184 async def fail_get_runtime(*args, **kwargs):
4185 raise AssertionError("shared sessions refresh must not fetch one runtime")
4186
4187 monkeypatch.setattr(ws_browser_module, "get_browser_config", lambda: {"browser_tab_scope": "shared"})
4188 monkeypatch.setattr(ws_browser_module, "list_runtime_sessions", fake_list_runtime_sessions)
4189 monkeypatch.setattr(ws_browser_module, "get_runtime", fail_get_runtime)
4190
4191 handler = ws_browser_module.WsBrowser(
4192 SimpleNamespace(),
4193 threading.RLock(),
4194 manager=None,
4195 )
4196
4197 result = await handler.process(
4198 "browser_viewer_sessions",
4199 {"context_id": "ctx-b"},
4200 "sid-1",
4201 )
4202
4203 assert result == {
4204 "context_id": "ctx-b",
4205 "browsers": [
4206 {"id": 1, "context_id": "ctx-a", "currentUrl": "about:blank"},
4207 {"id": 1, "context_id": "ctx-b", "currentUrl": "https://example.org/"},
4208 ],
4209 "all_browsers": True,
4210 "tab_scope": "shared",
4211 }
4212
4213
4214 @pytest.mark.anyio
4215 async def test_browser_viewer_viewport_input_dispatches_resize(monkeypatch):
4216 calls = []
4217
4218 class FakeRuntime:
4219 async def call(self, method, *args, **kwargs):
4220 calls.append((method, args, kwargs))
4221 return {"ok": True, "method": method, "args": args}
4222
4223 async def fake_get_runtime(context_id, create=True):
4224 assert context_id == "ctx"
4225 assert create is False
4226 return FakeRuntime()
4227
4228 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
4229
4230 handler = ws_browser_module.WsBrowser(
4231 SimpleNamespace(),
4232 threading.RLock(),
4233 manager=None,
4234 )
4235
4236 result = await handler.process(
4237 "browser_viewer_input",
4238 {
4239 "context_id": "ctx",
4240 "browser_id": 7,
4241 "input_type": "viewport",
4242 "width": 1280,
4243 "height": 720,
4244 "restart_stream": True,
4245 "viewer_transport": "interactive",
4246 },
4247 "sid-1",
4248 )
4249
4250 assert result == {
4251 "state": {"ok": True, "method": "set_viewport", "args": (7, 1280, 720)},
4252 "snapshot": None,
4253 }
4254 assert calls == [
4255 (
4256 "set_viewport",
4257 (7, 1280, 720),
4258 {
4259 "restart_screencast": True,
4260 "resize_interactive": True,
4261 "include_state": False,
4262 },
4263 )
4264 ]
4265
4266
4267 @pytest.mark.anyio
4268 async def test_browser_interactive_resize_uses_native_window_viewport():
4269 fitted = []
4270
4271 class FakePage:
4272 viewport_size = None
4273
4274 async def set_viewport_size(self, viewport):
4275 raise AssertionError("Interactive native viewport must not enable emulation")
4276
4277 class FakeInteractiveView:
4278 display = 0
4279 width = 1024
4280 height = 768
4281
4282 def resize(self, width, height):
4283 self.width = width
4284 self.height = height
4285 return {"ok": True, "width": width, "height": height}
4286
4287 page = FakePage()
4288 core = _BrowserRuntimeCore("ctx")
4289 core.context = object()
4290 core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=page)
4291 core.interactive_view = FakeInteractiveView()
4292
4293 async def fake_fit(fitted_page):
4294 fitted.append(fitted_page)
4295
4296 async def fake_state(browser_id):
4297 return {"id": browser_id}
4298
4299 core._fit_browser_window = fake_fit
4300 core._state = fake_state
4301
4302 result = await core.set_viewport(
4303 7,
4304 1280,
4305 720,
4306 resize_interactive=True,
4307 include_state=False,
4308 )
4309
4310 assert result == {
4311 "state": None,
4312 "viewport": {"width": 1280, "height": 720},
4313 }
4314 assert fitted == [page]
4315
4316
4317 @pytest.mark.anyio
4318 async def test_browser_runtime_restarts_screencast_without_resizing_same_viewport():
4319 viewport_calls = []
4320 stopped = []
4321 settled = []
4322
4323 class FakePage:
4324 viewport_size = {"width": 1280, "height": 720}
4325
4326 async def set_viewport_size(self, viewport):
4327 viewport_calls.append(dict(viewport))
4328
4329 core = _BrowserRuntimeCore("ctx")
4330 core.context = object()
4331 core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=FakePage())
4332
4333 async def fake_stop_screencasts(browser_id):
4334 stopped.append(browser_id)
4335
4336 async def fake_settle(page, short=False):
4337 settled.append(short)
4338
4339 async def fake_state(browser_id):
4340 return {"id": browser_id}
4341
4342 core._stop_screencasts_for_browser = fake_stop_screencasts
4343 core._settle = fake_settle
4344 core._state = fake_state
4345
4346 result = await core.set_viewport(7, 1280, 720, restart_screencast=True)
4347
4348 assert result == {
4349 "state": {"id": 7},
4350 "viewport": {"width": 1280, "height": 720},
4351 }
4352 assert viewport_calls == []
4353 assert stopped == [7]
4354 assert settled == [True]
4355
4356
4357 @pytest.mark.anyio
4358 async def test_browser_runtime_applies_changed_viewport_once():
4359 calls = []
4360 stopped = []
4361 settled = []
4362
4363 class FakePage:
4364 viewport_size = {"width": 1024, "height": 768}
4365
4366 async def set_viewport_size(self, viewport):
4367 calls.append(("viewport", dict(viewport)))
4368
4369 core = _BrowserRuntimeCore("ctx")
4370 core.context = object()
4371 core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=FakePage())
4372
4373 async def fake_stop_screencasts(browser_id):
4374 stopped.append(browser_id)
4375
4376 async def fake_settle(page, short=False):
4377 settled.append(short)
4378
4379 async def fake_state(browser_id):
4380 return {"id": browser_id}
4381
4382 core._stop_screencasts_for_browser = fake_stop_screencasts
4383 core._settle = fake_settle
4384 core._state = fake_state
4385
4386 result = await core.set_viewport(7, 672, 789)
4387
4388 assert result == {
4389 "state": {"id": 7},
4390 "viewport": {"width": 672, "height": 789},
4391 }
4392 assert calls == [
4393 ("viewport", {"width": 672, "height": 789}),
4394 ]
4395 assert stopped == [7]
4396 assert settled == [True]
4397
4398
4399 @pytest.mark.anyio
4400 async def test_browser_runtime_screenshot_file_defaults_to_chat_scoped_artifact(monkeypatch, tmp_path):
4401 screenshot_calls = []
4402
4403 def fake_get_abs_path(*parts):
4404 return str(tmp_path.joinpath(*parts))
4405
4406 def fake_normalize_a0_path(path):
4407 return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
4408
4409 monkeypatch.setattr(browser_runtime_module.files, "get_abs_path", fake_get_abs_path)
4410 monkeypatch.setattr(browser_runtime_module.files, "normalize_a0_path", fake_normalize_a0_path)
4411
4412 class FakePage:
4413 url = "about:blank"
4414 viewport_size = {"width": 1024, "height": 768}
4415
4416 async def screenshot(self, **kwargs):
4417 screenshot_calls.append(kwargs)
4418 if kwargs.get("path"):
4419 Path(kwargs["path"]).parent.mkdir(parents=True, exist_ok=True)
4420 Path(kwargs["path"]).write_bytes(b"image-bytes")
4421 return b"image-bytes"
4422
4423 async def title(self):
4424 return "Blank"
4425
4426 async def evaluate(self, script, payload=None, **kwargs):
4427 return 1
4428
4429 core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
4430 core.context = object()
4431 core.pages[5] = browser_runtime_module.BrowserPage(
4432 id=5,
4433 page=FakePage(),
4434 context_id="ctx/id",
4435 )
4436 token = core.request_context_id.set("ctx/id")
4437
4438 try:
4439 result = await core.screenshot_file(5, quality=500)
4440 finally:
4441 core.request_context_id.reset(token)
4442
4443 assert Path(result["path"]).read_bytes() == b"image-bytes"
4444 assert result["a0_path"].startswith("/a0/usr/chats/ctx_id/screenshots/browser/browser-5-")
4445 assert result["context_id"] == "ctx/id"
4446 assert result["mime"] == "image/jpeg"
4447 assert result["ephemeral"] is False
4448 assert result["chat_scoped"] is True
4449 assert result["vision_load"] == {
4450 "tool_name": "vision_load",
4451 "tool_args": {"paths": [result["a0_path"]]},
4452 }
4453 assert "image" not in result
4454 assert not list((tmp_path / "tmp" / "browser" / "screenshots").rglob("*.jpg"))
4455 assert screenshot_calls[-1]["type"] == "jpeg"
4456 assert screenshot_calls[-1]["quality"] == 95
4457 assert screenshot_calls[-1]["full_page"] is False
4458 assert "path" not in screenshot_calls[-1]
4459
4460 png_path = tmp_path / "custom.png"
4461 token = core.request_context_id.set("ctx/id")
4462 try:
4463 png_result = await core.screenshot_file(5, quality=1, full_page=True, path=str(png_path))
4464 finally:
4465 core.request_context_id.reset(token)
4466
4467 assert png_result["path"] == str(png_path)
4468 assert png_result["mime"] == "image/png"
4469 assert screenshot_calls[-1] == {
4470 "path": str(png_path),
4471 "type": "png",
4472 "full_page": True,
4473 }
4474
4475
4476 @pytest.mark.anyio
4477 async def test_vision_load_materializes_ephemeral_browser_refs(monkeypatch, tmp_path):
4478 monkeypatch.setitem(sys.modules, "helpers.tool", SimpleNamespace(Response=_TestResponse, Tool=_TestTool))
4479 history_stub = ModuleType("helpers.history")
4480
4481 class _RawMessage(dict):
4482 def __init__(self, raw_content, preview):
4483 super().__init__(raw_content=raw_content, preview=preview)
4484
4485 history_stub.RawMessage = _RawMessage
4486 monkeypatch.setitem(sys.modules, "helpers.history", history_stub)
4487 monkeypatch.delitem(sys.modules, "tools.vision_load", raising=False)
4488 import tools.vision_load as vision_load_module
4489
4490 def fake_get_abs_path(*parts):
4491 return str(tmp_path.joinpath(*parts))
4492
4493 def fake_normalize_a0_path(path):
4494 return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
4495
4496 monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
4497 monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
4498 monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": True, "max_embeds": 10})
4499 monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
4500
4501 tool_results = []
4502 messages = []
4503 updates = []
4504 agent = SimpleNamespace(
4505 context=SimpleNamespace(id="ctx-vision"),
4506 agent_name="Agent 0",
4507 hist_add_tool_result=lambda *args, **kwargs: tool_results.append((args, kwargs)),
4508 hist_add_message=lambda *args, **kwargs: messages.append((args, kwargs)),
4509 )
4510 ref = vision_load_module.ephemeral_images.put_image(
4511 context_id="ctx-vision",
4512 mime="image/jpeg",
4513 data=SMALL_JPEG_10X10,
4514 name="browser-shot.jpg",
4515 )
4516 tool = vision_load_module.VisionLoad(
4517 agent=agent,
4518 name="vision_load",
4519 method=None,
4520 args={"paths": [ref]},
4521 message="",
4522 loop_data=None,
4523 )
4524 tool.log = SimpleNamespace(id="vision-log", update=lambda **kwargs: updates.append(kwargs))
4525
4526 response = await tool.execute(paths=[ref])
4527 await tool.after_execution(response)
4528
4529 assert vision_load_module.ephemeral_images.get_image(ref, context_id="ctx-vision") is None
4530 assert tool.loaded_paths == ["browser-shot.jpg"]
4531 raw_message = messages[0][1]["content"]
4532 stored_ref = raw_message["raw_content"][0]["image_url"]["url"]
4533 assert stored_ref.startswith("/a0/usr/chats/ctx-vision/screenshots/browser/browser-shot-")
4534 stored_path = tmp_path / stored_ref.removeprefix("/a0/")
4535 assert stored_path.read_bytes() == __import__("base64").b64decode(SMALL_JPEG_10X10)
4536 assert updates[-1]["content"] == response.message
4537
4538
4539 @pytest.mark.anyio
4540 async def test_browser_runtime_ref_point_resolution_applies_offsets():
4541 eval_payloads = []
4542 moves = []
4543
4544 class FakeMouse:
4545 async def move(self, x, y, **kwargs):
4546 moves.append((x, y, kwargs))
4547
4548 class FakePage:
4549 url = "about:blank"
4550
4551 def __init__(self):
4552 self.mouse = FakeMouse()
4553
4554 async def evaluate(self, script, payload=None, **kwargs):
4555 eval_payloads.append((script, payload))
4556 if payload and "offsets" in payload:
4557 return {
4558 "x": 10 + payload["offsets"]["offset_x"],
4559 "y": 20 + payload["offsets"]["offset_y"],
4560 "rect": {"x": 10, "y": 20, "width": 100, "height": 40},
4561 "selector": "#target",
4562 }
4563 return 1
4564
4565 async def title(self):
4566 return "Blank"
4567
4568 core = _BrowserRuntimeCore("ctx")
4569 core.context = object()
4570 core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=FakePage())
4571 core._ensure_content_helper = lambda _page: asyncio.sleep(0)
4572
4573 result = await core.hover(7, ref=4, offset_x=3, offset_y=5)
4574
4575 assert moves == [(13.0, 25.0, {})]
4576 assert result["action"]["point"]["selector"] == "#target"
4577 assert eval_payloads[0][1] == {
4578 "ref": 4,
4579 "offsets": {
4580 "offset_x": 3.0,
4581 "offset_y": 5.0,
4582 "useOffsets": True,
4583 },
4584 }
4585
4586
4587 def test_browser_runtime_upload_path_normalization(monkeypatch, tmp_path):
4588 first = tmp_path / "first.txt"
4589 second = tmp_path / "second.txt"
4590 first.write_text("one", encoding="utf-8")
4591 second.write_text("two", encoding="utf-8")
4592
4593 monkeypatch.setattr(
4594 browser_runtime_module.files,
4595 "get_abs_path",
4596 lambda *parts: str(tmp_path.joinpath(*parts)),
4597 )
4598
4599 assert _BrowserRuntimeCore._normalize_upload_paths(path=str(first)) == [str(first)]
4600 assert _BrowserRuntimeCore._normalize_upload_paths(paths=["second.txt"]) == [str(second)]
4601 assert _BrowserRuntimeCore._normalize_upload_paths(path=str(first), paths=["second.txt"]) == [
4602 str(second),
4603 str(first),
4604 ]
4605
4606
4607 @pytest.mark.anyio
4608 async def test_browser_runtime_clipboard_paste_uses_dom_bridge():
4609 eval_payloads = []
4610 settled = []
4611
4612 class FakeKeyboard:
4613 def __init__(self):
4614 self.inserted = []
4615
4616 async def insert_text(self, text):
4617 self.inserted.append(text)
4618
4619 class FakePage:
4620 url = "about:blank"
4621
4622 def __init__(self):
4623 self.keyboard = FakeKeyboard()
4624
4625 async def evaluate(self, script, payload=None, **kwargs):
4626 if payload is not None:
4627 eval_payloads.append((script, payload))
4628 return {
4629 "action": "paste",
4630 "text": payload["text"],
4631 "changed": True,
4632 "default_prevented": False,
4633 }
4634 return 1
4635
4636 async def title(self):
4637 return "Blank"
4638
4639 page = FakePage()
4640 core = _BrowserRuntimeCore("ctx")
4641 core.context = object()
4642 core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=page)
4643
4644 async def fake_settle(_page, short=False):
4645 settled.append(short)
4646
4647 core._settle = fake_settle
4648
4649 result = await core.clipboard(7, action="paste", text="hello")
4650
4651 assert result["state"]["id"] == 7
4652 assert result["clipboard"]["changed"] is True
4653 assert result["clipboard"]["text"] == "hello"
4654 assert eval_payloads[0][1] == {"action": "paste", "text": "hello"}
4655 assert "insertFromPaste" in eval_payloads[0][0]
4656 assert page.keyboard.inserted == []
4657 assert settled == [True]
4658
4659
4660 @pytest.mark.anyio
4661 async def test_browser_runtime_clipboard_paste_falls_back_to_keyboard_insert_text():
4662 class FakeKeyboard:
4663 def __init__(self):
4664 self.inserted = []
4665
4666 async def insert_text(self, text):
4667 self.inserted.append(text)
4668
4669 class FakePage:
4670 url = "about:blank"
4671
4672 def __init__(self):
4673 self.keyboard = FakeKeyboard()
4674
4675 async def evaluate(self, script, payload=None, **kwargs):
4676 if payload is not None:
4677 return {
4678 "action": "paste",
4679 "text": payload["text"],
4680 "changed": False,
4681 "default_prevented": False,
4682 }
4683 return 1
4684
4685 async def title(self):
4686 return "Blank"
4687
4688 page = FakePage()
4689 core = _BrowserRuntimeCore("ctx")
4690 core.context = object()
4691 core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=page)
4692 core._settle = lambda _page, short=False: asyncio.sleep(0)
4693
4694 result = await core.clipboard(7, action="paste", text="hello")
4695
4696 assert result["clipboard"]["changed"] is True
4697 assert result["clipboard"]["method"] == "keyboard.insert_text"
4698 assert page.keyboard.inserted == ["hello"]
4699
4700
4701 @pytest.mark.anyio
4702 async def test_browser_viewer_wheel_input_dispatches_scroll(monkeypatch):
4703 calls = []
4704
4705 class FakeRuntime:
4706 async def call(self, method, *args, **kwargs):
4707 calls.append((method, args, kwargs))
4708 return {"ok": True, "method": method, "args": args}
4709
4710 async def fake_get_runtime(context_id, create=True):
4711 assert context_id == "ctx"
4712 assert create is False
4713 return FakeRuntime()
4714
4715 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
4716
4717 handler = ws_browser_module.WsBrowser(
4718 SimpleNamespace(),
4719 threading.RLock(),
4720 manager=None,
4721 )
4722
4723 result = await handler.process(
4724 "browser_viewer_input",
4725 {
4726 "context_id": "ctx",
4727 "browser_id": 3,
4728 "input_type": "wheel",
4729 "x": 320,
4730 "y": 480,
4731 "delta_x": 0,
4732 "delta_y": 640,
4733 },
4734 "sid-1",
4735 )
4736
4737 assert result == {
4738 "state": {"ok": True, "method": "wheel", "args": (3, 320.0, 480.0, 0.0, 640.0)},
4739 "snapshot": None,
4740 }
4741 assert calls == [("wheel", (3, 320.0, 480.0, 0.0, 640.0), {})]
4742
4743
4744 @pytest.mark.anyio
4745 async def test_browser_viewer_clipboard_input_dispatches_runtime(monkeypatch):
4746 calls = []
4747 clipboard = {"action": "paste", "text": "hello", "changed": True}
4748
4749 class FakeRuntime:
4750 async def call(self, method, *args, **kwargs):
4751 calls.append((method, args, kwargs))
4752 return {
4753 "state": {"id": args[0], "currentUrl": "about:blank"},
4754 "clipboard": clipboard,
4755 }
4756
4757 async def fake_get_runtime(context_id, create=True):
4758 assert context_id == "ctx"
4759 assert create is False
4760 return FakeRuntime()
4761
4762 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
4763
4764 handler = ws_browser_module.WsBrowser(
4765 SimpleNamespace(),
4766 threading.RLock(),
4767 manager=None,
4768 )
4769
4770 result = await handler.process(
4771 "browser_viewer_input",
4772 {
4773 "context_id": "ctx",
4774 "browser_id": 3,
4775 "input_type": "clipboard",
4776 "action": "paste",
4777 "text": "hello",
4778 },
4779 "sid-1",
4780 )
4781
4782 assert result == {
4783 "state": {"id": 3, "currentUrl": "about:blank"},
4784 "clipboard": clipboard,
4785 "snapshot": None,
4786 }
4787 assert calls == [
4788 ("clipboard", (3,), {"action": "paste", "text": "hello"})
4789 ]
4790
4791
4792 @pytest.mark.anyio
4793 async def test_browser_viewer_annotation_dispatches_runtime(monkeypatch):
4794 calls = []
4795
4796 class FakeRuntime:
4797 async def call(self, method, *args, **kwargs):
4798 calls.append((method, args, kwargs))
4799 return {
4800 "kind": "element",
4801 "point": {"x": 320, "y": 180},
4802 "target": {"tagName": "BUTTON", "selector": "#save"},
4803 }
4804
4805 async def fake_get_runtime(context_id, create=True):
4806 assert context_id == "ctx"
4807 assert create is False
4808 return FakeRuntime()
4809
4810 monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
4811
4812 handler = ws_browser_module.WsBrowser(
4813 SimpleNamespace(),
4814 threading.RLock(),
4815 manager=None,
4816 )
4817
4818 payload = {
4819 "kind": "element",
4820 "point": {"x": 320, "y": 180},
4821 "viewport": {"width": 1280, "height": 720},
4822 }
4823 result = await handler.process(
4824 "browser_viewer_annotation",
4825 {
4826 "context_id": "ctx",
4827 "browser_id": 4,
4828 "viewer_id": "viewer-1",
4829 "payload": payload,
4830 },
4831 "sid-1",
4832 )
4833
4834 assert result == {
4835 "annotation": {
4836 "kind": "element",
4837 "point": {"x": 320, "y": 180},
4838 "target": {"tagName": "BUTTON", "selector": "#save"},
4839 },
4840 "context_id": "ctx",
4841 "browser_id": 4,
4842 "viewer_id": "viewer-1",
4843 }
4844 assert calls == [("annotation_target", (4, payload), {})]
4845
4846
4847 def test_browser_runtime_normalizes_multi_group_ids_and_modifiers():
4848 core = _BrowserRuntimeCore("ctx")
4849
4850 assert core._multi_group_key({"browser_id": 7}) == 7
4851 assert core._multi_group_key({"browser_id": "7"}) == 7
4852 assert core._multi_group_key({"browser_id": "browser-7"}) == 7
4853 assert core._multi_group_key({"browser_id": ""}) is None
4854 assert core._normalize_modifiers("Control") == ["Control"]
4855 assert core._normalize_modifiers(["Control", " Shift "]) == ["Control", "Shift"]
4856 assert core._normalize_modifiers([]) is None
4857
4858 with pytest.raises(ValueError):
4859 core._normalize_modifiers("Ctrl")
4860
4861
4862 def test_browser_runtime_background_focus_restores_previous_active_tab():
4863 core = _BrowserRuntimeCore("ctx")
4864 core.pages[1] = browser_runtime_module.BrowserPage(id=1, page=object())
4865 core.pages[2] = browser_runtime_module.BrowserPage(id=2, page=object())
4866
4867 assert core._background_focus_target(previous_focus=1, fallback_id=2) == 1
4868
4869 core.pages.pop(1)
4870
4871 assert core._background_focus_target(previous_focus=1, fallback_id=2) == 2
4872
4873
4874 def test_browser_cleanup_extensions_follow_extensible_path_layout():
4875 extension = __import__("helpers.extension", fromlist=["_get_extension_classes"])
4876 remove_classes = extension._get_extension_classes( # type: ignore[attr-defined]
4877 "_functions/agent/AgentContext/remove/start"
4878 )
4879 reset_classes = extension._get_extension_classes( # type: ignore[attr-defined]
4880 "_functions/agent/AgentContext/reset/start"
4881 )
4882
4883 assert any(cls.__name__ == "CleanupBrowserRuntimeOnRemove" for cls in remove_classes)
4884 assert any(cls.__name__ == "CleanupBrowserRuntimeOnReset" for cls in reset_classes)
4885
4886
4887 def test_legacy_browser_dependency_is_removed():
4888 assert not (PROJECT_ROOT / "plugins" / ("_browser" + "_agent")).exists()
4889 assert ("browser" + "-use") not in (PROJECT_ROOT / "requirements.txt").read_text(
4890 encoding="utf-8"
4891 )