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,196 +1,36 @@
1
- /**
2
- * Cookie Persistence Integration Test — end-to-end validation of Bug A
3
- * (router loads storage state) and Bug B (plugin saves state on re-navigate).
4
- *
5
- * Uses a live Chromium browser against a local HTTP test server that serves
6
- * a page with a cookie-based consent dialog. The test:
7
- *
8
- * 1. Navigates with a named profile — dialog visible
9
- * 2. Clicks "Accept All" — sets a consent cookie
10
- * 3. Navigates again with the same profile — dialog should NOT reappear
11
- * because `_persistState` saves the cookie to disk before closing the
12
- * old context, and the returned state is passed to the new context
13
- * via the in-memory fallback (options?.storageState ?? savedState).
14
- *
15
- * Auto-skips the entire suite when Playwright Chromium is not installed.
16
- */
17
-
18
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
1
+ import { existsSync } from "node:fs";
19
2
  import { chromium } from "playwright";
3
+ import { describe, beforeAll, afterAll } from "vitest";
20
4
  import { ChromiumPlugin } from "../backends/chromium/index.js";
21
- import {
22
- startTestServer,
23
- COOKIE_PERSISTENCE_HTML,
24
- } from "./helpers/test-server.js";
25
- import {
26
- loadStorageState,
27
- deleteStorageState,
28
- } from "../core/shared/storage-state.js";
29
- import { sessionManager } from "../core/shared/session-manager.js";
30
-
31
- // ─── Chromium availability check — skip gracefully if absent ───────
32
-
33
- let chromiumAvailable = false;
34
- try {
35
- chromium.executablePath();
36
- chromiumAvailable = true;
37
- } catch {
38
- chromiumAvailable = false;
39
- }
40
-
41
- const describeIfChromium = chromiumAvailable ? describe : describe.skip;
5
+ import { startCookiePersistenceServer } from "./helpers/test-server.js";
6
+ import { runPersistenceSuite } from "./helpers/persistence-suite.js";
42
7
 
43
- // ─── Test fixture HTML ─────────────────────────────────────────────
44
-
45
- /**
46
- * A page that shows a consent dialog when no cookie is present, and hides
47
- * it when the "consent=accepted" cookie exists. Clicking "Accept All" sets
48
- * the cookie and hides the dialog via JS.
49
- *
50
- * See ``COOKIE_PERSISTENCE_HTML`` in ``helpers/test-server.ts``.
51
- */
52
-
53
- // ─── Test Server Setup (always runs, server is cheap to start/stop) ─
8
+ const describeIfAvailable = (() => {
9
+ try {
10
+ return existsSync(chromium.executablePath()) ? describe : describe.skip;
11
+ } catch {
12
+ return describe.skip;
13
+ }
14
+ })();
54
15
 
55
16
  let serverUrl: string;
56
17
  let stopServer: () => Promise<void>;
57
18
 
58
19
  beforeAll(async () => {
59
- const server = await startTestServer((req, res) => {
60
- const url = new URL(req.url ?? "/", "http://localhost");
61
- if (url.pathname === "/") {
62
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
63
- res.end(COOKIE_PERSISTENCE_HTML);
64
- } else {
65
- res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
66
- res.end("404");
67
- }
68
- });
69
- serverUrl = server.url;
70
- stopServer = server.stop;
20
+ const s = await startCookiePersistenceServer();
21
+ serverUrl = s.url;
22
+ stopServer = s.stop;
71
23
  });
72
-
73
24
  afterAll(async () => {
74
25
  await stopServer();
75
26
  });
76
27
 
