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
@@ -21,10 +21,12 @@ import re
21
21
  import sys
22
22
  import time
23
23
  from typing import Any, Optional
24
+ from urllib.parse import unquote as _urlunquote
24
25
 
25
26
  from .bridge import BrowserBridge, SessionNotFoundError
26
27
  from .bot_detection import check_bot_detection
27
28
  from .accessibility import parse_snapshot, build_locator_args
29
+ from .browser_data import NAV_SETTLE
28
30
 
29
31
  # ─── Playwright import (lazy, for better error messages) ──────────────
30
32
 
@@ -37,6 +39,11 @@ except ImportError:
37
39
  PlaywrightTimeout = TimeoutError # type: ignore[misc]
38
40
 
39
41
 
42
+ # ─── Nav-settle constants (loaded from shared browser-data.json) ──────
43
+
44
+ _DOM_STABILIZE_JS: str = NAV_SETTLE["domStabilizationJs"]
45
+
46
+
40
47
  # ─── Shared helper for bridge entry points ───────────────────────────
41
48
 
42
49
 
@@ -102,6 +109,133 @@ class PlaywrightBridge(BrowserBridge):
102
109
  #: Engine-specific install hint (shown when browser executable missing).
103
110
  _install_hint: str = ""
104
111
 
112
+ # ── Stealth quirks (Phase 0) ──────────────────────────────────
113
+ #
114
+ # These opt-in flags let stealth subclasses (Camoufox and other stealth engines)
115
+ # disable the base's hard-coded Playwright defaults that would otherwise
116
+ # clobber a fingerprint-managed browser context. All default to ``off``
117
+ # so the shipped ``chromium-py`` / ``firefox-py`` bridges are bit-identical.
118
+ #
119
+ # See stealth-browser-plan-v3.md "Quirks schema" for the concrete
120
+ # correctness problem each flag fixes.
121
+
122
+ #: When True, ``create_browser_context()`` does NOT pass ``viewport`` or
123
+ #: ``user_agent`` to ``new_context`` — the fingerprint package (e.g.
124
+ #: Camoufox at browser launch via ``NewBrowser``) generates those from
125
+ #: the fingerprint, and the base's hard-coded
126
+ #: values would override them with a detectable mismatch.
127
+ _fingerprint_managed_context: bool = False
128
+
129
+ #: Prefix prepended to every ``page.evaluate`` expression in ``do_evaluate``.
130
+ #: Camoufox runs eval in an isolated world with read-only page access by
131
+ #: default; ``"mw:"`` routes the script to the main world where writes work.
132
+ #: Reads work with the prefix too, so it's safe to apply unconditionally.
133
+ #: Empty string = no prefix (the shipped bridges).
134
+ _eval_prefix: str = ""
135
+
136
+ #: 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.
140
+ _scroll_via_wheel: bool = False
141
+
142
+ #: When True, ``create_browser_context`` passes ``no_viewport=True`` to
143
+ #: ``browser.new_context()``, telling Playwright to skip the
144
+ #: ``Browser.setDefaultViewport`` CDP call. The Camoufox patched Firefox
145
+ #: binary does not accept the ``isMobile`` property that Playwright Firefox
146
+ #: includes in ``setDefaultViewport``, which would otherwise cause a
147
+ #: ``Protocol error`` on context creation. Other backends that patch
148
+ #: ``new_context`` do not need this flag;
149
+ #: only set it when the binary rejects the default viewport call.
150
+ #: Default ``False`` so the shipped bridges stay bit-identical.
151
+ _skip_default_viewport: bool = False
152
+
153
+ #: When True, navigation-settle waits skip the ``networkidle`` load state.
154
+ #: The patched Firefox binaries used by some stealth backends
155
+ #: do not fire ``networkidle`` reliably, so waiting for it either
156
+ #: times out (``do_go_back``'s 30s default) or loiters in the Playwright sync
157
+ #: greenlet's event loop long enough to deadlock the Juggler driver when a
158
+ #: subsequent BrowserContext's ``new_page()`` is created. When True,
159
+ #: ``do_go_back`` uses ``wait_until="load"`` and ``_wait_for_page_ready``
160
+ #: skips its ``networkidle`` wait — matching ``do_navigate``'s load-based
161
+ #: settle, which works reliably on the patched binaries. Default ``False``
162
+ #: so the shipped chromium-py / firefox-py bridges keep their networkidle
163
+ #: settle behaviour bit-identical.
164
+ _skip_networkidle: bool = False
165
+
166
+ #: When True, ``do_evaluate`` wraps the expression in ``eval(<json>)``
167
+ #: before prepending :attr:`_eval_prefix` **and** enables a one-retry
168
+ #: recovery on genuine ``Execution context was destroyed`` errors.
169
+ #: Default ``False`` so the shipped ``chromium-py`` / ``firefox-py``
170
+ #: bridges (which have ``_eval_prefix = ""``) stay bit-identical.
171
+ #:
172
+ #: **Layer 1 — SyntaxError class (``eval(<json>)`` wrap).** Camoufox's
173
+ #: patched Juggler main-world eval path
174
+ #: (``MainWorldContext.executeInGlobal`` in the binary's ``omni.ja``)
175
+ #: wraps every ``mw:``-prefixed script as
176
+ #: ``(() => { let _s = (${script}); ... })()``. That wrapper requires
177
+ #: ``${script}`` to be a single *expression*; any *statement* (``let`` /
178
+ #: ``var`` / multiple ``;``-separated statements) is a ``SyntaxError``
179
+ #: (``missing ) in parenthetical``) that surfaces through Playwright as
180
+ #: ``"Execution context was destroyed, most likely because of a
181
+ #: navigation."`` — an uncatchable-looking error that is NOT a navigation
182
+ #: race at all. Rewriting the script as ``eval(<JSON-string of script>)``
183
+ #: makes it a single expression (valid inside ``let _s = (...)``) while
184
+ #: ``eval`` itself correctly handles both expressions and multi-statement
185
+ #: scripts, returning the completion value of the last statement.
186
+ #: This class is **terminal** — a SyntaxError after the wrap means the
187
+ #: wrap itself is broken and there is no retry.
188
+ #:
189
+ #: **Layer 2 — Genuine context-destruction class (retry).** On a
190
+ #: bot-detection challenge page the page may settle-navigate after
191
+ #: ``browser.navigate`` returns, destroying the ``mw:`` eval context
192
+ #: between the snapshot and a subsequent ``browser.inspect`` or
193
+ #: ``browser-console`` call. The error string is identical to Layer 1
194
+ #: (both produce ``"Execution context was destroyed…"`` through
195
+ #: Playwright) but the class is **transient** — a single
196
+ #: ``wait_for_load_state("load")`` + one ``page.evaluate`` retry
197
+ #: succeeds once the challenge settles. ``do_evaluate`` distinguishes
198
+ #: the two classes in its ``except`` handler: a retry is attempted
199
+ #: only when ``_wrap_mw_eval_in_eval`` is True (Camoufox-only; shipped
200
+ #: bridges never hit the ``mw:`` path) and only for the transient class.
201
+ #: See ``do_evaluate`` for the implementation.
202
+ _wrap_mw_eval_in_eval: bool = False
203
+
204
+ #: When True, read-only evals (``do_evaluate(read_only=True)``, used
205
+ #: only for the EXTRACTOR_SCRIPT) bypass ``page.evaluate`` entirely and
206
+ #: read a result that an ``add_init_script`` stashed in the DOM.
207
+ #:
208
+ #: **Why this exists.** Some patched-Firefox stealth binaries route
209
+ #: ``page.evaluate`` through ``eval()`` in the page's *main* world (a
210
+ #: stealth measure that kills Juggler's isolated-world debugger
211
+ #: signature). The page's CSP then applies, so on CSP-strict sites
212
+ #: (e.g. Reddit, which forbids ``unsafe-eval``) every ``page.evaluate``
213
+ #: fails with ``"call to eval() blocked by CSP"``. Camoufox is NOT
214
+ #: affected — its binary keeps Juggler's CSP-free isolated-world and
215
+ #: ``MainWorldContext.executeInGlobal`` paths; other patched-Firefox
216
+ #: stealth binaries are affected.
217
+ #:
218
+ #: **How it works.** ``create_browser_context`` calls
219
+ #: :meth:`_register_readonly_extractor_init_script`, which wraps the
220
+ #: EXTRACTOR_SCRIPT (plumbed from the TypeScript side via the
221
+ #: ``browser.init`` config key ``readOnlyExtractorScript``) in a
222
+ #: ``DOMContentLoaded``-deferred IIFE that writes its JSON result to
223
+ #: ``<meta id="__pi-extract" content="<urlencoded JSON>">``. The init
224
+ #: script runs in the isolated world, which is CSP-free.
225
+ #: ``do_evaluate(read_only=True)`` then reads that meta via
226
+ #: ``page.query_selector`` + ``get_attribute`` — both native Juggler
227
+ #: element commands, also CSP-free — instead of calling
228
+ #: ``page.evaluate``.
229
+ #:
230
+ #: **Limitation.** The meta is repopulated only on a full document
231
+ #: load (``page.goto``); SPA-style in-page route changes do NOT re-run
232
+ #: the init script, so the stashed result goes stale until the next
233
+ #: ``browser-navigate``. For the navigate→inspect agent flow this is
234
+ #: fine; document with a ``ponytail:`` note if you lift it.
235
+ #: Default ``False`` so shipped bridges stay bit-identical.
236
+ _csp_safe_readonly_via_init_script: bool = False
237
+
238
+
105
239
  # ── Shared Playwright state ─────────────────────────────────
