pi-lean-dimension 0.1.0 → 0.2.1

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 (130) hide show
  1. package/README.md +96 -36
  2. package/node_modules/pi-lean-portal/AGENTS.md +146 -4
  3. package/node_modules/pi-lean-portal/README.md +112 -50
  4. package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
  5. package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
  6. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
  7. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
  8. package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
  9. package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
  10. package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
  11. package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
  12. package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
  13. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
  14. package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
  15. package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
  16. package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
  17. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  18. package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
  19. package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
  20. package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
  21. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
  22. package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
  23. package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
  24. package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
  25. package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
  26. package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
  27. package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
  28. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
  29. package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
  30. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
  31. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
  32. package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
  33. package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
  34. package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
  35. package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
  36. package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
  37. package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
  38. package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
  39. package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
  40. package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
  41. package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
  42. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
  43. package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
  44. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
  45. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
  46. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
  48. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
  49. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
  50. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
  51. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
  52. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
  53. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
  54. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
  55. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
  56. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
  57. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  58. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  59. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  60. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  61. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
  62. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
  63. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
  64. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
  65. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
  66. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
  67. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  68. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_accessibility.cpython-313-pytest-9.1.0.pyc → test_accessibility.cpython-313-pytest-9.1.1.pyc} +0 -0
  69. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
  70. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
  72. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_chromium_py_bridge.cpython-313-pytest-9.1.0.pyc → test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_firefox_py_bridge.cpython-313-pytest-9.1.0.pyc → test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_transport.cpython-313-pytest-9.1.0.pyc → test_transport.cpython-313-pytest-9.1.1.pyc} +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
  84. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
  85. package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
  87. package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
  88. package/node_modules/pi-lean-portal/contributed/README.md +304 -0
  89. package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
  90. package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
  91. package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
  92. package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
  93. package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
  94. package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
  95. package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
  96. package/node_modules/pi-lean-portal/core/router.ts +59 -98
  97. package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
  98. package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
  99. package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
  100. package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
  101. package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
  102. package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
  103. package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
  104. package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
  105. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
  106. package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
  107. package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
  108. package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
  109. package/node_modules/pi-lean-portal/index.ts +42 -7
  110. package/node_modules/pi-lean-portal/package.json +8 -3
  111. package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
  112. package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
  113. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
  114. package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
  115. package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
  116. package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
  117. package/node_modules/pi-lean-search/README.md +6 -2
  118. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
  119. package/node_modules/pi-lean-search/index.ts +4 -21
  120. package/node_modules/pi-lean-search/package.json +2 -2
  121. package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
  122. package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
  123. package/package.json +4 -4
  124. package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
  125. package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
  126. package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
  127. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
  128. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
  129. package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
  130. package/node_modules/pi-lean-portal/backends/python-base/tests/test_firefox_py_bridge.py +0 -212
@@ -2,9 +2,9 @@
2
2
  BrowserBridge — base class for Python browser automation backends.
3
3
 
4
4
  Provides JSON-RPC command routing, session lifecycle, element caching,
5
- and a ``run()`` main loop. Subclasses override ``create_browser_session()``
6
- and individual operation methods to implement specific browser backends
7
- (e.g. Chromium via Playwright, Camoufox, etc.).
5
+ and a ``run()`` main loop. Subclasses override ``create_browser_session()``,
6
+ ``create_browser_context()``, and the ``do_*`` operation methods to implement
7
+ specific browser backends (e.g. Chromium via Playwright, Camoufox, etc.).
8
8
 
9
9
  Protocol
10
10
  --------
@@ -21,11 +21,28 @@ Lifecycle
21
21
  4. Subsequent commands use the existing session.
22
22
  5. ``browser.cleanup`` closes the session for a taskId.
23
23
  6. ``browser.shutdown`` (sent via cleanupAll) terminates the process.