77
- // ─── ChromiumPlugin Create Helper ─────────────────────────────────
78
-
79
- async function createPlugin(): Promise<ChromiumPlugin> {
80
- const p = new ChromiumPlugin();
81
- await p.init({});
82
- return p;
83
- }
84
-
85
- const NAV_TIMEOUT = 30_000;
86
- const TASK_ID = "cookie-persistence";
87
-
88
- // ═════════════════════════════════════════════════════════════════
89
- // Tests — only run if Chromium is available
90
- // ═════════════════════════════════════════════════════════════════
91
-
92
- describeIfChromium(
93
- "Cookie persistence across navigations",
94
- () => {
95
- const TEST_PROFILE = `cookie-persistence-test-${Date.now()}`;
96
-
97
- let plugin: ChromiumPlugin;
98
-
99
- beforeAll(async () => {
100
- plugin = await createPlugin();
101
- });
102
-
103
- afterAll(async () => {
104
- await plugin.cleanupAll().catch(() => {});
105
- deleteStorageState(TEST_PROFILE);
106
- });
107
-
108
- it("navigates with a named profile — consent dialog is visible", async () => {
109
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
110
- profileName: TEST_PROFILE,
111
- profileMode: "named",
112
- });
113
- expect(nav.success).toBe(true);
114
- expect(nav.title).toContain("Cookie Persistence Test");
115
- expect(nav.snapshot).toContain("Consent");
116
- expect(nav.snapshot).toContain("Accept All");
117
- });
118
-
119
- it("clicks Accept All — cookie set, consent dialog closes", async () => {
120
- const snap = await plugin.snapshot(TASK_ID);
121
- expect(snap.success).toBe(true);
122
-
123
- // Find "Accept All" button in the snapshot and extract its @e ref
124
- const btnMatch = snap.snapshot.match(/@(e\d+)\b.*?button.*?Accept All/);
125
- expect(btnMatch).toBeTruthy();
126
- if (!btnMatch) return;
127
-
128
- const ref = `@${btnMatch[1]!}`;
129
- const clickResult = await plugin.click(TASK_ID, ref);
130
- expect(clickResult.success).toBe(true);
131
-
132
- // Verify dialog is gone from snapshot
133
- if (clickResult.snapshot) {
134
- expect(clickResult.snapshot).not.toContain("Consent");
135
- }
136
- });
137
-
138
- it("creates session and sets persistState (simulating the router's setup)", async () => {
139
- // In production, router.navigate() creates the session and sets
140
- // persistState & profileName before calling plugin.navigate().
141
- // Since we're testing the plugin directly, we simulate this setup.
142
- sessionManager.createSession(TASK_ID, "chromium");
143
- const session = sessionManager.getSession(TASK_ID);
144
- expect(session).toBeDefined();
145
- if (session) {
146
- session.persistState = true;
147
- session.profileName = TEST_PROFILE;
148
- }
149
- });
150
-
151
- it("navigates again with same profile — consent dialog does NOT reappear", async () => {
152
- // This navigate triggers getOrCreateContext, which:
153
- // 1. Calls _persistState → saves cookies to disk, returns state object
154
- // 2. Closes old context
155
- // 3. Uses savedState as fallback for the new context's storageState
156
- // The cookie set during step 2 is thus available for the new context.
157
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
158
- profileName: TEST_PROFILE,
159
- profileMode: "named",
160
- });
161
- expect(nav.success).toBe(true);
162
-
163
- // Consent dialog should NOT be present — cookies were restored
164
- // from the in-memory fallback (savedState returned by _persistState).
165
- expect(nav.snapshot).not.toContain("Consent");
166
- expect(nav.snapshot).not.toContain("Accept All");
167
- });
168
-
169
- it("storage-state.json exists on disk with the consent cookie", async () => {
170
- const state = loadStorageState(TEST_PROFILE);
171
- expect(state).not.toBeNull();
172
- expect(state!.cookies.length).toBeGreaterThan(0);
173
-
174
- const consentCookie = state!.cookies.find(
175
- (c) => c.name === "consent" && c.value === "accepted",
176
- );
177
- expect(consentCookie).toBeDefined();
178
- });
179
-
180
- it("third navigate also has no consent dialog (full disk-path round-trip)", async () => {
181
- // The state saved to disk during the second navigate (test 4's
182
- // _persistState call) is now available. The in-memory fallback
183
- // returns it again, and the disk copy exists for the router to load.
184
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
185
- profileName: TEST_PROFILE,
186
- profileMode: "named",
187
- });
188
- expect(nav.success).toBe(true);
189
-
190
- // Dialog should still be absent
191
- expect(nav.snapshot).not.toContain("Consent");
192
- expect(nav.snapshot).not.toContain("Accept All");
193
- });
28
+ runPersistenceSuite(describeIfAvailable, {
29
+ name: "chromium",
30
+ getServerUrl: () => serverUrl,
31
+ createPlugin: async () => {
32
+ const p = new ChromiumPlugin();
33
+ await p.init({});
34
+ return p;
194
35
  },
195
- 120_000,
196
- );
36
+ });
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
- import { webFetch, cleanupFetchTempFiles } from "../core/fetch-backend";
10
+ import { webFetch, cleanupFetchTempFiles } from "../core/fetch-backend.js";
11
11
 
