| 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="document.body.dataset.clicked = 'yes'; this.textContent = 'Clicked inside frame'"> |
| 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 | ) |