pi-lean-dimension 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +1 -1
  2. package/node_modules/pi-lean-portal/AGENTS.md +15 -23
  3. package/node_modules/pi-lean-portal/README.md +31 -1
  4. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +12 -1
  5. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +263 -1126
  6. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +6 -32
  7. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +1 -1
  8. package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +1 -2
  9. package/node_modules/pi-lean-portal/__tests__/helpers/setup-temp-dir.ts +26 -0
  10. package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +1 -1
  11. package/node_modules/pi-lean-portal/__tests__/plugin-contract.test.ts +1 -1
  12. package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +1 -1
  13. package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +1 -1
  14. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +65 -69
  15. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +1 -14
  16. package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +31 -16
  17. package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +1 -1
  18. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +146 -19
  19. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  20. package/node_modules/pi-lean-portal/backends/python-adapter.ts +3 -3
  21. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +2 -4
  22. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
  23. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
  24. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  25. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  26. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +572 -81
  27. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  28. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +13 -43
  29. package/node_modules/pi-lean-portal/browser-profile.ts +2 -3
  30. package/node_modules/pi-lean-portal/browser-toggle.ts +168 -385
  31. package/node_modules/pi-lean-portal/contributed/CHOOSING.md +2 -2
  32. package/node_modules/pi-lean-portal/contributed/README.md +65 -6
  33. package/node_modules/pi-lean-portal/contributed/camoufox-py/__pycache__/bridge.cpython-312.pyc +0 -0
  34. package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +108 -14
  35. package/node_modules/pi-lean-portal/contributed/camoufox-py/pin.json +4 -0
  36. package/node_modules/pi-lean-portal/core/fetch-backend.ts +10 -30
  37. package/node_modules/pi-lean-portal/core/guides.ts +14 -18
  38. package/node_modules/pi-lean-portal/core/router.ts +2 -20
  39. package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +19 -30
  40. package/node_modules/pi-lean-portal/core/shared/browser-data.ts +4 -4
  41. package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +2 -2
  42. package/node_modules/pi-lean-portal/core/shared/paths.ts +16 -2
  43. package/node_modules/pi-lean-portal/core/shared/settings-reader.ts +2 -7
  44. package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +1 -1
  45. package/node_modules/pi-lean-portal/index.ts +8 -1
  46. package/node_modules/pi-lean-portal/package.json +2 -1
  47. package/node_modules/pi-lean-portal/tools/web-learn.ts +1 -4
  48. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +87 -0
  49. package/node_modules/pi-lean-search/index.ts +134 -64
  50. package/node_modules/pi-lean-search/package.json +2 -1
  51. package/node_modules/playwright/README.md +3 -3
  52. package/node_modules/playwright/lib/agents/copilot-setup-steps.yml +2 -2
  53. package/node_modules/playwright/lib/cli/reportActions.js +2 -2
  54. package/node_modules/playwright/lib/common/index.js +77 -52
  55. package/node_modules/playwright/lib/common/index.js.txt +10 -11
  56. package/node_modules/playwright/lib/errorContext.js +2 -3
  57. package/node_modules/playwright/lib/index.js +30 -8
  58. package/node_modules/playwright/lib/matchers/expect.js +4811 -4379
  59. package/node_modules/playwright/lib/matchers/expect.js.LICENSE +87 -140
  60. package/node_modules/playwright/lib/matchers/expect.js.txt +30 -35
  61. package/node_modules/playwright/lib/program.js +19 -0
  62. package/node_modules/playwright/lib/runner/index.js +309 -153
  63. package/node_modules/playwright/lib/runner/index.js.txt +13 -12
  64. package/node_modules/playwright/lib/transform/babelBundle.js +12899 -11654
  65. package/node_modules/playwright/lib/transform/babelBundle.js.LICENSE +379 -354
  66. package/node_modules/playwright/lib/transform/babelBundle.js.txt +39 -39
  67. package/node_modules/playwright/lib/transform/esmLoader.js +3129 -1116
  68. package/node_modules/playwright/lib/transform/esmLoader.js.LICENSE +81 -61
  69. package/node_modules/playwright/lib/transform/esmLoader.js.txt +21 -18
  70. package/node_modules/playwright/lib/util.js +2 -29
  71. package/node_modules/playwright/lib/worker/workerProcessEntry.js +14 -32
  72. package/node_modules/playwright/lib/worker/workerProcessEntry.js.txt +7 -8
  73. package/node_modules/playwright/package.json +3 -6
  74. package/node_modules/playwright/types/test.d.ts +391 -4
  75. package/node_modules/playwright/types/testReporter.d.ts +79 -4
  76. package/node_modules/playwright-core/browsers.json +12 -18
  77. package/node_modules/playwright-core/lib/bootstrap.js +1 -1
  78. package/node_modules/playwright-core/lib/coreBundle.js +11344 -9900
  79. package/node_modules/playwright-core/lib/server/electron/loader.js +5 -2
  80. package/node_modules/playwright-core/lib/serverRegistry.js +5 -1
  81. package/node_modules/playwright-core/lib/tools/cli-client/help.json +21 -6
  82. package/node_modules/playwright-core/lib/tools/cli-client/program.js +1 -1
  83. package/node_modules/playwright-core/lib/tools/cli-client/session.js +4 -0
  84. package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/SKILL.md +21 -5
  85. package/node_modules/playwright-core/lib/tools/{cli-client/skill/references/spec-driven-testing.md → skills/playwright-cli/references/test-generation.md} +135 -7
  86. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +143 -0
  87. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
  88. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +85 -0
  89. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +67 -0
  90. package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +75 -0
  91. package/node_modules/playwright-core/lib/tools/utils/extension.js +31 -8
  92. package/node_modules/playwright-core/lib/utilsBundle.js +37698 -33670
  93. package/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +391 -268
  94. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-CyWAfh-p.js +11 -0
  95. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-DhC616m4.css +1 -0
  96. package/node_modules/playwright-core/lib/vite/dashboard/index.html +2 -2
  97. package/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -1
  98. package/node_modules/playwright-core/lib/vite/htmlReport/report.js +15 -55
  99. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
  100. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CwYYHbcZ.js +32 -0
  101. package/node_modules/playwright-core/lib/vite/recorder/assets/index-DYjdXIbE.js +129 -0
  102. package/node_modules/playwright-core/lib/vite/recorder/assets/index-l_lX622x.css +1 -0
  103. package/node_modules/playwright-core/lib/vite/recorder/index.html +2 -2
  104. package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-By56iMx7.js +32 -0
  105. package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-B34OrIms.js +181 -0
  106. package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
  107. package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-COQkjINf.js +7 -0
  108. package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
  109. package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.BLFoOugd.css +1 -0
  110. package/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
  111. package/node_modules/playwright-core/lib/vite/traceViewer/index.Dl36UVQT.js +1 -0
  112. package/node_modules/playwright-core/lib/vite/traceViewer/index.html +5 -5
  113. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
  114. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +2 -2
  115. package/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +3 -4
  116. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.C7UW1sC9.css +1 -0
  117. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.D962mr9b.js +5 -0
  118. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +5 -5
  119. package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
  120. package/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  121. package/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  122. package/node_modules/playwright-core/lib/xdg-open +338 -137
  123. package/node_modules/playwright-core/package.json +2 -2
  124. package/node_modules/playwright-core/types/protocol.d.ts +249 -36
  125. package/node_modules/playwright-core/types/structs.d.ts +3 -1
  126. package/node_modules/playwright-core/types/types.d.ts +2009 -266
  127. package/package.json +3 -3
  128. package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +0 -188
  129. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +0 -525
  130. package/node_modules/pi-lean-portal/core/shared/url-safety.ts +0 -164
  131. package/node_modules/playwright-core/lib/server/deviceDescriptorsSource.json +0 -2739
  132. package/node_modules/playwright-core/lib/tools/cli-client/skill/references/test-generation.md +0 -134
  133. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-BY2S1tHT.css +0 -1
  134. package/node_modules/playwright-core/lib/vite/dashboard/assets/index-C_5TMfeg.js +0 -52
  135. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-DYBRYzYX.css +0 -1
  136. package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-DeBYQozu.js +0 -32
  137. package/node_modules/playwright-core/lib/vite/recorder/assets/index-4ZiSSCmn.css +0 -1
  138. package/node_modules/playwright-core/lib/vite/recorder/assets/index-Bq-mQf8S.js +0 -193
  139. package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-LEHpjmcn.js +0 -32
  140. package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-BNmKHKpQ.js +0 -264
  141. package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-BYQrIQwR.js +0 -1
  142. package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-CsJ4vdCR.js +0 -9
  143. package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.DYBRYzYX.css +0 -1
  144. package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.CjdS-WJx.css +0 -1
  145. package/node_modules/playwright-core/lib/vite/traceViewer/index.CzXZzn5A.css +0 -1
  146. package/node_modules/playwright-core/lib/vite/traceViewer/index.DMMX1gXU.js +0 -2
  147. package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.v8KI4P3m.js +0 -2
  148. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.BZQ54Kgt.css +0 -1
  149. package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.Ut8wwJNp.js +0 -6
  150. package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.DYP7pi_n.css +0 -32
  151. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/element-attributes.md +0 -0
  152. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/playwright-tests.md +0 -0
  153. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/request-mocking.md +0 -0
  154. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/running-code.md +0 -0
  155. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/session-management.md +0 -0
  156. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/storage-state.md +0 -0
  157. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/tracing.md +0 -0
  158. /package/node_modules/playwright-core/lib/tools/{cli-client/skill → skills/playwright-cli}/references/video-recording.md +0 -0
  159. /package/node_modules/playwright-core/lib/tools/{trace → skills/playwright-trace}/SKILL.md +0 -0