12
12
  // ─── Setup / Teardown ────────────────────────────────────────
13
13
 
@@ -1,192 +1,29 @@
1
- /**
2
- * Firefox-Py Cookie Persistence Integration Test — end-to-end validation
3
- * of cross-process storage-state flow for the Python adapter / firefox-py bridge.
4
- *
5
- * Uses a live Firefox browser via the Firefox-Py bridge against a local
6
- * HTTP test server that serves a page with a cookie-based consent dialog.
7
- *
8
- * Auto-skips the entire suite when Python/firefox-py prerequisites are
9
- * unavailable.
10
- */
11
-
12
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
13
- import { resolve } from "node:path";
14
- import { existsSync } from "node:fs";
15
- import { spawnSync } from "node:child_process";
16
-
1
+ import { describe, beforeAll, afterAll } from "vitest";
17
2
  import { PythonPluginAdapter } from "../backends/python-adapter.js";
18
- import {
19
- startTestServer,
20
- COOKIE_PERSISTENCE_HTML,
21
- } from "./helpers/test-server.js";
22
- import {
23
- loadStorageState,
24
- deleteStorageState,
25
- } from "../core/shared/storage-state.js";
26
- import { sessionManager } from "../core/shared/session-manager.js";
27
-
28
- // ─── Paths ──────────────────────────────────────────────────────────
29
-
30
- const BRIDGE_SCRIPT = resolve(__dirname, "../backends/firefox-py/bridge.py");
31
- const PYTHON_PATH = resolve(
32
- __dirname,
33
- "../backends/python-base/.venv/bin/python3",
34
- );
35
-
36
- // ─── Prerequisites check — skip gracefully if absent ────────────────
37
-
38
- const prerequisitesMet = (() => {
39
- if (!existsSync(PYTHON_PATH)) return false;
40
- if (!existsSync(BRIDGE_SCRIPT)) return false;
41
- const result = spawnSync(PYTHON_PATH, ["--version"], {
42
- stdio: "ignore",
43
- timeout: 5_000,
44
- });
45
- if (result.status !== 0) return false;
46
-
47
- // Quick check: can we import playwright AND is Firefox installed?
48
- const check = spawnSync(
49
- PYTHON_PATH,
50
- [
51
- "-c",
52
- "from playwright.sync_api import sync_playwright; " +
53
- "p = sync_playwright().start(); " +
54
- "import os; ok = os.path.exists(p.firefox.executable_path); " +
55
- "p.stop(); print('ok' if ok else 'no')",
56
- ],
57
- { stdio: "pipe", timeout: 10_000 },
58
- );
59
- return check.status === 0 && check.stdout.toString().trim() === "ok";
60
- })();
61
-
62
- const describeIfAvailable = prerequisitesMet ? describe : describe.skip;
63
-
64
- // ─── Test Server Setup (always runs, server is cheap to start/stop) ─
65
-
66
- let serverUrl: string;
67
- 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";
68
6
 