24
+
25
+ Subclass contract (all ``@abstractmethod``)
26
+ --------------------------------------------
27
+ * ``create_browser_session(task_id, config)`` — build a per-task session dict.
28
+ * ``create_browser_context(config)`` — build an isolated BrowserContext.
29
+ * ``do_navigate / do_snapshot / do_click / do_type / do_scroll / do_go_back
30
+ / do_press / do_screenshot / do_get_console_messages / do_clear_console
31
+ / do_evaluate / do_get_cookies / do_add_cookies / do_clear_cookies
32
+ / do_get_storage_state`` — the 15 operation methods. Each returns a
33
+ dict whose shape is documented at the dispatch site (``_h_*`` handlers).
34
+ ``do_cleanup`` has a concrete default (calls ``close_browser_session``)
35
+ and need not be overridden.
36
+
37
+ Named profiles are fully handled by the TypeScript side via
38
+ ``core/shared/storage-state.ts`` (disk persistence). The Python
39
+ bridge receives ``storageState`` in the navigate request and applies
40
+ it when creating a new BrowserContext — it does NOT track shared
41
+ contexts across tasks.
24
42
  """
25
43
 
26
- import inspect
27
- import sys
28
44
  import traceback
45
+ from abc import ABC, abstractmethod
29
46
  from typing import Any, Optional
30
47
 
31
48
  from .transport import (
@@ -35,10 +52,8 @@ from .transport import (
35
52
  make_error_response,
36
53
  make_parse_error,
37
54
  make_invalid_request,
38
- make_internal_error,
39
55
  make_application_error,
40
56
  InvalidRequestError,
41
- APPLICATION_ERROR,
42
57
  METHOD_NOT_FOUND,
43
58
  INVALID_PARAMS,
44
59
  SESSION_ERROR,
@@ -51,23 +66,11 @@ DEFAULT_NAVIGATION_TIMEOUT_MS: int = 30_000
51
66
  DEFAULT_INTERACTION_TIMEOUT_MS: int = 10_000
52
67
 
53
68
 
54
- class BrowserBridge:
69
+ class BrowserBridge(ABC):
55
70
  """Base class for Python browser automation bridges.
56
71
 
57
- Subclass this and override:
58
-
59
- * ``create_browser_session(task_id, config)`` — mandatory
60
- * ``create_browser_context(config)`` — mandatory (creates isolated context per task)
61
- * Any operation methods you want to customise (optional)
62
- * ``close_browser_session(task_id)`` — optional (default unregisters)
63
-
64
- Call ``run()`` to start the JSON-RPC command loop.
65
-
66
- Named profiles are fully handled by the TypeScript side via
67
- ``core/shared/storage-state.ts`` (disk persistence). The Python
68
- bridge receives ``storageState`` in the navigate request and applies
69
- it when creating a new BrowserContext — it does NOT track shared
70
- contexts across tasks.
72
+ Subclass this and override the abstract methods listed in the module
73
+ docstring. Call ``run()`` to start the JSON-RPC command loop.
71
74
  """
72
75
 
73
76
  # ── Session storage ─────────────────────────────────────────
@@ -83,25 +86,40 @@ class BrowserBridge:
83
86
  #: Whether the bridge is still running.
84
87
  _running: bool
85
88
 
89
+ #: Plugin configuration dict forwarded from the TypeScript adapter via
90
+ #: the ``browser.init`` RPC. Defaults to ``{}`` for bridges that never
91
+ #: receive an init call (e.g. older adapters, or the shipped
92
+ #: ``chromium-py``/``firefox-py`` when run standalone). Subclasses read
93
+ #: engine-specific options from ``self.plugin_config.get("launch", {})``.
94
+ _plugin_config: dict[str, Any]
95
+
86
96
  def __init__(self) -> None:
87
97
  self.sessions = {}
88
98
  self.element_caches = {}
89
99
  self._running = False
100
+ self._plugin_config = {}
101
+
102
+ # ── Plugin config (forwarded via browser.init) ───────────────
103
+
104
+ @property
105
+ def plugin_config(self) -> dict[str, Any]:
106
+ """Return the plugin config dict forwarded from the TypeScript adapter.
107
+
108
+ Populated by the ``browser.init`` RPC handler. Always returns a
109
+ dict (empty when no init was received) so subclasses can safely
110
+ call ``self.plugin_config.get("launch", {})``.
111
+ """
112
+ return self._plugin_config
90
113
 
91
114
  # ── Subclass hooks ──────────────────────────────────────────
92
115
 
116
+ @abstractmethod
93
117
  def create_browser_session(self, task_id: str, config: dict[str, Any]) -> dict[str, Any]:
94
118
  """Create a new browser session for the given task.
95
119
 
96
120
  Must return a dict that will be stored in ``self.sessions[task_id]``.
97
121
  The dict is backend-specific (e.g. containing a Playwright page/context).