@@ -1,8 +1,9 @@
1
1
  """
2
- Playwright Bridge Base — shared Playwright logic for Python browser bridges.
2
+ PlaywrightBridge — Python browser automation bridge for Playwright-based backends.
3
3
 
4
- Extracts the Playwright-specific implementation from chromium-py/bridge.py
5
- into a parameterized base class. Subclasses override:
4
+ Provides JSON-RPC command routing, session lifecycle, element caching,
5
+ and a ``run()`` main loop. ``PlaywrightBridge`` is a concrete base
6
+ class that subclasses override with engine-specific settings:
6
7
 
7
8
  * ``_plugin_name`` — e.g. ``"chromium-py"``, ``"firefox-py"``
8
9
  * ``_user_agent`` — fallback UA string (when dynamic probe is disabled or fails)
@@ -12,6 +13,28 @@ into a parameterized base class. Subclasses override:
12
13
 
13
14
  All navigation, interaction, console, cookie, and storage operations
14
15
  are shared across engines.
16
+
17
+ Protocol
18
+ --------
19
+ All communication is JSON-RPC 2.0 over stdin/stdout with newline-delimited
20
+ framing. See ``transport.py`` for details.
21
+
22
+ Lifecycle
23
+ ---------
24
+ 1. The TypeScript ``PythonPluginAdapter`` spawns the Python process.
25
+ 2. The bridge starts its ``run()`` loop, waiting for commands.
26
+ 3. First command is typically ``browser.navigate``, which calls
27
+ ``create_browser_session()`` (if no session exists yet for that taskId)
28
+ and then navigates.
29
+ 4. Subsequent commands use the existing session.
30
+ 5. ``browser.cleanup`` closes the session for a taskId.
31
+ 6. ``browser.shutdown`` (sent via cleanupAll) terminates the process.
32
+
33
+ Named profiles are fully handled by the TypeScript side via
34
+ ``core/shared/storage-state.ts`` (disk persistence). The Python
35
+ bridge receives ``storageState`` in the navigate request and applies
36
+ it when creating a new BrowserContext — it does NOT track shared
37
+ contexts across tasks.
15
38
  """
16
39
 
17
40
  import base64
@@ -20,18 +43,32 @@ import os
20
43
  import re
21
44
  import sys
22
45
  import time
46
+ import traceback
23
47
  from typing import Any, Optional
24
48
  from urllib.parse import unquote as _urlunquote
25
49
 
26
- from .bridge import BrowserBridge, SessionNotFoundError
27
50
  from .bot_detection import check_bot_detection
28
- from .accessibility import parse_snapshot, build_locator_args
51
+ from .accessibility import parse_snapshot, build_locator_args, AriaParseResult
29
52
  from .browser_data import NAV_SETTLE
53
+ from .transport import (
54
+ read_request,
55
+ write_response,
56
+ make_success_response,
57
+ make_error_response,
58
+ make_parse_error,
59
+ make_invalid_request,
60
+ make_application_error,
61
+ InvalidRequestError,
62
+ METHOD_NOT_FOUND,
63
+ INVALID_PARAMS,
64
+ SESSION_ERROR,
65
+ )
30
66
 