106
240
 
107
241
  _pw: Any # Playwright instance (lazy, shared)
@@ -112,6 +246,13 @@ class PlaywrightBridge(BrowserBridge):
112
246
  self._pw = None
113
247
  self._browser = None
114
248
  self._cached_ua: str = ""
249
+ # The read-only extractor script plumbed from the TypeScript side
250
+ # via the ``browser.init`` config key ``readOnlyExtractorScript``.
251
+ # Populated lazily by _register_readonly_extractor_init_script;
252
+ # used by do_evaluate to gate the CSP-free meta handoff so a
253
+ # non-extractor read_only eval (none today) can't silently receive
254
+ # the extractor's stale result.
255
+ self._readonly_extractor_script: str = ""
115
256
 
116
257
  # ── Subclass extension point ───────────────────────────────
117
258
 
@@ -163,6 +304,7 @@ class PlaywrightBridge(BrowserBridge):
163
304
  except Exception:
164
305
  pass
165
306
 
307
+
166
308
  # ── Debug logging ───────────────────────────────────────────
167
309
 
168
310
  @property
@@ -179,6 +321,16 @@ class PlaywrightBridge(BrowserBridge):
179
321
  flush=True,
180
322
  )
181
323
 
324
+ def _log_op(self, event: str, ctx: dict, result: dict) -> dict:
325
+ """Log result and return it. Replaces success/failure _log pairs."""
326
+ if self._debug:
327
+ log_data = dict(ctx)
328
+ log_data["success"] = result.get("success", False)
329
+ if result.get("error"):
330
+ log_data["error"] = result["error"]
331
+ self._log(event, **log_data)
332
+ return result
333
+
182
334
  # ── Shared Playwright lifecycle ────────────────────────────
183
335
 
184
336
  def _ensure_playwright(self) -> tuple[Any, Any]:
@@ -232,17 +384,38 @@ class PlaywrightBridge(BrowserBridge):
232
384
  """Create a new isolated BrowserContext for a task session.
233
385
 
234
386
  Applies the default viewport, :attr:`effective_user_agent`, and
235
- ``storageState`` from config. Starts Playwright tracing if
236
- ``BROWSER_TRACE_DIR`` is set.
387
+ ``storageState`` from config — **unless**
388
+ :attr:`_fingerprint_managed_context` is True, in which case viewport
389
+ and user_agent are omitted so the stealth fingerprint package can
390
+ generate them from the fingerprint without being clobbered.
391
+ (Camoufox v135.x injects the fingerprint at browser launch via
392
+ ``NewBrowser``; some stealth engines patch ``browser.new_context``.)
393
+
394
+ Context creation always goes through ``browser.new_context(**kwargs)``.
395
+ Stealth backends that need fingerprint injection at context creation
396
+ time can override this method; the shipped Camoufox bridge injects at
397
+ browser launch time and needs no override.
398
+
399
+ Starts Playwright tracing if ``BROWSER_TRACE_DIR`` is set.
237
400
 
238
401
  Returns a Playwright ``BrowserContext`` (no Page yet).
239
402
  """
240
403
  _pw, browser = self._ensure_playwright()
241
404
 
242
- context_kwargs: dict[str, Any] = {
243
- "viewport": {"width": 1280, "height": 720},
244
- "user_agent": self.effective_user_agent,
245
- }
405
+ context_kwargs: dict[str, Any] = {}
406
+ if not self._fingerprint_managed_context:
407
+ # Shipped bridges: hard-coded defaults.
408
+ context_kwargs["viewport"] = {"width": 1280, "height": 720}
409
+ context_kwargs["user_agent"] = self.effective_user_agent
410
+ elif self._skip_default_viewport:
411
+ # The Camoufox patched Firefox binary does not accept the
412
+ # ``isMobile`` property that Playwright Firefox includes in
413
+ # ``Browser.setDefaultViewport``. Skip the call entirely.
414
+ context_kwargs["no_viewport"] = True
415
+ # When fingerprint-managed, ONLY pass storage_state (and let the
416
+ # fingerprint package set viewport/UA/screen/dpr). proxy/geolocation
417
+ # are forwarded by stealth subclasses at browser launch via their
418
+ # ``_launch_browser`` override (e.g. ``camoufox.NewBrowser``).
246
419
  storage_state = config.get("storageState")