7
+ const { bridgeScript, pythonPath, prerequisitesMet } =
8
+ createPyBackendTestHarness("firefox-py", { engine: "firefox" });
9
+ let serverUrl: string, stopServer: () => Promise<void>;
69
10
  beforeAll(async () => {
70
- const server = await startTestServer((req, res) => {
71
- const url = new URL(req.url ?? "/", "http://localhost");
72
- if (url.pathname === "/") {
73
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
74
- res.end(COOKIE_PERSISTENCE_HTML);
75
- } else {
76
- res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
77
- res.end("404");
78
- }
79
- });
80
- serverUrl = server.url;
81
- stopServer = server.stop;
11
+ const s = await startCookiePersistenceServer();
12
+ serverUrl = s.url;
13
+ stopServer = s.stop;
82
14
  });
83
-
84
15
  afterAll(async () => {
85
16
  await stopServer();
86
17
  });
87
-
88
- const NAV_TIMEOUT = 30_000;
89
- const TASK_ID = "firefox-py-persistence";
90
-
91
- // ═════════════════════════════════════════════════════════════════
92
- // Tests — only run if Firefox + Python prerequisites are met
93
- // ═════════════════════════════════════════════════════════════════
94
-
95
- describeIfAvailable(
96
- "Firefox-Py cookie persistence across navigations",
97
- () => {
98
- const TEST_PROFILE = `firefox-py-persist-${Date.now()}`;
99
-
100
- let plugin: PythonPluginAdapter;
101
-
102
- beforeAll(async () => {
103
- plugin = new PythonPluginAdapter("firefox-py-persist", {
104
- bridgeScript: BRIDGE_SCRIPT,
105
- pythonPath: PYTHON_PATH,
106
- });
107
- await plugin.init();
108
- });
109
-
110
- afterAll(async () => {
111
- await plugin.cleanupAll().catch(() => {});
112
- deleteStorageState(TEST_PROFILE);
113
- });
114
-
115
- it("navigates with a named profile — consent dialog is visible", async () => {
116
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
117
- profileName: TEST_PROFILE,
118
- profileMode: "named",
119
- });
120
- expect(nav.success).toBe(true);
121
- expect(nav.title).toContain("Cookie Persistence Test");
122
- expect(nav.snapshot).toContain("Consent");
123
- expect(nav.snapshot).toContain("Accept All");
124
- });
125
-
126
- it("clicks Accept All — cookie set, consent dialog closes", async () => {
127
- const snap = await plugin.snapshot(TASK_ID);
128
- expect(snap.success).toBe(true);
129
-
130
- // Find "Accept All" button in the snapshot and extract its @e ref
131
- const btnMatch = snap.snapshot.match(/@(e\d+)\b.*?button.*?Accept All/);
132
- expect(btnMatch).toBeTruthy();
133
- if (!btnMatch) return;
134
-
135
- const ref = `@${btnMatch[1]!}`;
136
- const clickResult = await plugin.click(TASK_ID, ref);
137
- expect(clickResult.success).toBe(true);
138
-
139
- // Verify dialog is gone from snapshot
140
- if (clickResult.snapshot) {
141
- expect(clickResult.snapshot).not.toContain("Consent");
142
- }
143
- });
144
-
145
- it("creates session and sets persistState (simulating the router's setup)", async () => {
146
- sessionManager.createSession(TASK_ID, "firefox-py-persist");
147
- const session = sessionManager.getSession(TASK_ID);
148
- expect(session).toBeDefined();
149
- if (session) {
150
- session.persistState = true;
151
- session.profileName = TEST_PROFILE;
152
- }
153
- });
154
-
155
- it("navigates again with same profile — consent dialog does NOT reappear", async () => {
156
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
157
- profileName: TEST_PROFILE,
158
- profileMode: "named",
159
- });
160
- expect(nav.success).toBe(true);
161
-
162
- // Consent dialog should NOT be present — cookies survived in-context
163
- expect(nav.snapshot).not.toContain("Consent");
164
- expect(nav.snapshot).not.toContain("Accept All");
165
- });
166
-
167
- it("storage-state.json exists on disk with the consent cookie", async () => {
168
- const state = loadStorageState(TEST_PROFILE);
169
- expect(state).not.toBeNull();
170
- expect(state!.cookies.length).toBeGreaterThan(0);
171
-
172
- const consentCookie = state!.cookies.find(
173
- (c: { name: string; value: string }) =>
174
- c.name === "consent" && c.value === "accepted",
175
- );
176
- expect(consentCookie).toBeDefined();
177
- });
178
-
179
- it("third navigate also has no consent dialog (persistence confirmed)", async () => {
180
- const nav = await plugin.navigate(serverUrl, TASK_ID, NAV_TIMEOUT, {
181
- profileName: TEST_PROFILE,
182
- profileMode: "named",
183
- });
184
- expect(nav.success).toBe(true);
185
-
186
- // Dialog should still be absent
187
- expect(nav.snapshot).not.toContain("Consent");
188
- expect(nav.snapshot).not.toContain("Accept All");
189
- });
18
+ runPersistenceSuite(prerequisitesMet ? describe : describe.skip, {
19
+ name: "firefox-py",
20
+ getServerUrl: () => serverUrl,
21
+ createPlugin: async () => {
22
+ const p = new PythonPluginAdapter("firefox-py-persist", {
23
+ bridgeScript,
24
+ pythonPath,
25
+ });
26
+ await p.init();
27
+ return p;
190
28
  },
191
- 120_000,
192
- );
29
+ });
@@ -1,107 +1,23 @@
1
- /**
2
- * Contract tests for the Firefox-Py backend — validates that the
3
- * PythonPluginAdapter + Firefox-Py bridge satisfies the BrowserPlugin
4
- * contract against a real browser.
5
- *
6
- * This test file instantiates a PythonPluginAdapter pointing at the
7
- * Firefox-Py bridge script and runs both structural and behavioral
8
- * contract tests. The Python subprocess is managed by the adapter;
9
- * no manual setup is needed beyond having Playwright Firefox installed
10
- * in the Python virtual environment.
11
- *
12
- * Prerequisites:
13
- * - Python venv at backends/python-base/.venv with playwright installed
14
- * - Playwright Firefox browsers installed (playwright install firefox)
15
- */
16
-
17
- import { resolve } from "node:path";
18
- import { existsSync } from "node:fs";
19
- import { spawnSync } from "node:child_process";
20
-
21
1
  import { PythonPluginAdapter } from "../backends/python-adapter.js";