31
67
  # ─── Playwright import (lazy, for better error messages) ──────────────
32
68
 
33
69
  try:
34
70
  from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout # type: ignore[import-unresolved]
71
+
35
72
  HAS_PLAYWRIGHT = True
36
73
  except ImportError:
37
74
  HAS_PLAYWRIGHT = False
@@ -39,11 +76,28 @@ except ImportError:
39
76
  PlaywrightTimeout = TimeoutError # type: ignore[misc]
40
77
 
41
78
 
79
+ # ─── Default timeout ──────────────────────────────────────────────────
80
+
81
+ DEFAULT_NAVIGATION_TIMEOUT_MS: int = 30_000
82
+ DEFAULT_INTERACTION_TIMEOUT_MS: int = 10_000
83
+
84
+
42
85
  # ─── Nav-settle constants (loaded from shared browser-data.json) ──────
43
86
 
44
87
  _DOM_STABILIZE_JS: str = NAV_SETTLE["domStabilizationJs"]
45
88
 
46
89
 
90
+ # ─── Custom exceptions ────────────────────────────────────────────────
91
+
92
+
93
+ class SessionNotFoundError(Exception):
94
+ """Raised when an operation requires a session but none exists."""
95
+
96
+
97
+ class InvalidParamsError(Exception):
98
+ """Raised when a required parameter is missing or has the wrong type."""
99
+
100
+
47
101
  # ─── Shared helper for bridge entry points ───────────────────────────
48
102
 
49
103
 
@@ -64,16 +118,20 @@ def check_playwright_or_exit(browser: str) -> None:
64
118
  " pip install playwright\n"
65
119
  f" playwright install {browser}\n"
66
120
  )
67
- print(json.dumps({
68
- "jsonrpc": "2.0",
69
- "id": None,
70
- "error": {"code": -32000, "message": msg.strip()},
71
- }))
121
+ print(
122
+ json.dumps(
123
+ {
124
+ "jsonrpc": "2.0",
125
+ "id": None,
126
+ "error": {"code": -32000, "message": msg.strip()},
127
+ }
128
+ )
129
+ )
72
130
  sys.stdout.flush()
73
131
  sys.exit(1)
74
132
 
75
133
 