247
420
  if storage_state is not None:
248
421
  context_kwargs["storage_state"] = storage_state
@@ -263,8 +436,94 @@ class PlaywrightBridge(BrowserBridge):
263
436
  except Exception:
264
437
  pass # Best-effort
265
438
 
439
+ # CSP-safe read-only eval path (see _csp_safe_readonly_via_init_script).
440
+ # Registers an init script that stashes the EXTRACTOR_SCRIPT result in
441
+ # the DOM so do_evaluate(read_only=True) can read it without
442
+ # page.evaluate (which is CSP-blocked on some patched binaries).
443
+ if self._csp_safe_readonly_via_init_script:
444
+ self._register_readonly_extractor_init_script(context)
445
+
266
446
  return context
267
447
 
448
+ def _register_readonly_extractor_init_script(self, context: Any) -> None:
449
+ """Register a CSP-free init script that stashes the read-only
450
+ extractor's JSON result in the DOM.
451
+
452
+ Reads the EXTRACTOR_SCRIPT from ``self.plugin_config[
453
+ "readOnlyExtractorScript"]`` (plumbed from the TypeScript adapter via
454
+ the ``browser.init`` RPC). Wraps it in a ``DOMContentLoaded``-deferred
455
+ IIFE that runs in the isolated world (CSP-free) and writes its JSON
456
+ return value to ``<meta id="__pi-extract" content="<urlencoded JSON>">``
457
+ once the DOM is parsed.
458
+
459
+ No-op (with a debug log) when the script is absent — e.g. an older
460
+ adapter that doesn't forward ``readOnlyExtractorScript``. In that
461
+ case ``do_evaluate(read_only=True)`` falls back to ``page.evaluate``,
462
+ which is the pre-fix behaviour (CSP-fails on strict sites, works on
463
+ lax sites). Idempotent: re-registering on a fresh context is fine
464
+ (init scripts are per-context, not global).
465
+ """
466
+ script = (self.plugin_config or {}).get("readOnlyExtractorScript")
467
+ if not script:
468
+ self._log(
469
+ "registerReadonlyExtractor",
470
+ taskId="shared",
471
+ success=False,
472
+ reason="readOnlyExtractorScript not present in plugin config",
473
+ )
474
+ return
475
+ self._readonly_extractor_script = script
476
+ # The EXTRACTOR_SCRIPT is a self-contained IIFE that returns a JSON
477
+ # string. It ends with a trailing ``;``; strip it so the assignment
478
+ # ``__r = <script>`` is a single expression statement (wrapping it as
479
+ # ``__r = (<script>);`` would put the script's ``;`` *inside* parens
480
+ # → ``__r = (...})(););`` → SyntaxError, and the whole init script
481
+ # then silently no-ops at document-start).
482
+ script_body = script
483
+ while script_body.endswith(";"):
484
+ script_body = script_body[:-1]
485
+ # Run the extractor at ``DOMContentLoaded`` (NOT ``load``) and write
486
+ # its JSON result to a meta tag. Two reasons specific to the
487
+ # affected patched-Firefox binaries force this shape:
488
+ # 1. ``document.head`` is null at document-start, so a synchronous
489
+ # write at init time would throw — defer until DCL when the head
490
+ # exists and the DOM is parsed.
491
+ # 2. A ``window.addEventListener('load', ...)`` listener registered
492
+ # from the isolated world does NOT fire on this patched binary
493
+ # (the isolated world's window doesn't receive the page's load
494
+ # event), while ``document.addEventListener('DOMContentLoaded'
495
+ # , ...)`` does fire. DCL is also early enough that the
496
+ # extractor's offsetParent / getClientRects / computed-visibility
497
+ # checks are accurate for text elements (layout is done at DCL;
498
+ # only images are still loading, and they don't affect text
499
+ # element layout).
500
+ # The init script runs in the isolated world, so it is NOT subject to
501
+ # the page's CSP (the whole reason this path exists).
502
+ # encodeURIComponent keeps the JSON safe inside an HTML attribute.
503
+ wrapper = (
504
+ "(() => {\n"
505
+ " const __piRun = () => {\n"
506
+ " let __r;\n"
507
+ " try { __r = " + script_body + "; }\n"
508
+ " catch (e) { __r = JSON.stringify({ error: (e && e.message) || String(e) }); }\n"
509
+ " let __m = document.getElementById('__pi-extract');\n"
510
+ " if (!__m) { __m = document.createElement('meta'); __m.id = '__pi-extract'; document.head.appendChild(__m); }\n"
511
+ " __m.setAttribute('content', encodeURIComponent(__r));\n"
512
+ " };\n"
513
+ " if (document.readyState !== 'loading') { __piRun(); }\n"
514
+ " else { document.addEventListener('DOMContentLoaded', __piRun, { once: true }); }\n"
515
+ "})();"
516
+ )
517
+ try:
518
+ context.add_init_script(wrapper)
519
+ except Exception as exc:
520
+ self._log(
521
+ "registerReadonlyExtractor",
522
+ taskId="shared",
523
+ success=False,
524
+ error=str(exc),
525
+ )
526
+
268
527
  def _setup_page_session(self, page: Any) -> dict[str, Any]:
269
528
  """Attach console capture and dialog handlers to a new page.
270
529
 
@@ -367,15 +626,24 @@ class PlaywrightBridge(BrowserBridge):
367
626
  # ── Internal helpers ───────────────────────────────────────────
368
627
 
369
628
  def _get_page(self, task_id: str) -> Any:
370
- """Get the Playwright Page for a task, or raise SessionNotFoundError."""
371
629
  session = self.require_session(task_id)
372
630
  return session["page"]
373
631
 
374
- def _get_dialog_events(self, task_id: str) -> list[dict[str, str]]:
375
- """Get up to 10 most recent auto-dismissed dialog events for a task.
632
+ def _get_page_or_error(
633
+ self, task_id: str, op: str
634
+ ) -> tuple[Any, dict[str, Any] | None]:
635
+ """Resolve the page for a task, or return an error dict for the caller to emit."""
636
+ try:
637
+ return self._get_page(task_id), None
638
+ except SessionNotFoundError:
639
+ raise
640
+ except Exception as exc:
641
+ return None, {
642
+ "success": False,
643
+ "error": f"{op[0].upper()}{op[1:]} failed: {exc}",
644
+ }
376
645
 
