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
@@ -1,203 +1,29 @@
1
- /**
2
- * Chromium-Py Cookie Persistence Integration Test — end-to-end validation
3
- * of Bug A (router loads storage state) and Bug B (plugin saves state on
4
- * re-navigate) for the Python adapter / chromium-py bridge.
5
- *
6
- * Uses a live Chromium browser via the Chromium-Py bridge against a local
7
- * HTTP test server that serves a page with a cookie-based consent dialog.
8
- *
9
- * The test:
10
- * 1. Navigates with a named profile — dialog visible
11
- * 2. Clicks "Accept All" — sets a consent cookie
12
- * 3. Navigates again with the same profile — dialog should NOT reappear
13
- * because the bridge reuses the BrowserContext (so cookies survive), and
14
- * `_persistState` saves the state to disk for cross-session persistence.
15
- *
16
- * Auto-skips the entire suite when Python/chromium-py prerequisites are
17
- * unavailable.
18
- */
19
-
20
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
21
- import { resolve } from "node:path";
22
- import { existsSync } from "node:fs";
23
- import { spawnSync } from "node:child_process";
24
-
1
+ import { describe, beforeAll, afterAll } from "vitest";
25
2
  import { PythonPluginAdapter } from "../backends/python-adapter.js";
26
- import {
27
- startTestServer,
28
- COOKIE_PERSISTENCE_HTML,
29
- } from "./helpers/test-server.js";
30
- import {
31
- loadStorageState,
32
- deleteStorageState,
33
- } from "../core/shared/storage-state.js";
34
- import { sessionManager } from "../core/shared/session-manager.js";
35
-
36
- // ─── Paths ──────────────────────────────────────────────────────────
37
-
38
- const BRIDGE_SCRIPT = resolve(__dirname, "../backends/chromium-py/bridge.py");
39
- const PYTHON_PATH = resolve(
40
- __dirname,
41
- "../backends/python-base/.venv/bin/python3",
42
- );
43
-
44
- // ─── Prerequisites check — skip gracefully if absent ────────────────
45
-
46
- const prerequisitesMet = (() => {
47
- if (!existsSync(PYTHON_PATH)) return false;
48
- if (!existsSync(BRIDGE_SCRIPT)) return false;
49
- const result = spawnSync(PYTHON_PATH, ["--version"], {
50
- stdio: "ignore",
51
- timeout: 5_000,
52
- });
53
- if (result.status !== 0) return false;
54
-
55
- // Quick check: can we import playwright?
56
- const check = spawnSync(
57
- PYTHON_PATH,
58
- ["-c", "import playwright; print('ok')"],
59
- { stdio: "pipe", timeout: 5_000 },
60
- );
61
- return check.status === 0 && check.stdout.toString().trim() === "ok";
62
- })();
63
-
64
- const describeIfAvailable = prerequisitesMet ? describe : describe.skip;
65
-
66
- // ─── Test fixture HTML (imported from shared helper) ───────────────
67
- // See ``COOKIE_PERSISTENCE_HTML`` in ``helpers/test-server.ts``.
68
-
69
- // ─── Test Server Setup (always runs, server is cheap to start/stop) ─
70
-
71
- let serverUrl: string;
72
- let stopServer: () => Promise<void>;
3
+ import { createPyBackendTestHarness } from "./helpers/create-py-backend-harness.js";
4
+ import { startCookiePersistenceServer } from "./helpers/test-server.js";
5
+ import { runPersistenceSuite } from "./helpers/persistence-suite.js";
73
6
 
7
+ const { bridgeScript, pythonPath, prerequisitesMet } =
8
+ createPyBackendTestHarness("chromium-py");
9
+ let serverUrl: string, stopServer: () => Promise<void>;
74
10
  beforeAll(async () => {
75
- const server = await startTestServer((req, res) => {
76
- const url = new URL(req.url ?? "/", "http://localhost");
77
- if (url.pathname === "/") {
78
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
79
- res.end(COOKIE_PERSISTENCE_HTML);
80
- } else {
81
- res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
82
- res.end("404");
83
- }
84
- });
85
- serverUrl = server.url;
86
- stopServer = server.stop;
11
+ const s = await startCookiePersistenceServer();
12
+ serverUrl = s.url;
13
+ stopServer = s.stop;
87
14
  });