76
- class PlaywrightBridge(BrowserBridge):
134
+ class PlaywrightBridge:
77
135
  """Base class for Playwright-based browser bridges.
78
136
 
79
137
  Subclasses must set these class/instance attributes:
@@ -109,14 +167,14 @@ class PlaywrightBridge(BrowserBridge):
109
167
  #: Engine-specific install hint (shown when browser executable missing).
110
168
  _install_hint: str = ""
111
169
 
112
- # ── Stealth quirks (Phase 0) ──────────────────────────────────
170
+ # ── Stealth quirks ───────────────────────────────────────────────
113
171
  #
114
172
  # These opt-in flags let stealth subclasses (Camoufox and other stealth engines)
115
173
  # disable the base's hard-coded Playwright defaults that would otherwise
116
174
  # clobber a fingerprint-managed browser context. All default to ``off``
117
175
  # so the shipped ``chromium-py`` / ``firefox-py`` bridges are bit-identical.
118
176
  #
119
- # See stealth-browser-plan-v3.md "Quirks schema" for the concrete
177
+ # See the portal AGENTS.md quirks table for the concrete
120
178
  # correctness problem each flag fixes.
121
179
 
122
180
  #: When True, ``create_browser_context()`` does NOT pass ``viewport`` or
@@ -134,9 +192,14 @@ class PlaywrightBridge(BrowserBridge):
134
192
  _eval_prefix: str = ""
135
193
 
136
194
  #: When True, ``do_scroll`` uses ``page.mouse.wheel`` instead of
137
- #: ``page.evaluate("window.scrollBy")``. The eval-based scroll is a
138
- #: write that silently no-ops under Camoufox's isolated world; the wheel
139
- #: event performs the scroll via input events instead.
195
+ #: ``page.evaluate("window.scrollBy")``. On older Camoufox binaries
196
+ #: (``135.0.1-beta.24`` and earlier) the eval-based scroll was a write
197
+ #: that silently no-op'd under the isolated world, so the wheel event
198
+ #: was the only path that moved the page. On ``152.0.4-beta.27+`` the
199
+ #: wheel event itself no-ops (the ``wheel`` listener never fires) while
200
+ #: the eval-based ``window.scrollBy`` now works — so the flag must be
201
+ #: ``False`` on the current binary. Default ``False`` (eval scroll);
202
+ #: only set ``True`` for the legacy Camoufox binary.
140
203
  _scroll_via_wheel: bool = False
141
204
 
142
205
  #: When True, ``create_browser_context`` passes ``no_viewport=True`` to
@@ -235,6 +298,43 @@ class PlaywrightBridge(BrowserBridge):
235
298
  #: Default ``False`` so shipped bridges stay bit-identical.
236
299
  _csp_safe_readonly_via_init_script: bool = False
237
300
 
301
+ #: When non-zero, ``_wait_for_navigation_settle`` uses this as the
302
+ #: settle poll budget (ms) instead of the default 400. Stealth backends
303
+ #: whose patched browser fires navigation events with higher latency
304
+ #: (e.g. Camoufox's Juggler) set a larger value to avoid settling before
305
+ #: the navigation commit is observable. Default 400 matches the
306
+ #: historical hard-coded value so shipped chromium-py / firefox-py are
307
+ #: unchanged.
308
+ _settle_budget_ms: int = 400
309
+
310
+ #: When True, ``_wait_for_navigation_settle`` switches the no-nav
311
+ #: polling branch from a fixed budget to "URL stable for 150 ms **or**
312
+ #: budget exhausted". Stealth backends whose patched Juggler fires
313
+ #: ``page.url`` updates with higher latency set this along with a wider
314
+ #: :attr:`_settle_budget_ms` so the settle waits for the URL to
315
+ #: stabilize at the target value before declaring no navigation
316
+ #: occurred. Default ``False`` so shipped chromium-py / firefox-py keep
317
+ #: the current tight settle behaviour.
318
+ _url_stability_settle: bool = False
319
+
320
+ # ── Session storage (persists across RPC calls) ─────────────
321
+
322
+ #: Per-taskId session data: {task_id: {...}}.
323
+ #: The dict contents are backend-specific (page, context, etc.).
324
+ sessions: dict[str, dict[str, Any]]
325
+
326
+ #: Per-taskId element cache: {task_id: AriaParseResult}.
327
+ element_caches: dict[str, AriaParseResult]
328
+
329
+ #: Whether the bridge is still running.
330
+ _running: bool
331
+
332
+ #: Plugin configuration dict forwarded from the TypeScript adapter via
333
+ #: the ``browser.init`` RPC. Defaults to ``{}`` for bridges that never
334
+ #: receive an init call (e.g. older adapters, or the shipped
335
+ #: ``chromium-py``/``firefox-py`` when run standalone). Subclasses read
336
+ #: engine-specific options from ``self.plugin_config.get("launch", {})``.
337
+ _plugin_config: dict[str, Any]
238
338
 
239
339
  # ── Shared Playwright state ─────────────────────────────────
240
340
 
@@ -242,7 +342,10 @@ class PlaywrightBridge(BrowserBridge):
242
342
  _browser: Any # Browser instance (lazy, shared)
243
343
 
244
344
  def __init__(self) -> None:
245
- super().__init__()
345
+ self.sessions = {}
346
+ self.element_caches = {}
347
+ self._running = False
348
+ self._plugin_config = {}
246
349
  self._pw = None
247
350
  self._browser = None
248
351
  self._cached_ua: str = ""
@@ -254,6 +357,52 @@ class PlaywrightBridge(BrowserBridge):
254
357
  # the extractor's stale result.
255
358
  self._readonly_extractor_script: str = ""
256
359
 
360
+ # ── Plugin config (forwarded via browser.init) ───────────────
361
+
362
+ @property
363
+ def plugin_config(self) -> dict[str, Any]:
364
+ """Return the plugin config dict forwarded from the TypeScript adapter.
365
+
366
+ Populated by the ``browser.init`` RPC handler. Always returns a
367
+ dict (empty when no init was received) so subclasses can safely
368
+ call ``self.plugin_config.get("launch", {})``.
369
+ """
370
+ return self._plugin_config
371
+
372
+ # ── Session helpers ─────────────────────────────────────────
373
+
374
+ def get_session(self, task_id: str) -> Optional[dict[str, Any]]:
375
+ """Get the session data for a task, or None."""
376
+ return self.sessions.get(task_id)
377
+
378
+ def require_session(self, task_id: str) -> dict[str, Any]:
379
+ """Get the session for a task, raising SESSION_ERROR if absent."""
380
+ session = self.get_session(task_id)
381
+ if session is None:
382
+ raise SessionNotFoundError(
383
+ f"No active session for task '{task_id}'. Call browser.navigate first."
384
+ )
385
+ return session
386
+
387
+ def ensure_session(
388
+ self, task_id: str, config: Optional[dict[str, Any]] = None
389
+ ) -> dict[str, Any]:
390
+ """Get or create a session for the given task."""
391
+ session = self.get_session(task_id)
392
+ if session is not None:
393
+ return session
394
+ new_session = self.create_browser_session(task_id, config or {})
395
+ self.sessions[task_id] = new_session
396
+ return new_session
397
+
398
+ def get_element_cache(self, task_id: str) -> Optional[AriaParseResult]:
399
+ """Get the cached element parse result for a task, or None."""
400
+ return self.element_caches.get(task_id)
401
+
402
+ def set_element_cache(self, task_id: str, result: AriaParseResult) -> None:
403
+ """Store a parsed element cache for a task."""
404
+ self.element_caches[task_id] = result
405
+
257
406
  # ── Subclass extension point ───────────────────────────────
258
407
 
259
408
  def _launch_browser(self) -> Any:
@@ -304,7 +453,6 @@ class PlaywrightBridge(BrowserBridge):
304
453
  except Exception:
305
454
  pass
306
455
 
307
-
308
456
  # ── Debug logging ───────────────────────────────────────────
309
457
 
310
458
  @property
@@ -345,7 +493,11 @@ class PlaywrightBridge(BrowserBridge):
345
493
  raise RuntimeError(
346
494
  "Playwright is not installed. "
347
495
  "Run: pip install playwright && "
348
- + (self._install_hint.lower() if self._install_hint else "playwright install <browser>")
496
+ + (
497
+ self._install_hint.lower()
498
+ if self._install_hint
499
+ else "playwright install <browser>"
500
+ )
349
501
  )
350
502
  self._pw = sync_playwright().start() # type: ignore[union-attr]
351
503
  try:
@@ -431,8 +583,12 @@ class PlaywrightBridge(BrowserBridge):
431
583
  snapshots=True,
432
584
  sources=True,
433
585
  )
434
- self._log("tracing", taskId=config.get("_task_id", "shared"),
435
- action="start", dir=_trace_dir)
586
+ self._log(
587
+ "tracing",
588
+ taskId=config.get("_task_id", "shared"),
589
+ action="start",
590
+ dir=_trace_dir,
591
+ )
436
592
  except Exception:
437
593
  pass # Best-effort
438
594
 
@@ -542,14 +698,19 @@ class PlaywrightBridge(BrowserBridge):
542
698
 
543
699
  # ── Dialog auto-dismissal ───────────────────────────────
544
700
  dialog_log: list[dict[str, str]] = []
545
- page.on("dialog", lambda dialog: (
546
- dialog_log.append({
547
- "type": dialog.type,
548
- "message": dialog.message[:200],
549
- "handledAs": "accepted",
550
- }),
551
- dialog.accept(),
552
- ))
701
+ page.on(
702
+ "dialog",
703
+ lambda dialog: (
704
+ dialog_log.append(
705
+ {
706
+ "type": dialog.type,
707
+ "message": dialog.message[:200],
708
+ "handledAs": "accepted",
709
+ }
710
+ ),
711
+ dialog.accept(),
712
+ ),
713
+ )
553
714
 
554
715
  return {
555
716
  "page": page,
@@ -605,8 +766,7 @@ class PlaywrightBridge(BrowserBridge):
605
766
  f"trace-{task_id}-{int(time.time() * 1000)}.zip",
606
767
  )
607
768
  context.tracing.stop(path=_trace_path)
608
- self._log("tracing", taskId=task_id, action="stop",
609
- dir=_trace_dir)
769
+ self._log("tracing", taskId=task_id, action="stop", dir=_trace_dir)
610
770
  except Exception:
611
771
  pass
612
772
 
@@ -667,18 +827,22 @@ class PlaywrightBridge(BrowserBridge):
667
827
  parsed = parse_snapshot(snap_text)
668
828
  self.set_element_cache(task_id, parsed)
669
829
 
670
- return parsed.text, parsed.count, {
671
- ref: {
672
- "role": node.role,
673
- "name": node.name,
674
- "props": list(node.props),
675
- "depth": node.depth,
676
- "raw": node.raw,
677
- "occurrenceIndex": node.occurrence_index,
678
- "parentRef": node.parent_ref,
679
- }
680
- for ref, node in parsed.elements.items()
681
- }
830
+ return (
831
+ parsed.text,
832
+ parsed.count,
833
+ {
834
+ ref: {
835
+ "role": node.role,
836
+ "name": node.name,
837
+ "props": list(node.props),
838
+ "depth": node.depth,
839
+ "raw": node.raw,
840
+ "occurrenceIndex": node.occurrence_index,
841
+ "parentRef": node.parent_ref,
842
+ }
843
+ for ref, node in parsed.elements.items()
844
+ },
845
+ )
682
846
 
683
847
  def _build_interaction_result(
684
848
  self, task_id: str, page: Any, **extra: Any
@@ -705,9 +869,7 @@ class PlaywrightBridge(BrowserBridge):
705
869
  getattr(node, "name", "unknown") if node else "unknown",
706
870
  )
707
871
 
708
- def _locate_element(
709
- self, page: Any, task_id: str, ref: str
710
- ) -> Any:
872
+ def _locate_element(self, page: Any, task_id: str, ref: str) -> Any:
711
873
  """Resolve an @e ref to a Playwright locator.