98
-
99
- Raises:
100
- RuntimeError: if a session cannot be created.
101
122
  """
102
- raise NotImplementedError(
103
- f"{type(self).__name__} must implement create_browser_session()"
104
- )
105
123
 
106
124
  def close_browser_session(self, task_id: str) -> None:
107
125
  """Close and clean up the session for the given task.
@@ -112,6 +130,7 @@ class BrowserBridge:
112
130
  self.sessions.pop(task_id, None)
113
131
  self.element_caches.pop(task_id, None)
114
132
 
133
+ @abstractmethod
115
134
  def create_browser_context(self, config: dict[str, Any]) -> Any:
116
135
  """Create a new isolated BrowserContext for a task session.
117
136
 
@@ -119,20 +138,7 @@ class BrowserBridge:
119
138
  tasks. Named profiles are handled by the TypeScript side via
120
139
  ``core/shared/storage-state.ts`` — the ``config`` may contain
121
140
  ``storageState`` to restore cookies and localStorage.
122
-
123
- Args:
124
- config: Configuration dict (may contain ``storageState``,
125
- ``viewport``, ``userAgent``, etc.)
126
-
127
- Returns:
128
- A backend-specific BrowserContext object.
129
-
130
- Raises:
131
- NotImplementedError: if the subclass doesn't implement this.
132
141
  """
133
- raise NotImplementedError(
134
- f"{type(self).__name__} must implement create_browser_context()"
135
- )
136
142
 
137
143
  # ── Session helpers ─────────────────────────────────────────
138
144
 
@@ -159,17 +165,6 @@ class BrowserBridge:
159
165
  self.sessions[task_id] = new_session
160
166
  return new_session
161
167
 
162
- # ── Page session setup hook ──────────────────────────────────
163
-
164
- def _setup_page_session(self, page: Any) -> dict[str, Any]:
165
- """Set up event handlers (console capture, dialog dismissal) on a new page.
166
-
167
- Base implementation returns a minimal session dict. Subclasses
168
- (e.g. ChromiumPyBridge) override this to attach console-message
169
- accumulators and dialog handlers.
170
- """
171
- return {"page": page}
172
-
173
168
  def get_element_cache(self, task_id: str) -> Optional[AriaParseResult]:
174
169
  """Get the cached element parse result for a task, or None."""
175
170
  return self.element_caches.get(task_id)
@@ -179,7 +174,13 @@ class BrowserBridge:
179
174
  self.element_caches[task_id] = result
180
175
 
181
176
  # ── Operation stubs (override in subclasses) ────────────────
177
+ #
178
+ # The 15 ``do_*`` methods below are the operation contract. Each
179
+ # returns a dict whose shape is enforced at the dispatch site
180
+ # (``_h_*`` handlers in ``_DISPATCH``). ``do_cleanup`` has a concrete
181
+ # default and is not abstract.
182
182
 
183
+ @abstractmethod
183
184
  def do_navigate(
184
185
  self,
185
186
  task_id: str,
@@ -189,160 +190,63 @@ class BrowserBridge:
189
190
  profileName: Optional[str] = None,
190
191
  profileMode: Optional[str] = None,
191
192
  ) -> dict[str, Any]:
192
- """Navigate the browser to a URL.
193
-
194
- Subclasses interpret the extra params:
195
-
196
- - ``storageState``: Playwright storage state for session restoration.
197
- - ``profileName``: Named profile name (e.g. "work", "shopping").
198
- - ``profileMode``: ``"none"`` / ``"session"`` / ``"named"``.
199
- All modes create task-isolated BrowserContexts; named profiles
200
- are handled by the TypeScript side via ``storageState``
201
- (``core/shared/storage-state.ts``, disk persistence).
202
-
203
- Must return a dict with keys:
204
- success (bool), url (str), title (str),
205
- snapshot (str), elementCount (int)
206
- Optionally: botDetected (bool), profileName (str)
207
- """
208
- raise NotImplementedError(
209
- f"{type(self).__name__} must implement do_navigate()"
210
- )
193
+ """Navigate the browser to a URL."""
211
194
 
195
+ @abstractmethod
212
196
  def do_snapshot(self, task_id: str) -> dict[str, Any]:
213
- """Take an accessibility snapshot of the current page.
214
-
215
- Must return a dict with keys:
216
- success (bool), snapshot (str), elementCount (int)
217
- """
218
- raise NotImplementedError(
219
- f"{type(self).__name__} must implement do_snapshot()"
220
- )
197
+ """Take an accessibility snapshot of the current page."""
221
198
 
199
+ @abstractmethod
222
200
  def do_click(self, task_id: str, ref: str) -> dict[str, Any]:
223
- """Click an element by @e ref.
224
-
225
- Must return a dict with keys:
226
- success (bool)
227
- Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
228
- """
229
- raise NotImplementedError(
230
- f"{type(self).__name__} must implement do_click()"
231
- )
201
+ """Click an element by @e ref."""
232
202
 
203
+ @abstractmethod
233
204
  def do_type(self, task_id: str, ref: str, text: str) -> dict[str, Any]:
234
- """Type text into an element by @e ref.
235
-
236
- Must return a dict with keys:
237
- success (bool)
238
- Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
239
- """
240
- raise NotImplementedError(
241
- f"{type(self).__name__} must implement do_type()"
242
- )
205
+ """Type text into an element by @e ref."""
243
206
 
207
+ @abstractmethod
244
208
  def do_scroll(self, task_id: str, direction: str) -> dict[str, Any]:
245
- """Scroll the page up or down.
246
-
247
- Must return a dict with keys:
248
- success (bool)
249
- Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
250
- """
251
- raise NotImplementedError(
252
- f"{type(self).__name__} must implement do_scroll()"
253
- )
209
+ """Scroll the page up or down."""
254
210
 
211
+ @abstractmethod
255
212
  def do_go_back(self, task_id: str) -> dict[str, Any]:
256
- """Navigate back in history.
257
-
258
- Must return a dict with keys:
259
- success (bool)
260
- Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
261
- """
262
- raise NotImplementedError(
263
- f"{type(self).__name__} must implement do_go_back()"
264
- )
213
+ """Navigate back in history."""
265
214
 
215
+ @abstractmethod
266
216
  def do_press(self, task_id: str, key: str) -> dict[str, Any]:
267
- """Press a keyboard key on the current page (or focused element).
217
+ """Press a keyboard key on the current page (or focused element)."""
268
218
 
269
- Must return a dict with keys:
270
- success (bool)
271
- Optionally: snapshot (str), elementCount (int), newUrl (str), newTitle (str)
272
- """
273
- raise NotImplementedError(
274
- f"{type(self).__name__} must implement do_press()"
275
- )
276
-
277
- def do_screenshot(
278
- self,
279
- task_id: str,
280
- full_page: bool = False,
281
- ) -> dict[str, Any]:
282
- """Take a screenshot of the current page.
283
-
284
- Must return a dict with keys:
285
- success (bool), dataUri (str) — JPEG base64 data URI
286
- """
287
- raise NotImplementedError(
288
- f"{type(self).__name__} must implement do_screenshot()"
289
- )
219
+ @abstractmethod
220
+ def do_screenshot(self, task_id: str, full_page: bool = False) -> dict[str, Any]:
221
+ """Take a screenshot of the current page (JPEG base64 data URI)."""
290
222
 
223
+ @abstractmethod
291
224
  def do_get_console_messages(self, task_id: str) -> dict[str, Any]:
292
- """Get captured console messages.
293
-
294
- Must return a dict with keys:
295
- success (bool), messages (list) — each with type, text
296
- """
297
- raise NotImplementedError(
298
- f"{type(self).__name__} must implement do_get_console_messages()"
299
- )
225
+ """Get captured console messages."""
300
226
 
227
+ @abstractmethod
301
228
  def do_clear_console(self, task_id: str) -> dict[str, Any]:
302
- """Clear captured console messages.
229
+ """Clear captured console messages."""
303
230
 
304
- Must return a dict with keys:
305
- success (bool)
306
- """
307
- raise NotImplementedError(
308
- f"{type(self).__name__} must implement do_clear_console()"
309
- )
310
-
311
- def do_evaluate(self, task_id: str, expression: str) -> dict[str, Any]:
312
- """Evaluate JavaScript in the page.
313
-
314
- Must return a dict with keys:
315
- success (bool)
316
- Optionally: result (any)
317
- """
318
- raise NotImplementedError(
319
- f"{type(self).__name__} must implement do_evaluate()"
320
- )
231
+ @abstractmethod
232
+ def do_evaluate(
233
+ self, task_id: str, expression: str, *, read_only: bool = False
234
+ ) -> dict[str, Any]:
235
+ """Evaluate JavaScript in the page."""
321
236
 
