pi-lean-dimension 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Python bridge backends (chromium-py, firefox-py).
|
|
3
|
+
|
|
4
|
+
Pure-logic unit tests parametrized over both backends. Engine-specific
|
|
5
|
+
assertions live in top-level test functions (not parametrized).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from conftest import METHOD_NAMES, _MockConsolePage, _load_bridge_class
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
12
|
+
# Engine-specific construction tests
|
|
13
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestChromiumPyBridgeConstruction:
|
|
17
|
+
def test_constructs_without_playwright_browser(self):
|
|
18
|
+
"""ChromiumPyBridge can be instantiated without a Playwright browser."""
|
|
19
|
+
Cls = _load_bridge_class("chromium-py")
|
|
20
|
+
bridge = Cls()
|
|
21
|
+
assert bridge.sessions == {}
|
|
22
|
+
assert bridge.element_caches == {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestFirefoxPyBridgeConstruction:
|
|
26
|
+
def test_constructs_without_playwright_browser(self):
|
|
27
|
+
"""FirefoxPyBridge has engine-specific name, UA hint, and install hint."""
|
|
28
|
+
Cls = _load_bridge_class("firefox-py")
|
|
29
|
+
bridge = Cls()
|
|
30
|
+
assert bridge._plugin_name == "firefox-py"
|
|
31
|
+
assert bridge._user_agent is not None
|
|
32
|
+
assert bridge._install_hint is not None
|
|
33
|
+
assert "playwright install firefox" in bridge._install_hint
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
37
|
+
# Test: Dispatch layer (all methods routed correctly)
|
|
38
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestDispatch:
|
|
42
|
+
"""Verify that all methods are routed correctly in handle_command."""
|
|
43
|
+
|
|
44
|
+
def test_all_methods_have_dispatch_entries(self, bridge_factory):
|
|
45
|
+
"""Every method gets routed (even if it returns a session error)."""
|
|
46
|
+
_, bridge = bridge_factory
|
|
47
|
+
no_session_ok = {
|
|
48
|
+
"browser.getConsoleMessages",
|
|
49
|
+
"browser.clearConsole",
|
|
50
|
+
"browser.cleanup",
|
|
51
|
+
}
|
|
52
|
+
for method in METHOD_NAMES:
|
|
53
|
+
params = {"taskId": "no-session-test"}
|
|
54
|
+
result = bridge.handle_command(method, params, 99)
|
|
55
|
+
if method in no_session_ok:
|
|
56
|
+
assert "result" in result, f"{method} should return a result"
|
|
57
|
+
continue
|
|
58
|
+
assert "error" in result, f"{method} did not produce an error"
|
|
59
|
+
code = result["error"]["code"]
|
|
60
|
+
_valid = {-32002, -32602, -32601, -32000}
|
|
61
|
+
_msg = f"{method}: unexpected error code {code}: {result['error']['message'][:60]}"
|
|
62
|
+
assert code in _valid, _msg
|
|
63
|
+
|
|
64
|
+
def test_navigate_missing_url(self, bridge_factory):
|
|
65
|
+
"""navigate without url returns an error mentioning url."""
|
|
66
|
+
_, bridge = bridge_factory
|
|
67
|
+
result = bridge.handle_command("browser.navigate", {"taskId": "t"}, 1)
|
|
68
|
+
assert "error" in result
|
|
69
|
+
assert "url" in result["error"]["message"]
|
|
70
|
+
|
|
71
|
+
def test_navigate_missing_task_id(self, bridge_factory):
|
|
72
|
+
"""navigate without taskId returns an error mentioning taskId."""
|
|
73
|
+
_, bridge = bridge_factory
|
|
74
|
+
result = bridge.handle_command(
|
|
75
|
+
"browser.navigate", {"url": "https://example.com"}, 2
|
|
76
|
+
)
|
|
77
|
+
assert "error" in result
|
|
78
|
+
assert "taskId" in result["error"]["message"]
|
|
79
|
+
|
|
80
|
+
def test_click_missing_ref(self, bridge_factory):
|
|
81
|
+
"""click without ref returns INVALID_PARAMS."""
|
|
82
|
+
_, bridge = bridge_factory
|
|
83
|
+
result = bridge.handle_command("browser.click", {"taskId": "t"}, 3)
|
|
84
|
+
assert "error" in result
|
|
85
|
+
assert "ref" in result["error"]["message"]
|
|
86
|
+
|
|
87
|
+
def test_type_missing_text(self, bridge_factory):
|
|
88
|
+
"""type without text returns INVALID_PARAMS."""
|
|
89
|
+
_, bridge = bridge_factory
|
|
90
|
+
result = bridge.handle_command(
|
|
91
|
+
"browser.type", {"taskId": "t", "ref": "e1"}, 4
|
|
92
|
+
)
|
|
93
|
+
assert "error" in result
|
|
94
|
+
assert "text" in result["error"]["message"]
|
|
95
|
+
|
|
96
|
+
def test_evaluate_missing_expression(self, bridge_factory):
|
|
97
|
+
"""evaluate without expression returns INVALID_PARAMS."""
|
|
98
|
+
_, bridge = bridge_factory
|
|
99
|
+
result = bridge.handle_command("browser.evaluate", {"taskId": "t"}, 5)
|
|
100
|
+
assert "error" in result
|
|
101
|
+
assert "expression" in result["error"]["message"]
|
|
102
|
+
|
|
103
|
+
def test_shutdown_command(self, bridge_factory):
|
|
104
|
+
"""shutdown returns success and sets _running to False."""
|
|
105
|
+
_, bridge = bridge_factory
|
|
106
|
+
result = bridge.handle_command("shutdown", {}, 6)
|
|
107
|
+
assert result["result"] == "shutting_down"
|
|
108
|
+
assert not bridge._running
|
|
109
|
+
|
|
110
|
+
def test_cleanup_missing_task_id(self, bridge_factory):
|
|
111
|
+
"""cleanup without taskId returns INVALID_PARAMS."""
|
|
112
|
+
_, bridge = bridge_factory
|
|
113
|
+
result = bridge.handle_command("browser.cleanup", {}, 7)
|
|
114
|
+
assert "error" in result
|
|
115
|
+
assert "taskId" in result["error"]["message"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
119
|
+
# Test: Console capture ring-buffer cap
|
|
120
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestConsoleCap:
|
|
124
|
+
"""Verify the 500-entry ring buffer on console capture."""
|
|
125
|
+
|
|
126
|
+
def test_console_capped_at_500(self, bridge_factory):
|
|
127
|
+
"""After 501 events, only the last 500 are retained."""
|
|
128
|
+
_, bridge = bridge_factory
|
|
129
|
+
page = _MockConsolePage()
|
|
130
|
+
session = bridge._setup_page_session(page)
|
|
131
|
+
messages: list[dict[str, str]] = session["console_messages"]
|
|
132
|
+
|
|
133
|
+
for i in range(501):
|
|
134
|
+
page.fire_console(f"msg-{i}")
|
|
135
|
+
|
|
136
|
+
assert len(messages) == 500, f"Expected 500, got {len(messages)}"
|
|
137
|
+
assert (
|
|
138
|
+
messages[0]["text"] == "msg-1"
|
|
139
|
+
), "First entry should be msg-1 (msg-0 popped)"
|
|
140
|
+
assert messages[-1]["text"] == "msg-500", "Last entry should be msg-500"
|
|
141
|
+
|
|
142
|
+
def test_console_under_cap_retains_all(self, bridge_factory):
|
|
143
|
+
"""Fewer than 501 events are all retained."""
|
|
144
|
+
_, bridge = bridge_factory
|
|
145
|
+
page = _MockConsolePage()
|
|
146
|
+
session = bridge._setup_page_session(page)
|
|
147
|
+
messages: list[dict[str, str]] = session["console_messages"]
|
|
148
|
+
|
|
149
|
+
for i in range(10):
|
|
150
|
+
page.fire_console(f"msg-{i}")
|
|
151
|
+
|
|
152
|
+
assert len(messages) == 10, f"Expected 10, got {len(messages)}"
|
|
153
|
+
assert messages[0]["text"] == "msg-0"
|
|
154
|
+
assert messages[-1]["text"] == "msg-9"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
158
|
+
# Test: browser.evaluate RPC dispatch with readOnly
|
|
159
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestEvaluateReadOnly:
|
|
163
|
+
"""Verify ``browser.evaluate`` reads the ``readOnly`` param
|
|
164
|
+
and forwards it to ``do_evaluate`` as ``read_only``."""
|
|
165
|
+
|
|
166
|
+
def test_readonly_true_reaches_do_evaluate(self, bridge_factory):
|
|
167
|
+
"""readOnly: True in params reaches do_evaluate as read_only=True."""
|
|
168
|
+
_, bridge = bridge_factory
|
|
169
|
+
recorded: list[bool] = []
|
|
170
|
+
|
|
171
|
+
def spy(task_id, expression, *, read_only=False):
|
|
172
|
+
recorded.append(read_only)
|
|
173
|
+
return {"success": True, "result": None}
|
|
174
|
+
|
|
175
|
+
bridge.do_evaluate = spy # type: ignore[assignment]
|
|
176
|
+
bridge.handle_command(
|
|
177
|
+
"browser.evaluate",
|
|
178
|
+
{"taskId": "t", "expression": "1+1", "readOnly": True},
|
|
179
|
+
999,
|
|
180
|
+
)
|
|
181
|
+
assert recorded == [True]
|
|
182
|
+
|
|
183
|
+
def test_readonly_omitted_defaults_false(self, bridge_factory):
|
|
184
|
+
"""No readOnly param: defaults to False."""
|
|
185
|
+
_, bridge = bridge_factory
|
|
186
|
+
recorded: list[bool] = []
|
|
187
|
+
|
|
188
|
+
def spy(task_id, expression, *, read_only=False):
|
|
189
|
+
recorded.append(read_only)
|
|
190
|
+
return {"success": True, "result": None}
|
|
191
|
+
|
|
192
|
+
bridge.do_evaluate = spy # type: ignore[assignment]
|
|
193
|
+
bridge.handle_command(
|
|
194
|
+
"browser.evaluate",
|
|
195
|
+
{"taskId": "t", "expression": "1+1"},
|
|
196
|
+
1000,
|
|
197
|
+
)
|
|
198
|
+
assert recorded == [False]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
3
4
|
ExtensionContext,
|
|
4
5
|
} from "@earendil-works/pi-coding-agent";
|
|
5
6
|
// fs functions used by profile/cookies handlers are in their respective modules
|
|
@@ -60,6 +61,21 @@ const LEARN_TOOL_NAMES = new Set(["web-learn"]);
|
|
|
60
61
|
*/
|
|
61
62
|
const SIBLING_TOOL_NAMES = new Set<string>(["web-search"]);
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Update the search status bar glyph if sibling tools are installed.
|
|
66
|
+
* Used by ``/web on|learn|off`` to keep the ``search`` slot in sync.
|
|
67
|
+
*/
|
|
68
|
+
function setSearchSlot(
|
|
69
|
+
pi: ExtensionAPI,
|
|
70
|
+
ctx: ExtensionCommandContext,
|
|
71
|
+
glyph: string,
|
|
72
|
+
): void {
|
|
73
|
+
const hasSiblingTools = getRegisteredIn(pi, SIBLING_TOOL_NAMES).length > 0;
|
|
74
|
+
if (hasSiblingTools) {
|
|
75
|
+
ctx.ui.setStatus("search", glyph);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
/** Persisted state shape — two independent booleans plus conversation-scoped default profile. */
|
|
64
80
|
interface BrowserToggleState {
|
|
65
81
|
browserToolsEnabled: boolean;
|
|
@@ -88,25 +104,19 @@ export function getConversationDefaultProfile(): string | undefined {
|
|
|
88
104
|
|
|
89
105
|
// ---- Helpers ---------------------------------------------------
|
|
90
106
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
* (With the toggle integrated into pi-lean-portal, this is always non-empty
|
|
94
|
-
* when pi-lean-portal is loaded, but the helper remains for robustness.)
|
|
95
|
-
*/
|
|
96
|
-
function getRegisteredBrowserTools(pi: ExtensionAPI): string[] {
|
|
107
|
+
/** Return the subset of a tool-name set that is actually registered. */
|
|
108
|
+
function getRegisteredIn(pi: ExtensionAPI, names: Set<string>): string[] {
|
|
97
109
|
return pi
|
|
98
110
|
.getAllTools()
|
|
99
111
|
.map((t) => t.name)
|
|
100
|
-
.filter((n) =>
|
|
112
|
+
.filter((n) => names.has(n));
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
/**
|
|
104
116
|
* Check whether browser tools are currently active in the system prompt.
|
|
105
|
-
* Returns true when no browser tools exist (nothing to toggle).
|
|
106
117
|
*/
|
|
107
118
|
function isBrowserEnabled(pi: ExtensionAPI): boolean {
|
|
108
|
-
const registered =
|
|
109
|
-
if (registered.length === 0) return true; // nothing registered → vacuously "enabled"
|
|
119
|
+
const registered = getRegisteredIn(pi, BROWSER_TOOL_NAMES);
|
|
110
120
|
|
|
111
121
|
const active = new Set(pi.getActiveTools());
|
|
112
122
|
return registered.some((name) => active.has(name));
|
|
@@ -121,8 +131,8 @@ function applyBrowserState(pi: ExtensionAPI, enable: boolean): void {
|
|
|
121
131
|
// Combine browser tools + sibling tools into one toggle set.
|
|
122
132
|
// (/web on enables both; /web off disables both.)
|
|
123
133
|
const registered = new Set([
|
|
124
|
-
...
|
|
125
|
-
...
|
|
134
|
+
...getRegisteredIn(pi, BROWSER_TOOL_NAMES),
|
|
135
|
+
...getRegisteredIn(pi, SIBLING_TOOL_NAMES),
|
|
126
136
|
]);
|
|
127
137
|
if (registered.size === 0) return;
|
|
128
138
|
_lastToggleState = enable;
|
|
@@ -149,7 +159,7 @@ function persistState(pi: ExtensionAPI, state: BrowserToggleState): void {
|
|
|
149
159
|
// ---- Branch-aware restoration ----------------------------------
|
|
150
160
|
|
|
151
161
|
function restoreFromBranch(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
|
|
152
|
-
const registered =
|
|
162
|
+
const registered = getRegisteredIn(pi, BROWSER_TOOL_NAMES);
|
|
153
163
|
if (registered.length === 0) return false;
|
|
154
164
|
|
|
155
165
|
let savedState: BrowserToggleState | undefined;
|
|
@@ -261,34 +271,11 @@ function readBrowserToggleConfig(): boolean {
|
|
|
261
271
|
return true; // default: enabled
|
|
262
272
|
}
|
|
263
273
|
|
|
264
|
-
/**
|
|
265
|
-
* Return the subset of LEARN_TOOL_NAMES that are actually registered.
|
|
266
|
-
*/
|
|
267
|
-
function getRegisteredLearnTools(pi: ExtensionAPI): string[] {
|
|
268
|
-
return pi
|
|
269
|
-
.getAllTools()
|
|
270
|
-
.map((t) => t.name)
|
|
271
|
-
.filter((n) => LEARN_TOOL_NAMES.has(n));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Return the subset of SIBLING_TOOL_NAMES that are actually registered.
|
|
276
|
-
* Used by applyBrowserState to include sibling tools in toggle operations.
|
|
277
|
-
*/
|
|
278
|
-
function getRegisteredSiblingTools(pi: ExtensionAPI): string[] {
|
|
279
|
-
return pi
|
|
280
|
-
.getAllTools()
|
|
281
|
-
.map((t) => t.name)
|
|
282
|
-
.filter((n) => SIBLING_TOOL_NAMES.has(n));
|
|
283
|
-
}
|
|
284
|
-
|
|
285
274
|
/**
|
|
286
275
|
* Check whether learn tools are currently active.
|
|
287
|
-
* Returns true when no learn tools exist (vacuously enabled).
|
|
288
276
|
*/
|
|
289
277
|
function isLearnEnabled(pi: ExtensionAPI): boolean {
|
|
290
|
-
const registered =
|
|
291
|
-
if (registered.length === 0) return true;
|
|
278
|
+
const registered = getRegisteredIn(pi, LEARN_TOOL_NAMES);
|
|
292
279
|
const active = new Set(pi.getActiveTools());
|
|
293
280
|
return registered.some((name) => active.has(name));
|
|
294
281
|
}
|
|
@@ -300,7 +287,7 @@ function isLearnEnabled(pi: ExtensionAPI): boolean {
|
|
|
300
287
|
*/
|
|
301
288
|
function applyLearnState(pi: ExtensionAPI, enable: boolean): void {
|
|
302
289
|
_lastLearnState = enable;
|
|
303
|
-
const registered = new Set(
|
|
290
|
+
const registered = new Set(getRegisteredIn(pi, LEARN_TOOL_NAMES));
|
|
304
291
|
if (registered.size === 0) return;
|
|
305
292
|
if (enable) {
|
|
306
293
|
const current = pi.getActiveTools();
|
|
@@ -313,20 +300,17 @@ function applyLearnState(pi: ExtensionAPI, enable: boolean): void {
|
|
|
313
300
|
}
|
|
314
301
|
|
|
315
302
|
// ── Test-only exports ──────────────────────────────────
|
|
316
|
-
// These are exported solely for unit testing
|
|
317
|
-
//
|
|
318
|
-
// them directly from production code.
|
|
303
|
+
// These are exported solely for unit testing.
|
|
304
|
+
// Do not import them directly from production code.
|
|
319
305
|
/** @internal */
|
|
320
306
|
export {
|
|
321
|
-
|
|
307
|
+
getRegisteredIn,
|
|
322
308
|
isBrowserEnabled,
|
|
323
309
|
applyBrowserState,
|
|
324
310
|
persistState,
|
|
325
311
|
restoreFromBranch,
|
|
326
312
|
readBrowserToggleConfig,
|
|
327
313
|
applyConfigDefault,
|
|
328
|
-
getRegisteredLearnTools,
|
|
329
|
-
getRegisteredSiblingTools,
|
|
330
314
|
isLearnEnabled,
|
|
331
315
|
applyLearnState,
|
|
332
316
|
_resetToggleStateForTest,
|
|
@@ -389,7 +373,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
389
373
|
"Usage: /web on | off | learn | status",
|
|
390
374
|
handler: async (args, ctx) => {
|
|
391
375
|
const cmd = args.trim().toLowerCase();
|
|
392
|
-
const hasBrowserTools =
|
|
376
|
+
const hasBrowserTools = getRegisteredIn(pi, BROWSER_TOOL_NAMES).length > 0;
|
|
393
377
|
|
|
394
378
|
if (!hasBrowserTools) {
|
|
395
379
|
ctx.ui.notify(
|
|
@@ -409,14 +393,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
409
393
|
});
|
|
410
394
|
ctx.ui.setStatus("browser", ctx.ui.theme.fg("accent", "●") + " idle");
|
|
411
395
|
|
|
412
|
-
|
|
413
|
-
const hasSiblingTools = getRegisteredSiblingTools(pi).length > 0;
|
|
414
|
-
if (hasSiblingTools) {
|
|
415
|
-
ctx.ui.setStatus(
|
|
416
|
-
"search",
|
|
417
|
-
ctx.ui.theme.fg("accent", "●") + " searxng",
|
|
418
|
-
);
|
|
419
|
-
}
|
|
396
|
+
setSearchSlot(pi, ctx, ctx.ui.theme.fg("accent", "●") + " searxng");
|
|
420
397
|
|
|
421
398
|
ctx.ui.notify(
|
|
422
399
|
"🌐 Browser tools enabled. /web learn to make web-learn available.",
|
|
@@ -432,14 +409,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
432
409
|
});
|
|
433
410
|
ctx.ui.setStatus("browser", ctx.ui.theme.fg("success", "●") + " idle");
|
|
434
411
|
|
|
435
|
-
|
|
436
|
-
const hasSiblingTools = getRegisteredSiblingTools(pi).length > 0;
|
|
437
|
-
if (hasSiblingTools) {
|
|
438
|
-
ctx.ui.setStatus(
|
|
439
|
-
"search",
|
|
440
|
-
ctx.ui.theme.fg("accent", "●") + " searxng",
|
|
441
|
-
);
|
|
442
|
-
}
|
|
412
|
+
setSearchSlot(pi, ctx, ctx.ui.theme.fg("accent", "●") + " searxng");
|
|
443
413
|
|
|
444
414
|
ctx.ui.notify(
|
|
445
415
|
"📖 web-learn tool is now available. Agent will save/update guides when asked.",
|
|
@@ -455,13 +425,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
455
425
|
});
|
|
456
426
|
ctx.ui.setStatus("browser", "○ web off");
|
|
457
427
|
|
|
458
|
-
|
|
459
|
-
// Search owns the colored glyph for on states; portal only writes
|
|
460
|
-
// the off state when tools are explicitly disabled.
|
|
461
|
-
const hasSiblingTools = getRegisteredSiblingTools(pi).length > 0;
|
|
462
|
-
if (hasSiblingTools) {
|
|
463
|
-
ctx.ui.setStatus("search", "○ searxng");
|
|
464
|
-
}
|
|
428
|
+
setSearchSlot(pi, ctx, "○ searxng");
|
|
465
429
|
|
|
466
430
|
ctx.ui.notify(
|
|
467
431
|
"🌐 Browser tools disabled. /web on to re-enable.",
|
|
@@ -515,7 +479,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
515
479
|
// toggle state. Without this, a race between portal's session_start
|
|
516
480
|
// (async — restores/applies state) and search's session_start (probes
|
|
517
481
|
// health) can leave the search glyph showing blue when tools are off.
|
|
518
|
-
const hasSiblingTools =
|
|
482
|
+
const hasSiblingTools = getRegisteredIn(pi, SIBLING_TOOL_NAMES).length > 0;
|
|
519
483
|
if (hasSiblingTools && !isBrowserEnabled(pi)) {
|
|
520
484
|
ctx.ui.setStatus("search", "○ searxng");
|
|
521
485
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Choosing a Backend
|
|
2
|
+
|
|
3
|
+
A short decision doc: when to reach for a stealth backend at all, when
|
|
4
|
+
to use the shipped Camoufox template specifically, and what the
|
|
5
|
+
contract looks like if you implement your own. For the install flow,
|
|
6
|
+
see [`README.md`](./README.md). For the architecture, see the
|
|
7
|
+
"Stealth backends (user-managed)" section of
|
|
8
|
+
[`packages/pi-lean-portal/AGENTS.md`](../../AGENTS.md).
|
|
9
|
+
|
|
10
|
+
## When to use a stealth backend at all
|
|
11
|
+
|
|
12
|
+
**Usually: don't.** The four shipped backends (`chromium`, `firefox`,
|
|
13
|
+
`chromium-py`, `firefox-py`) are faster, have better ARIA-tree parity,
|
|
14
|
+
need no per-engine venv, and need no binary fetch. Reach for a stealth
|
|
15
|
+
backend only when:
|
|
16
|
+
|
|
17
|
+
- The target site runs bot detection that blocks the shipped
|
|
18
|
+
Chromium/Firefox (Cloudflare Turnstile, Datadome, PerimeterX, etc.
|
|
19
|
+
that fingerprint the browser binary), **and**
|
|
20
|
+
- The `bot-detection` guide + a real User-Agent + cookie persistence
|
|
21
|
+
(named profiles) are not enough to get past the challenge.
|
|
22
|
+
|
|
23
|
+
Most sites do not need it. The cost is real: a ~100 MB binary fetch, a
|
|
24
|
+
dedicated venv, slower humanized input, and more moving parts to keep
|
|
25
|
+
working across engine version bumps. Treat stealth as a last resort,
|
|
26
|
+
not a default.
|
|
27
|
+
|
|
28
|
+
## When to use Camoufox specifically
|
|
29
|
+
|
|
30
|
+
[Camoufox](https://github.com/daijro/camoufox) is the **shipped,
|
|
31
|
+
tested template** — there is a reference `bridge.py` under
|
|
32
|
+
[`camoufox-py/`](./camoufox-py/), and a generic discovery runner at
|
|
33
|
+
`__tests__/run-contributed-suites.test.ts` auto-discovers any
|
|
34
|
+
installed user backend and runs the shared contract + persistence +
|
|
35
|
+
parity + quirks introspection suites against it. Properties:
|
|
36
|
+
|
|
37
|
+
- **Firefox-based** — a patched Firefox binary, so it shares Firefox's
|
|
38
|
+
ARIA-tree shape with the shipped `firefox` / `firefox-py` backends.
|
|
39
|
+
- **Fingerprint injected at browser launch** — via
|
|
40
|
+
`camoufox.NewBrowser(playwright, ...)`, not at context creation. This
|
|
41
|
+
is why Camoufox sets `_fingerprint_managed_context = True` and uses
|
|
42
|
+
standard `browser.new_context()` rather than a custom context factory.
|
|
43
|
+
- **`mw:`-prefix main-world eval** — `_eval_prefix = "mw:"` routes
|
|
44
|
+
`page.evaluate` writes to the main world (Camoufox's isolated-world
|
|
45
|
+
stealth otherwise blocks them). The bridge also sets
|
|
46
|
+
`main_world_eval=True` in the `NewBrowser` kwargs.
|
|
47
|
+
- **Wheel-based scroll** — `_scroll_via_wheel = True` uses
|
|
48
|
+
`page.mouse.wheel` instead of `window.scrollBy` eval (avoids
|
|
49
|
+
eval-write under isolated-world stealth).
|
|
50
|
+
|
|
51
|
+
If you want a stealth backend and you do not have a strong reason to
|
|
52
|
+
pick something else, use Camoufox via the template in
|
|
53
|
+
[`camoufox-py/bridge.py`](./camoufox-py/bridge.py). The install flow is
|
|
54
|
+
in [`README.md`](./README.md).
|
|
55
|
+
|
|
56
|
+
## Implementing your own stealth backend
|
|
57
|
+
|
|
58
|
+
The contract is the **quirks schema** — the set of class attributes on
|
|
59
|
+
`PlaywrightBridge` documented in
|
|
60
|
+
`backends/python-base/pi_browser_bridge/playwright_base.py` (and
|
|
61
|
+
reproduced in [`README.md`](./README.md#quirks-schema-reference)). Set
|
|
62
|
+
the flags your engine needs as class attributes on a subclass, and
|
|
63
|
+
override the launch hook that matches how your engine owns Playwright.
|
|
64
|
+
There are two lifecycle patterns:
|
|
65
|
+
|
|
66
|
+
### Pattern 1 — Engine accepts an external Playwright instance
|
|
67
|
+
|
|
68
|
+
The engine exposes a constructor like `NewBrowser(playwright, ...)`
|
|
69
|
+
that takes an already-running Playwright and returns a browser. **Do
|
|
70
|
+
not** override `_ensure_playwright` — let the base class own the
|
|
71
|
+
Playwright lifecycle. Override **`_launch_browser` only**, handing the
|
|
72
|
+
base-supplied `playwright` to the engine. (Camoufox is the example of
|
|
73
|
+
this pattern; see `camoufox-py/bridge.py`.)
|
|
74
|
+
|
|
75
|
+
### Pattern 2 — Engine owns its own Playwright instance
|
|
76
|
+
|
|
77
|
+
The engine brings its own Playwright (e.g. it wraps a context manager
|
|
78
|
+
that starts Playwright internally). Override **`_ensure_playwright`**
|
|
79
|
+
and **`_maybe_stop_playwright`** to delegate to the engine's context
|
|
80
|
+
manager, and **do not call super** in those overrides — the base
|
|
81
|
+
implementation would start a redundant Playwright. The base class's
|
|
82
|
+
`_newBrowserContext` still drives the page through the standard
|
|
83
|
+
`BrowserContext` / `Page` path, so navigation, snapshot, click, etc.
|
|
84
|
+
keep working unchanged.
|
|
85
|
+
|
|
86
|
+
In both patterns, read user launch options from
|
|
87
|
+
`self.plugin_config.get("launch", {})` (forwarded via the `browser.init`
|
|
88
|
+
RPC from `settings.json` `config.launch`), and surface a helpful
|
|
89
|
+
`_install_hint` string so a missing binary tells the user what to run
|
|
90
|
+
rather than failing opaquely.
|
|
91
|
+
|
|
92
|
+
## Trade-offs
|
|
93
|
+
|
|
94
|
+
- **~100 MB binary fetch** per engine, plus the engine's pip package.
|
|
95
|
+
- **Per-engine venv** at `user-backends/<name>-py/.venv/` — each
|
|
96
|
+
stealth backend maintains its own dependencies.
|
|
97
|
+
- **Slower humanized input** — `humanize=True` (Camoufox default) uses
|
|
98
|
+
bezier-curved mouse motion; fine for bot-detection sites, slower for
|
|
99
|
+
bulk automation.
|
|
100
|
+
- **`xvfb` on Linux** for `headless='virtual'` modes; true headless
|
|
101
|
+
(`headless=True`) works without it.
|
|
102
|
+
- **Back-navigation may be limited on patched binaries.** Camoufox
|
|
103
|
+
needs `enable_cache=True` (the template's default) to restore session
|
|
104
|
+
history so `do_go_back()` works. Some patched Firefox binaries have a
|
|
105
|
+
binary-level back-navigation bug that requires a `document.referrer`
|
|
106
|
+
workaround; Camoufox does not need it with `enable_cache=True`. If
|
|
107
|
+
your engine disables the bfcache and `do_go_back` misbehaves, fall
|
|
108
|
+
back to navigating to the `document.referrer`.
|
|
109
|
+
- **Version drift** — patched binaries are pinned to specific engine
|
|
110
|
+
releases. A binary version bump can change which quirks are needed
|
|
111
|
+
(e.g. an `isMobile` rejection gets fixed, or a `NewContext` path
|
|
112
|
+
starts working). Re-validate your bridge on engine releases; the
|
|
113
|
+
quirks flags degrade gracefully (default off).
|
|
114
|
+
|
|
115
|
+
## What this is not
|
|
116
|
+
|
|
117
|
+
- **Not a plugin marketplace.** The extension never downloads or
|
|
118
|
+
executes stealth backends automatically. `user-backends/` is trusted
|
|
119
|
+
user code — you wrote or audited it.
|
|
120
|
+
- **Not shipped in the npm tarball.** The templates live in the source
|
|
121
|
+
repo under `contributed/`; `docs/` is excluded from
|
|
122
|
+
`package.json` `files`. You need the git repo (or a copy of the
|
|
123
|
+
files) to install a stealth backend.
|
|
124
|
+
- **Not in the default fallback list.** Stealth backends are loaded
|
|
125
|
+
only when explicitly listed in `browser.plugins`. A fresh install
|
|
126
|
+
never validates plugins the user never asked for.
|