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.
- package/README.md +96 -36
- package/node_modules/pi-lean-portal/AGENTS.md +146 -4
- package/node_modules/pi-lean-portal/README.md +112 -50
- package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
- package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
- package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
- package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
- package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
- package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
- package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
- package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
- package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
- package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
- package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
- package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
- package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
- package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
- package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
- package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
- package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
- package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
- package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
- package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
- package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- 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
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
- 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
- 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
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
- 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
- package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
- package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
- package/node_modules/pi-lean-portal/contributed/README.md +304 -0
- package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
- package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
- package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
- package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
- package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
- package/node_modules/pi-lean-portal/core/router.ts +59 -98
- package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
- package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
- package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
- package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
- package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
- package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
- package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
- package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
- package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
- package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
- package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
- package/node_modules/pi-lean-portal/index.ts +42 -7
- package/node_modules/pi-lean-portal/package.json +8 -3
- package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
- package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
- package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
- package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
- package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
- package/node_modules/pi-lean-search/README.md +6 -2
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
- package/node_modules/pi-lean-search/index.ts +4 -21
- package/node_modules/pi-lean-search/package.json +2 -2
- package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
- package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
- package/package.json +4 -4
- package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
- package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
- package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
- 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 == []
|