237
+ @abstractmethod
322
238
  def do_get_cookies(
323
239
  self, task_id: str, urls: Optional[list[str]] = None
324
240
  ) -> dict[str, Any]:
325
- """Get all cookies, optionally filtered by URL.
326
-
327
- Must return a dict with keys:
328
- success (bool), cookies (list) — each with name, value, domain, ...
329
- """
330
- raise NotImplementedError(
331
- f"{type(self).__name__} must implement do_get_cookies()"
332
- )
241
+ """Get all cookies, optionally filtered by URL."""
333
242
 
243
+ @abstractmethod
334
244
  def do_add_cookies(
335
245
  self, task_id: str, cookies: list[dict[str, Any]]
336
246
  ) -> dict[str, Any]:
337
- """Add cookies to the browser context.
338
-
339
- Must return a dict with keys:
340
- success (bool)
341
- """
342
- raise NotImplementedError(
343
- f"{type(self).__name__} must implement do_add_cookies()"
344
- )
247
+ """Add cookies to the browser context."""
345
248
 
249
+ @abstractmethod
346
250
  def do_clear_cookies(
347
251
  self,
348
252
  task_id: str,
@@ -350,38 +254,176 @@ class BrowserBridge:
350
254
  domain: Optional[str] = None,
351
255
  path: Optional[str] = None,
352
256
  ) -> dict[str, Any]:
353
- """Clear cookies, optionally filtered by name/domain/path.
354
-
355
- Must return a dict with keys:
356
- success (bool)
357
- """
358
- raise NotImplementedError(
359
- f"{type(self).__name__} must implement do_clear_cookies()"
360
- )
257
+ """Clear cookies, optionally filtered by name/domain/path."""
361
258
 
259
+ @abstractmethod
362
260
  def do_get_storage_state(self, task_id: str) -> dict[str, Any]:
363
- """Get full storage state (cookies + localStorage + IndexedDB).
364
-
365
- Must return a dict with keys:
366
- success (bool), cookies (list), origins (list)
367
- """
368
- raise NotImplementedError(
369
- f"{type(self).__name__} must implement do_get_storage_state()"
370
- )
261
+ """Get full storage state (cookies + localStorage + IndexedDB)."""
371
262
 
372
263
  def do_cleanup(self, task_id: str) -> dict[str, Any]:
373
264
  """Clean up resources for a specific task.
374
265
 
375
266
  Profile persistence is handled by the TypeScript side
376
267
  (``python-adapter.ts`` auto-saves storage state before calling
377
- cleanup), so this method always calls ``close_browser_session()``.
378
-
379
- Must return a dict with keys:
380
- success (bool)
268
+ cleanup), so this default always calls ``close_browser_session()``.
381
269
  """
382
270
  self.close_browser_session(task_id)
383
271
  return {"success": True}
384
272
 
