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.
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
@@ -11,9 +11,12 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { tmpdir } from "node:os";
13
13
 
14
- import { detectPluginType, DEFAULT_BACKENDS_ROOT } from "../core/plugin-config";
15
- import { PluginRegistry } from "../core/plugin-registry";
16
- import type { PluginConfig } from "../core/plugin-config";
14
+ import {
15
+ detectPluginType,
16
+ DEFAULT_BACKENDS_ROOT,
17
+ } from "../core/plugin-config.js";
18
+ import { PluginRegistry } from "../core/plugin-registry.js";
19
+ import type { PluginConfig } from "../core/plugin-config.js";
17
20
 
18
21
  // ─── Helpers ─────────────────────────────────────────────────────
19
22
 
@@ -54,7 +57,7 @@ describe("detectPluginType", () => {
54
57
  backendsRoot = createTestBackendsRoot({
55
58
  "my-node-plugin": { "index.ts": "export class MyPlugin {}" },
56
59
  });
57
- const result = detectPluginType("my-node-plugin", backendsRoot);
60
+ const result = detectPluginType("my-node-plugin", [backendsRoot]);
58
61
  expect(result.type).toBe("node");
59
62
  expect(result.entryPoint).toBe(
60
63
  join(backendsRoot, "my-node-plugin", "index.ts"),
@@ -65,7 +68,7 @@ describe("detectPluginType", () => {
65
68
  backendsRoot = createTestBackendsRoot({
66
69
  "my-py-plugin": { "bridge.py": "class MyBridge: pass" },
67
70
  });
68
- const result = detectPluginType("my-py-plugin", backendsRoot);
71
+ const result = detectPluginType("my-py-plugin", [backendsRoot]);
69
72
  expect(result.type).toBe("python");
70
73
  expect(result.entryPoint).toBe(
71
74
  join(backendsRoot, "my-py-plugin", "bridge.py"),
@@ -79,7 +82,7 @@ describe("detectPluginType", () => {
79
82
  "bridge.py": "class X: pass",
80
83
  },
81
84
  });
82
- expect(() => detectPluginType("ambiguous", backendsRoot)).toThrow(
85
+ expect(() => detectPluginType("ambiguous", [backendsRoot])).toThrow(
83
86
  /ambiguous/,
84
87
  );
85
88
  });
@@ -88,14 +91,14 @@ describe("detectPluginType", () => {
88
91
  backendsRoot = createTestBackendsRoot({
89
92
  "empty-plugin": { "readme.md": "# Nothing useful" },
90
93
  });
91
- expect(() => detectPluginType("empty-plugin", backendsRoot)).toThrow(
94
+ expect(() => detectPluginType("empty-plugin", [backendsRoot])).toThrow(
92
95
  /no entry point/i,
93
96
  );
94
97
  });
95
98
 
96
99
  it("throws for non-existent dir", () => {
97
100
  backendsRoot = createTestBackendsRoot({});
98
- expect(() => detectPluginType("nonexistent", backendsRoot)).toThrow(
101
+ expect(() => detectPluginType("nonexistent", [backendsRoot])).toThrow(
99
102
  /no entry point/i,
100
103
  );
101
104
  });
@@ -106,28 +109,28 @@ describe("detectPluginType", () => {
106
109
  describe("detectPluginType — production backends", () => {
107
110
  it("detects the chromium backend as a Node plugin", () => {
108
111
  // This tests the actual backends/chromium/ directory
109
- const result = detectPluginType("chromium", DEFAULT_BACKENDS_ROOT);
112
+ const result = detectPluginType("chromium", [DEFAULT_BACKENDS_ROOT]);
110
113
  expect(result.type).toBe("node");
111
114
  expect(result.entryPoint).toContain("chromium/index.ts");
112
115
  });
113
116
 
114
117
  it("detects the firefox backend as a Node plugin", () => {
115
118
  // This tests the actual backends/firefox/ directory
116
- const result = detectPluginType("firefox", DEFAULT_BACKENDS_ROOT);
119
+ const result = detectPluginType("firefox", [DEFAULT_BACKENDS_ROOT]);
117
120
  expect(result.type).toBe("node");
118
121
  expect(result.entryPoint).toContain("firefox/index.ts");
119
122
  });
120
123
 
121
124
  it("detects the chromium-py backend as a Python plugin", () => {
122
125
  // This tests the actual backends/chromium-py/ directory
123
- const result = detectPluginType("chromium-py", DEFAULT_BACKENDS_ROOT);
126
+ const result = detectPluginType("chromium-py", [DEFAULT_BACKENDS_ROOT]);
124
127
  expect(result.type).toBe("python");
125
128
  expect(result.entryPoint).toContain("chromium-py/bridge.py");
126
129
  });
127
130
 
128
131
  it("detects the firefox-py backend as a Python plugin", () => {
129
132
  // This tests the actual backends/firefox-py/ directory
130
- const result = detectPluginType("firefox-py", DEFAULT_BACKENDS_ROOT);
133
+ const result = detectPluginType("firefox-py", [DEFAULT_BACKENDS_ROOT]);
131
134
  expect(result.type).toBe("python");
132
135
  expect(result.entryPoint).toContain("firefox-py/bridge.py");
133
136
  });
@@ -170,7 +173,7 @@ describe("plugin startup loop integration", () => {
170
173
  const errors: string[] = [];
171
174
  for (const config of configs) {
172
175
  try {
173
- detectPluginType(config.dir, backendsRoot);
176
+ detectPluginType(config.dir, [backendsRoot]);
174
177
  } catch (err) {
175
178
  errors.push(
176
179
  `Plugin '${config.name}' (dir: '${config.dir}'): ${err instanceof Error ? err.message : String(err)}`,
@@ -181,7 +184,6 @@ describe("plugin startup loop integration", () => {
181
184
  expect(errors).toHaveLength(1);
182
185
  expect(errors[0]).toContain("nonexistent");
183
186
  expect(errors[0]).toContain("no entry point");
184
- expect(registry.size).toBe(0);
185
187
  });
186
188
 
187
189
  it("skips plugins with ambiguous directories and logs error", () => {
@@ -198,7 +200,7 @@ describe("plugin startup loop integration", () => {
198
200
  const errors: string[] = [];
199
201
  for (const config of configs) {
200
202
  try {
201
- detectPluginType(config.dir, backendsRoot);
203
+ detectPluginType(config.dir, [backendsRoot]);
202
204
  } catch (err) {
203
205
  errors.push(
204
206
  `Plugin '${config.name}' (dir: '${config.dir}'): ${err instanceof Error ? err.message : String(err)}`,
@@ -208,7 +210,6 @@ describe("plugin startup loop integration", () => {
208
210
 
209
211
  expect(errors).toHaveLength(1);
210
212
  expect(errors[0]).toContain("ambiguous");
211
- expect(registry.size).toBe(0);
212
213
  });
213
214
 
214
215
  it("correctly classifies multiple plugins of different types", () => {
@@ -223,7 +224,7 @@ describe("plugin startup loop integration", () => {
223
224
 
224
225
  const detections = configs.map((config) => ({
225
226
  config,
226
- detection: detectPluginType(config.dir, backendsRoot),
227
+ detection: detectPluginType(config.dir, [backendsRoot]),
227
228
  }));
228
229
 
229
230
  expect(detections[0]!.detection.type).toBe("node");
@@ -240,7 +241,7 @@ describe("plugin startup loop integration", () => {
240
241
 
241
242
  const detections = configs.map((config) => ({
242
243
  config,
243
- detection: detectPluginType(config.dir, backendsRoot),
244
+ detection: detectPluginType(config.dir, [backendsRoot]),
244
245
  }));
245
246
 
246
247
  expect(detections[0]!.detection.type).toBe("python");
@@ -257,3 +258,104 @@ describe("DEFAULT_BACKENDS_ROOT", () => {
257
258
  expect(DEFAULT_BACKENDS_ROOT).toMatch(/^\//);
258
259
  });
259
260
  });
261
+
262
+ // ─── Multi-root discovery (Phase 0b) ─────────────────────────────
263
+
264
+ describe("detectPluginType — multi-root", () => {
265
+ let packageRoot: string;
266
+ let userRoot: string;
267
+ const createdRoots: string[] = [];
268
+
269
+ beforeEach(() => {
270
+ packageRoot = createTestBackendsRoot({});
271
+ userRoot = createTestBackendsRoot({});
272
+ createdRoots.push(packageRoot, userRoot);
273
+ });
274
+
275
+ afterEach(() => {
276
+ for (const r of createdRoots.splice(0)) {
277
+ try {
278
+ cleanupTestBackendsRoot(r);
279
+ } catch {
280
+ /* ignore */
281
+ }
282
+ }
283
+ });
284
+
285
+ it("resolves a backend found in the user root when absent from the package root", () => {
286
+ // Place a Python backend only in the user root
287
+ const dir = join(userRoot, "camoufox-py");
288
+ mkdirSync(dir, { recursive: true });
289
+ writeFileSync(join(dir, "bridge.py"), "class X: pass");
290
+
291
+ const result = detectPluginType("camoufox-py", [packageRoot, userRoot]);
292
+ expect(result.type).toBe("python");
293
+ expect(result.entryPoint).toBe(join(userRoot, "camoufox-py", "bridge.py"));
294
+ });
295
+
296
+ it("package root wins over user root when both contain the same dir name", () => {
297
+ // 'chromium' in package root (index.ts) AND user root (bridge.py)
298
+ const pkgDir = join(packageRoot, "shared-name");
299
+ mkdirSync(pkgDir, { recursive: true });
300
+ writeFileSync(join(pkgDir, "index.ts"), "export class X {}");
301
+ const usrDir = join(userRoot, "shared-name");
302
+ mkdirSync(usrDir, { recursive: true });
303
+ writeFileSync(join(usrDir, "bridge.py"), "class X: pass");
304
+
305
+ const result = detectPluginType("shared-name", [packageRoot, userRoot]);
306
+ expect(result.type).toBe("node");
307
+ expect(result.entryPoint).toBe(
308
+ join(packageRoot, "shared-name", "index.ts"),
309
+ );
310
+ });
311
+
312
+ it("absolute dir short-circuits the roots list", () => {
313
+ const absDir = mkdtempSync(join(tmpdir(), "pi-lean-portal-abs-"));
314
+ createdRoots.push(absDir);
315
+ writeFileSync(join(absDir, "bridge.py"), "class X: pass");
316
+
317
+ // Pass roots that do NOT contain this dir; the absolute path must still win.
318
+ const result = detectPluginType(absDir, [packageRoot, userRoot]);
319
+ expect(result.type).toBe("python");
320
+ expect(result.entryPoint).toBe(join(absDir, "bridge.py"));
321
+ });
322
+
323
+ it("throws an error naming all roots searched when no root has an entry point", () => {
324
+ expect(() => detectPluginType("missing", [packageRoot, userRoot])).toThrow(
325
+ /no entry point/i,
326
+ );
327
+
328
+ try {
329
+ detectPluginType("missing", [packageRoot, userRoot]);
330
+ } catch (err) {
331
+ const msg = err instanceof Error ? err.message : String(err);
332
+ // Both roots are named in the error so users can see where it looked.
333
+ expect(msg).toContain(packageRoot);
334
+ expect(msg).toContain(userRoot);
335
+ }
336
+ });
337
+
338
+ it("per-root ambiguity is still an error even with multiple roots", () => {
339
+ const dir = join(userRoot, "ambiguous");
340
+ mkdirSync(dir, { recursive: true });
341
+ writeFileSync(join(dir, "index.ts"), "export class X {}");
342
+ writeFileSync(join(dir, "bridge.py"), "class X: pass");
343
+
344
+ expect(() =>
345
+ detectPluginType("ambiguous", [packageRoot, userRoot]),
346
+ ).toThrow(/ambiguous/);
347
+ });
348
+
349
+ it("a later root with the opposite entry type is NOT treated as ambiguity", () => {
350
+ // package root: index.ts ; user root (same name): bridge.py — first wins, no error.
351
+ const pkgDir = join(packageRoot, "split");
352
+ mkdirSync(pkgDir, { recursive: true });
353
+ writeFileSync(join(pkgDir, "index.ts"), "export class X {}");
354
+ const usrDir = join(userRoot, "split");
355
+ mkdirSync(usrDir, { recursive: true });
356
+ writeFileSync(join(usrDir, "bridge.py"), "class X: pass");
357
+
358
+ const result = detectPluginType("split", [packageRoot, userRoot]);
359
+ expect(result.type).toBe("node");
360
+ });
361
+ });
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, beforeEach } from "vitest";
6
- import { PluginRegistry, validatePlugin } from "../core/plugin-registry";
7
- import { MockPlugin, makeConfig } from "./helpers/mock-plugin";
6
+ import { PluginRegistry, validatePlugin } from "../core/plugin-registry.js";
7
+ import { MockPlugin, makeConfig } from "./helpers/mock-plugin.js";
8
8
 
9
9
  // ─── validatePlugin ──────────────────────────────────────────────
10
10
 
@@ -70,7 +70,6 @@ describe("PluginRegistry.register", () => {
70
70
  it("registers a valid plugin", () => {
71
71
  const plugin = new MockPlugin("chromium");
72
72
  registry.register(plugin, makeConfig({ name: "chromium" }));
73
- expect(registry.size).toBe(1);
74
73
  });
75
74
 
76
75
  it("throws on duplicate name", () => {
@@ -98,7 +97,6 @@ describe("PluginRegistry.register", () => {
98
97
  const p2 = new MockPlugin("firefox");
99
98
  registry.register(p1, makeConfig({ name: "chromium" }));
100
99
  registry.register(p2, makeConfig({ name: "firefox" }));
101
- expect(registry.size).toBe(2);
102
100
  });
103
101
  });
104
102
 
@@ -181,11 +179,7 @@ describe("PluginRegistry.getOrdered", () => {
181
179
  registry.register(p2, makeConfig({ name: "beta" }));
182
180
  registry.register(p3, makeConfig({ name: "gamma" }));
183
181
  const ordered = registry.getOrdered();
184
- expect(ordered.map((e) => e.plugin.name)).toEqual([
185
- "alpha",
186
- "beta",
187
- "gamma",
188
- ]);
182
+ expect(ordered.map((p) => p.name)).toEqual(["alpha", "beta", "gamma"]);
189
183
  });
190
184
 
191
185
  it("excludes disabled plugins", () => {
@@ -196,17 +190,16 @@ describe("PluginRegistry.getOrdered", () => {
196
190
  registry.register(p2, makeConfig({ name: "beta", enabled: false }));
197
191
  registry.register(p3, makeConfig({ name: "gamma" }));
198
192
  const ordered = registry.getOrdered();
199
- expect(ordered.map((e) => e.plugin.name)).toEqual(["alpha", "gamma"]);
193
+ expect(ordered.map((p) => p.name)).toEqual(["alpha", "gamma"]);
200
194
  });
201
195
 
202
- it("assigns correct priority levels", () => {
196
+ it("preserves registration order across enabled plugins", () => {
203
197
  const p1 = new MockPlugin("alpha");
204
198
  const p2 = new MockPlugin("beta");
205
199
  registry.register(p1, makeConfig({ name: "alpha" }));
206
200
  registry.register(p2, makeConfig({ name: "beta" }));
207
201
  const ordered = registry.getOrdered();
208
- expect(ordered[0]!.level).toBe(0);
209
- expect(ordered[1]!.level).toBe(1);
202
+ expect(ordered.map((p) => p.name)).toEqual(["alpha", "beta"]);
210
203
  });
211
204
 
212
205
  it("returns empty array when no plugins are enabled", () => {
@@ -257,39 +250,6 @@ describe("PluginRegistry.availableAll", () => {
257
250
  });
258
251
  });
259
252
 
260
- // ─── PluginRegistry.getHigherStealth ──────────────────────────────
261
-
262
- describe("PluginRegistry.getHigherStealth", () => {
263
- let registry: PluginRegistry;
264
-
265
- beforeEach(() => {
266
- registry = new PluginRegistry();
267
- });
268
-
269
- it("returns plugins at higher priority levels", () => {
270
- const p1 = new MockPlugin("chromium");
271
- const p2 = new MockPlugin("stealth");
272
- registry.register(p1, makeConfig({ name: "chromium" }));
273
- registry.register(p2, makeConfig({ name: "stealth" }));
274
- const higher = registry.getHigherStealth(0);
275
- expect(higher.map((e) => e.plugin.name)).toEqual(["stealth"]);
276
- });
277
-
278
- it("returns empty array when no higher-priority plugins exist", () => {
279
- const p1 = new MockPlugin("chromium");
280
- registry.register(p1, makeConfig({ name: "chromium" }));
281
- expect(registry.getHigherStealth(0)).toEqual([]);
282
- });
283
-
284
- it("excludes disabled plugins from higher priority", () => {
285
- const p1 = new MockPlugin("chromium");
286
- const p2 = new MockPlugin("stealth");
287
- registry.register(p1, makeConfig({ name: "chromium" }));
288
- registry.register(p2, makeConfig({ name: "stealth", enabled: false }));
289
- expect(registry.getHigherStealth(0)).toEqual([]);
290
- });
291
- });
292
-
293
253
  // ─── PluginRegistry.resolveStrategy ──────────────────────────────
294
254
 
295
255
  describe("PluginRegistry.resolveStrategy", () => {
@@ -371,32 +331,11 @@ describe("PluginRegistry.clear", () => {
371
331
  const p2 = new MockPlugin("firefox");
372
332
  registry.register(p1, makeConfig({ name: "chromium" }));
373
333
  registry.register(p2, makeConfig({ name: "firefox" }));
374
- expect(registry.size).toBe(2);
375
334
  registry.clear();
376
- expect(registry.size).toBe(0);
377
335
  expect(registry.available()).toEqual([]);
378
336
  });
379
337
  });
380
338
 
381
- // ─── PluginRegistry.getLevel ─────────────────────────────────────
382
-
383
- describe("PluginRegistry.getLevel", () => {
384
- it("returns the priority level of a registered plugin", () => {
385
- const registry = new PluginRegistry();
386
- const p1 = new MockPlugin("chromium");
387
- const p2 = new MockPlugin("stealth");
388
- registry.register(p1, makeConfig({ name: "chromium" }));
389
- registry.register(p2, makeConfig({ name: "stealth" }));
390
- expect(registry.getLevel("chromium")).toBe(0);
391
- expect(registry.getLevel("stealth")).toBe(1);
392
- });
393
-
394
- it("returns undefined for unknown plugin", () => {
395
- const registry = new PluginRegistry();
396
- expect(registry.getLevel("nonexistent")).toBeUndefined();
397
- });
398
- });
399
-
400
339
  // ─── MockPlugin call tracking ────────────────────────────────────
401
340
 
402
341
  describe("MockPlugin call tracking", () => {
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Unit tests for `probeUserBackend` (Sprint 1, AC 1.2).
3
+ *
4
+ * Structural — no real browser, no MiniWoB content. Uses a temp
5
+ * directory as `PI_USER_BACKENDS_DIR` so nothing is written to the
6
+ * real `~/.pi/agent/pi-lean-portal/user-backends/`. The "venv runs"
7
+ * case uses a tiny `#!/bin/sh\nexit 0` stub executable so the test is
8
+ * self-contained and does not depend on python3 being installed at a
9
+ * known path; the "non-runnable" case uses an exit-1 stub.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+ import {
16
+ chmodSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ mkdtempSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { homedir, tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ import {
27
+ probeUserBackend,
28
+ userBackendsDir,
29
+ } from "./helpers/probe-user-backend.js";
30
+
31
+ // ─── Helpers ──────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Create a stub "python3" executable at `<dir>/.venv/bin/python3` that
35
+ * exits with `exitCode`. Mimics a runnable (exit 0) or broken (exit 1)
36
+ * venv interpreter for the probe's `spawnSync(…, ["--version"])` check.
37
+ */
38
+ function makeStubPython(dir: string, exitCode: number): string {
39
+ const binDir = join(dir, ".venv", "bin");
40
+ mkdirSync(binDir, { recursive: true });
41
+ const pyPath = join(binDir, "python3");
42
+ writeFileSync(pyPath, `#!/bin/sh\nexit ${exitCode}\n`);
43
+ chmodSync(pyPath, 0o755);
44
+ return pyPath;
45
+ }
46
+
47
+ /** Write a stub `bridge.py` at `<dir>/bridge.py`. */
48
+ function makeStubBridge(dir: string): string {
49
+ mkdirSync(dir, { recursive: true });
50
+ const bridgePath = join(dir, "bridge.py");
51
+ writeFileSync(bridgePath, "# stub bridge\n");
52
+ return bridgePath;
53
+ }
54
+
55
+ // ─── Test env snapshot / restore ──────────────────────────────────
56
+
57
+ let tempRoot: string;
58
+ let savedDir: string | undefined;
59
+ /** Per-name `PI_USER_BACKEND_<UPPERNAME>_PYTHON` keys set during a test. */
60
+ let touchedPythonKeys: string[] = [];
61
+ /** Saved values of per-name python env overrides, for afterEach restore. */
62
+ const savedEnv = new Map<string, string | undefined>();
63
+
64
+ function setPythonOverride(name: string, value: string): void {
65
+ const key = `PI_USER_BACKEND_${name.toUpperCase()}_PYTHON`;
66
+ if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
67
+ touchedPythonKeys.push(key);
68
+ process.env[key] = value;
69
+ }
70
+
71
+ beforeEach(() => {
72
+ tempRoot = mkdtempSync(join(tmpdir(), "probe-user-backend-"));
73
+ savedDir = process.env.PI_USER_BACKENDS_DIR;
74
+ savedEnv.clear();
75
+ touchedPythonKeys = [];
76
+ process.env.PI_USER_BACKENDS_DIR = tempRoot;
77
+ });
78
+
79
+ afterEach(() => {
80
+ rmSync(tempRoot, { recursive: true, force: true });
81
+ // Restore PI_USER_BACKENDS_DIR.
82
+ if (savedDir === undefined)
83
+ Reflect.deleteProperty(process.env, "PI_USER_BACKENDS_DIR");
84
+ else process.env.PI_USER_BACKENDS_DIR = savedDir;
85
+ // Restore (or delete) any per-name python overrides set during the test.
86
+ for (const key of touchedPythonKeys) {
87
+ const saved = savedEnv.get(key);
88
+ if (saved === undefined) Reflect.deleteProperty(process.env, key);
89
+ else process.env[key] = saved;
90
+ }
91
+ touchedPythonKeys = [];
92
+ });
93
+
94
+ // ─── userBackendsDir ──────────────────────────────────────────────
95
+
96
+ describe("userBackendsDir", () => {
97
+ it("honors PI_USER_BACKENDS_DIR when set", () => {
98
+ expect(userBackendsDir()).toBe(tempRoot);
99
+ });
100
+
101
+ it("falls back to ~/.pi/agent/pi-lean-portal/user-backends when unset", () => {
102
+ Reflect.deleteProperty(process.env, "PI_USER_BACKENDS_DIR");
103
+ const expected = join(
104
+ homedir(),
105
+ ".pi",
106
+ "agent",
107
+ "pi-lean-portal",
108
+ "user-backends",
109
+ );
110
+ expect(userBackendsDir()).toBe(expected);
111
+ });
112
+
113
+ it("treats an empty PI_USER_BACKENDS_DIR as unset", () => {
114
+ process.env.PI_USER_BACKENDS_DIR = " ";
115
+ const expected = join(
116
+ homedir(),
117
+ ".pi",
118
+ "agent",
119
+ "pi-lean-portal",
120
+ "user-backends",
121
+ );
122
+ expect(userBackendsDir()).toBe(expected);
123
+ });
124
+ });
125
+
126
+ // ─── probeUserBackend ─────────────────────────────────────────────
127
+
128
+ describe("probeUserBackend", () => {
129
+ it("returns available=true with correct paths when bridge + venv present", () => {
130
+ const name = "camoufox-py";
131
+ const backendDir = join(tempRoot, name);
132
+ const bridge = makeStubBridge(backendDir);
133
+ const py = makeStubPython(backendDir, 0);
134
+
135
+ const result = probeUserBackend(name);
136
+
137
+ expect(result.available).toBe(true);
138
+ expect(result.reason).toBeUndefined();
139
+ expect(result.bridgePath).toBe(bridge);
140
+ expect(result.venvPython).toBe(py);
141
+ });
142
+
143
+ it("returns available=false with a bridge-mentioning reason when bridge.py is missing", () => {
144
+ const name = "camoufox-py";
145
+ // venv present but no bridge.py
146
+ const py = makeStubPython(join(tempRoot, name), 0);
147
+ const expectedBridge = join(tempRoot, name, "bridge.py");
148
+
149
+ const result = probeUserBackend(name);
150
+
151
+ expect(result.available).toBe(false);
152
+ expect(result.reason).toMatch(/bridge/i);
153
+ expect(result.reason).toContain(expectedBridge);
154
+ expect(result.bridgePath).toBe(expectedBridge);
155
+ expect(result.venvPython).toBe(py);
156
+ });
157
+
158
+ it("returns available=false with a venv-mentioning reason when venv python is missing", () => {
159
+ const name = "stealth-py";
160
+ const backendDir = join(tempRoot, name);
161
+ makeStubBridge(backendDir);
162
+ const expectedPy = join(backendDir, ".venv", "bin", "python3");
163
+
164
+ const result = probeUserBackend(name);
165
+
166
+ expect(result.available).toBe(false);
167
+ expect(result.reason).toMatch(/venv|python/i);
168
+ expect(result.reason).toContain(expectedPy);
169
+ expect(result.venvPython).toBe(expectedPy);
170
+ });
171
+
172
+ it("returns available=false with a venv-mentioning reason when venv python exists but does not run", () => {
173
+ const name = "stealth-py";
174
+ const backendDir = join(tempRoot, name);
175
+ makeStubBridge(backendDir);
176
+ const py = makeStubPython(backendDir, 1); // exits non-zero
177
+
178
+ const result = probeUserBackend(name);
179
+
180
+ expect(result.available).toBe(false);
181
+ expect(result.reason).toMatch(/venv|python/i);
182
+ expect(result.reason).toContain(py);
183
+ expect(result.venvPython).toBe(py);
184
+ });
185
+
186
+ it("respects PI_USER_BACKEND_<UPPERNAME>_PYTHON env override for venvPython", () => {
187
+ const name = "camoufox-py";
188
+ const backendDir = join(tempRoot, name);
189
+ makeStubBridge(backendDir);
190
+ // Do NOT create the default .venv; instead point the env override at a
191
+ // stub python that lives outside the default venv path (but still under
192
+ // tempRoot so afterEach cleans it up).
193
+ const overrideDir = join(tempRoot, "override-venv");
194
+ const overridePy = makeStubPython(overrideDir, 0);
195
+ setPythonOverride(name, overridePy);
196
+
197
+ const result = probeUserBackend(name);
198
+
199
+ expect(result.available).toBe(true);
200
+ expect(result.venvPython).toBe(overridePy);
201
+ expect(existsSync(join(backendDir, ".venv", "bin", "python3"))).toBe(false);
202
+ });
203
+
204
+ it("respects opts.bridgePath absolute override for bridgePath", () => {
205
+ const name = "camoufox-py";
206
+ // Create a bridge.py at an arbitrary absolute location outside the
207
+ // default user-backends/<name>/ tree (but still under tempRoot so
208
+ // afterEach cleans it up).
209
+ const externalDir = join(tempRoot, "override-bridge");
210
+ const externalBridge = makeStubBridge(externalDir);
211
+ // Also provide a working venv under the default path so the only thing
212
+ // being overridden is the bridge location.
213
+ makeStubPython(join(tempRoot, name), 0);
214
+
215
+ const result = probeUserBackend(name, { bridgePath: externalBridge });
216
+
217
+ expect(result.available).toBe(true);
218
+ expect(result.bridgePath).toBe(externalBridge);
219
+ // The default bridge path is NOT used.
220
+ expect(result.bridgePath).not.toBe(join(tempRoot, name, "bridge.py"));
221
+ });
222
+
223
+ it("ignores a non-absolute opts.bridgePath and falls back to default resolution", () => {
224
+ const name = "camoufox-py";
225
+ const backendDir = join(tempRoot, name);
226
+ const bridge = makeStubBridge(backendDir);
227
+ makeStubPython(backendDir, 0);
228
+
229
+ // A relative bridgePath must not short-circuit (mirrors detectPluginType,
230
+ // which only short-circuits on isAbsolute(dir)).
231
+ const result = probeUserBackend(name, { bridgePath: "relative/bridge.py" });
232
+
233
+ expect(result.available).toBe(true);
234
+ expect(result.bridgePath).toBe(bridge);
235
+ });
236
+ });