22
2
  import { runContractTests } from "./helpers/plugin-contract.js";
3
+ import { createPyBackendTestHarness } from "./helpers/create-py-backend-harness.js";
23
4
 
24
- // ─── Paths ──────────────────────────────────────────────────────────
25
-
26
- const BRIDGE_SCRIPT = resolve(__dirname, "../backends/firefox-py/bridge.py");
27
-
28
- const PYTHON_PATH = resolve(
29
- __dirname,
30
- "../backends/python-base/.venv/bin/python3",
31
- );
32
-
33
- // ─── Environment check ───────────────────────────────────────────────
34
-
35
- const pythonAvailable = (() => {
36
- if (!existsSync(PYTHON_PATH)) return false;
37
- const result = spawnSync(PYTHON_PATH, ["--version"], {
38
- stdio: "ignore",
39
- timeout: 5_000,
40
- });
41
- return result.status === 0;
42
- })();
43
-
44
- const bridgeExists = existsSync(BRIDGE_SCRIPT);
45
-
46
- const firefoxAvailable = (() => {
47
- if (!PYTHON_PATH) return false;
48
- try {
49
- const result = spawnSync(
50
- PYTHON_PATH,
51
- [
52
- "-c",
53
- "from playwright.sync_api import sync_playwright; " +
54
- "p = sync_playwright().start(); " +
55
- "import os; print(os.path.exists(p.firefox.executable_path)); " +
56
- "p.stop()",
57
- ],
58
- { stdio: "pipe", timeout: 10_000 },
59
- );
60
- return result.status === 0 && result.stdout.toString().trim() === "True";
61
- } catch {
62
- return false;
63
- }
64
- })();
65
-
66
- // ─── Plugin factory ─────────────────────────────────────────────────
67
-
68
- /**
69
- * Create a PythonPluginAdapter that drives the Firefox-Py bridge.
70
- *
71
- * The adapter is lazy — the Python subprocess won't start until the
72
- * first operation. We pass the venv Python path so the bridge has
73
- * access to playwright and the shared pi_browser_bridge library.
74
- */
75
- function createFirefoxPyPlugin(): PythonPluginAdapter {
76
- return new PythonPluginAdapter("firefox-py", {
77
- bridgeScript: BRIDGE_SCRIPT,
78
- pythonPath: PYTHON_PATH,
79
- // Firefox-Py supports everything except AbortSignal
80
- capabilities: {
81
- supportsFullPageScreenshot: true,
82
- supportsConsoleCapture: true,
83
- supportsJavaScriptEvaluate: true,
84
- supportsBotDetection: true,
85
- supportsDialogAutoDismissal: true,
86
- supportsAbortSignal: false,
87
- engine: "firefox",
88
- },
89
- });
90
- }
91
-
92
- // ─── Run contract tests ──────────────────────────────────────────────
93
-
94
- // Skip the entire suite if prerequisites aren't met (CI without
95
- // Playwright installed, local dev without venv, etc.)
96
- const describeIfAvailable =
97
- pythonAvailable && bridgeExists && firefoxAvailable
98
- ? describe
99
- : describe.skip;
5
+ const { bridgeScript, pythonPath, describeIfAvailable } =
6
+ createPyBackendTestHarness("firefox-py", { engine: "firefox" });
100
7
 