88
-
89
15
  afterAll(async () => {
90
16
  await stopServer();
91
17
  });
92
-
93
- const NAV_TIMEOUT = 30_000;
94
- const TASK_ID = "chromium-py-persistence";
95
-
96
- // ═════════════════════════════════════════════════════════════════
97
- // Tests — only run if Chromium + Python prerequisites are met
98
- // ═════════════════════════════════════════════════════════════════
99
-
100
- describeIfAvailable(
101
- "Chromium-Py cookie persistence across navigations",
102
- () => {
103
- const TEST_PROFILE = `chromium-py-persist-${Date.now()}`;
104
-
105
- let plugin: PythonPluginAdapter;
106
-
107
- beforeAll(async () => {
108
- plugin = new PythonPluginAdapter("chromium-py-persist", {
109
- bridgeScript: BRIDGE_SCRIPT,
110
- pythonPath: PYTHON_PATH,
111
- });
112
- await plugin.init();
113
- });
114
-
115
- afterAll(async () => {
116
- await plugin.cleanupAll().catch(() => {});
117
- deleteStorageState(TEST_PROFILE);
118
- });
119
-
120
- it("navigates with a named profile — consent dialog is visible", async () => {
121
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
122
- profileName: TEST_PROFILE,
123
- profileMode: "named",
124
- });
125
- expect(nav.success).toBe(true);
126
- expect(nav.title).toContain("Cookie Persistence Test");
127
- expect(nav.snapshot).toContain("Consent");
128
- expect(nav.snapshot).toContain("Accept All");
129
- });
130
-
131
- it("clicks Accept All — cookie set, consent dialog closes", async () => {
132
- const snap = await plugin.snapshot(TASK_ID);
133
- expect(snap.success).toBe(true);
134
-
135
- // Find "Accept All" button in the snapshot and extract its @e ref
136
- const btnMatch = snap.snapshot.match(/@(e\d+)\b.*?button.*?Accept All/);
137
- expect(btnMatch).toBeTruthy();
138
- if (!btnMatch) return;
139
-
140
- const ref = `@${btnMatch[1]!}`;
141
- const clickResult = await plugin.click(TASK_ID, ref);
142
- expect(clickResult.success).toBe(true);
143
-
144
- // Verify dialog is gone from snapshot
145
- if (clickResult.snapshot) {
146
- expect(clickResult.snapshot).not.toContain("Consent");
147
- }
148
- });
149
-
150
- it("creates session and sets persistState (simulating the router's setup)", async () => {
151
- sessionManager.createSession(TASK_ID, "chromium-py-persist");
152
- const session = sessionManager.getSession(TASK_ID);
153
- expect(session).toBeDefined();
154
- if (session) {
155
- session.persistState = true;
156
- session.profileName = TEST_PROFILE;
157
- }
158
- });
159
-
160
- it("navigates again with same profile — consent dialog does NOT reappear", async () => {
161
- // This navigate triggers navigate() which:
162
- // 1. Detects re-navigate (task already in _pages)
163
- // 2. Calls _persistState → saves cookies to disk via getStorageState RPC
164
- // 3. Sends browser.navigate RPC → bridge reuses existing context
165
- // The cookie set during the previous click is still present in the
166
- // reused BrowserContext, so the dialog should not reappear.
167
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
168
- profileName: TEST_PROFILE,
169
- profileMode: "named",
170
- });
171
- expect(nav.success).toBe(true);
172
-
173
- // Consent dialog should NOT be present — cookies survived in-context
174
- expect(nav.snapshot).not.toContain("Consent");
175
- expect(nav.snapshot).not.toContain("Accept All");
176
- });
177
-
178
- it("storage-state.json exists on disk with the consent cookie", async () => {
179
- const state = loadStorageState(TEST_PROFILE);
180
- expect(state).not.toBeNull();
181
- expect(state!.cookies.length).toBeGreaterThan(0);
182
-
183
- const consentCookie = state!.cookies.find(
184
- (c: { name: string; value: string }) =>
185
- c.name === "consent" && c.value === "accepted",
186
- );
187
- expect(consentCookie).toBeDefined();
188
- });
189
-
190
- it("third navigate also has no consent dialog (persistence confirmed)", async () => {
191
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
192
- profileName: TEST_PROFILE,
193
- profileMode: "named",
194
- });
195
- expect(nav.success).toBe(true);
196
-
197
- // Dialog should still be absent
198
- expect(nav.snapshot).not.toContain("Consent");
199
- expect(nav.snapshot).not.toContain("Accept All");
200
- });
18
+ runPersistenceSuite(prerequisitesMet ? describe : describe.skip, {
19
+ name: "chromium-py",
20
+ getServerUrl: () => serverUrl,
21
+ createPlugin: async () => {
22
+ const p = new PythonPluginAdapter("chromium-py-persist", {
23
+ bridgeScript,
24
+ pythonPath,
25
+ });
26
+ await p.init();
27
+ return p;
201
28
  },
202
- 120_000,
203
- );
29
+ });
@@ -1,87 +1,23 @@
1
- /**
2
- * Contract tests for the Chromium-Py backend — validates that the
3
- * PythonPluginAdapter + Chromium-Py bridge satisfies the BrowserPlugin
4
- * contract against a real browser.
5
- *
6
- * Phase B6: Test Chromium-Py against the contract harness.
7
- *
8
- * This test file instantiates a PythonPluginAdapter pointing at the
9
- * Chromium-Py bridge script and runs both structural and behavioral
10
- * contract tests. The Python subprocess is managed by the adapter;
11
- * no manual setup is needed beyond having Playwright Chromium installed
12
- * in the Python virtual environment.
13
- *
14
- * Prerequisites:
15
- * - Python venv at backends/python-base/.venv with playwright installed
16
- * - Playwright Chromium browsers installed (playwright install chromium)
17
- */
18
-
19
- import { resolve } from "node:path";
20
- import { existsSync } from "node:fs";
21
- import { spawnSync } from "node:child_process";
22
-
23
1
  import { PythonPluginAdapter } from "../backends/python-adapter.js";