712
874
 
713
875
  Args:
@@ -746,7 +908,9 @@ class PlaywrightBridge(BrowserBridge):
746
908
  # ── Navigation settle helpers ───────────────────────────────
747
909
 
748
910
  @staticmethod
749
- def _wait_for_page_ready(page: Any, timeout_ms: int, skip_networkidle: bool = False) -> None:
911
+ def _wait_for_page_ready(
912
+ page: Any, timeout_ms: int, skip_networkidle: bool = False
913
+ ) -> None:
750
914
  """Wait for page readiness after a navigation.
751
915
 
752
916
  Mirrors the TypeScript ``waitForPageReady`` helper — each load
@@ -772,19 +936,21 @@ class PlaywrightBridge(BrowserBridge):
772
936
  except Exception:
773
937
  pass
774
938
 
775
- @staticmethod
776
939
  def _wait_for_navigation_settle(
940
+ self,
777
941
  page: Any,
778
942
  url_before: str,
779
943
  nav_timeout_ms: int = 5000,
780
- settle_timeout_ms: int = 400,
781
944
  skip_networkidle: bool = False,
782
945
  ) -> tuple[bool, str]:
783
946
  """Wait for navigation to settle after a user interaction.
784
947
 
785
- Replaces fixed ``time.sleep()`` calls that race against navigation
786
- commit. Instead, listens for the ``framenavigated`` event and waits
787
- for page readiness only when a navigation has actually started.
948
+ Listens for the ``framenavigated`` event and waits for page
949
+ readiness only when a navigation has actually started.
950
+
951
+ Uses :attr:`_settle_budget_ms` and :attr:`_url_stability_settle`
952
+ to allow stealth backends with higher event latency (e.g.
953
+ Camoufox's Juggler) to widen the settle window.
788
954
 
789
955
  Args:
790
956
  page: Playwright Page.
@@ -792,8 +958,7 @@ class PlaywrightBridge(BrowserBridge):
792
958
  nav_timeout_ms: Max time (ms) to wait for each page readiness
793
959
  check (load and networkidle each get the full
794
960
  budget). Default: 5000.
795
- settle_timeout_ms: Short settle delay (ms) when no navigation
796
- occurs (default: 400).
961
+ skip_networkidle: When True, skip the ``networkidle`` wait.
797
962
 
798
963
  Returns:
799
964
  ``(navigated, url)`` — whether a main-frame navigation was
@@ -814,19 +979,53 @@ class PlaywrightBridge(BrowserBridge):
814
979
 
815
980
  waited_for_load = False
816
981
  if navigated:
817
- PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
982
+ self._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
818
983
  waited_for_load = True
819
984
  elif page.url != url_before:
820
985
  # URL changed without framenavigated event
821
- PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
986
+ self._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
822
987
  waited_for_load = True
823
988
  else:
824
- # No navigation — settle for client-side rerenders
825
- page.wait_for_timeout(settle_timeout_ms)
989
+ # No navigation yet — poll for a late-starting navigation
990
+ # (e.g. ``setTimeout``-delayed redirects). A fixed blind
991
+ # ``wait_for_timeout`` races the ``framenavigated`` event under
992
+ # load; polling at 50 ms granularity catches the nav as soon
993
+ # as it fires within the settle budget.
994
+ # ponytail: 50 ms poll, budget = self._settle_budget_ms
995
+ # (default 400, Camoufox 2000). Switch to
996
+ # ``page.wait_for_function`` if sub-50 ms latency matters.
997
+ settle_budget = self._settle_budget_ms
998
+ remaining = settle_budget
999
+
1000
+ if self._url_stability_settle:
1001
+ # URL-stability mode: wait for the URL to stabilize at a
1002
+ # new value for 150 ms, or until the budget expires.
1003
+ # Stealth backends with high Juggler event latency (e.g.
1004
+ # Camoufox) benefit from confirming the URL has finished
1005
+ # changing before declaring no navigation occurred.
1006
+ url_stable_since: float | None = None
1007
+ while remaining > 0 and not navigated:
1008
+ page.wait_for_timeout(50)
1009
+ remaining -= 50
1010
+ if navigated:
1011
+ break
1012
+ if page.url != url_before:
1013
+ if url_stable_since is None:
1014
+ url_stable_since = time.monotonic()
1015
+ elif (time.monotonic() - url_stable_since) >= 0.150:
1016
+ break # URL stable for 150 ms
1017
+ else:
1018
+ url_stable_since = None
1019
+ else:
1020
+ # Original tight-poll mode: exit as soon as the URL
1021
+ # changes or the event fires.
1022
+ while remaining > 0 and not navigated and page.url == url_before:
1023
+ page.wait_for_timeout(50)
1024
+ remaining -= 50
826
1025
 
827
1026
  # Late-arrival gate: catch navigations that started during settle
828
1027
  if not waited_for_load and (navigated or page.url != url_before):
829
- PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
1028
+ self._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
830
1029
 
831
1030
  finally:
832
1031
  page.remove_listener("framenavigated", _on_nav)
@@ -940,10 +1139,15 @@ class PlaywrightBridge(BrowserBridge):
940
1139
  title = ""
941
1140
 
942
1141
  if last_error is None:
943
- self._log("navigate", url=url, plugin=self._plugin_name,
944
- success=True, botDetected=bot_detected,
945
- elementCount=element_count,
946
- time=round((time.time() - _t_start) * 1000))
1142
+ self._log(
1143
+ "navigate",
1144
+ url=url,
1145
+ plugin=self._plugin_name,
1146
+ success=True,
1147
+ botDetected=bot_detected,
1148
+ elementCount=element_count,
1149
+ time=round((time.time() - _t_start) * 1000),
1150
+ )
947
1151
  return {
948
1152
  "success": True,
949
1153
  "url": page.url,
@@ -955,10 +1159,16 @@ class PlaywrightBridge(BrowserBridge):
955
1159
  "dialogEvents": self._get_dialog_events(task_id),
956
1160
  }
957
1161
  else:
958
- self._log("navigate", url=url, plugin=self._plugin_name,
959
- success=False, botDetected=bot_detected,
960
- elementCount=element_count, error=last_error,
961
- time=round((time.time() - _t_start) * 1000))
1162
+ self._log(
1163
+ "navigate",
1164
+ url=url,
1165
+ plugin=self._plugin_name,
1166
+ success=False,
1167
+ botDetected=bot_detected,
1168
+ elementCount=element_count,
1169
+ error=last_error,
1170
+ time=round((time.time() - _t_start) * 1000),
1171
+ )
962
1172
  return {
963
1173
  "success": False,
964
1174
  "url": url,
@@ -1011,8 +1221,9 @@ class PlaywrightBridge(BrowserBridge):
1011
1221
  try:
1012
1222
  locator = self._locate_element(page, task_id, ref)
1013
1223
  except RuntimeError as exc:
1014
- self._log("click", taskId=task_id, ref=ref, role=_role,
1015
- name=_name, result="fail")
1224
+ self._log(
1225
+ "click", taskId=task_id, ref=ref, role=_role, name=_name, result="fail"
1226
+ )
1016
1227
  return {"success": False, "error": str(exc)}
1017
1228
 
1018
1229
  try:
@@ -1020,21 +1231,30 @@ class PlaywrightBridge(BrowserBridge):
1020
1231
  locator.click(timeout=5_000)
1021
1232
 
1022
1233
  navigated, _ = self._wait_for_navigation_settle(
1023
- page, url_before, skip_networkidle=self._skip_networkidle,
1234
+ page,
1235
+ url_before,
1236
+ skip_networkidle=self._skip_networkidle,
1024
1237
  )
1025
1238
 
1026
1239
  new_url = page.url
1027
1240
  new_title = page.title()
1028
1241
 
1029
1242
  result = self._build_interaction_result(
1030
- task_id, page, newUrl=new_url, newTitle=new_title,
1243
+ task_id,
1244
+ page,
1245
+ newUrl=new_url,
1246
+ newTitle=new_title,
1031
1247
  )
1032
1248
  except Exception as exc:
1033
1249
  result = {
1034
1250
  "success": False,
1035
1251
  "error": f"Click failed: {exc}",
1036
1252
  }
1037
- return self._log_op("click", {"taskId": task_id, "ref": ref, "role": _role, "name": _name}, result)
1253
+ return self._log_op(
1254
+ "click",
1255
+ {"taskId": task_id, "ref": ref, "role": _role, "name": _name},
1256
+ result,
1257
+ )
1038
1258
 
1039
1259
  def do_type(self, task_id: str, ref: str, text: str) -> dict[str, Any]:
1040
1260
  _role, _name = self._ref_debug_info(task_id, ref)
@@ -1046,8 +1266,9 @@ class PlaywrightBridge(BrowserBridge):
1046
1266
  try:
1047
1267
  locator = self._locate_element(page, task_id, ref)
1048
1268
  except RuntimeError as exc:
1049
- self._log("type", taskId=task_id, ref=ref, role=_role,
1050
- name=_name, result="fail")
1269
+ self._log(
1270
+ "type", taskId=task_id, ref=ref, role=_role, name=_name, result="fail"
1271
+ )
1051
1272
  return {"success": False, "error": str(exc)}
1052
1273
 
1053
1274
  try:
@@ -1060,7 +1281,11 @@ class PlaywrightBridge(BrowserBridge):
1060
1281
  "success": False,
1061
1282
  "error": f"Type failed: {exc}",
1062
1283
  }
1063
- return self._log_op("type", {"taskId": task_id, "ref": ref, "role": _role, "name": _name}, result)
1284
+ return self._log_op(
1285
+ "type",
1286
+ {"taskId": task_id, "ref": ref, "role": _role, "name": _name},
1287
+ result,
1288
+ )
1064
1289
 
1065
1290
  def do_scroll(self, task_id: str, direction: str) -> dict[str, Any]:
1066
1291
  page, err = self._get_page_or_error(task_id, "scroll")
@@ -1087,7 +1312,9 @@ class PlaywrightBridge(BrowserBridge):
1087
1312
  "success": False,
1088
1313
  "error": f"Scroll failed: {exc}",
1089
1314
  }
