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,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
- * Return the subset of BROWSER_TOOL_NAMES that are actually registered.
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) => BROWSER_TOOL_NAMES.has(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 = getRegisteredBrowserTools(pi);
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
- ...getRegisteredBrowserTools(pi),
125
- ...getRegisteredSiblingTools(pi),
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 = getRegisteredBrowserTools(pi);
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 = getRegisteredLearnTools(pi);
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(getRegisteredLearnTools(pi));
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 via
317
- // __tests__/helpers/toggle-test-utils.ts. Do not import
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
- getRegisteredBrowserTools,
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 = getRegisteredBrowserTools(pi).length > 0;
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
- // Clear any stale search off-glyph — sibling tools are now enabled.
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
- // Clear any stale search off-glyph — sibling tools are now enabled.
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
- // Set the search tool status to off if sibling apps are present.
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 = getRegisteredSiblingTools(pi).length > 0;
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/nichochar/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.