pi-lean-dimension 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -0,0 +1,768 @@
1
+ """
2
+ Tests for ``pi_browser_bridge.playwright_base.PlaywrightBridge`` Phase 0 work:
3
+
4
+ * ``plugin_config`` defaults to ``{}`` and is populated by the
5
+ ``browser.init`` RPC handler (inherited from ``BrowserBridge``).
6
+ * The stealth quirks change base-class behavior:
7
+ - ``_fingerprint_managed_context`` → viewport/user_agent omitted
8
+ - ``_skip_default_viewport`` → ``no_viewport=True`` passed to avoid
9
+ the Camoufox binary's ``setDefaultViewport`` ``isMobile`` rejection
10
+ - ``_scroll_via_wheel`` → ``do_scroll`` uses ``page.mouse.wheel``
11
+ - ``_eval_prefix`` → ``do_evaluate`` prepends the prefix
12
+ - ``_wrap_mw_eval_in_eval`` → ``do_evaluate`` wraps the script as
13
+ ``eval(<json>)`` so multi-statement scripts survive Camoufox's
14
+ ``let _s = (${script})`` main-world wrapper
15
+
16
+ These are pure-logic tests — no Playwright browser required. A small
17
+ fake page/context mocks the few Playwright methods the code paths touch.
18
+ """
19
+
20
+ import sys
21
+ from typing import Any
22
+
23
+ import pytest
24
+
25
+ from pi_browser_bridge.bridge import BrowserBridge
26
+ from pi_browser_bridge.playwright_base import PlaywrightBridge
27
+
28
+
29
+ # ═══════════════════════════════════════════════════════════════════════
30
+ # Fakes
31
+ # ═══════════════════════════════════════════════════════════════════════
32
+
33
+
34
+ class _FakeMouse:
35
+ """Records ``wheel`` calls so we can assert wheel-based scroll."""
36
+
37
+ def __init__(self) -> None:
38
+ self.wheel_calls: list[tuple[int, int]] = []
39
+
40
+ def wheel(self, dx: int, dy: int) -> None:
41
+ self.wheel_calls.append((dx, dy))
42
+
43
+
44
+ class _FakePage:
45
+ """Minimal Playwright Page stand-in for the quirks code paths."""
46
+
47
+ def __init__(self) -> None:
48
+ self.mouse = _FakeMouse()
49
+ self.eval_calls: list[Any] = [] # list of expressions/args evaluated
50
+ self.eval_results: dict[str, Any] = {}
51
+ # Always-raise mode: every call raises
52
+ self._eval_raise_always: bool = False
53
+ self._eval_raise_error: str = ""
54
+ # Raise-once mode: raise on first call, then fall through to canned results
55
+ self._eval_raise_once_error: str = ""
56
+ self._eval_raise_once_count: int = 0
57
+ # wait_for_load_state recorder
58
+ self.wait_for_load_state_calls: list[tuple[str, int]] = []
59
+
60
+ def evaluate(self, expression: str, arg: Any = None) -> Any:
61
+ self.eval_calls.append((expression, arg))
62
+ # Always-raise mode: every call raises
63
+ if self._eval_raise_always and self._eval_raise_error:
64
+ raise Exception(self._eval_raise_error)
65
+ # Raise-once mode: raise on the first call only, then fall through
66
+ if self._eval_raise_once_error and self._eval_raise_once_count == 0:
67
+ self._eval_raise_once_count += 1
68
+ raise Exception(self._eval_raise_once_error)
69
+ # Echo back a canned result keyed by a substring of the expression
70
+ for key, val in self.eval_results.items():
71
+ if key in expression:
72
+ return val
73
+ return None
74
+
75
+ def wait_for_load_state(self, state: str, timeout: int = 30_000) -> None:
76
+ self.wait_for_load_state_calls.append((state, timeout))
77
+
78
+
79
+ class _FakeBrowser:
80
+ """Records ``new_context`` kwargs so we can assert what was passed."""
81
+
82
+ def __init__(self) -> None:
83
+ self.new_context_calls: list[dict[str, Any]] = []
84
+
85
+ def new_context(self, **kwargs: Any) -> "_FakeContext":
86
+ self.new_context_calls.append(kwargs)
87
+ return _FakeContext()
88
+
89
+
90
+ class _FakeContext:
91
+ """Minimal BrowserContext stand-in (tracing no-op)."""
92
+
93
+ def __init__(self) -> None:
94
+ self.tracing = _FakeTracing()
95
+
96
+ def new_page(self) -> _FakePage:
97
+ return _FakePage()
98
+
99
+
100
+ class _FakeTracing:
101
+ def start(self, *args: Any, **kwargs: Any) -> None:
102
+ pass
103
+
104
+ def stop(self, *args: Any, **kwargs: Any) -> None:
105
+ pass
106
+
107
+
108
+ # ═══════════════════════════════════════════════════════════════════════
109
+ # Test bridge subclass that uses fakes
110
+ # ═══════════════════════════════════════════════════════════════════════
111
+
112
+
113
+ class _FakePlaywrightBridge(PlaywrightBridge):
114
+ """A ``PlaywrightBridge`` whose ``_ensure_playwright`` returns fakes.
115
+
116
+ Skips the real Playwright import/lifecycle by overriding
117
+ ``_ensure_playwright`` to hand back a ``_FakeBrowser``. The quirks
118
+ under test don't depend on a real Playwright instance.
119
+ """
120
+
121
+ _plugin_name = "fake-py"
122
+ _install_hint = "install hint"
123
+
124
+ def __init__(self) -> None:
125
+ super().__init__()
126
+ self._fake_browser = _FakeBrowser()
127
+
128
+ def _ensure_playwright(self) -> tuple[Any, Any]:
129
+ # Return (pw, browser); pw is unused by the code paths under test.
130
+ return (None, self._fake_browser)
131
+
132
+ def _launch_browser(self) -> Any: # pragma: no cover — not called
133
+ return self._fake_browser
134
+
135
+
136
+ def _bind_session(bridge: _FakePlaywrightBridge, page: _FakePage) -> None:
137
+ """Bind a fake page/context to ``task_id="t"`` on ``bridge``."""
138
+ bridge.sessions["t"] = {"page": page, "context": _FakeContext()}
139
+
140
+
141
+ class _BareBridge(BrowserBridge):
142
+ """Trivial concrete ``BrowserBridge`` for base-class handler tests.
143
+
144
+ Only exercises base-class handlers (``browser.init``,
145
+ ``browser.describeQuirks``); the ``do_*`` stubs below are never
146
+ invoked but satisfy the abstract contract so the class instantiates.
147
+ """
148
+
149
+ def create_browser_session(self, task_id, config): # pragma: no cover
150
+ return {}
151
+
152
+ def create_browser_context(self, config): # pragma: no cover
153
+ return None
154
+
155
+ def _do_stub(self, *args, **kwargs): # pragma: no cover
156
+ return {}
157
+
158
+ do_navigate = do_snapshot = do_click = do_type = do_scroll = _do_stub
159
+ do_go_back = do_press = do_screenshot = _do_stub
160
+ do_get_console_messages = do_clear_console = do_evaluate = _do_stub
161
+ do_get_cookies = do_add_cookies = do_clear_cookies = _do_stub
162
+ do_get_storage_state = _do_stub
163
+
164
+
165
+ # ═══════════════════════════════════════════════════════════════════════
166
+ # plugin_config (browser.init RPC)
167
+ # ═══════════════════════════════════════════════════════════════════════
168
+
169
+
170
+ class TestPluginConfig:
171
+ def test_defaults_to_empty_dict(self):
172
+ bridge = _FakePlaywrightBridge()
173
+ assert bridge.plugin_config == {}
174
+
175
+ def test_init_handler_populates_plugin_config(self):
176
+ bridge = _FakePlaywrightBridge()
177
+ result = bridge.handle_command(
178
+ "browser.init",
179
+ {"config": {"launch": {"headless": True}}},
180
+ cmd_id=1,
181
+ )
182
+ assert result["result"] == {"ok": True}
183
+ assert bridge.plugin_config == {"launch": {"headless": True}}
184
+
185
+ @pytest.mark.parametrize("init_arg", [{}, {"config": None}])
186
+ def test_init_handler_defaults_to_empty_when_config_missing(self, init_arg):
187
+ bridge = _FakePlaywrightBridge()
188
+ result = bridge.handle_command("browser.init", init_arg, cmd_id=2)
189
+ assert result["result"] == {"ok": True}
190
+ assert bridge.plugin_config == {}
191
+
192
+ def test_init_is_inherited_from_browser_bridge(self):
193
+ """The handler lives on BrowserBridge (so non-Playwright bridges get it too)."""
194
+ # A bare BrowserBridge can't be instantiated meaningfully (create_browser_session
195
+ # is abstract), but the handler routing is on the base class.
196
+ bare = _BareBridge()
197
+ result = bare.handle_command(
198
+ "browser.init", {"config": {"x": 1}}, cmd_id=7
199
+ )
200
+ assert result["result"] == {"ok": True}
201
+ assert bare.plugin_config == {"x": 1}
202
+
203
+
204
+ # ═══════════════════════════════════════════════════════════════════════
205
+ # _fingerprint_managed_context
206
+ # ═══════════════════════════════════════════════════════════════════════
207
+
208
+
209
+ class TestFingerprintManagedContext:
210
+ @pytest.mark.parametrize("flag,has_viewport,has_ua", [(False, True, True), (True, False, False)])
211
+ def test_context_viewport_and_user_agent_presence(self, flag, has_viewport, has_ua):
212
+ bridge = _FakePlaywrightBridge()
213
+ bridge._fingerprint_managed_context = flag
214
+ bridge.create_browser_context({})
215
+ call = bridge._fake_browser.new_context_calls[-1]
216
+ assert ("viewport" in call) == has_viewport
217
+ if has_viewport:
218
+ assert call["viewport"] == {"width": 1280, "height": 720}
219
+ assert ("user_agent" in call) == has_ua
220
+
221
+ def test_storage_state_forwarded_in_both_modes(self):
222
+ bridge = _FakePlaywrightBridge()
223
+ state = {"cookies": [], "origins": []}
224
+ # Default mode
225
+ bridge.create_browser_context({"storageState": state})
226
+ assert bridge._fake_browser.new_context_calls[-1]["storage_state"] is state
227
+ # Fingerprint-managed mode
228
+ bridge._fingerprint_managed_context = True
229
+ bridge.create_browser_context({"storageState": state})
230
+ assert bridge._fake_browser.new_context_calls[-1]["storage_state"] is state
231
+
232
+
233
+ # ═══════════════════════════════════════════════════════════════════════
234
+ # _skip_default_viewport
235
+ # ═══════════════════════════════════════════════════════════════════════
236
+
237
+
238
+ class TestSkipDefaultViewport:
239
+ """``_skip_default_viewport`` makes ``create_browser_context`` pass
240
+ ``no_viewport=True`` so the Camoufox patched Firefox binary doesn't
241
+ reject the ``Browser.setDefaultViewport`` CDP call (its ``isMobile``
242
+ property is not in the binary's schema). Only meaningful alongside
243
+ ``_fingerprint_managed_context = True``.
244
+ """
245
+
246
+ def test_default_does_not_pass_no_viewport(self):
247
+ bridge = _FakePlaywrightBridge()
248
+ bridge._fingerprint_managed_context = True
249
+ # default _skip_default_viewport = False
250
+ bridge.create_browser_context({})
251
+ call = bridge._fake_browser.new_context_calls[-1]
252
+ assert "no_viewport" not in call
253
+
254
+ def test_skip_passes_no_viewport_true(self):
255
+ bridge = _FakePlaywrightBridge()
256
+ bridge._fingerprint_managed_context = True
257
+ bridge._skip_default_viewport = True
258
+ bridge.create_browser_context({})
259
+ call = bridge._fake_browser.new_context_calls[-1]
260
+ assert call["no_viewport"]
261
+ # viewport/user_agent still omitted (fingerprint-managed)
262
+ assert "viewport" not in call
263
+ assert "user_agent" not in call
264
+
265
+ def test_skip_ignored_when_not_fingerprint_managed(self):
266
+ # The elif branch only fires under fingerprint-managed mode;
267
+ # shipped bridges keep their hard-coded viewport/UA regardless.
268
+ bridge = _FakePlaywrightBridge()
269
+ bridge._skip_default_viewport = True
270
+ bridge.create_browser_context({})
271
+ call = bridge._fake_browser.new_context_calls[-1]
272
+ assert "no_viewport" not in call
273
+ assert call["viewport"] == {"width": 1280, "height": 720}
274
+ assert "user_agent" in call
275
+
276
+ def test_skip_forwards_storage_state(self):
277
+ bridge = _FakePlaywrightBridge()
278
+ bridge._fingerprint_managed_context = True
279
+ bridge._skip_default_viewport = True
280
+ state = {"cookies": [], "origins": []}
281
+ bridge.create_browser_context({"storageState": state})
282
+ call = bridge._fake_browser.new_context_calls[-1]
283
+ assert call["no_viewport"]
284
+ assert call["storage_state"] is state
285
+
286
+
287
+ # ═══════════════════════════════════════════════════════════════════════
288
+ # _scroll_via_wheel
289
+ # ═══════════════════════════════════════════════════════════════════════
290
+
291
+
292
+ class TestScrollViaWheel:
293
+ def test_default_uses_eval_scrollby(self):
294
+ bridge = _FakePlaywrightBridge()
295
+ page = _FakePage()
296
+ _bind_session(bridge, page)
297
+ bridge.do_scroll("t", "down")
298
+ assert page.mouse.wheel_calls == []
299
+ assert len(page.eval_calls) == 1
300
+ expr, arg = page.eval_calls[0]
301
+ assert "window.scrollBy" in expr
302
+ assert arg == 800
303
+
304
+ @pytest.mark.parametrize("direction,expected_dy", [("down", 800), ("up", -800)])
305
+ def test_scroll_via_wheel_uses_mouse_wheel(self, direction, expected_dy):
306
+ bridge = _FakePlaywrightBridge()
307
+ bridge._scroll_via_wheel = True
308
+ page = _FakePage()
309
+ _bind_session(bridge, page)
310
+ bridge.do_scroll("t", direction)
311
+ assert len(page.mouse.wheel_calls) == 1
312
+ dx, dy = page.mouse.wheel_calls[0]
313
+ assert dx == 0
314
+ assert dy == expected_dy
315
+ assert page.eval_calls == [] # no eval path taken
316
+
317
+
318
+ # ═══════════════════════════════════════════════════════════════════════
319
+ # _eval_prefix
320
+ # ═══════════════════════════════════════════════════════════════════════
321
+
322
+
323
+ class TestEvalPrefix:
324
+ @pytest.mark.parametrize("prefix,expression,eval_key,expected_result", [
325
+ ("", "() => 1 + 1", "1 + 1", 2),
326
+ ("mw:", "() => 1 + 1", "mw:", 2),
327
+ ("mw:", "() => navigator.userAgent", "mw:", "UA-string"),
328
+ ])
329
+ def test_eval_prefix(self, prefix, expression, eval_key, expected_result):
330
+ # Prefix is prepended unconditionally — writes and reads both get it.
331
+ bridge = _FakePlaywrightBridge()
332
+ bridge._eval_prefix = prefix
333
+ page = _FakePage()
334
+ page.eval_results = {eval_key: expected_result}
335
+ _bind_session(bridge, page)
336
+ result = bridge.do_evaluate("t", expression)
337
+ assert result["success"]
338
+ assert result["result"] == expected_result
339
+ assert page.eval_calls[0][0] == prefix + expression
340
+
341
+
342
+ # ═══════════════════════════════════════════════════════════════════════
343
+ # Sanity: quirks default to off (bit-identical shipped bridges)
344
+ # ═══════════════════════════════════════════════════════════════════════
345
+
346
+
347
+ class TestQuirksDefaultOff:
348
+ def test_all_quirks_default_off(self):
349
+ bridge = _FakePlaywrightBridge()
350
+ assert bridge._fingerprint_managed_context == False
351
+ assert bridge._eval_prefix == ""
352
+ assert bridge._scroll_via_wheel == False
353
+ assert bridge._skip_default_viewport == False
354
+ assert bridge._skip_networkidle == False
355
+ assert bridge._wrap_mw_eval_in_eval == False
356
+
357
+
358
+ # ═══════════════════════════════════════════════════════════════════════
359
+ # _wrap_mw_eval_in_eval
360
+ # ═══════════════════════════════════════════════════════════════════════
361
+
362
+
363
+ class TestWrapMwEvalInEval:
364
+ """``_wrap_mw_eval_in_eval`` makes ``do_evaluate`` rewrite the
365
+ expression as ``eval(<JSON-string of expression>)`` before prepending
366
+ ``_eval_prefix``, so multi-statement scripts survive Camoufox's
367
+ ``let _s = (${script})`` main-world wrapper.
368
+ """
369
+
370
+ @pytest.mark.parametrize("prefix,expected", [("", "let x = 5; x + 1"), ("mw:", "mw:let x = 5; x + 1")])
371
+ def test_default_off_expression_unwrapped(self, prefix, expected):
372
+ """Default off: expression is passed through unwrapped (bit-identical),
373
+ with only the prefix prepended when set."""
374
+ bridge = _FakePlaywrightBridge()
375
+ bridge._eval_prefix = prefix
376
+ page = _FakePage()
377
+ _bind_session(bridge, page)
378
+ bridge.do_evaluate("t", "let x = 5; x + 1")
379
+ assert page.eval_calls[0][0] == expected
380
+ assert len(page.eval_calls) == 1
381
+
382
+ @pytest.mark.parametrize("prefix,script,expected_expr,eval_key,expected_result", [
383
+ ("", "let x = 5; x + 1", 'eval("let x = 5; x + 1")', None, None),
384
+ ("mw:", "let x = 5; x + 1", 'mw:eval("let x = 5; x + 1")', None, None),
385
+ ("mw:", "3 + 4", 'mw:eval("3 + 4")', "eval(", 7),
386
+ ])
387
+ def test_flag_on_wraps_in_eval(self, prefix, script, expected_expr, eval_key, expected_result):
388
+ """Flag on: expression becomes ``eval(<json>)`` (with ``_eval_prefix``
389
+ prepended when set) — the production shape; a plain expression still
390
+ round-trips its value."""
391
+ bridge = _FakePlaywrightBridge()
392
+ bridge._wrap_mw_eval_in_eval = True
393
+ bridge._eval_prefix = prefix
394
+ page = _FakePage()
395
+ if eval_key is not None:
396
+ page.eval_results = {eval_key: expected_result}
397
+ _bind_session(bridge, page)
398
+ result = bridge.do_evaluate("t", script)
399
+ assert page.eval_calls[0][0] == expected_expr
400
+ assert len(page.eval_calls) == 1
401
+ if expected_result is not None:
402
+ assert result["success"] == True
403
+ assert result["result"] == expected_result
404
+
405
+ def test_flag_on_escapes_special_characters(self):
406
+ """Flag on: quotes / newlines / backslashes in the script are JSON-escaped
407
+ so the embedded string literal is always valid JS."""
408
+ bridge = _FakePlaywrightBridge()
409
+ bridge._wrap_mw_eval_in_eval = True
410
+ bridge._eval_prefix = "mw:"
411
+ page = _FakePage()
412
+ _bind_session(bridge, page)
413
+ # a script with a double-quote and a newline (JSON must escape both)
414
+ script = "let s = 'a\"b';\n s"
415
+ bridge.do_evaluate("t", script)
416
+ sent = page.eval_calls[0][0]
417
+ assert sent.startswith("mw:eval(")
418
+ assert sent.endswith(")")
419
+ # The inner literal must be a valid JSON string (round-trips to script)
420
+ import json as _json
421
+ literal = sent[len("mw:eval("):-1]
422
+ assert _json.loads(literal) == script
423
+
424
+ def test_no_retry_on_syntax_error(self):
425
+ """A terminal eval error (e.g. a genuine SyntaxError through the
426
+ eval wrap) propagates after a SINGLE call — no retry, no
427
+ wait_for_load_state. This guards against the old unconditional retry
428
+ quirk regressing."""
429
+ bridge = _FakePlaywrightBridge()
430
+ bridge._wrap_mw_eval_in_eval = True
431
+ bridge._eval_prefix = "mw:"
432
+ page = _FakePage()
433
+ page._eval_raise_always = True
434
+ page._eval_raise_error = "SyntaxError: Unexpected identifier"
435
+ _bind_session(bridge, page)
436
+ result = bridge.do_evaluate("t", "let x = 5; x + 1")
437
+ assert result["success"] == False
438
+ assert "syntaxerror" in result["error"].lower()
439
+ assert len(page.eval_calls) == 1 # no retry
440
+ assert page.wait_for_load_state_calls == [] # no recovery machinery
441
+
442
+ def test_retry_once_on_context_destroyed(self):
443
+ """A transient "Execution context was destroyed" error triggers one
444
+ wait_for_load_state + retry. After the retry succeeds, the result
445
+ is returned as success."""
446
+ bridge = _FakePlaywrightBridge()
447
+ bridge._wrap_mw_eval_in_eval = True
448
+ bridge._eval_prefix = "mw:"
449
+ page = _FakePage()
450
+ page._eval_raise_once_error = "Execution context was destroyed, most likely because of a navigation."
451
+ page.eval_results = {"eval(": "retry_success"}
452
+ _bind_session(bridge, page)
453
+ result = bridge.do_evaluate("t", "let x = 5; x + 1")
454
+ assert result["success"] == True
455
+ assert result["result"] == "retry_success"
456
+ assert len(page.eval_calls) == 2 # first raise, retry succeeds
457
+ assert len(page.wait_for_load_state_calls) == 1
458
+ assert page.wait_for_load_state_calls[0][0] == "load"
459
+
460
+
461
+ # ═══════════════════════════════════════════════════════════════════════
462
+ # browser.describeQuirks RPC handler
463
+ # ═══════════════════════════════════════════════════════════════════════
464
+
465
+
466
+ class TestDescribeQuirks:
467
+ """Tests for the ``browser.describeQuirks`` introspection RPC handler
468
+ on ``BrowserBridge`` (``bridge.py``).
469
+
470
+ Verifies the handler returns the bridge's class-attribute quirks
471
+ flags correctly, and that the getattr-based defaulting handles both
472
+ PlaywrightBridge instances (which have the quirks attrs) and bare
473
+ BrowserBridge instances (which don't).
474
+ """
475
+
476
+ @pytest.mark.parametrize("overrides,expected", [
477
+ (
478
+ {},
479
+ {"fingerprint_managed_context": False, "eval_prefix": "", "scroll_via_wheel": False, "skip_default_viewport": False, "skip_networkidle": False, "wrap_mw_eval_in_eval": False, "csp_safe_readonly_via_init_script": False},
480
+ ),
481
+ (
482
+ {"_fingerprint_managed_context": True, "_eval_prefix": "mw:", "_scroll_via_wheel": True, "_skip_default_viewport": True, "_skip_networkidle": True, "_wrap_mw_eval_in_eval": True, "_csp_safe_readonly_via_init_script": True},
483
+ {"fingerprint_managed_context": True, "eval_prefix": "mw:", "scroll_via_wheel": True, "skip_default_viewport": True, "skip_networkidle": True, "wrap_mw_eval_in_eval": True, "csp_safe_readonly_via_init_script": True},
484
+ ),
485
+ (
486
+ {"_scroll_via_wheel": True, "_eval_prefix": "mw:"},
487
+ {"scroll_via_wheel": True, "eval_prefix": "mw:", "fingerprint_managed_context": False, "skip_default_viewport": False, "skip_networkidle": False, "wrap_mw_eval_in_eval": False, "csp_safe_readonly_via_init_script": False},
488
+ ),
489
+ ])
490
+ def test_describe_quirks_surfaces_overrides(self, overrides, expected):
491
+ """The handler surfaces overridden quirks alongside the defaults."""
492
+ bridge = _FakePlaywrightBridge()
493
+ for k, v in overrides.items():
494
+ setattr(bridge, k, v)
495
+ result = bridge.handle_command("browser.describeQuirks", {}, 1)
496
+ assert "result" in result
497
+ assert result["result"] == expected
498
+
499
+ def test_bare_bridge_returns_all_defaults(self):
500
+ """A bare ``BrowserBridge`` (no Playwright quirks attrs) also returns
501
+ default values via ``getattr`` — the handler doesn't throw."""
502
+ bare = _BareBridge()
503
+ result = bare.handle_command("browser.describeQuirks", {}, 4)
504
+ assert "result" in result
505
+ q = result["result"]
506
+ assert q["fingerprint_managed_context"] == False
507
+ assert q["eval_prefix"] == ""
508
+ assert q["scroll_via_wheel"] == False
509
+ assert q["skip_default_viewport"] == False
510
+ assert q["skip_networkidle"] == False
511
+ assert q["wrap_mw_eval_in_eval"] == False
512
+ assert q["csp_safe_readonly_via_init_script"] == False
513
+
514
+
515
+ # ═══════════════════════════════════════════════════════════════════════
516
+ # read_only eval path
517
+ # ═══════════════════════════════════════════════════════════════════════
518
+
519
+
520
+ class TestReadOnlyEval:
521
+ """``do_evaluate(…, read_only=True)`` bypasses ``_eval_prefix`` and
522
+ the ``eval()`` wrap, routing the expression through the isolated-world
523
+ context instead of the main-world (``mw:``) context.
524
+ """
525
+
526
+ @pytest.mark.parametrize("prefix,wrap,eval_key,expected_result", [
527
+ ("mw:", True, None, None), # bypasses prefix and wrap
528
+ ("", False, "1 + 1", 2), # bit-identical to read_only=False
529
+ ])
530
+ def test_read_only_bypasses_prefix_and_wrap(self, prefix, wrap, eval_key, expected_result):
531
+ """read_only=True: no mw: prefix, no eval() wrap — raw expression."""
532
+ bridge = _FakePlaywrightBridge()
533
+ bridge._eval_prefix = prefix
534
+ bridge._wrap_mw_eval_in_eval = wrap
535
+ page = _FakePage()
536
+ if eval_key is not None:
537
+ page.eval_results = {eval_key: expected_result}
538
+ _bind_session(bridge, page)
539
+ result = bridge.do_evaluate("t", "() => 1 + 1", read_only=True)
540
+ assert page.eval_calls[0][0] == "() => 1 + 1"
541
+ if expected_result is not None:
542
+ assert result["success"]
543
+ assert result["result"] == expected_result
544
+
545
+ @pytest.mark.parametrize("prefix,read_only,expected_expr", [
546
+ ("", None, 'eval("let x = 5; x + 1")'), # default (no read_only arg)
547
+ ("mw:", False, 'mw:eval("let x = 5; x + 1")'), # explicit False
548
+ ])
549
+ def test_write_path_unchanged(self, prefix, read_only, expected_expr):
550
+ """The write path (read_only unset or False) still applies the eval wrap."""
551
+ bridge = _FakePlaywrightBridge()
552
+ bridge._wrap_mw_eval_in_eval = True
553
+ bridge._eval_prefix = prefix
554
+ page = _FakePage()
555
+ _bind_session(bridge, page)
556
+ if read_only is None:
557
+ bridge.do_evaluate("t", "let x = 5; x + 1")
558
+ else:
559
+ bridge.do_evaluate("t", "let x = 5; x + 1", read_only=read_only)
560
+ assert page.eval_calls[0][0] == expected_expr
561
+
562
+
563
+ # ═══════════════════════════════════════════════════════════════════════
564
+ # stdout hygiene (Change 2 — Camoufox launch pollution)
565
+ # ═══════════════════════════════════════════════════════════════════════
566
+
567
+
568
+ class TestStdoutHygiene:
569
+ """Verify the stdout→stderr redirect around ``_launch_browser``.
570
+
571
+ The Camoufox bridge redirects ``sys.stdout`` to ``sys.stderr`` around the
572
+ ``camoufox.NewBrowser`` call so third-party print() pollution doesn't
573
+ corrupt the JSON-RPC wire. This test verifies the pattern works without
574
+ importing camoufox: a stub that prints like camoufox does, wrapped in the
575
+ same swap pattern, must leave the captured stdout buffer empty.
576
+ """
577
+
578
+ def test_stdout_pollution_redirected_to_stderr(self):
579
+ """print() calls inside the swap block go to stderr, not stdout."""
580
+ import io
581
+
582
+ real_stdout = sys.stdout
583
+ real_stderr = sys.stderr
584
+
585
+ captured_stdout = io.StringIO()
586
+ captured_stderr = io.StringIO()
587
+
588
+ sys.stdout = captured_stdout
589
+ sys.stderr = captured_stderr
590
+ try:
591
+ # Simulate the swap pattern from _launch_browser
592
+ _real_stdout = sys.stdout
593
+ sys.stdout = sys.stderr
594
+ try:
595
+ # Camoufox print()s like these (utils.py:154, addons.py:92)
596
+ print("Skipping unknown patch: X")
597
+ print("Applying addon: Y")
598
+ finally:
599
+ sys.stdout = _real_stdout
600
+ finally:
601
+ sys.stdout = real_stdout
602
+ sys.stderr = real_stderr
603
+
604
+ # stdout must be clean — pollution went to stderr
605
+ assert captured_stdout.getvalue() == ""
606
+ assert "Skipping unknown patch" in captured_stderr.getvalue()
607
+ assert "Applying addon" in captured_stderr.getvalue()
608
+
609
+ def test_swap_restores_stdout_on_error(self):
610
+ """sys.stdout is restored even when the stub raises."""
611
+ real_stdout = sys.stdout
612
+
613
+ class _TestError(Exception):
614
+ pass
615
+
616
+ try:
617
+ _real_stdout = sys.stdout
618
+ sys.stdout = sys.stderr
619
+ try:
620
+ raise _TestError("launch failed")
621
+ finally:
622
+ sys.stdout = _real_stdout
623
+ except _TestError:
624
+ pass
625
+
626
+ assert sys.stdout is real_stdout
627
+
628
+
629
+ # ═══════════════════════════════════════════════════════════════════════
630
+ # CSP-safe read-only eval (_csp_safe_readonly_via_init_script)
631
+ # ═══════════════════════════════════════════════════════════════════════
632
+
633
+
634
+ class _RecordingContext(_FakeContext):
635
+ """``_FakeContext`` that records the init script passed to ``add_init_script``."""
636
+
637
+ def __init__(self) -> None:
638
+ super().__init__()
639
+ self.init_scripts: list[str] = []
640
+
641
+ def add_init_script(self, script: str) -> None:
642
+ self.init_scripts.append(script)
643
+
644
+
645
+ class _FakeMeta:
646
+ """Stand-in for the ``<meta id="__pi-extract">`` ElementHandle."""
647
+
648
+ def __init__(self, content: str) -> None:
649
+ self._content = content
650
+
651
+ def get_attribute(self, name: str) -> str | None:
652
+ if name == "content":
653
+ return self._content
654
+ return None
655
+
656
+
657
+ class _CspFakePage(_FakePage):
658
+ """``_FakePage`` whose ``query_selector`` returns a canned meta element."""
659
+
660
+ def __init__(self, meta_content: str | None) -> None:
661
+ super().__init__()
662
+ self._meta_content = meta_content
663
+ self.query_calls: list[str] = []
664
+
665
+ def query_selector(self, selector: str) -> _FakeMeta | None:
666
+ self.query_calls.append(selector)
667
+ if self._meta_content is None:
668
+ return None
669
+ return _FakeMeta(self._meta_content)
670
+
671
+
672
+ class TestCspSafeReadonlyViaInitScript:
673
+ """Tests for the CSP-safe read-only eval quirk (patched-Firefox stealth
674
+ binaries whose page.evaluate is CSP-blocked on strict sites)."""
675
+
676
+ def test_default_off(self):
677
+ """Shipped bridges keep the quirk off (bit-identical behaviour)."""
678
+ assert not PlaywrightBridge._csp_safe_readonly_via_init_script
679
+
680
+ def test_register_noops_without_script(self):
681
+ """No ``readOnlyExtractorScript`` in config → no init script registered."""
682
+ bridge = _FakePlaywrightBridge()
683
+ bridge._plugin_config = {}
684
+ ctx = _RecordingContext()
685
+ bridge._register_readonly_extractor_init_script(ctx)
686
+ assert ctx.init_scripts == []
687
+ assert bridge._readonly_extractor_script == ""
688
+
689
+ def test_register_strips_trailing_semicolon_and_uses_dcl(self):
690
+ """The wrapper assigns the script directly (no wrapping parens that
691
+ would put the script's trailing ``;`` inside parens → SyntaxError),
692
+ and defers to ``DOMContentLoaded`` (not ``load``)."""
693
+ bridge = _FakePlaywrightBridge()
694
+ # A self-contained IIFE ending with a trailing ';' — the real
695
+ # EXTRACTOR_SCRIPT shape.
696
+ script = "(() => { return JSON.stringify({a:1}); })();"
697
+ bridge._plugin_config = {"readOnlyExtractorScript": script}
698
+ ctx = _RecordingContext()
699
+ bridge._register_readonly_extractor_init_script(ctx)
700
+ assert bridge._readonly_extractor_script == script
701
+ assert len(ctx.init_scripts) == 1
702
+ wrapper = ctx.init_scripts[0]
703
+ # Direct assignment of the IIFE's return value — the script is
704
+ # ``(() => {...})()`` so ``__r = (() =>`` is the correct shape.
705
+ assert "__r = (() =>" in wrapper
706
+ # The bug shape was ``__r = (<script>);`` which, with the script's
707
+ # trailing ``;``, produced ``__r = (...})(););`` (a ``;`` inside
708
+ # parens → SyntaxError → the whole init script silently no-ops).
709
+ assert "})(););" not in wrapper
710
+ # Defers to DOMContentLoaded, not the window load event (which does
711
+ # not fire from the isolated world on the affected binary).
712
+ assert "DOMContentLoaded" in wrapper
713
+ assert "addEventListener('load'" not in wrapper
714
+ assert "addEventListener(\"load\"" not in wrapper
715
+ # Writes to the known meta tag.
716
+ assert "__pi-extract" in wrapper
717
+
718
+ def test_do_evaluate_read_only_serves_from_meta(self):
719
+ """``do_evaluate(read_only=True)`` with the matching expression reads
720
+ the stashed meta and returns the urldecoded JSON — no ``page.evaluate``."""
721
+ from urllib.parse import quote
722
+ bridge = _FakePlaywrightBridge()
723
+ bridge._csp_safe_readonly_via_init_script = True
724
+ script = "(() => { return JSON.stringify({title: 'x'}); })();"
725
+ bridge._plugin_config = {"readOnlyExtractorScript": script}
726
+ # Populate the registered-script gate.
727
+ bridge._register_readonly_extractor_init_script(_RecordingContext())
728
+ payload = '{"title": "Reddit Clone"}'
729
+ page = _CspFakePage(meta_content=quote(payload))
730
+ _bind_session(bridge, page)
731
+ res = bridge.do_evaluate("t", script, read_only=True)
732
+ assert res["success"] == True
733
+ assert res["result"] == payload
734
+ # The meta was read via the native query_selector (CSP-free).
735
+ assert page.query_calls == ["meta#__pi-extract"]
736
+ # page.evaluate was NOT called (CSP would block it).
737
+ assert page.eval_calls == []
738
+
739
+ @pytest.mark.parametrize("registered_script,sent_expression,eval_key,expected_result,expect_query", [
740
+ # Expression doesn't match the gate → query_selector skipped → eval.
741
+ ("REAL_EXTRACTOR;", "other expression", "other", "ok", False),
742
+ # Expression matches the gate but meta is absent → eval best-effort.
743
+ (
744
+ "(() => { return JSON.stringify({a:1}); })();",
745
+ "(() => { return JSON.stringify({a:1}); })();",
746
+ "(() => { return JSON.stringify({a:1}); })();",
747
+ "fallback",
748
+ True,
749
+ ),
750
+ ])
751
+ def test_do_evaluate_read_only_falls_through_to_eval(self, registered_script, sent_expression, eval_key, expected_result, expect_query):
752
+ """A read_only eval that can't be served from the meta (expression
753
+ doesn't match the gate, or the meta is absent) falls through to
754
+ ``page.evaluate`` — no silent stale-meta return."""
755
+ bridge = _FakePlaywrightBridge()
756
+ bridge._csp_safe_readonly_via_init_script = True
757
+ bridge._plugin_config = {"readOnlyExtractorScript": registered_script}
758
+ bridge._register_readonly_extractor_init_script(_RecordingContext())
759
+ page = _CspFakePage(meta_content=None)
760
+ page.eval_results[eval_key] = expected_result
761
+ _bind_session(bridge, page)
762
+ res = bridge.do_evaluate("t", sent_expression, read_only=True)
763
+ assert res["success"] == True
764
+ assert res["result"] == expected_result
765
+ if expect_query:
766
+ assert page.query_calls == ["meta#__pi-extract"]
767
+ else:
768
+ assert page.query_calls == []