1090
- return self._log_op("scroll", {"taskId": task_id, "direction": direction}, result)
1315
+ return self._log_op(
1316
+ "scroll", {"taskId": task_id, "direction": direction}, result
1317
+ )
1091
1318
 
1092
1319
  def do_go_back(self, task_id: str) -> dict[str, Any]:
1093
1320
  page, err = self._get_page_or_error(task_id, "goBack")
@@ -1136,7 +1363,9 @@ class PlaywrightBridge(BrowserBridge):
1136
1363
  page.keyboard.press(key)
1137
1364
 
1138
1365
  navigated, _ = self._wait_for_navigation_settle(
1139
- page, url_before, nav_timeout_ms=3000,
1366
+ page,
1367
+ url_before,
1368
+ nav_timeout_ms=3000,
1140
1369
  skip_networkidle=self._skip_networkidle,
1141
1370
  )
1142
1371
 
@@ -1144,7 +1373,10 @@ class PlaywrightBridge(BrowserBridge):
1144
1373
  new_title = page.title()
1145
1374
 
1146
1375
  result = self._build_interaction_result(
1147
- task_id, page, newUrl=new_url, newTitle=new_title,
1376
+ task_id,
1377
+ page,
1378
+ newUrl=new_url,
1379
+ newTitle=new_title,
1148
1380
  )
