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
+ * `probeUserBackend` — availability probe for a user-managed stealth
3
+ * Python backend.
4
+ *
5
+ * Lets a user-managed stealth backend be exercised through the 130-task
6
+ * MiniWoB++ harness (and the shipped parity-test template) without
7
+ * forking the repo. The probe is engine-agnostic — it checks only that
8
+ * the backend's `bridge.py` exists on disk and the backend's venv
9
+ * python interpreter runs. No engine-specific binary probing; a
10
+ * template that needs a binary probe (e.g. Camoufox's
11
+ * `python -m camoufox fetch` check) can layer that on top.
12
+ *
13
+ * Resolution mirrors the user-backend discovery in `detectPluginType` in
14
+ * `pi-lean-portal/core/plugin-config.ts` so this probe and the runtime
15
+ * loader agree on where a backend lives:
16
+ * - `bridgePath` defaults to `${userBackendsDir()}/${name}/bridge.py`;
17
+ * an absolute `opts.bridgePath` short-circuits (the power-user escape
18
+ * hatch, mirroring the absolute-`dir` short-circuit).
19
+ * - `venvPython` defaults to
20
+ * `${userBackendsDir()}/${name}/.venv/bin/python3`; overridable via
21
+ * `PI_USER_BACKEND_<UPPERNAME>_PYTHON` so a user can point at a
22
+ * non-`user-backends` venv without editing the test/template.
23
+ *
24
+ * Lives in `__tests__/helpers/` because it is only ever called from
25
+ * tests (and the user-backends parity template). The env overrides
26
+ * (`PI_USER_BACKENDS_DIR` + per-name `PI_USER_BACKEND_<NAME>_PYTHON`)
27
+ * are preserved from the original vendored helper.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ import { spawnSync } from "node:child_process";
33
+ import { existsSync, readdirSync, statSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { isAbsolute, join } from "node:path";
36
+
37
+ // ─── User-backends root ───────────────────────────────────────────
38
+
39
+ /**
40
+ * Resolve the user-backends root directory.
41
+ *
42
+ * Honors `PI_USER_BACKENDS_DIR` (an absolute path override) when set
43
+ * and non-empty; otherwise defaults to
44
+ * `~/.pi/agent/pi-lean-portal/user-backends/`.
45
+ *
46
+ * Vendored locally — see the module doc for the rationale. Sprint 2's
47
+ * generic runner reuses this same helper for consistency.
48
+ */
49
+ export function userBackendsDir(): string {
50
+ const env = process.env.PI_USER_BACKENDS_DIR;
51
+ if (env !== undefined && env.trim() !== "") return env;
52
+ return join(homedir(), ".pi", "agent", "pi-lean-portal", "user-backends");
53
+ }
54
+
55
+ // ─── Backend discovery ──────────────────────────────────────────
56
+
57
+ /**
58
+ * Discover user-managed Python backends under a user-backends root.
59
+ *
60
+ * Returns the list of `<name>` such that `${root}/<name>/bridge.py`
61
+ * exists and `${root}/<name>` is a directory whose basename ends with
62
+ * `-py` (the conventional suffix for Python bridge backends, mirroring
63
+ * the shipped `chromium-py` / `firefox-py` naming). Sorted for
64
+ * deterministic registration order. Empty when the root is missing or
65
+ * contains no matching directory (the normal bare-CI state — the
66
+ * caller treats this as a no-op).
67
+ *
68
+ * Used by the generic `bench/miniwob/suites/` runners that exercise
69
+ * whatever stealth backend the user has installed, without naming any.
70
+ */
71
+ export function discoverUserBackends(root: string): string[] {
72
+ if (!existsSync(root) || !statSync(root).isDirectory()) return [];
73
+ let entries: string[];
74
+ try {
75
+ entries = readdirSync(root);
76
+ } catch {
77
+ return [];
78
+ }
79
+ const found: string[] = [];
80
+ for (const entry of entries) {
81
+ if (!entry.endsWith("-py")) continue;
82
+ const dir = join(root, entry);
83
+ try {
84
+ if (!statSync(dir).isDirectory()) continue;
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (existsSync(join(dir, "bridge.py"))) found.push(entry);
89
+ }
90
+ return found.sort();
91
+ }
92
+
93
+ // ─── Types ────────────────────────────────────────────────────────
94
+
95
+ /** Options for {@link probeUserBackend}. */
96
+ export interface ProbeUserBackendOptions {
97
+ /**
98
+ * Absolute path to the backend's `bridge.py`, overriding the default
99
+ * `${userBackendsDir()}/${name}/bridge.py` resolution. Mirrors the
100
+ * absolute-`dir` short-circuit in `detectPluginType`: when this is an
101
+ * absolute path it wins; any other value is ignored in favor of the
102
+ * default resolution.
103
+ */
104
+ bridgePath?: string;
105
+ }
106
+
107
+ /** Result of {@link probeUserBackend}. */
108
+ export interface ProbeUserBackendResult {
109
+ /**
110
+ * `true` iff the bridge file exists AND the venv python interpreter
111
+ * runs (`python3 --version` exits 0).
112
+ */
113
+ available: boolean;
114
+ /** Resolved path to the backend's `bridge.py`. */
115
+ bridgePath: string;
116
+ /** Resolved path to the backend's venv python interpreter. */
117
+ venvPython: string;
118
+ /**
119
+ * When `available` is `false`, explains the first missing piece so a
120
+ * skip message is useful. `undefined` when `available` is `true`.
121
+ */
122
+ reason?: string;
123
+ }
124
+
125
+ // ─── Public API ───────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Probe a user-managed Python backend for availability.
129
+ *
130
+ * Checks, in order:
131
+ * 1. The `bridge.py` exists at the resolved `bridgePath`.
132
+ * 2. The venv python interpreter runs (`--version` exits 0).
133
+ *
134
+ * Returns a plain object (synchronous — one `spawnSync` for the venv
135
+ * check, mirroring the existing `probePythonBackend` in
136
+ * `suites/miniwob-suite-helper.ts`). Runs once at suite-load, so
137
+ * blocking briefly is acceptable.
138
+ *
139
+ * @param name Backend directory name under `user-backends/`
140
+ * (e.g. `"camoufox-py"`).
141
+ * @param opts Optional overrides; see {@link ProbeUserBackendOptions}.
142
+ *
143
+ * @note The `PI_USER_BACKEND_<UPPERNAME>_PYTHON` override uppercases
144
+ * `name` verbatim. For names containing `-` (e.g. `camoufox-py`)
145
+ * the env var contains a hyphen (`PI_USER_BACKEND_CAMOUFOX-PY_PYTHON`);
146
+ * shells that forbid hyphens in `export`ed names must set it via
147
+ * `settings.json` `env` or another Node-respected channel.
148
+ */
149
+ export function probeUserBackend(
150
+ name: string,
151
+ opts?: ProbeUserBackendOptions,
152
+ ): ProbeUserBackendResult {
153
+ const root = userBackendsDir();
154
+
155
+ const bridgePath =
156
+ opts?.bridgePath !== undefined && isAbsolute(opts.bridgePath)
157
+ ? opts.bridgePath
158
+ : join(root, name, "bridge.py");
159
+
160
+ const envPython = process.env[`PI_USER_BACKEND_${name.toUpperCase()}_PYTHON`];
161
+ const venvPython =
162
+ envPython !== undefined && envPython.trim() !== ""
163
+ ? envPython
164
+ : join(root, name, ".venv", "bin", "python3");
165
+
166
+ if (!existsSync(bridgePath)) {
167
+ return {
168
+ available: false,
169
+ bridgePath,
170
+ venvPython,
171
+ reason: `bridge script not found: ${bridgePath}`,
172
+ };
173
+ }
174
+
175
+ const pythonRuns = (() => {
176
+ if (!existsSync(venvPython)) return false;
177
+ try {
178
+ const result = spawnSync(venvPython, ["--version"], {
179
+ stdio: "ignore",
180
+ timeout: 5_000,
181
+ });
182
+ return result.status === 0;
183
+ } catch {
184
+ return false;
185
+ }
186
+ })();
187
+
188
+ if (!pythonRuns) {
189
+ return {
190
+ available: false,
191
+ bridgePath,
192
+ venvPython,
193
+ reason: `venv python not runnable: ${venvPython}`,
194
+ };
195
+ }
196
+
197
+ return { available: true, bridgePath, venvPython };
198
+ }
@@ -34,6 +34,20 @@ export const COOKIE_PERSISTENCE_HTML = `<!DOCTYPE html>
34
34
  </script>
35
35
  </body></html>`;
36
36
 
37
+ /** Convenience: start a test server serving COOKIE_PERSISTENCE_HTML at "/". */
38
+ export async function startCookiePersistenceServer(): Promise<TestServer> {
39
+ return startTestServer((req, res) => {
40
+ const url = new URL(req.url ?? "/", "http://localhost");
41
+ if (url.pathname === "/") {
42
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
43
+ res.end(COOKIE_PERSISTENCE_HTML);
44
+ } else {
45
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
46
+ res.end("404");
47
+ }
48
+ });
49
+ }
50
+
37
51
  export async function startTestServer(
38
52
  handler: (req: IncomingMessage, res: ServerResponse) => void,
39
53
  ): Promise<TestServer> {
@@ -2,7 +2,7 @@
2
2
  * Tests for the browser configuration parsing in plugin-config.ts.
3
3
  *
4
4
  * Covers:
5
- * - loadBrowserConfig() defaults when no config exists
5
+ * - loadFullConfig().browser defaults when no config exists
6
6
  * - defaultProfile validation ("none", "session", or a named profile)
7
7
  * - defaultProfile validation
8
8
  * - maxStorageStateSize validation
@@ -11,11 +11,14 @@
11
11
  * - Merged global + project settings
12
12
  */
13
13
 
14
+ import { join } from "node:path";
15
+
14
16
  import { describe, it, expect, beforeEach, vi } from "vitest";
15
17
  import {
16
- loadBrowserConfig,
18
+ loadFullConfig,
17
19
  invalidateConfigCache,
18
- } from "../core/plugin-config";
20
+ } from "../core/plugin-config.js";
21
+ import { loadPluginConfigFromFile } from "./helpers/load-plugin-config-from-file.js";
19
22
 
20
23
  // ─── Mock fs to intercept settings.json reads ────────────────────
21
24
 
@@ -72,22 +75,22 @@ beforeEach(() => {
72
75
 
73
76
  // ─── Tests ───────────────────────────────────────────────────────
74
77
 
75
- describe("loadBrowserConfig()", () => {
78
+ describe("loadFullConfig().browser", () => {
76
79
  it("returns defaults when no settings.json exists", () => {
77
80
  mockNoSettings();
78
- const config = loadBrowserConfig();
81
+ const config = loadFullConfig().browser;
79
82
  expect(config.defaultProfile).toBe("session");
80
83
  });
81
84
 
82
85
  it("returns defaults when browser section is missing", () => {
83
86
  mockGlobalSettings({});
84
- const config = loadBrowserConfig();
87
+ const config = loadFullConfig().browser;
85
88
  expect(config.defaultProfile).toBe("session");
86
89
  });
87
90
 
88
91
  it("returns defaults when browser section has no config keys", () => {
89
92
  mockGlobalSettings({ unrelated: true });
90
- const config = loadBrowserConfig();
93
+ const config = loadFullConfig().browser;
91
94
  expect(config.defaultProfile).toBe("session");
92
95
  });
93
96
 
@@ -96,36 +99,36 @@ describe("loadBrowserConfig()", () => {
96
99
  describe("defaultProfile", () => {
97
100
  it("accepts a valid profile name", () => {
98
101
  mockGlobalSettings({ defaultProfile: "work" });
99
- expect(loadBrowserConfig().defaultProfile).toBe("work");
102
+ expect(loadFullConfig().browser.defaultProfile).toBe("work");
100
103
  });
101
104
 
102
105
  it("accepts hyphens and underscores", () => {
103
106
  mockGlobalSettings({ defaultProfile: "my-work-profile" });
104
- expect(loadBrowserConfig().defaultProfile).toBe("my-work-profile");
107
+ expect(loadFullConfig().browser.defaultProfile).toBe("my-work-profile");
105
108
  });
106
109
 
107
110
  it("rejects reserved names and falls back to default", () => {
108
111
  mockGlobalSettings({ defaultProfile: "none" });
109
112
  // "none" is reserved as a profile name but valid as a defaultProfile mode
110
- const config = loadBrowserConfig();
113
+ const config = loadFullConfig().browser;
111
114
  expect(config.defaultProfile).toBe("none");
112
115
  });
113
116
 
114
117
  it("rejects 'session' as a profile name for named profiles", () => {
115
118
  mockGlobalSettings({ defaultProfile: "session" });
116
119
  // "session" is reserved as a profile name but valid as a defaultProfile mode
117
- const config = loadBrowserConfig();
120
+ const config = loadFullConfig().browser;
118
121
  expect(config.defaultProfile).toBe("session");
119
122
  });
120
123
 
121
124
  it("rejects empty string", () => {
122
125
  mockGlobalSettings({ defaultProfile: "" });
123
- expect(loadBrowserConfig().defaultProfile).toBe("session");
126
+ expect(loadFullConfig().browser.defaultProfile).toBe("session");
124
127
  });
125
128
 
126
129
  it("rejects names with path traversal", () => {
127
130
  mockGlobalSettings({ defaultProfile: "../../evil" });
128
- expect(loadBrowserConfig().defaultProfile).toBe("session");
131
+ expect(loadFullConfig().browser.defaultProfile).toBe("session");
129
132
  });
130
133
  });
131
134
 
@@ -137,7 +140,7 @@ describe("loadBrowserConfig()", () => {
137
140
  { browser: { defaultProfile: "none" } },
138
141
  { browser: { defaultProfile: "work" } },
139
142
  );
140
- expect(loadBrowserConfig().defaultProfile).toBe("work");
143
+ expect(loadFullConfig().browser.defaultProfile).toBe("work");
141
144
  });
142
145
 
143
146
  it("project settings entirely replace global browser config (shallow merge)", () => {
@@ -153,8 +156,140 @@ describe("loadBrowserConfig()", () => {
153
156
  },
154
157
  },
155
158
  );
156
- const config = loadBrowserConfig();
159
+ const config = loadFullConfig().browser;
157
160
  expect(config.defaultProfile).toBe("session");
158
161
  });
159
162
  });
160
163
  });
164
+
165
+ // ─── Default plugin fallback (Phase 0b) ─────────────────────────
166
+
167
+ describe("loadFullConfig().plugins default fallback", () => {
168
+ it("returns the four shipped backends when browser.plugins is absent", () => {
169
+ // No settings files → default fallback branch in parsePluginConfig fires.
170
+ mockNoSettings();
171
+ const { plugins, errors } = loadFullConfig().plugins;
172
+ expect(errors).toEqual([]);
173
+ const names = plugins.map((p) => p.name);
174
+ expect(names).toEqual(["chromium", "firefox", "chromium-py", "firefox-py"]);
175
+ });
176
+
177
+ it("does NOT include stealth backends (camoufox-py / stealth-py) in the default fallback", () => {
178
+ mockNoSettings();
179
+ const { plugins } = loadFullConfig().plugins;
180
+ const names = plugins.map((p) => p.name);
181
+ expect(names).not.toContain("camoufox-py");
182
+ expect(names).not.toContain("stealth-py");
183
+ });
184
+ });
185
+
186
+ // ─── loadPluginConfigFromFile ─────────────────────────────────────
187
+
188
+ describe("loadPluginConfigFromFile()", () => {
189
+ /** Set up mock to behave like one valid bridge.py exists at a root dir. */
190
+ function mockValidBackendRoot(dirPath: string): void {
191
+ mockFs.existsSync.mockImplementation((p: string) => {
192
+ // The settings file itself exists
193
+ if (p === "/tmp/settings.json") return true;
194
+ if (p === "/tmp/empty.json") return true;
195
+ if (p === "/tmp/cached.json") return true;
196
+ // The backend directory exists
197
+ if (p === join(dirPath, "example-py")) return true;
198
+ if (p === join(dirPath, "my-backend")) return true;
199
+ // bridge.py exists (Python plugin)
200
+ if (p === join(dirPath, "example-py", "bridge.py")) return true;
201
+ if (p === join(dirPath, "my-backend", "bridge.py")) return true;
202
+ // index.ts does NOT exist → Python plugin, unambiguous
203
+ // Default stub dirs that detectPluginType checks as directories
204
+ if (p.startsWith("/tmp/")) return false;
205
+ // Everything else (e.g. global settings paths from fallback) is absent
206
+ return false;
207
+ });
208
+ mockFs.readFileSync.mockImplementation((p: string) => {
209
+ if (p === "/tmp/settings.json") {
210
+ return JSON.stringify({
211
+ browser: {
212
+ plugins: [
213
+ {
214
+ name: "example-py",
215
+ dir: "example-py",
216
+ enabled: true,
217
+ config: {
218
+ pythonPath: "/custom/python3",
219
+ capabilities: { engine: "firefox" },
220
+ transportTimeoutMs: 30_000,
221
+ launch: { headless: true },
222
+ },
223
+ },
224
+ ],
225
+ },
226
+ });
227
+ }
228
+ if (p === "/tmp/empty.json") return "{}";
229
+ return "{}";
230
+ });
231
+ }
232
+
233
+ it("parses a test-local settings file with browser.plugins", () => {
234
+ // Point at a root with a valid bridge.py so detectPluginType passes.
235
+ mockValidBackendRoot("/tmp/backends");
236
+
237
+ const { plugins, errors } = loadPluginConfigFromFile("/tmp/settings.json", [
238
+ "/tmp/backends",
239
+ ]);
240
+ expect(errors).toEqual([]);
241
+ expect(plugins).toHaveLength(1);
242
+ const p = plugins[0]!;
243
+ expect(p.name).toBe("example-py");
244
+ expect(p.enabled).toBe(true);
245
+ expect(p.config.pythonPath).toBe("/custom/python3");
246
+ expect(p.config.launch).toEqual({ headless: true });
247
+ });
248
+
249
+ it("falls back to default plugins when settings file has no browser key", () => {
250
+ // No browser key → falls through to parsePluginConfig(undefined) → defaults
251
+ mockFs.existsSync.mockReturnValue(true);
252
+ mockFs.readFileSync.mockImplementation((p: string) => {
253
+ if (p === "/tmp/no-browser.json")
254
+ return JSON.stringify({ unrelated: true });
255
+ return "{}";
256
+ });
257
+
258
+ const { plugins, errors } = loadPluginConfigFromFile(
259
+ "/tmp/no-browser.json",
260
+ );
261
+ expect(errors).toEqual([]);
262
+ const names = plugins.map((p) => p.name);
263
+ expect(names).toEqual(["chromium", "firefox", "chromium-py", "firefox-py"]);
264
+ });
265
+
266
+ it("falls back to default plugins when settings file is empty", () => {
267
+ // Empty JSON object → no "browser" key → same fallback.
268
+ mockFs.existsSync.mockReturnValue(true);
269
+ mockFs.readFileSync.mockImplementation((p: string) => {
270
+ if (p === "/tmp/empty.json") return "{}";
271
+ return "{}";
272
+ });
273
+
274
+ const { plugins, errors } = loadPluginConfigFromFile("/tmp/empty.json");
275
+ expect(errors).toEqual([]);
276
+ const names = plugins.map((p) => p.name);
277
+ expect(names).toEqual(["chromium", "firefox", "chromium-py", "firefox-py"]);
278
+ });
279
+
280
+ it("is one-shot with no caching (consecutive calls read the file each time)", () => {
281
+ const spy = vi.fn((_: string) =>
282
+ JSON.stringify({ browser: { plugins: [] } }),
283
+ );
284
+ mockFs.existsSync.mockReturnValue(true);
285
+ mockFs.readFileSync.mockImplementation(spy);
286
+
287
+ const testPath = "/tmp/cached.json";
288
+ // Call twice
289
+ loadPluginConfigFromFile(testPath);
290
+ loadPluginConfigFromFile(testPath);
291
+
292
+ // Must have been called twice (no caching)
293
+ expect(spy).toHaveBeenCalledTimes(2);
294
+ });
295
+ });