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
@@ -31,6 +31,8 @@ import {
31
31
  loadStorageState,
32
32
  deleteStorageState,
33
33
  } from "../core/shared/storage-state.js";
34
+ import { DEFAULT_BACKENDS_ROOT } from "../core/plugin-config.js";
35
+ import { EXTRACTOR_SCRIPT } from "../core/shared/dom-extractor.js";
34
36
 
35
37
  // ─── Helpers ──────────────────────────────────────────────────────────
36
38
 
@@ -105,6 +107,8 @@ function writeBridge(handlers: Record<string, string>, dir?: string): string {
105
107
  " _rid = _req.get('id')",
106
108
  " if _m == 'ping':",
107
109
  ` sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":_rid,"result":"pong"}) + "\\n")`,
110
+ " elif _m == 'browser.init':",
111
+ ` sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":_rid,"result":{"ok":True}}) + "\\n")`,
108
112
  " elif _m == 'shutdown':",
109
113
  ` sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":_rid,"result":"bye"}) + "\\n")`,
110
114
  " sys.stdout.flush()",
@@ -220,11 +224,7 @@ describe("capabilities", () => {
220
224
  bridgeScript: path,
221
225
  });
222
226
  expect(adapter.capabilities.supportsFullPageScreenshot).toBe(true);
223
- expect(adapter.capabilities.supportsConsoleCapture).toBe(true);
224
227
  expect(adapter.capabilities.supportsJavaScriptEvaluate).toBe(true);
225
- expect(adapter.capabilities.supportsBotDetection).toBe(true);
226
- expect(adapter.capabilities.supportsDialogAutoDismissal).toBe(true);
227
- expect(adapter.capabilities.supportsAbortSignal).toBe(false);
228
228
  expect(adapter.capabilities.engine).toBe("chromium");
229
229
  } finally {
230
230
  cleanTempBridge(path);
@@ -237,11 +237,9 @@ describe("capabilities", () => {
237
237
  const adapter = new PythonPluginAdapter("test", {
238
238
  bridgeScript: path,
239
239
  capabilities: {
240
- supportsAbortSignal: true,
241
240
  engine: "firefox",
242
241
  },
243
242
  });
244
- expect(adapter.capabilities.supportsAbortSignal).toBe(true);
245
243
  expect(adapter.capabilities.engine).toBe("firefox");
246
244
  // Defaults preserved
247
245
  expect(adapter.capabilities.supportsFullPageScreenshot).toBe(true);
@@ -722,11 +720,17 @@ describeStderr("stderr capture", () => {
722
720
  );
723
721
  expect(result.success).toBe(true);
724
722
 
725
- const stderr = (adapter as unknown as Record<string, string>)[
726
- "_stderrAccumulated"
727
- ];
728
- expect(stderr).toContain("BOOT_ERR_ABC");
729
- expect(stderr).toContain("NAV_ERR_XYZ");
723
+ // Startup stderr (BOOT_ERR_ABC) is written during init, so it arrives
724
+ // before navigate resolves — safe to assert synchronously.
725
+ const stderrAccessor = () =>
726
+ (adapter as unknown as Record<string, string>)["_stderrAccumulated"];
727
+ expect(stderrAccessor()).toContain("BOOT_ERR_ABC");
728
+
729
+ // Navigate stderr (NAV_ERR_XYZ) is written to a separate stream; the
730
+ // data event may fire after the stdout response resolves, so we poll.
731
+ await expect
732
+ .poll(stderrAccessor, { timeout: 1_000, interval: 50 })
733
+ .toContain("NAV_ERR_XYZ");
730
734
  } finally {
731
735
  await adapter.cleanupAll().catch(() => {});
732
736
  try {
@@ -809,6 +813,392 @@ describeStderr("stderr capture", () => {
809
813
  // Storage state persistence (via _persistState)
810
814
  // ═════════════════════════════════════════════════════════════════════
811
815
 
816
+ // ══════════════════════════════════════════════════════════════════════
817
+ // browser.init RPC (Phase 0)
818
+ // ══════════════════════════════════════════════════════════════════════
819
+
820
+ const describeBrowserInit = PYTHON_AVAILABLE ? describe : describe.skip;
821
+
822
+ describeBrowserInit("browser.init RPC (Phase 0)", () => {
823
+ /**
824
+ * Build a bridge that records every method received (in order) and
825
+ * optionally answers `browser.init` with success or an error.
826
+ */
827
+ function recordingBridge(opts: {
828
+ initResult?: "ok" | "error";
829
+ navigate?: boolean;
830
+ }): { path: string; dir: string } {
831
+ const dir = mkdtempSync(join(tmpdir(), "pi-lean-portal-init-"));
832
+ const path = join(dir, "bridge.py");
833
+ const INDENT = " "; // 8 spaces — body of `elif` inside the `for` loop
834
+ const initBranch =
835
+ opts.initResult === "error"
836
+ ? INDENT +
837
+ 'sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":_rid,"error":{"code":-32601,"message":"Method not found: browser.init"}}) + "\\n")'
838
+ : INDENT +
839
+ 'sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":_rid,"result":{"ok":True}}) + "\\n")';
840
+ const navBranch = opts.navigate
841
+ ? INDENT +
842
+ 'sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":_rid,"result":{"success":True,"url":_req.get("params",{}).get("url",""),"title":"P","snapshot":"- [link] x","elementCount":1}}) + "\\n")'
843
+ : "";
844
+ const lines: string[] = [
845
+ "import json, sys",
846
+ "_order = []",
847
+ "def _emit(_rid, result=None, error=None):",
848
+ ' d = {"jsonrpc":"2.0","id":_rid}',
849
+ ' if error is not None: d["error"] = error',
850
+ ' else: d["result"] = result',
851
+ ' sys.stdout.write(json.dumps(d) + "\\n")',
852
+ " sys.stdout.flush()",
853
+ "for _line in sys.stdin:",
854
+ " _line = _line.strip()",
855
+ " if not _line: continue",
856
+ " _req = json.loads(_line)",
857
+ " _m = _req.get('method','')",
858
+ " _rid = _req.get('id')",
859
+ " _order.append(_m)",
860
+ " sys.stderr.write('ORDER:' + ','.join(_order) + '\\n')",
861
+ " sys.stderr.flush()",
862
+ " if _m == 'ping':",
863
+ " _emit(_rid, result='pong')",
864
+ " elif _m == 'browser.init':",
865
+ initBranch,
866
+ " elif _m == 'shutdown':",
867
+ " _emit(_rid, result='bye')",
868
+ " break",
869
+ " elif _m == 'browser.navigate':",
870
+ navBranch ||
871
+ INDENT +
872
+ "_emit(_rid, result={'success':True,'url':'','title':'','snapshot':'','elementCount':0})",
873
+ " elif _m == 'browser.cleanup':",
874
+ " _emit(_rid, result={'success':True})",
875
+ " else:",
876
+ " _emit(_rid, error={'code':-32601,'message':'unknown:'+_m})",
877
+ ];
878
+ writeFileSync(path, lines.join("\n"), "utf-8");
879
+ return { path, dir };
880
+ }
881
+
882
+ it("sends browser.init exactly once after ping, before any other RPC", async () => {
883
+ const { path: bridgePath, dir } = recordingBridge({ navigate: true });
884
+ const adapter = new PythonPluginAdapter("init-order-test", {
885
+ bridgeScript: bridgePath,
886
+ pythonPath: "python3",
887
+ });
888
+ try {
889
+ await adapter.init({ launch: { headless: true } });
890
+ // Trigger process start + handshake + navigate
891
+ const r = await adapter.navigate(
892
+ "https://example.com",
893
+ "init-t1",
894
+ 30_000,
895
+ );
896
+ expect(r.success).toBe(true);
897
+
898
+ const stderr =
899
+ (adapter as unknown as Record<string, string>)["_stderrAccumulated"] ??
900
+ "";
901
+ const orderLines = stderr
902
+ .split("\n")
903
+ .filter((l) => l.startsWith("ORDER:"));
904
+ expect(orderLines.length).toBeGreaterThan(0);
905
+ // The second ORDER emission captures ping,init — proving init came
906
+ // immediately after ping, before any other RPC.
907
+ const second = orderLines[1] ?? orderLines[0] ?? "";
908
+ const methods = second.slice("ORDER:".length).split(",");
909
+ expect(methods[0]).toBe("ping");
910
+ expect(methods[1]).toBe("browser.init");
911
+ // init appears exactly once across the whole session
912
+ const allMethods = (orderLines[orderLines.length - 1] ?? "")
913
+ .slice("ORDER:".length)
914
+ .split(",");
915
+ const initCount = allMethods.filter((m) => m === "browser.init").length;
916
+ expect(initCount).toBe(1);
917
+ } finally {
918
+ await adapter.cleanupAll().catch(() => {});
919
+ try {
920
+ rmSync(dir, { recursive: true, force: true });
921
+ } catch {
922
+ /* ignore */
923
+ }
924
+ }
925
+ });
926
+
927
+ it("forwards the plugin config dict to browser.init", async () => {
928
+ const dir = mkdtempSync(join(tmpdir(), "pi-lean-portal-init-cfg-"));
929
+ const bridgePath = join(dir, "bridge.py");
930
+ const lines: string[] = [
931
+ "import json, sys",
932
+ "for _line in sys.stdin:",
933
+ " _line = _line.strip()",
934
+ " if not _line: continue",
935
+ " _req = json.loads(_line)",
936
+ " _m = _req.get('method','')",
937
+ " _rid = _req.get('id')",
938
+ " if _m == 'ping':",
939
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'result':'pong'}) + '\\n')",
940
+ " elif _m == 'browser.init':",
941
+ " _cfg = _req.get('params',{}).get('config',{})",
942
+ " sys.stderr.write('CFG:' + json.dumps(_cfg, sort_keys=True) + '\\n')",
943
+ " sys.stderr.flush()",
944
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'result':{'ok':True}}) + '\\n')",
945
+ " elif _m == 'shutdown':",
946
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'result':'bye'}) + '\\n')",
947
+ " sys.stdout.flush()",
948
+ " break",
949
+ " else:",
950
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'result':{}}) + '\\n')",
951
+ " sys.stdout.flush()",
952
+ ];
953
+ writeFileSync(bridgePath, lines.join("\n"), "utf-8");
954
+
955
+ const adapter = new PythonPluginAdapter("init-cfg-test", {
956
+ bridgeScript: bridgePath,
957
+ pythonPath: "python3",
958
+ });
959
+ try {
960
+ const launchConfig = { launch: { headless: true, os: "windows" } };
961
+ await adapter.init(launchConfig);
962
+ // Trigger handshake
963
+ await adapter
964
+ .navigate("https://example.com", "cfg-t1", 30_000)
965
+ .catch(() => {});
966
+
967
+ const stderr =
968
+ (adapter as unknown as Record<string, string>)["_stderrAccumulated"] ??
969
+ "";
970
+ const cfgLine = stderr.split("\n").find((l) => l.startsWith("CFG:"));
971
+ expect(cfgLine).toBeDefined();
972
+ const parsed = JSON.parse(cfgLine!.slice("CFG:".length));
973
+ // The adapter merges the read-only extractor script into the config
974
+ // so stealth backends that opt into the CSP-safe eval quirk can
975
+ // register it as an init script. The original launch config is
976
+ // preserved verbatim; only the plumbing key is added.
977
+ expect(parsed).toEqual({
978
+ ...launchConfig,
979
+ readOnlyExtractorScript: EXTRACTOR_SCRIPT,
980
+ });
981
+ } finally {
982
+ await adapter.cleanupAll().catch(() => {});
983
+ try {
984
+ rmSync(dir, { recursive: true, force: true });
985
+ } catch {
986
+ /* ignore */
987
+ }
988
+ }
989
+ });
990
+
991
+ it("rejects with a 'bridge too old' message when browser.init is unknown", async () => {
992
+ const { path: bridgePath, dir } = recordingBridge({
993
+ initResult: "error",
994
+ navigate: true,
995
+ });
996
+ const adapter = new PythonPluginAdapter("init-old-bridge", {
997
+ bridgeScript: bridgePath,
998
+ pythonPath: "python3",
999
+ });
1000
+ try {
1001
+ await adapter.init({});
1002
+ // The first operation triggers _startProcess; the init RPC returns
1003
+ // METHOD_NOT_FOUND, so the handshake rejects and navigate fails.
1004
+ const r = await adapter.navigate("https://example.com", "old-t1", 30_000);
1005
+ expect(r.success).toBe(false);
1006
+ expect(r.error).toMatch(/browser\.init \(bridge too old/);
1007
+ } finally {
1008
+ await adapter.cleanupAll().catch(() => {});
1009
+ try {
1010
+ rmSync(dir, { recursive: true, force: true });
1011
+ } catch {
1012
+ /* ignore */
1013
+ }
1014
+ }
1015
+ });
1016
+
1017
+ it("re-sends browser.init after a crash recovery restart", async () => {
1018
+ const { path: bridgePath, dir } = recordingBridge({ navigate: true });
1019
+ const adapter = new PythonPluginAdapter("init-restart", {
1020
+ bridgeScript: bridgePath,
1021
+ pythonPath: "python3",
1022
+ });
1023
+ try {
1024
+ await adapter.init({ launch: { headless: true } });
1025
+ const r1 = await adapter.navigate(
1026
+ "https://example.com",
1027
+ "restart-t1",
1028
+ 30_000,
1029
+ );
1030
+ expect(r1.success).toBe(true);
1031
+
1032
+ // Capture stderr from the FIRST process before killing it.
1033
+ // (The adapter resets _stderrAccumulated on restart, so we must
1034
+ // snapshot now to prove the first handshake sent init.)
1035
+ const stderrBefore =
1036
+ (adapter as unknown as Record<string, string>)["_stderrAccumulated"] ??
1037
+ "";
1038
+ const orderBefore = stderrBefore
1039
+ .split("\n")
1040
+ .filter((l) => l.startsWith("ORDER:"));
1041
+ const methodsBefore = (orderBefore[orderBefore.length - 1] ?? "")
1042
+ .slice("ORDER:".length)
1043
+ .split(",");
1044
+ expect(methodsBefore[0]).toBe("ping");
1045
+ expect(methodsBefore[1]).toBe("browser.init");
1046
+
1047
+ // Kill the subprocess
1048
+ const proc = privateMethod<{ kill: (s: string) => void }>(
1049
+ adapter,
1050
+ "_process",
1051
+ );
1052
+ expect(proc).not.toBeNull();
1053
+ proc!.kill("SIGKILL");
1054
+ await new Promise((r) => setTimeout(r, 200));
1055
+
1056
+ // Restart — browser.init must be re-sent (the new process has no config).
1057
+ const r2 = await adapter.navigate(
1058
+ "https://example.com/2",
1059
+ "restart-t2",
1060
+ 30_000,
1061
+ );
1062
+ expect(r2.success).toBe(true);
1063
+
1064
+ // The restarted process's stderr (freshly accumulated) must again
1065
+ // show ping → browser.init as the first two methods, proving init
1066
+ // is re-sent on crash recovery, not just on the first start.
1067
+ const stderrAfter =
1068
+ (adapter as unknown as Record<string, string>)["_stderrAccumulated"] ??
1069
+ "";
1070
+ const orderAfter = stderrAfter
1071
+ .split("\n")
1072
+ .filter((l) => l.startsWith("ORDER:"));
1073
+ const methodsAfter = (orderAfter[orderAfter.length - 1] ?? "")
1074
+ .slice("ORDER:".length)
1075
+ .split(",");
1076
+ expect(methodsAfter[0]).toBe("ping");
1077
+ expect(methodsAfter[1]).toBe("browser.init");
1078
+ } finally {
1079
+ await adapter.cleanupAll().catch(() => {});
1080
+ try {
1081
+ rmSync(dir, { recursive: true, force: true });
1082
+ } catch {
1083
+ /* ignore */
1084
+ }
1085
+ }
1086
+ });
1087
+ });
1088
+
1089
+ // ═════════════════════════════════════════════════════════════════════
1090
+ // PYTHONPATH injection (Phase 0b)
1091
+ // ═════════════════════════════════════════════════════════════════════
1092
+
1093
+ /**
1094
+ * `_buildPythonPath` is a pure method (reads `process.env.PYTHONPATH` +
1095
+ * module-level `DEFAULT_BACKENDS_ROOT` only, never `this`), so it can be
1096
+ * invoked without binding. These unit tests don't need a subprocess.
1097
+ */
1098
+ describe("_buildPythonPath — PYTHONPATH injection (Phase 0b)", () => {
1099
+ it("includes the package python-base directory when PYTHONPATH is unset", () => {
1100
+ const adapter = createAdapter();
1101
+ const saved = process.env.PYTHONPATH;
1102
+ delete process.env.PYTHONPATH;
1103
+ try {
1104
+ const fn = privateMethod<() => string>(adapter, "_buildPythonPath");
1105
+ const value = fn();
1106
+ expect(value).toContain(join(DEFAULT_BACKENDS_ROOT, "python-base"));
1107
+ } finally {
1108
+ if (saved !== undefined) process.env.PYTHONPATH = saved;
1109
+ }
1110
+ });
1111
+
1112
+ it("appends the package path after an existing PYTHONPATH (user entries keep precedence)", () => {
1113
+ const adapter = createAdapter();
1114
+ const saved = process.env.PYTHONPATH;
1115
+ const userEntry = "/some/user/path";
1116
+ process.env.PYTHONPATH = userEntry;
1117
+ try {
1118
+ const fn = privateMethod<() => string>(adapter, "_buildPythonPath");
1119
+ const value = fn();
1120
+ const expected = join(DEFAULT_BACKENDS_ROOT, "python-base");
1121
+ expect(value).toContain(userEntry);
1122
+ expect(value).toContain(expected);
1123
+ // Appended (not prepended): the user's entry comes first.
1124
+ expect(value.indexOf(userEntry)).toBeLessThan(value.indexOf(expected));
1125
+ } finally {
1126
+ if (saved !== undefined) process.env.PYTHONPATH = saved;
1127
+ else delete process.env.PYTHONPATH;
1128
+ }
1129
+ });
1130
+ });
1131
+
1132
+ const describePythonPathEnv = PYTHON_AVAILABLE ? describe : describe.skip;
1133
+
1134
+ describePythonPathEnv(
1135
+ "PYTHONPATH reaches the spawned bridge (Phase 0b)",
1136
+ () => {
1137
+ it("the bridge process env includes the package python-base path", async () => {
1138
+ const dir = mkdtempSync(join(tmpdir(), "pi-lean-portal-pypath-"));
1139
+ const bridgePath = join(dir, "bridge.py");
1140
+ const lines: string[] = [
1141
+ "import json, sys, os",
1142
+ "sys.stderr.write('PYPATH:' + (os.environ.get('PYTHONPATH') or '') + '\\n')",
1143
+ "sys.stderr.flush()",
1144
+ "for _line in sys.stdin:",
1145
+ " _line = _line.strip()",
1146
+ " if not _line: continue",
1147
+ " _req = json.loads(_line)",
1148
+ " _m = _req.get('method','')",
1149
+ " _rid = _req.get('id')",
1150
+ " if _m == 'ping':",
1151
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'result':'pong'}) + '\\n')",
1152
+ " sys.stdout.flush()",
1153
+ " elif _m == 'browser.init':",
1154
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'result':{'ok':True}}) + '\\n')",
1155
+ " sys.stdout.flush()",
1156
+ " elif _m == 'shutdown':",
1157
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'result':'bye'}) + '\\n')",
1158
+ " sys.stdout.flush()",
1159
+ " break",
1160
+ " elif _m == 'browser.navigate':",
1161
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'result':{'success':True,'url':_req.get('params',{}).get('url',''),'title':'P','snapshot':'','elementCount':0}}) + '\\n')",
1162
+ " sys.stdout.flush()",
1163
+ " else:",
1164
+ " sys.stdout.write(json.dumps({'jsonrpc':'2.0','id':_rid,'error':{'code':-32601,'message':'unknown'}}) + '\\n')",
1165
+ " sys.stdout.flush()",
1166
+ ];
1167
+ writeFileSync(bridgePath, lines.join("\n"), "utf-8");
1168
+ const adapter = new PythonPluginAdapter("pypath-test", {
1169
+ bridgeScript: bridgePath,
1170
+ pythonPath: "python3",
1171
+ });
1172
+ try {
1173
+ await adapter.init({});
1174
+ const r = await adapter.navigate(
1175
+ "https://example.com",
1176
+ "pypath-t1",
1177
+ 30_000,
1178
+ );
1179
+ expect(r.success).toBe(true);
1180
+ const stderr =
1181
+ (adapter as unknown as Record<string, string>)[
1182
+ "_stderrAccumulated"
1183
+ ] ?? "";
1184
+ const pypathLine = stderr
1185
+ .split("\n")
1186
+ .find((l) => l.startsWith("PYPATH:"));
1187
+ expect(pypathLine).toBeDefined();
1188
+ const pypath = (pypathLine ?? "").slice("PYPATH:".length);
1189
+ expect(pypath).toContain(join(DEFAULT_BACKENDS_ROOT, "python-base"));
1190
+ } finally {
1191
+ await adapter.cleanupAll().catch(() => {});
1192
+ try {
1193
+ rmSync(dir, { recursive: true, force: true });
1194
+ } catch {
1195
+ /* ignore */
1196
+ }
1197
+ }
1198
+ });
1199
+ },
1200
+ );
1201
+
812
1202
  const describePersistence = PYTHON_AVAILABLE ? describe : describe.skip;
813
1203
 
814
1204
  describePersistence("_persistState — storage state persistence", () => {
@@ -46,7 +46,10 @@ const mockConfig = vi.hoisted(() => ({
46
46
  }));
47
47
 
48
48
  vi.mock("../core/plugin-config.js", () => ({
49
- loadBrowserConfig: () => mockConfig,
49
+ loadFullConfig: () => ({
50
+ browser: mockConfig,
51
+ plugins: { plugins: [], errors: [] },
52
+ }),
50
53
  }));
51
54
 
52
55
  // Must import router AFTER the mock is set up