1149
1381
  except Exception as exc:
1150
1382
  result = {
@@ -1195,9 +1427,7 @@ class PlaywrightBridge(BrowserBridge):
1195
1427
  "success": True,
1196
1428
  "messages": [],
1197
1429
  }
1198
- messages: list[dict[str, str]] = session.get(
1199
- "console_messages", []
1200
- )
1430
+ messages: list[dict[str, str]] = session.get("console_messages", [])
1201
1431
  return {
1202
1432
  "success": True,
1203
1433
  "messages": messages,
@@ -1432,3 +1662,264 @@ class PlaywrightBridge(BrowserBridge):
1432
1662
  "origins": [],
1433
1663
  "error": str(exc),
1434
1664
  }
1665
+
1666
+ # ── Command handlers ────────────────────────────────────────
1667
+ #
1668
+ # One ``_h_*`` method per JSON-RPC method. Each extracts params and
1669
+ # calls the matching ``do_*`` method. ``_DISPATCH`` (class attribute,
1670
+ # built at the bottom of the class body) maps method name → handler.
1671
+
1672
+ def _h_ping(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1673
+ return make_success_response(cmd_id, "pong")
1674
+
1675
+ def _h_init(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1676
+ # Forward plugin config from the TypeScript adapter. Sent exactly
1677
+ # once after the ping handshake, before any other RPC.
1678
+ self._plugin_config = params.get("config") or {}
1679
+ return make_success_response(cmd_id, {"ok": True})
1680
+
1681
+ def _h_shutdown(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1682
+ self._running = False
1683
+ return make_success_response(cmd_id, "shutting_down")
1684
+
1685
+ def _h_navigate(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1686
+ url = self._require_param(params, "url", str)
1687
+ task_id = self._require_param(params, "taskId", str)
1688
+ timeout_ms = params.get("timeoutMs", DEFAULT_NAVIGATION_TIMEOUT_MS)
1689
+ result = self.do_navigate(
1690
+ task_id,
1691
+ url,
1692
+ timeout_ms,
1693
+ storageState=params.get("storageState"),
1694
+ profileName=params.get("profileName"),
1695
+ profileMode=params.get("profileMode"),
1696
+ )
1697
+ return make_success_response(cmd_id, result)
1698
+
1699
+ def _h_snapshot(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1700
+ task_id = self._require_param(params, "taskId", str)
1701
+ return make_success_response(cmd_id, self.do_snapshot(task_id))
1702
+
1703
+ def _h_click(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1704
+ task_id = self._require_param(params, "taskId", str)
1705
+ ref = self._require_param(params, "ref", str)
1706
+ return make_success_response(cmd_id, self.do_click(task_id, ref))
1707
+
1708
+ def _h_type(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1709
+ task_id = self._require_param(params, "taskId", str)
1710
+ ref = self._require_param(params, "ref", str)
1711
+ text = self._require_param(params, "text", str)
1712
+ return make_success_response(cmd_id, self.do_type(task_id, ref, text))
1713
+
1714
+ def _h_scroll(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1715
+ task_id = self._require_param(params, "taskId", str)
1716
+ direction = self._require_param(params, "direction", str)
1717
+ if direction not in ("up", "down"):
1718
+ return make_error_response(
1719
+ cmd_id,
1720
+ INVALID_PARAMS,
1721
+ 'direction must be "up" or "down"',
1722
+ )
1723
+ return make_success_response(cmd_id, self.do_scroll(task_id, direction))
1724
+
1725
+ def _h_go_back(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1726
+ task_id = self._require_param(params, "taskId", str)
1727
+ return make_success_response(cmd_id, self.do_go_back(task_id))
1728
+
1729
+ def _h_press(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1730
+ task_id = self._require_param(params, "taskId", str)
1731
+ key = self._require_param(params, "key", str)
1732
+ return make_success_response(cmd_id, self.do_press(task_id, key))
1733
+
1734
+ def _h_screenshot(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1735
+ task_id = self._require_param(params, "taskId", str)
1736
+ full_page = params.get("fullPage", False)
1737
+ return make_success_response(cmd_id, self.do_screenshot(task_id, full_page))
1738
+
1739
+ def _h_get_console_messages(
1740
+ self, params: dict[str, Any], cmd_id: Any
1741
+ ) -> dict[str, Any]:
1742
+ task_id = self._require_param(params, "taskId", str)
1743
+ return make_success_response(cmd_id, self.do_get_console_messages(task_id))
1744
+
1745
+ def _h_clear_console(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1746
+ task_id = self._require_param(params, "taskId", str)
1747
+ return make_success_response(cmd_id, self.do_clear_console(task_id))
1748
+
1749
+ def _h_evaluate(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1750
+ task_id = self._require_param(params, "taskId", str)
1751
+ expression = self._require_param(params, "expression", str)
1752
+ read_only = bool(params.get("readOnly", False))
1753
+ result = self.do_evaluate(task_id, expression, read_only=read_only)
1754
+ return make_success_response(cmd_id, result)
1755
+
1756
+ def _h_get_cookies(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1757
+ task_id = self._require_param(params, "taskId", str)
1758
+ return make_success_response(
1759
+ cmd_id, self.do_get_cookies(task_id, params.get("urls"))
1760
+ )
1761
+
1762
+ def _h_add_cookies(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1763
+ task_id = self._require_param(params, "taskId", str)
1764
+ cookies = self._require_param(params, "cookies", list)
1765
+ return make_success_response(cmd_id, self.do_add_cookies(task_id, cookies))
1766
+
1767
+ def _h_clear_cookies(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1768
+ task_id = self._require_param(params, "taskId", str)
1769
+ # No required params beyond taskId — empty call clears ALL cookies
1770
+ result = self.do_clear_cookies(
1771
+ task_id, params.get("name"), params.get("domain"), params.get("path")
1772
+ )
1773
+ return make_success_response(cmd_id, result)
1774
+
1775
+ def _h_get_storage_state(
1776
+ self, params: dict[str, Any], cmd_id: Any
1777
+ ) -> dict[str, Any]:
1778
+ task_id = self._require_param(params, "taskId", str)
1779
+ return make_success_response(cmd_id, self.do_get_storage_state(task_id))
1780
+
1781
+ def _h_cleanup(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1782
+ task_id = self._require_param(params, "taskId", str)
1783
+ return make_success_response(cmd_id, self.do_cleanup(task_id))
1784
+
1785
+ def _h_describe_quirks(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
1786
+ # Return the bridge's declared quirks flags.
1787
+ return make_success_response(
1788
+ cmd_id,
1789
+ {
1790
+ "fingerprint_managed_context": getattr(
1791
+ self, "_fingerprint_managed_context", False
1792
+ ),
1793
+ "eval_prefix": getattr(self, "_eval_prefix", ""),
1794
+ "scroll_via_wheel": getattr(self, "_scroll_via_wheel", False),
1795
+ "skip_default_viewport": getattr(self, "_skip_default_viewport", False),
1796
+ "skip_networkidle": getattr(self, "_skip_networkidle", False),
1797
+ "wrap_mw_eval_in_eval": getattr(self, "_wrap_mw_eval_in_eval", False),
1798
+ "csp_safe_readonly_via_init_script": getattr(
1799
+ self, "_csp_safe_readonly_via_init_script", False
1800
+ ),
1801
+ },
1802
+ )
1803
+
1804
+ #: JSON-RPC method name → handler. Built after the handlers are
1805
+ #: defined so the names are in scope. ``handle_command`` looks up
1806
+ #: here and calls ``handler(self, params, cmd_id)``.
1807
+ _DISPATCH = {
1808
+ "ping": _h_ping,
1809
+ "browser.init": _h_init,
1810
+ "shutdown": _h_shutdown,
1811
+ "browser.navigate": _h_navigate,
1812
+ "browser.snapshot": _h_snapshot,
1813
+ "browser.click": _h_click,
1814
+ "browser.type": _h_type,
1815
+ "browser.scroll": _h_scroll,
1816
+ "browser.goBack": _h_go_back,
1817
+ "browser.press": _h_press,
1818
+ "browser.screenshot": _h_screenshot,
1819
+ "browser.getConsoleMessages": _h_get_console_messages,
1820
+ "browser.clearConsole": _h_clear_console,
1821
+ "browser.evaluate": _h_evaluate,
1822
+ "browser.getCookies": _h_get_cookies,
1823
+ "browser.addCookies": _h_add_cookies,
1824
+ "browser.clearCookies": _h_clear_cookies,
1825
+ "browser.getStorageState": _h_get_storage_state,
1826
+ "browser.cleanup": _h_cleanup,
1827
+ "browser.describeQuirks": _h_describe_quirks,
1828
+ }
1829
+
1830
+ # ── Command routing ─────────────────────────────────────────
1831
+
1832
+ def handle_command(
1833
+ self, method: str, params: dict[str, Any], cmd_id: Any
1834
+ ) -> dict[str, Any]:
1835
+ """Route a JSON-RPC method to the appropriate operation handler.
1836
+
1837
+ Returns a JSON-RPC response dict (either result or error).
1838
+ """
1839
+ try:
1840
+ handler = self._DISPATCH.get(method)
1841
+ if handler is None:
1842
+ return make_error_response(
1843
+ cmd_id, METHOD_NOT_FOUND, f"Method not found: {method}"
1844
+ )
1845
+ return handler(self, params, cmd_id)
1846
+
1847
+ except SessionNotFoundError as exc:
1848
+ return make_error_response(cmd_id, SESSION_ERROR, str(exc))
1849
+ except NotImplementedError as exc:
1850
+ return make_application_error(cmd_id, str(exc))
1851
+ except Exception as exc:
1852
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
1853
+ return make_application_error(cmd_id, str(exc), traceback_str=tb)
1854
+
1855
+ # ── Main loop ─────────────────────────────────────────────
1856
+
1857
+ def run(self) -> None:
1858
+ """Start the main JSON-RPC command loop.
1859
+
1860
+ Reads requests from stdin, dispatches them, and writes responses
1861
+ to stdout. Runs until EOF or a ``shutdown`` command.
1862
+ """
1863
+ self._running = True
1864
+ while self._running:
1865
+ try:
1866
+ request = read_request()
1867
+ if request is None:
1868
+ break # EOF
1869
+
1870
+ cmd_id = request.get("id")
1871
+ method = request.get("method", "")
1872
+ params = request.get("params", {})
1873
+
1874
+ if not isinstance(params, dict):
1875
+ write_response(
1876
+ make_error_response(
1877
+ cmd_id,
1878
+ INVALID_PARAMS,
1879
+ '"params" must be a JSON object',
1880
+ )
1881
+ )
1882
+ continue
1883
+
1884
+ response = self.handle_command(method, params, cmd_id)
1885
+ write_response(response)
1886
+
1887
+ except InvalidRequestError:
1888
+ # Valid JSON but not a valid JSON-RPC Request object
1889
+ write_response(make_invalid_request(None))
1890
+ except ValueError:
1891
+ # JSON parse error
1892
+ write_response(make_parse_error(None))
1893
+ except EOFError:
1894
+ break
1895
+ except KeyboardInterrupt:
1896
+ break
1897
+ except Exception as exc:
1898
+ tb = "".join(
1899
+ traceback.format_exception(type(exc), exc, exc.__traceback__)
1900
+ )
1901
+ write_response(make_application_error(None, str(exc), traceback_str=tb))
1902
+
1903
+ # ── Internal helpers ──────────────────────────────────────
1904
+
1905
+ @staticmethod
1906
+ def _require_param(
1907
+ params: dict[str, Any],
1908
+ key: str,
1909
+ expected_type: type,
1910
+ ) -> Any:
1911
+ """Require a param to exist and be of the expected type.
1912
+
1913
+ Raises ``InvalidParamsError`` if the param is missing or has the
1914
+ wrong type. The caller's ``except Exception`` clause turns this
1915
+ into a JSON-RPC application-error response.
1916
+ """
1917
+ if key not in params:
1918
+ raise InvalidParamsError(f'Missing required parameter: "{key}"')
1919
+ value = params[key]
1920
+ if not isinstance(value, expected_type):
1921
+ raise InvalidParamsError(
1922
+ f'Parameter "{key}" must be of type {expected_type.__name__}, '
1923
+ f"got {type(value).__name__}"
1924
+ )
1925
+ return value