pi-lean-dimension 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -36
- package/node_modules/pi-lean-portal/AGENTS.md +146 -4
- package/node_modules/pi-lean-portal/README.md +112 -50
- package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
- package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
- package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
- package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
- package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
- package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
- package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
- package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
- package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
- package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
- package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
- package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
- package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
- package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
- package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
- package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
- package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
- package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
- package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
- package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
- package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_accessibility.cpython-313-pytest-9.1.0.pyc → test_accessibility.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_chromium_py_bridge.cpython-313-pytest-9.1.0.pyc → test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_firefox_py_bridge.cpython-313-pytest-9.1.0.pyc → test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_transport.cpython-313-pytest-9.1.0.pyc → test_transport.cpython-313-pytest-9.1.1.pyc} +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
- package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
- package/node_modules/pi-lean-portal/contributed/README.md +304 -0
- package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
- package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
- package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
- package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
- package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
- package/node_modules/pi-lean-portal/core/router.ts +59 -98
- package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
- package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
- package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
- package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
- package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
- package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
- package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
- package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
- package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
- package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
- package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
- package/node_modules/pi-lean-portal/index.ts +42 -7
- package/node_modules/pi-lean-portal/package.json +8 -3
- package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
- package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
- package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
- package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
- package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
- package/node_modules/pi-lean-search/README.md +6 -2
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
- package/node_modules/pi-lean-search/index.ts +4 -21
- package/node_modules/pi-lean-search/package.json +2 -2
- package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
- package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
- package/package.json +4 -4
- package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
- package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
- package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_firefox_py_bridge.py +0 -212
|
@@ -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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
expect(
|
|
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
|
-
|
|
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
|