377
- Returns a list of ``{type, message, handledAs}`` dicts.
378
- """
646
+ def _get_dialog_events(self, task_id: str) -> list[dict[str, str]]:
379
647
  session = self.get_session(task_id)
380
648
  if not session:
381
649
  return []
@@ -388,7 +656,6 @@ class PlaywrightBridge(BrowserBridge):
388
656
  def _take_snapshot_and_cache(
389
657
  self, task_id: str, page: Any
390
658
  ) -> tuple[str, int, dict[str, dict[str, Any]]]:
391
- """Take snapshot, cache elements, return formatted text + count."""
392
659
  try:
393
660
  snap_text: str = page.aria_snapshot()
394
661
  except Exception:
@@ -413,6 +680,31 @@ class PlaywrightBridge(BrowserBridge):
413
680
  for ref, node in parsed.elements.items()
414
681
  }
415
682
 
683
+ def _build_interaction_result(
684
+ self, task_id: str, page: Any, **extra: Any
685
+ ) -> dict[str, Any]:
686
+ snap_text, element_count, elements = self._take_snapshot_and_cache(
687
+ task_id, page
688
+ )
689
+ result: dict[str, Any] = {
690
+ "success": True,
691
+ "snapshot": snap_text,
692
+ "elementCount": element_count,
693
+ "elements": elements,
694
+ "dialogEvents": self._get_dialog_events(task_id),
695
+ }
696
+ result.update(extra)
697
+ return result
698
+
699
+ def _ref_debug_info(self, task_id: str, ref: str) -> tuple[str, str]:
700
+ key = ref[1:] if ref.startswith("@") else ref
701
+ cache = self.get_element_cache(task_id)
702
+ node = cache.elements.get(key) if cache else None
703
+ return (
704
+ getattr(node, "role", "unknown") if node else "unknown",
705
+ getattr(node, "name", "unknown") if node else "unknown",
706
+ )
707
+
416
708
  def _locate_element(
417
709
  self, page: Any, task_id: str, ref: str
418
710
  ) -> Any:
@@ -454,7 +746,7 @@ class PlaywrightBridge(BrowserBridge):
454
746
  # ── Navigation settle helpers ───────────────────────────────
455
747
 
456
748
  @staticmethod
457
- def _wait_for_page_ready(page: Any, timeout_ms: int) -> None:
749
+ def _wait_for_page_ready(page: Any, timeout_ms: int, skip_networkidle: bool = False) -> None:
458
750
  """Wait for page readiness after a navigation.
459
751
 
460
752
  Mirrors the TypeScript ``waitForPageReady`` helper — each load
@@ -466,15 +758,19 @@ class PlaywrightBridge(BrowserBridge):
466
758
  Args:
467
759
  page: Playwright Page.
468
760
  timeout_ms: Timeout for each wait_for_load_state call.
761
+ skip_networkidle: When True, skip the ``networkidle`` wait —
762
+ stealth patched-Firefox binaries don't fire it reliably and
763
+ loitering for it can deadlock the Juggler driver.
469
764
  """
470
765
  try:
471
766
  page.wait_for_load_state("load", timeout=timeout_ms)
472
767
  except Exception:
473
768
  pass
474
- try:
475
- page.wait_for_load_state("networkidle", timeout=timeout_ms)
476
- except Exception:
477
- pass
769
+ if not skip_networkidle:
770
+ try:
771
+ page.wait_for_load_state("networkidle", timeout=timeout_ms)
772
+ except Exception:
773
+ pass
478
774
 
479
775
  @staticmethod
480
776
  def _wait_for_navigation_settle(
@@ -482,6 +778,7 @@ class PlaywrightBridge(BrowserBridge):
482
778
  url_before: str,
483
779
  nav_timeout_ms: int = 5000,
484
780
  settle_timeout_ms: int = 400,
781
+ skip_networkidle: bool = False,
485
782
  ) -> tuple[bool, str]:
486
783
  """Wait for navigation to settle after a user interaction.
487
784
 
@@ -517,11 +814,11 @@ class PlaywrightBridge(BrowserBridge):
517
814
 
518
815
  waited_for_load = False
519
816
  if navigated:
520
- PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
817
+ PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
521
818
  waited_for_load = True
522
819
  elif page.url != url_before:
523
820
  # URL changed without framenavigated event
524
- PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
821
+ PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
525
822
  waited_for_load = True
526
823
  else:
527
824
  # No navigation — settle for client-side rerenders
@@ -529,7 +826,7 @@ class PlaywrightBridge(BrowserBridge):
529
826
 
530
827
  # Late-arrival gate: catch navigations that started during settle
531
828
  if not waited_for_load and (navigated or page.url != url_before):
532
- PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms)
829
+ PlaywrightBridge._wait_for_page_ready(page, nav_timeout_ms, skip_networkidle)
533
830
 
534
831
  finally:
535
832
  page.remove_listener("framenavigated", _on_nav)
@@ -591,9 +888,16 @@ class PlaywrightBridge(BrowserBridge):
591
888
  re.IGNORECASE,
592
889
  )
593
890
  )
594
- if is_transient and attempt == 0:
595
- time.sleep(2)
596
- last_error = msg
891
+ # Nested ifs (rather than `is_transient and attempt == 0`)
892
+ # keep the no-boolean-in-except lint calm; behavior is
893
+ # identical: retry once on a transient error, then give up.
894
+ if is_transient:
895
+ if attempt == 0:
896
+ time.sleep(2)
897
+ last_error = msg
898
+ else:
899
+ last_error = msg
900
+ break
597
901
  else:
598
902
  last_error = msg
599
903
  break
@@ -601,16 +905,8 @@ class PlaywrightBridge(BrowserBridge):
601
905
  # ── DOM stabilization wait ──────────────────────────────
602
906
  try:
603
907
  page.wait_for_function(
604
- """() => new Promise(resolve => {
605
- const count = document.querySelectorAll("*").length;
606
- setTimeout(() => {
607
- resolve(
608
- document.querySelectorAll("*").length === count
609
- || count > 5000
610
- );
611
- }, 400);
612
- })""",
613
- timeout=5_000,
908
+ _DOM_STABILIZE_JS,
909
+ timeout=NAV_SETTLE["navTimeoutMs"],
614
910
  )
615
911
  except Exception:
616
912
  pass # Stabilization timed out — proceed
@@ -675,46 +971,19 @@ class PlaywrightBridge(BrowserBridge):
675
971
  }
676
972
 
677
973
  def do_cleanup(self, task_id: str) -> dict[str, Any]:
678
- """Clean up resources for a specific task.
679
-
680
- Profile persistence is handled by the TypeScript side
681
- (``python-adapter.ts`` auto-saves storage state before calling
682
- cleanup), so this always calls :meth:`close_browser_session`.
683
- """
684
974
  self.close_browser_session(task_id)
685
975
  return {"success": True}
686
976
 
687
977
  def do_snapshot(self, task_id: str) -> dict[str, Any]:
688
- """Take a fresh accessibility snapshot and refresh element cache."""
689
- _t_start = time.time()
690
- try:
691
- page = self._get_page(task_id)
692
- except SessionNotFoundError:
693
- raise
694
- except Exception as exc:
695
- self._log("snapshot", taskId=task_id, success=False,
696
- elementCount=0, dialogBlocks=0, fingerprint="",
697
- time=round((time.time() - _t_start) * 1000))
698
- return {
699
- "success": False,
700
- "snapshot": "",
701
- "elementCount": 0,
702
- "error": str(exc),
703
- }
978
+ page, err = self._get_page_or_error(task_id, "snapshot")
979
+ if err:
980
+ return {**err, "snapshot": "", "elementCount": 0}
704
981
 
705
982
  try:
706
983
  snap_text, element_count, elements = self._take_snapshot_and_cache(
707
984
  task_id, page
708
985
  )