101
8
  describeIfAvailable("Firefox-Py contract tests", () => {
102
- runContractTests("firefox-py", createFirefoxPyPlugin, {
103
- realBrowser: true,
104
- navigateTimeout: 30_000,
105
- navigationSettle: true,
106
- });
9
+ runContractTests(
10
+ "firefox-py",
11
+ () =>
12
+ new PythonPluginAdapter("firefox-py", {
13
+ bridgeScript,
14
+ pythonPath,
15
+ capabilities: {
16
+ supportsFullPageScreenshot: true,
17
+ supportsJavaScriptEvaluate: true,
18
+ engine: "firefox",
19
+ },
20
+ }),
21
+ { realBrowser: true, navigateTimeout: 30_000, navigationSettle: true },
22
+ );
107
23
  });
@@ -1,28 +1,12 @@
1
- /**
2
- * FirefoxPlugin contract tests.
3
- *
4
- * Runs the shared `runContractTests` suite against the Node Firefox backend.
5
- * Skips automatically when Playwright Firefox is unavailable (no browser installed).
6
- */
7
-
8
1
  import { existsSync } from "node:fs";
9
2
  import { firefox } from "playwright";
10
3
  import { describe } from "vitest";
11
4
  import { runContractTests } from "./helpers/plugin-contract.js";
12
5
  import { FirefoxPlugin } from "../backends/firefox/index.js";
13
6
 
14
- function createFirefoxPlugin(): FirefoxPlugin {
15
- return new FirefoxPlugin();
16
- }
17
-
18
- // Skip the entire suite if Playwright Firefox isn't installed.
19
- // We must check the file exists on disk — executablePath() returns a
20
- // path even when the browser hasn't been installed.
21
7
  const describeIfAvailable = (() => {
22
8
  try {
23
- const ffPath = firefox.executablePath();
24
- if (!existsSync(ffPath)) return describe.skip;
25
- return describe;
9
+ return existsSync(firefox.executablePath()) ? describe : describe.skip;
26
10
  } catch {
27
11
  return describe.skip;
28
12
  }
@@ -31,7 +15,7 @@ const describeIfAvailable = (() => {
31
15
  describeIfAvailable(
32
16
  "FirefoxPlugin contract tests",
33
17
  () => {
34
- runContractTests("firefox", createFirefoxPlugin, {
18
+ runContractTests("firefox", () => new FirefoxPlugin(), {
35
19
  realBrowser: true,
36
20
  navigateTimeout: 30_000,
37
21
  navigationSettle: true,