24
2
  import { runContractTests } from "./helpers/plugin-contract.js";
3
+ import { createPyBackendTestHarness } from "./helpers/create-py-backend-harness.js";
25
4
 
26
- // ─── Paths ──────────────────────────────────────────────────────────
27
-
28
- const BRIDGE_SCRIPT = resolve(__dirname, "../backends/chromium-py/bridge.py");
29
-
30
- const PYTHON_PATH = resolve(
31
- __dirname,
32
- "../backends/python-base/.venv/bin/python3",
33
- );
34
-
35
- // ─── Environment check ───────────────────────────────────────────────
36
-
37
- const pythonAvailable = (() => {
38
- if (!existsSync(PYTHON_PATH)) return false;
39
- const result = spawnSync(PYTHON_PATH, ["--version"], {
40
- stdio: "ignore",
41
- timeout: 5_000,
42
- });
43
- return result.status === 0;
44
- })();
45
-
46
- const bridgeExists = existsSync(BRIDGE_SCRIPT);
47
-
48
- // ─── Plugin factory ─────────────────────────────────────────────────
49
-
50
- /**
51
- * Create a PythonPluginAdapter that drives the Chromium-Py bridge.
52
- *
53
- * The adapter is lazy — the Python subprocess won't start until the
54
- * first operation. We pass the venv Python path so the bridge has
55
- * access to playwright and the shared pi_browser_bridge library.
56
- */
57
- function createChromiumPyPlugin(): PythonPluginAdapter {
58
- return new PythonPluginAdapter("chromium-py", {
59
- bridgeScript: BRIDGE_SCRIPT,
60
- pythonPath: PYTHON_PATH,
61
- // Chromium-Py supports everything except AbortSignal
62
- capabilities: {
63
- supportsFullPageScreenshot: true,
64
- supportsConsoleCapture: true,
65
- supportsJavaScriptEvaluate: true,
66
- supportsBotDetection: true,
67
- supportsDialogAutoDismissal: true,
68
- supportsAbortSignal: false,
69
- engine: "chromium",
70
- },
71
- });
72
- }
73
-
74
- // ─── Run contract tests ──────────────────────────────────────────────
75
-
76
- // Skip the entire suite if prerequisites aren't met (CI without
77
- // Playwright installed, local dev without venv, etc.)
78
- const describeIfAvailable =
79
- pythonAvailable && bridgeExists ? describe : describe.skip;
5
+ const { bridgeScript, pythonPath, describeIfAvailable } =
6
+ createPyBackendTestHarness("chromium-py");
80
7
 