709
- session = self.get_session(task_id)
710
- dialog_blocks = len(session.get("dialog_log", [])) if session else 0
711
- fingerprint = snap_text[:16] if snap_text else ""
712
- self._log("snapshot", taskId=task_id, success=True,
713
- elementCount=element_count,
714
- dialogBlocks=dialog_blocks,
715
- fingerprint=fingerprint,
716
- time=round((time.time() - _t_start) * 1000))
717
- return {
986
+ result = {
718
987
  "success": True,
719
988
  "snapshot": snap_text,
720
989
  "elementCount": element_count,
@@ -722,216 +991,118 @@ class PlaywrightBridge(BrowserBridge):
722
991
  "dialogEvents": self._get_dialog_events(task_id),
723
992
  }
724
993
  except Exception as exc:
725
- self._log("snapshot", taskId=task_id, success=False,
726
- elementCount=0, dialogBlocks=0, fingerprint="",
727
- time=round((time.time() - _t_start) * 1000))
728
- return {
994
+ result = {
729
995
  "success": False,
730
996
  "snapshot": "",
731
997
  "elementCount": 0,
732
998
  "error": str(exc),
733
999
  }
1000
+ return self._log_op("snapshot", {"taskId": task_id}, result)
734
1001
 
735
1002
  # ── Interaction ─────────────────────────────────────────────────
736
1003
 
737
1004
  def do_click(self, task_id: str, ref: str) -> dict[str, Any]:
738
- """Click an element by @e ref."""
739
- _t_start = time.time()
1005
+ _role, _name = self._ref_debug_info(task_id, ref)
740
1006
 
741
- # Extract role/name from element cache for debug logging
742
- _key = ref[1:] if ref.startswith("@") else ref
743
- _cache = self.get_element_cache(task_id)
744
- _node = _cache.elements.get(_key) if _cache else None
745
- _role: str = getattr(_node, "role", "unknown") if _node else "unknown"
746
- _name: str = getattr(_node, "name", "unknown") if _node else "unknown"
747
-
748
- try:
749
- page = self._get_page(task_id)
750
- except SessionNotFoundError:
751
- raise
752
- except Exception as exc:
753
- self._log("click", taskId=task_id, ref=ref, role=_role,
754
- name=_name, result="fail",
755
- time=round((time.time() - _t_start) * 1000))
756
- return {
757
- "success": False,
758
- "error": f"Click failed: {exc}",
759
- }
1007
+ page, err = self._get_page_or_error(task_id, "click")
1008
+ if err:
1009
+ return err
760
1010
 
761
1011
  try:
762
1012
  locator = self._locate_element(page, task_id, ref)
763
1013
  except RuntimeError as exc:
764
1014
  self._log("click", taskId=task_id, ref=ref, role=_role,
765
- name=_name, result="fail",
766
- time=round((time.time() - _t_start) * 1000))
1015
+ name=_name, result="fail")
767
1016
  return {"success": False, "error": str(exc)}
768
1017
 
769
1018
  try:
770
1019
  url_before = page.url
771
1020
  locator.click(timeout=5_000)
772
1021
 
773
- navigated, _ = self._wait_for_navigation_settle(page, url_before)
1022
+ navigated, _ = self._wait_for_navigation_settle(
1023
+ page, url_before, skip_networkidle=self._skip_networkidle,
1024
+ )
774
1025
 
775
1026
  new_url = page.url
776
1027
  new_title = page.title()
777
1028
 
778
- snap_text, element_count, elements = self._take_snapshot_and_cache(
779
- task_id, page
1029
+ result = self._build_interaction_result(
1030
+ task_id, page, newUrl=new_url, newTitle=new_title,
780
1031
  )
781
-
782
- self._log("click", taskId=task_id, ref=ref, role=_role,
783
- name=_name, result="success", navigated=navigated,
784
- time=round((time.time() - _t_start) * 1000))
785
-
786
- dialog_events = self._get_dialog_events(task_id)
787
- result: dict[str, Any] = {
788
- "success": True,
789
- "snapshot": snap_text,
790
- "elementCount": element_count,
791
- "elements": elements,
792
- "dialogEvents": dialog_events,
793
- "newUrl": new_url,
794
- "newTitle": new_title,
795
- }
796
- return result
797
-
798
1032
  except Exception as exc:
799
- self._log("click", taskId=task_id, ref=ref, role=_role,
800
- name=_name, result="fail",
801
- time=round((time.time() - _t_start) * 1000))
802
- return {
1033
+ result = {
803
1034
  "success": False,
804
1035
  "error": f"Click failed: {exc}",
805
1036
  }
1037
+ return self._log_op("click", {"taskId": task_id, "ref": ref, "role": _role, "name": _name}, result)
806
1038
 
807
1039
  def do_type(self, task_id: str, ref: str, text: str) -> dict[str, Any]:
808
- """Type text into an element by @e ref."""
809
- _t_start = time.time()
1040
+ _role, _name = self._ref_debug_info(task_id, ref)
810
1041
 
811
- # Extract role/name from element cache for debug logging
812
- _key = ref[1:] if ref.startswith("@") else ref
813
- _cache = self.get_element_cache(task_id)
814
- _node = _cache.elements.get(_key) if _cache else None
815
- _role: str = getattr(_node, "role", "unknown") if _node else "unknown"
816
- _name: str = getattr(_node, "name", "unknown") if _node else "unknown"
817
-
818
- try:
819
- page = self._get_page(task_id)
820
- except SessionNotFoundError:
821
- raise
822
- except Exception as exc:
823
- self._log("type", taskId=task_id, ref=ref, role=_role,
824
- name=_name, result="fail",
825
- time=round((time.time() - _t_start) * 1000))
826
- return {
827
- "success": False,
828
- "error": f"Type failed: {exc}",
829
- }
1042
+ page, err = self._get_page_or_error(task_id, "type")
1043
+ if err:
1044
+ return err
830
1045
 
831
1046
  try:
832
1047
  locator = self._locate_element(page, task_id, ref)
833
1048
  except RuntimeError as exc:
834
1049
  self._log("type", taskId=task_id, ref=ref, role=_role,
835
- name=_name, result="fail",
836
- time=round((time.time() - _t_start) * 1000))
1050
+ name=_name, result="fail")
837
1051
  return {"success": False, "error": str(exc)}
838
1052
 
839
1053
  try:
840
1054
  locator.click(timeout=5_000) # Focus first
841
1055
  locator.fill(text)
842
1056
 
843
- snap_text, element_count, elements = self._take_snapshot_and_cache(
844
- task_id, page
845
- )
846
-
847
- self._log("type", taskId=task_id, ref=ref, role=_role,
848
- name=_name, result="success",
849
- elementCount=element_count,
850
- time=round((time.time() - _t_start) * 1000))
851
-
852
- return {
853
- "success": True,
854
- "snapshot": snap_text,
855
- "elementCount": element_count,
856
- "elements": elements,
857
- "dialogEvents": self._get_dialog_events(task_id),
858
- }
859
-
1057
+ result = self._build_interaction_result(task_id, page)
860
1058
  except Exception as exc:
861
- self._log("type", taskId=task_id, ref=ref, role=_role,
862
- name=_name, result="fail",
863
- time=round((time.time() - _t_start) * 1000))
864
- return {
1059
+ result = {
865
1060
  "success": False,
866
1061
  "error": f"Type failed: {exc}",
867
1062
  }
1063
+ return self._log_op("type", {"taskId": task_id, "ref": ref, "role": _role, "name": _name}, result)
868
1064
 
869
1065
  def do_scroll(self, task_id: str, direction: str) -> dict[str, Any]:
870
- """Scroll the page up or down."""
871
- _t_start = time.time()
872
- try:
873
- page = self._get_page(task_id)
874
- except SessionNotFoundError:
875
- raise
876
- except Exception as exc:
877
- self._log("scroll", taskId=task_id, direction=direction,
878
- success=False,
879
- time=round((time.time() - _t_start) * 1000))
880
- return {
881
- "success": False,
882
- "error": f"Scroll failed: {exc}",
883
- }
1066
+ page, err = self._get_page_or_error(task_id, "scroll")
1067
+ if err:
1068
+ return err
884
1069
 
885
1070
  try:
886
1071
  delta = 800 if direction == "down" else -800
887
- page.evaluate(
888
- """(d) => window.scrollBy({ top: d, behavior: 'smooth' })""",
889
- delta,
890
- )
1072
+ if self._scroll_via_wheel:
1073
+ # Camoufox runs page.evaluate in an isolated world where
1074
+ # the eval-write window.scrollBy silently no-ops; drive the
1075
+ # scroll via input events instead.
1076
+ page.mouse.wheel(0, delta)
1077
+ else:
1078
+ page.evaluate(
1079
+ """(d) => window.scrollBy({ top: d, behavior: 'smooth' })""",
1080
+ delta,
1081
+ )
891
1082
  time.sleep(0.2)
892
1083
 
893
- snap_text, element_count, elements = self._take_snapshot_and_cache(
894
- task_id, page
895
- )
896
-
897
- self._log("scroll", taskId=task_id, direction=direction,
898
- success=True, elementCount=element_count,
899
- time=round((time.time() - _t_start) * 1000))
900
-
901
- return {
902
- "success": True,
903
- "snapshot": snap_text,
904
- "elementCount": element_count,
905
- "elements": elements,
906
- "dialogEvents": self._get_dialog_events(task_id),
907
- }
908
-
1084
+ result = self._build_interaction_result(task_id, page)
909
1085
  except Exception as exc:
910
- self._log("scroll", taskId=task_id, direction=direction,
911
- success=False,
912
- time=round((time.time() - _t_start) * 1000))
913
- return {
1086
+ result = {
914
1087
  "success": False,
915
1088
  "error": f"Scroll failed: {exc}",
916
1089
  }
1090
+ return self._log_op("scroll", {"taskId": task_id, "direction": direction}, result)
917
1091
 
918
1092
  def do_go_back(self, task_id: str) -> dict[str, Any]:
919
- """Navigate back in history."""
920
- _t_start = time.time()
921
- try:
922
- page = self._get_page(task_id)
923
- except SessionNotFoundError:
924
- raise
925
- except Exception as exc:
926
- self._log("goBack", taskId=task_id, success=False,
927
- time=round((time.time() - _t_start) * 1000))
928
- return {
929
- "success": False,
930
- "error": f"GoBack failed: {exc}",
931
- }
1093
+ page, err = self._get_page_or_error(task_id, "goBack")
1094
+ if err:
1095
+ return err
932
1096
 
933
1097
  try:
934
- page.go_back(wait_until="networkidle")
1098
+ if self._skip_networkidle:
1099
+ # Stealth patched Firefox doesn't fire networkidle; waiting for
1100
+ # it times out (30s default) and loitering in the event loop can
1101
+ # deadlock the Juggler driver for subsequent contexts. Match
1102
+ # ``do_navigate``'s load-based settle instead.
1103
+ page.go_back(wait_until="load", timeout=15_000)
1104
+ else:
1105
+ page.go_back(wait_until="networkidle")
935
1106
  time.sleep(0.3)
936
1107
 
937
1108
  new_url: Optional[str] = None
@@ -942,88 +1113,45 @@ class PlaywrightBridge(BrowserBridge):
942
1113
  except Exception:
943
1114
  pass
944
1115
 
945
- snap_text, element_count, elements = self._take_snapshot_and_cache(
946
- task_id, page
947
- )
948
-
949
- self._log("goBack", taskId=task_id, success=True,
950
- elementCount=element_count,
951
- time=round((time.time() - _t_start) * 1000))
952
-
953
- dialog_events = self._get_dialog_events(task_id)
954
- result: dict[str, Any] = {
955
- "success": True,
956
- "snapshot": snap_text,
957
- "elementCount": element_count,
958
- "elements": elements,
959
- "dialogEvents": dialog_events,
960
- }
1116
+ extra: dict[str, Any] = {}
961
1117
  if new_url is not None:
962
- result["newUrl"] = new_url
1118
+ extra["newUrl"] = new_url
963
1119
  if new_title is not None:
964
- result["newTitle"] = new_title
965
- return result
966
-
1120
+ extra["newTitle"] = new_title
1121
+ result = self._build_interaction_result(task_id, page, **extra)
967
1122
  except Exception as exc:
968
- self._log("goBack", taskId=task_id, success=False,
969
- time=round((time.time() - _t_start) * 1000))
970
- return {
1123
+ result = {
971
1124
  "success": False,
972
1125
  "error": f"GoBack failed: {exc}",
973
1126
  }
1127
+ return self._log_op("goBack", {"taskId": task_id}, result)
974
1128
 
975
1129
  def do_press(self, task_id: str, key: str) -> dict[str, Any]:
976
- """Press a keyboard key."""
977
- _t_start = time.time()
978
- try:
979
- page = self._get_page(task_id)
980
- except SessionNotFoundError:
981
- raise
982
- except Exception as exc:
983
- self._log("press", taskId=task_id, key=key, success=False,
984
- time=round((time.time() - _t_start) * 1000))
985
- return {
986
- "success": False,
987
- "error": f"Press failed: {exc}",
988
- }
1130
+ page, err = self._get_page_or_error(task_id, "press")
1131
+ if err:
1132
+ return err
989
1133
 
990
1134
  try:
991
1135
  url_before = page.url
992
1136
  page.keyboard.press(key)
993
1137
 
994
1138
  navigated, _ = self._wait_for_navigation_settle(
995
- page, url_before, nav_timeout_ms=3000
1139
+ page, url_before, nav_timeout_ms=3000,
1140
+ skip_networkidle=self._skip_networkidle,
996
1141
  )
997
1142
 
998
1143
  new_url = page.url
999
1144
  new_title = page.title()
1000
1145
 
1001
- snap_text, element_count, elements = self._take_snapshot_and_cache(
1002
- task_id, page
1146
+ result = self._build_interaction_result(
1147
+ task_id, page, newUrl=new_url, newTitle=new_title,
1003
1148
  )
1004
-
1005
- self._log("press", taskId=task_id, key=key, success=True,
1006
- navigated=navigated, elementCount=element_count,
1007
- time=round((time.time() - _t_start) * 1000))
1008
-
1009
- result: dict[str, Any] = {
1010
- "success": True,
1011
- "snapshot": snap_text,
1012
- "elementCount": element_count,
1013
- "elements": elements,
1014
- "dialogEvents": self._get_dialog_events(task_id),
1015
- "newUrl": new_url,
1016
- "newTitle": new_title,
1017
- }
1018
- return result
1019
-
1020
1149
  except Exception as exc:
1021
- self._log("press", taskId=task_id, key=key, success=False,
1022
- time=round((time.time() - _t_start) * 1000))
1023
- return {
1150
+ result = {
1024
1151
  "success": False,
1025
1152
  "error": f"Press failed: {exc}",
1026
1153
  }
1154
+ return self._log_op("press", {"taskId": task_id, "key": key}, result)
1027
1155
 
1028
1156
  # ── Media ───────────────────────────────────────────────────────
1029
1157
 
@@ -1032,17 +1160,9 @@ class PlaywrightBridge(BrowserBridge):
1032
1160
  task_id: str,
1033
1161
  full_page: bool = False,
1034
1162
  ) -> dict[str, Any]:
1035
- """Take a JPEG screenshot and return as a base64 data URI."""
1036
- try:
1037
- page = self._get_page(task_id)
1038
- except SessionNotFoundError:
1039
- raise
1040
- except Exception as exc:
1041
- return {
1042
- "success": False,
1043
- "dataUri": "",
1044
- "error": str(exc),
1045
- }
1163
+ page, err = self._get_page_or_error(task_id, "screenshot")
1164
+ if err:
1165
+ return {**err, "dataUri": ""}
1046
1166
 
1047
1167
  try:
1048
1168
  buffer: bytes = page.screenshot(
@@ -1068,7 +1188,6 @@ class PlaywrightBridge(BrowserBridge):
1068
1188
  # ── Console & eval ──────────────────────────────────────────────
1069
1189
 
1070
1190
  def do_get_console_messages(self, task_id: str) -> dict[str, Any]:
1071
- """Return captured console messages for the task."""
1072
1191
  try:
1073
1192
  session = self.get_session(task_id)
1074
1193
  if session is None:
@@ -1092,7 +1211,6 @@ class PlaywrightBridge(BrowserBridge):
1092
1211
  }
1093
1212
 
1094
1213
  def do_clear_console(self, task_id: str) -> dict[str, Any]:
1095
- """Clear captured console messages for the task."""
1096
1214
  try:
1097
1215
  session = self.get_session(task_id)
1098
1216
  if session is not None:
@@ -1105,29 +1223,127 @@ class PlaywrightBridge(BrowserBridge):
1105
1223
  "error": str(exc),
1106
1224
  }
1107
1225
 
1108
- def do_evaluate(self, task_id: str, expression: str) -> dict[str, Any]:
1109
- """Evaluate JavaScript in the page context."""
1110
- try:
1111
- page = self._get_page(task_id)
1112
- except SessionNotFoundError:
1113
- raise
1114
- except Exception as exc:
1115
- return {
1116
- "success": False,
1117
- "error": str(exc),
1118
- }
1226
+ def do_evaluate(
1227
+ self, task_id: str, expression: str, *, read_only: bool = False
1228
+ ) -> dict[str, Any]:
1229
+ """Evaluate JavaScript in the page context.
1230
+
1231
+ When :attr:`_eval_prefix` is non-empty (e.g. Camoufox's ``"mw:"``),
1232
+ it is prepended to the expression so the script runs in the main
1233
+ world where writes work. Reads work with the prefix too, so it is
1234
+ safe to apply unconditionally.
1235
+
1236
+ When :attr:`_wrap_mw_eval_in_eval` is True (Camoufox), the expression
1237
+ is first wrapped as ``eval(<JSON-string of expression>)`` so that
1238
+ multi-statement scripts survive Camoufox's
1239
+ ``let _s = (${script})`` main-world wrapper (see the quirk's
1240
+ docstring for the full rationale).
1241
+
1242
+ When *read_only* is True (used for the EXTRACTOR_SCRIPT), the
1243
+ ``_eval_prefix`` and ``_wrap_mw_eval_in_eval`` are both bypassed
1244
+ — the expression targets the isolated-world context, which survives
1245
+ in-page JS churn on Camoufox challenge pages. Writes still need
1246
+ the ``mw:`` prefix; pure DOM reads don't.
1247
+ """
1248
+ page, err = self._get_page_or_error(task_id, "evaluate")
1249
+ if err:
1250
+ return err
1251
+
1252
+ # CSP-safe read-only handoff (patched-Firefox stealth binaries).
1253
+ # On binaries that route page.evaluate through eval() in the main
1254
+ # world, the EXTRACTOR_SCRIPT is CSP-blocked on strict sites. An init
1255
+ # script stashed the result in <meta id="__pi-extract"> at load; read
1256
+ # it via native query_selector + get_attribute (both CSP-free). Only
1257
+ # honored when the expression is exactly the registered extractor — a
1258
+ # future non-extractor read_only eval falls through to page.evaluate.
1259
+ # ponytail: result is stale across SPA route changes (no new load);
1260
+ # fine for navigate→inspect. Re-run on demand if SPA freshness matters.
1261
+ if (
1262
+ read_only
1263
+ and self._csp_safe_readonly_via_init_script
1264
+ and self._readonly_extractor_script
1265
+ and expression == self._readonly_extractor_script
1266
+ ):
1267
+ try:
1268
+ meta = page.query_selector("meta#__pi-extract")
1269
+ if meta is not None:
1270
+ raw = meta.get_attribute("content")
1271
+ if raw:
1272
+ return {"success": True, "result": _urlunquote(raw)}
1273
+ except Exception as exc:
1274
+ # Fall through to page.evaluate below — on CSP-strict pages
1275
+ # that will also fail, but the error message is then the
1276
+ # truthful CSP one rather than a meta-read exception.
1277
+ self._log(
1278
+ "evaluate",
1279
+ taskId=task_id,
1280
+ success=False,
1281
+ via="csp_safe_meta",
1282
+ error=str(exc),
1283
+ )
1284
+ # Meta missing (page navigated before load fired, or init script
1285
+ # not registered). Fall through to page.evaluate as best-effort.
1286
+
1287
+ # Build effective expression before the try block so the
1288
+ # retry path can reference it regardless of where the exception
1289
+ # came from.
1290
+ #
1291
+ # Read-only evals (EXTRACTOR_SCRIPT) skip the mw: prefix and the
1292
+ # eval() wrap entirely — they run in the isolated-world context,
1293
+ # which survives in-page JS churn on Camoufox challenge pages.
1294
+ # Writes still need mw:; pure DOM reads don't.
1295
+ if read_only:
1296
+ effective_expression = expression
1297
+ elif self._wrap_mw_eval_in_eval:
1298
+ # ``eval(<json>)`` is a single expression (valid inside
1299
+ # Camoufox's ``let _s = (...)`` wrapper) that runs the script
1300
+ # verbatim and returns its completion value. ``json.dumps``
1301
+ # safely escapes the script into a JS string literal.
1302
+ inner = "eval(" + json.dumps(expression) + ")"
1303
+ effective_expression = (
1304
+ self._eval_prefix + inner if self._eval_prefix else inner
1305
+ )
1306
+ else:
1307
+ effective_expression = (
1308
+ self._eval_prefix + expression if self._eval_prefix else expression
1309
+ )
1119
1310
 
1120
1311
  try:
1121
- result: Any = page.evaluate(expression)
1312
+ result: Any = page.evaluate(effective_expression)
1122
1313
  return {
1123
1314
  "success": True,
1124
1315
  "result": result,
1125
1316
  }
1126
1317
 
1127
1318
  except Exception as exc:
1319
+ err_msg = str(exc)
1320
+ # Genuine context-destruction recovery (Camoufox mw: path only).
1321
+ # When _wrap_mw_eval_in_eval is True (Camoufox), distinguish:
1322
+ # (1) "Execution context was destroyed" — a transient page
1323
+ # navigation/challenge-settle — recover with one
1324
+ # wait_for_load_state + retry.
1325
+ # (2) Any other error (including a SyntaxError through the eval
1326
+ # wrap, which means the wrap itself is broken) — terminal.
1327
+ # ponytail: single retry, no backoff — challenge pages settle in
1328
+ # one load cycle; add exponential backoff if a real challenge
1329
+ # needs >1 retry.
1330
+ # Nested ifs (rather than
1331
+ # `self._wrap_mw_eval_in_eval and "Execution context was destroyed" in err_msg`)
1332
+ # keep the no-boolean-in-except lint calm; behavior is identical.
1333
+ if self._wrap_mw_eval_in_eval:
1334
+ if "Execution context was destroyed" in err_msg:
1335
+ try:
1336
+ page.wait_for_load_state("load")
1337
+ except Exception:
1338
+ pass # Best-effort: proceed to retry even if wait fails
1339
+ try:
1340
+ result = page.evaluate(effective_expression)
1341
+ return {"success": True, "result": result}
1342
+ except Exception as retry_exc:
1343
+ return {"success": False, "error": str(retry_exc)}
1128
1344
  return {
1129
1345
  "success": False,
1130
- "error": str(exc),
1346
+ "error": err_msg,
1131
1347
  }
1132
1348
 
1133
1349
  # ── Cookies & storage state ─────────────────────────────────
@@ -1135,7 +1351,6 @@ class PlaywrightBridge(BrowserBridge):
1135
1351
  def do_get_cookies(
1136
1352
  self, task_id: str, urls: Optional[list[str]] = None
1137
1353
  ) -> dict[str, Any]:
1138
- """Get cookies, optionally filtered by URL."""
1139
1354
  try:
1140
1355
  session = self.require_session(task_id)
1141
1356
  context: Any = session["context"]
@@ -1163,7 +1378,6 @@ class PlaywrightBridge(BrowserBridge):
1163
1378
  def do_add_cookies(
1164
1379
  self, task_id: str, cookies: list[dict[str, Any]]
1165
1380
  ) -> dict[str, Any]:
1166
- """Add cookies to the browser context."""
1167
1381
  try:
1168
1382
  session = self.require_session(task_id)
1169
1383
  context: Any = session["context"]
@@ -1181,7 +1395,6 @@ class PlaywrightBridge(BrowserBridge):
1181
1395
  domain: Optional[str] = None,
1182
1396
  path: Optional[str] = None,
1183
1397
  ) -> dict[str, Any]:
1184
- """Clear cookies, optionally filtered by name/domain/path."""
1185
1398
  try:
1186
1399
  session = self.require_session(task_id)
1187
1400
  context: Any = session["context"]
@@ -1201,7 +1414,6 @@ class PlaywrightBridge(BrowserBridge):
1201
1414
  return {"success": False, "error": str(exc)}
1202
1415
 
1203
1416
  def do_get_storage_state(self, task_id: str) -> dict[str, Any]:
1204
- """Get full storage state (cookies + localStorage + IndexedDB)."""
1205
1417
  try:
1206
1418
  session = self.require_session(task_id)
1207
1419
  context: Any = session["context"]