273
+ # ── Command handlers ────────────────────────────────────────
274
+ #
275
+ # One ``_h_*`` method per JSON-RPC method. Each extracts params and
276
+ # calls the matching ``do_*`` method. ``_DISPATCH`` (class attribute,
277
+ # built at the bottom of the class body) maps method name → handler.
278
+
279
+ def _h_ping(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
280
+ return make_success_response(cmd_id, "pong")
281
+
282
+ def _h_init(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
283
+ # Forward plugin config from the TypeScript adapter. Sent exactly
284
+ # once after the ping handshake, before any other RPC.
285
+ self._plugin_config = params.get("config") or {}
286
+ return make_success_response(cmd_id, {"ok": True})
287
+
288
+ def _h_shutdown(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
289
+ self._running = False
290
+ return make_success_response(cmd_id, "shutting_down")
291
+
292
+ def _h_navigate(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
293
+ url = self._require_param(params, "url", str)
294
+ task_id = self._require_param(params, "taskId", str)
295
+ timeout_ms = params.get("timeoutMs", DEFAULT_NAVIGATION_TIMEOUT_MS)
296
+ result = self.do_navigate(
297
+ task_id, url, timeout_ms,
298
+ storageState=params.get("storageState"),
299
+ profileName=params.get("profileName"),
300
+ profileMode=params.get("profileMode"),
301
+ )
302
+ return make_success_response(cmd_id, result)
303
+
304
+ def _h_snapshot(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
305
+ task_id = self._require_param(params, "taskId", str)
306
+ return make_success_response(cmd_id, self.do_snapshot(task_id))
307
+
308
+ def _h_click(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
309
+ task_id = self._require_param(params, "taskId", str)
310
+ ref = self._require_param(params, "ref", str)
311
+ return make_success_response(cmd_id, self.do_click(task_id, ref))
312
+
313
+ def _h_type(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
314
+ task_id = self._require_param(params, "taskId", str)
315
+ ref = self._require_param(params, "ref", str)
316
+ text = self._require_param(params, "text", str)
317
+ return make_success_response(cmd_id, self.do_type(task_id, ref, text))
318
+
319
+ def _h_scroll(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
320
+ task_id = self._require_param(params, "taskId", str)
321
+ direction = self._require_param(params, "direction", str)
322
+ if direction not in ("up", "down"):
323
+ return make_error_response(
324
+ cmd_id, INVALID_PARAMS,
325
+ 'direction must be "up" or "down"',
326
+ )
327
+ return make_success_response(cmd_id, self.do_scroll(task_id, direction))
328
+
329
+ def _h_go_back(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
330
+ task_id = self._require_param(params, "taskId", str)
331
+ return make_success_response(cmd_id, self.do_go_back(task_id))
332
+
333
+ def _h_press(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
334
+ task_id = self._require_param(params, "taskId", str)
335
+ key = self._require_param(params, "key", str)
336
+ return make_success_response(cmd_id, self.do_press(task_id, key))
337
+
338
+ def _h_screenshot(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
339
+ task_id = self._require_param(params, "taskId", str)
340
+ full_page = params.get("fullPage", False)
341
+ return make_success_response(cmd_id, self.do_screenshot(task_id, full_page))
342
+
343
+ def _h_get_console_messages(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
344
+ task_id = self._require_param(params, "taskId", str)
345
+ return make_success_response(cmd_id, self.do_get_console_messages(task_id))
346
+
347
+ def _h_clear_console(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
348
+ task_id = self._require_param(params, "taskId", str)
349
+ return make_success_response(cmd_id, self.do_clear_console(task_id))
350
+
351
+ def _h_evaluate(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
352
+ task_id = self._require_param(params, "taskId", str)
353
+ expression = self._require_param(params, "expression", str)
354
+ read_only = bool(params.get("readOnly", False))
355
+ result = self.do_evaluate(task_id, expression, read_only=read_only)
356
+ return make_success_response(cmd_id, result)
357
+
358
+ def _h_get_cookies(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
359
+ task_id = self._require_param(params, "taskId", str)
360
+ return make_success_response(cmd_id, self.do_get_cookies(task_id, params.get("urls")))
361
+
362
+ def _h_add_cookies(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
363
+ task_id = self._require_param(params, "taskId", str)
364
+ cookies = self._require_param(params, "cookies", list)
365
+ return make_success_response(cmd_id, self.do_add_cookies(task_id, cookies))
366
+
367
+ def _h_clear_cookies(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
368
+ task_id = self._require_param(params, "taskId", str)
369
+ # No required params beyond taskId — empty call clears ALL cookies
370
+ result = self.do_clear_cookies(
371
+ task_id, params.get("name"), params.get("domain"), params.get("path")
372
+ )
373
+ return make_success_response(cmd_id, result)
374
+
375
+ def _h_get_storage_state(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
376
+ task_id = self._require_param(params, "taskId", str)
377
+ return make_success_response(cmd_id, self.do_get_storage_state(task_id))
378
+
379
+ def _h_cleanup(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
380
+ task_id = self._require_param(params, "taskId", str)
381
+ return make_success_response(cmd_id, self.do_cleanup(task_id))
382
+
383
+ def _h_describe_quirks(self, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
384
+ # Return the bridge's declared quirks flags. Uses getattr with
385
+ # defaults so a bare BrowserBridge (no Playwright quirks) returns
386
+ # all-defaults rather than raising AttributeError.
387
+ return make_success_response(cmd_id, {
388
+ "fingerprint_managed_context": getattr(
389
+ self, "_fingerprint_managed_context", False
390
+ ),
391
+ "eval_prefix": getattr(self, "_eval_prefix", ""),
392
+ "scroll_via_wheel": getattr(self, "_scroll_via_wheel", False),
393
+ "skip_default_viewport": getattr(self, "_skip_default_viewport", False),
394
+ "skip_networkidle": getattr(self, "_skip_networkidle", False),
395
+ "wrap_mw_eval_in_eval": getattr(self, "_wrap_mw_eval_in_eval", False),
396
+ "csp_safe_readonly_via_init_script": getattr(
397
+ self, "_csp_safe_readonly_via_init_script", False
398
+ ),
399
+ })
400
+
401
+ #: JSON-RPC method name → handler. Built after the handlers are
402
+ #: defined so the names are in scope. ``handle_command`` looks up
403
+ #: here and calls ``handler(self, params, cmd_id)``.
404
+ _DISPATCH = {
405
+ "ping": _h_ping,
406
+ "browser.init": _h_init,
407
+ "shutdown": _h_shutdown,
408
+ "browser.navigate": _h_navigate,
409
+ "browser.snapshot": _h_snapshot,
410
+ "browser.click": _h_click,
411
+ "browser.type": _h_type,
412
+ "browser.scroll": _h_scroll,
413
+ "browser.goBack": _h_go_back,
414
+ "browser.press": _h_press,
415
+ "browser.screenshot": _h_screenshot,
416
+ "browser.getConsoleMessages": _h_get_console_messages,
417
+ "browser.clearConsole": _h_clear_console,
418
+ "browser.evaluate": _h_evaluate,
419
+ "browser.getCookies": _h_get_cookies,
420
+ "browser.addCookies": _h_add_cookies,
421
+ "browser.clearCookies": _h_clear_cookies,
422
+ "browser.getStorageState": _h_get_storage_state,
423
+ "browser.cleanup": _h_cleanup,
424
+ "browser.describeQuirks": _h_describe_quirks,
425
+ }
426
+
385
427
  # ── Command routing ─────────────────────────────────────────
386
428
 
387
429
  def handle_command(self, method: str, params: dict[str, Any], cmd_id: Any) -> dict[str, Any]:
@@ -390,127 +432,12 @@ class BrowserBridge:
390
432
  Returns a JSON-RPC response dict (either result or error).
391
433
  """
392
434
  try:
393
- if method == "ping":
394
- return make_success_response(cmd_id, "pong")
395
-
396
- if method == "shutdown":
397
- self._running = False
398
- return make_success_response(cmd_id, "shutting_down")
399
-
400
- if method == "browser.navigate":
401
- url = self._require_param(params, "url", str, cmd_id)
402
- task_id = self._require_param(params, "taskId", str, cmd_id)
403
- timeout_ms = params.get("timeoutMs", DEFAULT_NAVIGATION_TIMEOUT_MS)
404
- storage_state = params.get("storageState")
405
- profile_name = params.get("profileName")
406
- profile_mode = params.get("profileMode")
407
- result = self.do_navigate(
408
- task_id, url, timeout_ms,
409
- storageState=storage_state,
410
- profileName=profile_name,
411
- profileMode=profile_mode,
435
+ handler = self._DISPATCH.get(method)
436
+ if handler is None:
437
+ return make_error_response(
438
+ cmd_id, METHOD_NOT_FOUND, f"Method not found: {method}"
412
439
  )
413
- return make_success_response(cmd_id, result)
414
-
415
- if method == "browser.snapshot":
416
- task_id = self._require_param(params, "taskId", str, cmd_id)
417
- result = self.do_snapshot(task_id)
418
- return make_success_response(cmd_id, result)
419
-
420
- if method == "browser.click":
421
- task_id = self._require_param(params, "taskId", str, cmd_id)
422
- ref = self._require_param(params, "ref", str, cmd_id)
423
- result = self.do_click(task_id, ref)
424
- return make_success_response(cmd_id, result)
425
-
426
- if method == "browser.type":
427
- task_id = self._require_param(params, "taskId", str, cmd_id)
428
- ref = self._require_param(params, "ref", str, cmd_id)
429
- text = self._require_param(params, "text", str, cmd_id)
430
- result = self.do_type(task_id, ref, text)
431
- return make_success_response(cmd_id, result)
432
-
433
- if method == "browser.scroll":
434
- task_id = self._require_param(params, "taskId", str, cmd_id)
435
- direction = self._require_param(params, "direction", str, cmd_id)
436
- if direction not in ("up", "down"):
437
- return make_error_response(
438
- cmd_id, INVALID_PARAMS,
439
- 'direction must be "up" or "down"',
440
- )
441
- result = self.do_scroll(task_id, direction)
442
- return make_success_response(cmd_id, result)
443
-
444
- if method == "browser.goBack":
445
- task_id = self._require_param(params, "taskId", str, cmd_id)
446
- result = self.do_go_back(task_id)
447
- return make_success_response(cmd_id, result)
448
-
449
- if method == "browser.press":
450
- task_id = self._require_param(params, "taskId", str, cmd_id)
451
- key = self._require_param(params, "key", str, cmd_id)
452
- result = self.do_press(task_id, key)
453
- return make_success_response(cmd_id, result)
454
-
455
- if method == "browser.screenshot":
456
- task_id = self._require_param(params, "taskId", str, cmd_id)
457
- full_page = params.get("fullPage", False)
458
- result = self.do_screenshot(task_id, full_page)
459
- return make_success_response(cmd_id, result)
460
-
461
- if method == "browser.getConsoleMessages":
462
- task_id = self._require_param(params, "taskId", str, cmd_id)
463
- result = self.do_get_console_messages(task_id)
464
- return make_success_response(cmd_id, result)
465
-
466
- if method == "browser.clearConsole":
467
- task_id = self._require_param(params, "taskId", str, cmd_id)
468
- result = self.do_clear_console(task_id)
469
- return make_success_response(cmd_id, result)
470
-
471
- if method == "browser.evaluate":
472
- task_id = self._require_param(params, "taskId", str, cmd_id)
473
- expression = self._require_param(params, "expression", str, cmd_id)
474
- result = self.do_evaluate(task_id, expression)
475
- return make_success_response(cmd_id, result)
476
-
477
- if method == "browser.getCookies":
478
- task_id = self._require_param(params, "taskId", str, cmd_id)
479
- urls = params.get("urls")
480
- result = self.do_get_cookies(task_id, urls)
481
- return make_success_response(cmd_id, result)
482
-
483
- if method == "browser.addCookies":
484
- task_id = self._require_param(params, "taskId", str, cmd_id)
485
- cookies = self._require_param(params, "cookies", list, cmd_id)
486
- result = self.do_add_cookies(task_id, cookies)
487
- return make_success_response(cmd_id, result)
488
-
489
- if method == "browser.clearCookies":
490
- task_id = self._require_param(params, "taskId", str, cmd_id)
491
- name = params.get("name")
492
- domain = params.get("domain")
493
- path = params.get("path")
494
- # No required params beyond taskId — empty call clears ALL cookies
495
- result = self.do_clear_cookies(task_id, name, domain, path)
496
- return make_success_response(cmd_id, result)
497
-
498
- if method == "browser.getStorageState":
499
- task_id = self._require_param(params, "taskId", str, cmd_id)
500
- result = self.do_get_storage_state(task_id)
501
- return make_success_response(cmd_id, result)
502
-
503
- if method == "browser.cleanup":
504
- task_id = self._require_param(params, "taskId", str, cmd_id)
505
- result = self.do_cleanup(task_id)
506
- return make_success_response(cmd_id, result)
507
-
508
- # Unknown method
509
- return make_error_response(
510
- cmd_id,
511
- METHOD_NOT_FOUND,
512
- f"Method not found: {method}",
513
- )
440
+ return handler(self, params, cmd_id)
514
441
 
515
442
  except SessionNotFoundError as exc:
516
443
  return make_error_response(cmd_id, SESSION_ERROR, str(exc))
@@ -570,12 +497,12 @@ class BrowserBridge:
570
497
  params: dict[str, Any],
571
498
  key: str,
572
499
  expected_type: type,
573
- cmd_id: Any,
574
500
  ) -> Any:
575
501
  """Require a param to exist and be of the expected type.
576
502
 
577
- Raises a JSON-RPC invalid params error if the param is missing
578
- or has the wrong type.
503
+ Raises ``InvalidParamsError`` if the param is missing or has the
504
+ wrong type. The caller's ``except Exception`` clause turns this
505
+ into a JSON-RPC application-error response.
579
506
  """
580
507
  if key not in params:
581
508
  raise InvalidParamsError(f'Missing required parameter: "{key}"')