81
8
  describeIfAvailable("Chromium-Py contract tests", () => {
82
- runContractTests("chromium-py", createChromiumPyPlugin, {
83
- realBrowser: true,
84
- navigateTimeout: 30_000,
85
- navigationSettle: true,
86
- });
9
+ runContractTests(
10
+ "chromium-py",
11
+ () =>
12
+ new PythonPluginAdapter("chromium-py", {
13
+ bridgeScript,
14
+ pythonPath,
15
+ capabilities: {
16
+ supportsFullPageScreenshot: true,
17
+ supportsJavaScriptEvaluate: true,
18
+ engine: "chromium",
19
+ },
20
+ }),
21
+ { realBrowser: true, navigateTimeout: 30_000, navigationSettle: true },
22
+ );
87
23
  });
@@ -0,0 +1,25 @@
1
+ import { existsSync } from "node:fs";
2
+ import { chromium } from "playwright";
3
+ import { describe } from "vitest";
4
+ import { runContractTests } from "./helpers/plugin-contract.js";
5
+ import { ChromiumPlugin } from "../backends/chromium/index.js";
6
+
7
+ const describeIfAvailable = (() => {
8
+ try {
9
+ return existsSync(chromium.executablePath()) ? describe : describe.skip;
10
+ } catch {
11
+ return describe.skip;
12
+ }
13
+ })();
14
+
15
+ describeIfAvailable(
16
+ "ChromiumPlugin contract tests",
17
+ () => {
18
+ runContractTests("chromium", () => new ChromiumPlugin(), {
19
+ realBrowser: true,
20
+ navigateTimeout: 30_000,
21
+ navigationSettle: true,
22
+ });
23
+ },
24
+ 60_000,
25
+ );
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Invisible-py-specific behavioral tests (user-backends stealth engine,
3
+ * pattern #2) — exercises lifecycle pattern #2, multi-context
4
+ * deadlock avoidance, and the back-nav referrer workaround.
5
+ *
6
+ * The shared BrowserPlugin contract suite for invisible-py runs via
7
+ * `run-contributed-suites.test.ts` (`CONTRIB_RUN=1`). This file adds
8
+ * assertions unique to pattern #2 that the generic suite cannot cover.
9
+ *
10
+ * This is the **only** test suite that exercises lifecycle pattern #2
11
+ * (engine owns its own Playwright instance via `_ensure_playwright` /
12
+ * `_maybe_stop_playwright`). Camoufox uses pattern #1 (accepts an
13
+ * external Playwright), so this suite serves as the unique probe for
14
+ * the pattern-#2 code path.
15
+ *
16
+ * Prerequisites (all auto-detected via probeUserBackend):
17
+ * - `~/.pi/agent/pi-lean-portal/user-backends/invisible-py/bridge.py`
18
+ * exists on disk.
19
+ * - `.venv/bin/python3` runs and has `invisible_playwright` installed.
20
+ * - Patched Firefox binary is fetched and runnable.
21
+ *
22
+ * Auto-skips the entire suite when prerequisites are absent.
23
+ *
24
+ * Assertions:
25
+ * - **Pattern #2 lifecycle no-leak** — init/navigate/cleanupAll then
26
+ * init/navigate again proves `_maybe_stop_playwright` actually tears
27
+ * down the InvisiblePlaywright context manager and a second init
28
+ * doesn't spawn a second Playwright instance.
29
+ * - **Multi-context no-deadlock** — two sessions created concurrently
30
+ * both navigate successfully, validating the `_capture_user_agent =
31
+ * False` workaround that prevents the patched Firefox binary from
32
+ * leaking implicit BrowserContexts.
33
+ * - **Back-nav honest limitation** — `goBack()` after a link click
34
+ * succeeds via the `document.referrer` workaround; `goBack()` after
35
+ * a direct navigation (no referrer) returns `success: false` with
36
+ * the documented limitation error.
37
+ *
38
+ * @module
39
+ */
40
+
41
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
42
+
43
+ import { PythonPluginAdapter } from "../../../backends/python-adapter.js";
44
+ import { startTestServer } from "../../helpers/test-server.js";
45
+
46
+ import { probeUserBackend } from "../../helpers/probe-user-backend.js";
47
+
48
+ // ─── Prerequisites check via probeUserBackend ──────────────────────
49
+
50
+ const probe = probeUserBackend("invisible-py");
51
+
52
+ const invisiblePyAvailable = probe.available;
53
+
54
+ // ─── Plugin factory ─────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Create a PythonPluginAdapter that drives the invisible-py bridge
58
+ * from the user-backends directory.
59
+ *
60
+ * The adapter is lazy — the Python subprocess won't start until the
61
+ * first operation. Capabilities advertise `engine: "firefox"` because
62
+ * invisible-py wraps a patched Firefox binary.
63
+ */
64
+ function createInvisiblePyPlugin(): PythonPluginAdapter {
65
+ return new PythonPluginAdapter("invisible-py", {
66
+ bridgeScript: probe.bridgePath,
67
+ pythonPath: probe.venvPython,
68
+ capabilities: {
69
+ supportsFullPageScreenshot: true,
70
+ supportsJavaScriptEvaluate: true,
71
+ engine: "firefox",
72
+ },
73
+ });
74
+ }
75
+
76
+ // ─── Test Server Setup (used for invisible-py-specific tests) ────────
77
+
78
+ const PAGE_A_HTML = `<!DOCTYPE html>
79
+ <html><head><title>Page A</title></head>
80
+ <body>
81
+ <h1>Page A</h1>
82
+ <p>This is page A.</p>
83
+ <a href="/page-b" id="link-to-b">Go to Page B</a>
84
+ </body></html>`;
85
+
86
+ const PAGE_B_HTML = `<!DOCTYPE html>
87
+ <html><head><title>Page B</title></head>
88
+ <body>
89
+ <h1>Page B</h1>
90
+ <p>You reached page B via a link from page A.</p>
91
+ <a href="/direct-nav" id="link-to-direct">Go to Direct Nav</a>
92
+ </body></html>`;
93
+
94
+ const DIRECT_NAV_HTML = `<!DOCTYPE html>
95
+ <html><head><title>Direct Nav Page</title></head>
96
+ <body>
97
+ <h1>Direct Navigation Page</h1>
98
+ <p>Navigated here directly, no referrer.</p>
99
+ </body></html>`;
100
+
101
+ const SIMPLE_HTML = `<!DOCTYPE html>
102
+ <html><head><title>Simple Page</title></head>
103
+ <body><h1>Simple</h1></body></html>`;
104
+
105
+ const MULTI_CONTEXT_HTML = `<!DOCTYPE html>
106
+ <html><head><title>Multi Context Test</title></head>
107
+ <body><h1>Multi Context Test Page</h1>
108
+ <p id="marker">Session marker: loading complete.</p></body></html>`;
109
+
110
+ let serverUrl: string;
111
+ let stopServer: () => Promise<void>;
112
+
113
+ beforeAll(async () => {
114
+ const server = await startTestServer((req, res) => {
115
+ const url = new URL(req.url ?? "/", "http://localhost");
116
+ switch (url.pathname) {
117
+ case "/page-a":
118
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
119
+ res.end(PAGE_A_HTML);
120
+ break;
121
+ case "/page-b":
122
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
123
+ res.end(PAGE_B_HTML);
124
+ break;
125
+ case "/direct-nav":
126
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
127
+ res.end(DIRECT_NAV_HTML);
128
+ break;
129
+ case "/simple":
130
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
131
+ res.end(SIMPLE_HTML);
132
+ break;
133
+ case "/multi-context":
134
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
135
+ res.end(MULTI_CONTEXT_HTML);
136
+ break;
137
+ default:
138
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
139
+ res.end("404");
140
+ }
141
+ });
142
+ serverUrl = server.url;
143
+ stopServer = server.stop;
144
+ });
145
+
146
+ afterAll(async () => {
147
+ await stopServer();
148
+ });
149
+
150
+ const describeIfAvailable = invisiblePyAvailable ? describe : describe.skip;
151
+
152
+ describeIfAvailable("Invisible-Py contract tests", () => {
153
+ // ── Invisible-py-specific assertions ────────────────────────
154
+ //
155
+ // These go beyond the generic contract suite to exercise
156
+ // architecture-under-test features unique to pattern-#2 backends.
157
+
158
+ describe("Invisible-py-specific behaviors", () => {
159
+ let plugin: PythonPluginAdapter;
160
+
161
+ // Each test in this block runs with its own browser sessions.
162
+ // The patched Firefox binary limits concurrent BrowserContexts
163
+ // to ~2 per InvisiblePlaywright instance, so we tear down and
164
+ // re-init between tests to prevent session leaks — otherwise
165
+ // a test that creates 2 sessions blocks the next test's first
166
+ // navigate from creating a third context.
167
+ beforeEach(async () => {
168
+ plugin = createInvisiblePyPlugin();
169
+ await plugin.init();
170
+ });
171
+
172
+ afterEach(async () => {
173
+ await plugin.cleanupAll().catch(() => {});
174
+ });
175
+
176
+ // ── Pattern #2 lifecycle: no Playwright leak across cycles ─
177
+
178
+ it("pattern #2 lifecycle: init→navigate→cleanupAll→init→navigate succeeds without leak", async () => {
179
+ // Long timeout: two init cycles + two 30s navigations + cleanupAll.
180
+ // Each individual navigate call has a 30s timeout, so the test
181
+ // itself needs 60s+ to accommodate both cycles plus bridge ops.
182
+ // First cycle
183
+ const nav1 = await plugin.navigate(
184
+ `${serverUrl}/simple`,
185
+ "lifecycle-cycle-1",
186
+ 30_000,
187
+ );
188
+ expect(nav1.success).toBe(true);
189
+ expect(nav1.title).toContain("Simple Page");
190
+
191
+ // Full teardown
192
+ await plugin.cleanupAll();
193
+
194
+ // Second cycle — init again (must not leak a Playwright instance)
195
+ await plugin.init();
196
+
197
+ const nav2 = await plugin.navigate(
198
+ `${serverUrl}/simple`,
199
+ "lifecycle-cycle-2",
200
+ 30_000,
201
+ );
202
+ expect(nav2.success).toBe(true);
203
+ expect(nav2.title).toContain("Simple Page");
204
+ }, 120_000);
205
+
206
+ // ── Multi-context no-deadlock ─────────────────────────────
207
+ //
208
+ // Validates the `_capture_user_agent = False` workaround that
209
+ // prevents the patched Firefox binary from leaking implicit
210
+ // BrowserContexts. Two independent sessions must both navigate
211
+ // without hanging.
212
+
213
+ it("multi-context: two independent sessions both navigate without deadlock", async () => {
214
+ // Long timeout: two sequential 30s navigations on a patched
215
+ // binary that may be slower than stock. 90s keeps the budget
216
+ // comfortable while still failing fast on a real deadlock.
217
+ const navA = await plugin.navigate(
218
+ `${serverUrl}/multi-context`,
219
+ "multi-context-a",
220
+ 30_000,
221
+ );
222
+ expect(navA.success).toBe(true);
223
+ expect(navA.title).toContain("Multi Context Test");
224
+
225
+ const navB = await plugin.navigate(
226
+ `${serverUrl}/multi-context`,
227
+ "multi-context-b",
228
+ 30_000,
229
+ );
230
+ expect(navB.success).toBe(true);
231
+ expect(navB.title).toContain("Multi Context Test");
232
+ }, 90_000);
233
+
234
+ // ── Back-nav honest limitation ───────────────────────────
235
+ //
236
+ // invisible-py wraps a patched Firefox binary that disables
237
+ // bfcache. Standard `page.go_back()` is non-functional. The
238
+ // bridge implements a `document.referrer` workaround that
239
+ // handles link-click → back scenarios but cannot handle
240
+ // direct navigations (no referrer).
241
+
242
+ it("goBack succeeds after a link click (referrer workaround)", async () => {
243
+ // Long timeout: navigate + snapshot + click + snapshot + goBack
244
+ // + snapshot — six bridge round-trips on a slow binary.
245
+ // Navigate to page A
246
+ const navA = await plugin.navigate(
247
+ `${serverUrl}/page-a`,
248
+ "back-ref-a",
249
+ 30_000,
250
+ );
251
+ expect(navA.success).toBe(true);
252
+ expect(navA.title).toContain("Page A");
253
+
254
+ // Click the link to page B
255
+ const snapA = await plugin.snapshot("back-ref-a");
256
+ expect(snapA.success).toBe(true);
257
+
258
+ const linkMatch = snapA.snapshot.match(/@(e\d+)\b.*?link.*?Go to Page B/);
259
+ expect(linkMatch).toBeTruthy();
260
+ if (!linkMatch) return;
261
+
262
+ const ref = `@${linkMatch[1]!}`;
263
+ const clickResult = await plugin.click("back-ref-a", ref);
264
+ expect(clickResult.success).toBe(true);
265
+
266
+ // Verify we're on page B
267
+ const snapB = await plugin.snapshot("back-ref-a");
268
+ expect(snapB.snapshot).toContain("Page B");
269
+
270
+ // Go back via referrer workaround
271
+ const backResult = await plugin.goBack("back-ref-a");
272
+ expect(backResult.success).toBe(true);
273
+
274
+ // Verify we're back on page A
275
+ const snapAfterBack = await plugin.snapshot("back-ref-a");
276
+ expect(snapAfterBack.snapshot).toContain("Page A");
277
+ }, 90_000);
278
+
279
+ it("goBack after a direct navigation returns success: false (limitation)", async () => {
280
+ // navigate + goBack — two bridge calls, each may be slow.
281
+ // Navigate directly (no link click, no referrer)
282
+ const nav = await plugin.navigate(
283
+ `${serverUrl}/direct-nav`,
284
+ "back-direct",
285
+ 30_000,
286
+ );
287
+ expect(nav.success).toBe(true);
288
+ expect(nav.title).toContain("Direct Nav Page");
289
+
290
+ // Attempt goBack — should fail because there's no referrer
291
+ const backResult = await plugin.goBack("back-direct");
292
+ expect(backResult.success).toBe(false);
293
+
294
+ // Should include a descriptive error message about the limitation
295
+ expect(typeof backResult.error).toBe("string");
296
+ expect((backResult.error as string).length).toBeGreaterThan(0);
297
+ }, 60_000);
298
+ });
299
+ });