pi-lean-dimension 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +96 -36
  2. package/node_modules/pi-lean-portal/AGENTS.md +146 -4
  3. package/node_modules/pi-lean-portal/README.md +112 -50
  4. package/node_modules/pi-lean-portal/__tests__/browser-data.test.ts +124 -0
  5. package/node_modules/pi-lean-portal/__tests__/browser-inspect.test.ts +192 -16
  6. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -3
  7. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +49 -35
  8. package/node_modules/pi-lean-portal/__tests__/chromium-py-persistence.test.ts +21 -195
  9. package/node_modules/pi-lean-portal/__tests__/chromium-py.test.ts +17 -81
  10. package/node_modules/pi-lean-portal/__tests__/chromium.test.ts +25 -0
  11. package/node_modules/pi-lean-portal/__tests__/contributed/invisible-py/invisible-py.test.ts +299 -0
  12. package/node_modules/pi-lean-portal/__tests__/cookie-persistence.test.ts +22 -182
  13. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +1 -1
  14. package/node_modules/pi-lean-portal/__tests__/firefox-py-persistence.test.ts +21 -184
  15. package/node_modules/pi-lean-portal/__tests__/firefox-py.test.ts +17 -101
  16. package/node_modules/pi-lean-portal/__tests__/firefox.test.ts +2 -18
  17. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  18. package/node_modules/pi-lean-portal/__tests__/helpers/create-py-backend-harness.ts +105 -0
  19. package/node_modules/pi-lean-portal/__tests__/helpers/load-plugin-config-from-file.ts +53 -0
  20. package/node_modules/pi-lean-portal/__tests__/helpers/mock-plugin.ts +11 -7
  21. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +4 -0
  22. package/node_modules/pi-lean-portal/__tests__/helpers/persistence-suite.ts +218 -0
  23. package/node_modules/pi-lean-portal/__tests__/helpers/plugin-contract.ts +198 -318
  24. package/node_modules/pi-lean-portal/__tests__/helpers/probe-user-backend.ts +198 -0
  25. package/node_modules/pi-lean-portal/__tests__/helpers/test-server.ts +14 -0
  26. package/node_modules/pi-lean-portal/__tests__/plugin-config-browser.test.ts +150 -15
  27. package/node_modules/pi-lean-portal/__tests__/plugin-loading.test.ts +120 -18
  28. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +6 -67
  29. package/node_modules/pi-lean-portal/__tests__/probe-user-backend.test.ts +236 -0
  30. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +401 -11
  31. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +4 -1
  32. package/node_modules/pi-lean-portal/__tests__/run-contributed-suites.test.ts +318 -0
  33. package/node_modules/pi-lean-portal/__tests__/session-manager.test.ts +50 -0
  34. package/node_modules/pi-lean-portal/__tests__/snapshot-cache.test.ts +2 -2
  35. package/node_modules/pi-lean-portal/__tests__/url-safety.test.ts +1 -1
  36. package/node_modules/pi-lean-portal/backends/chromium/index.ts +5 -13
  37. package/node_modules/pi-lean-portal/backends/chromium-py/__pycache__/bridge.cpython-313.pyc +0 -0
  38. package/node_modules/pi-lean-portal/backends/chromium-py/bridge.py +0 -2
  39. package/node_modules/pi-lean-portal/backends/firefox/index.ts +6 -5
  40. package/node_modules/pi-lean-portal/backends/firefox-py/__pycache__/bridge.cpython-313.pyc +0 -0
  41. package/node_modules/pi-lean-portal/backends/firefox-py/bridge.py +7 -6
  42. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +241 -398
  43. package/node_modules/pi-lean-portal/backends/python-adapter.ts +182 -83
  44. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__init__.py +1 -42
  45. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-312.pyc +0 -0
  46. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-312.pyc +0 -0
  48. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/accessibility.cpython-313.pyc +0 -0
  49. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-312.pyc +0 -0
  50. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bot_detection.cpython-313.pyc +0 -0
  51. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-312.pyc +0 -0
  52. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/bridge.cpython-313.pyc +0 -0
  53. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-312.pyc +0 -0
  54. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/browser_data.cpython-313.pyc +0 -0
  55. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-312.pyc +0 -0
  56. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/patch_playwright.cpython-313.pyc +0 -0
  57. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  58. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  59. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  60. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  61. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/accessibility.py +12 -147
  62. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bot_detection.py +12 -37
  63. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/bridge.py +249 -322
  64. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/browser_data.py +92 -0
  65. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/patch_playwright.py +321 -0
  66. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +511 -299
  67. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  68. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_accessibility.cpython-313-pytest-9.1.0.pyc → test_accessibility.cpython-313-pytest-9.1.1.pyc} +0 -0
  69. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313.pyc +0 -0
  70. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313.pyc +0 -0
  72. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_browser_data.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_chromium_py_bridge.cpython-313-pytest-9.1.0.pyc → test_chromium_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_firefox_py_bridge.cpython-313-pytest-9.1.0.pyc → test_firefox_py_bridge.cpython-313-pytest-9.1.1.pyc} +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_py_bridges.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/{test_transport.cpython-313-pytest-9.1.0.pyc → test_transport.cpython-313-pytest-9.1.1.pyc} +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/conftest.py +95 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/test_accessibility.py +8 -132
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_bot_detection.py +8 -147
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_browser_data.py +131 -0
  84. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +768 -0
  85. package/node_modules/pi-lean-portal/backends/python-base/tests/test_py_bridges.py +198 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +33 -69
  87. package/node_modules/pi-lean-portal/contributed/CHOOSING.md +126 -0
  88. package/node_modules/pi-lean-portal/contributed/README.md +304 -0
  89. package/node_modules/pi-lean-portal/contributed/camoufox-py/bridge.py +216 -0
  90. package/node_modules/pi-lean-portal/contributed/invisible-py/__pycache__/bridge.cpython-313.pyc +0 -0
  91. package/node_modules/pi-lean-portal/contributed/invisible-py/bridge.py +434 -0
  92. package/node_modules/pi-lean-portal/core/fetch-backend.ts +0 -5
  93. package/node_modules/pi-lean-portal/core/plugin-api.ts +6 -33
  94. package/node_modules/pi-lean-portal/core/plugin-config.ts +75 -58
  95. package/node_modules/pi-lean-portal/core/plugin-registry.ts +11 -49
  96. package/node_modules/pi-lean-portal/core/router.ts +59 -98
  97. package/node_modules/pi-lean-portal/core/shared/accessibility-tree.ts +10 -143
  98. package/node_modules/pi-lean-portal/core/shared/bot-detection.ts +31 -77
  99. package/node_modules/pi-lean-portal/core/shared/browser-data.json +183 -0
  100. package/node_modules/pi-lean-portal/core/shared/browser-data.ts +50 -0
  101. package/node_modules/pi-lean-portal/core/shared/browser-events.ts +6 -6
  102. package/node_modules/pi-lean-portal/core/shared/dom-extractor.ts +97 -36
  103. package/node_modules/pi-lean-portal/core/shared/nav-settle.ts +12 -15
  104. package/node_modules/pi-lean-portal/core/shared/paths.ts +3 -0
  105. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +7 -21
  106. package/node_modules/pi-lean-portal/{verify-ship-manifest.ts → core/shared/ship-manifest.ts} +34 -9
  107. package/node_modules/pi-lean-portal/core/shared/snapshot-cache.ts +7 -6
  108. package/node_modules/pi-lean-portal/core/shared/storage-state.ts +40 -9
  109. package/node_modules/pi-lean-portal/index.ts +42 -7
  110. package/node_modules/pi-lean-portal/package.json +8 -3
  111. package/node_modules/pi-lean-portal/ship-manifest.test.ts +8 -3
  112. package/node_modules/pi-lean-portal/tools/browser-inspect.ts +2 -6
  113. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +7 -5
  114. package/node_modules/pi-lean-portal/tools/browser-snapshot.ts +2 -5
  115. package/node_modules/pi-lean-portal/tools/utils.ts +22 -4
  116. package/node_modules/pi-lean-portal/tools/web-fetch.ts +3 -4
  117. package/node_modules/pi-lean-search/README.md +6 -2
  118. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +11 -0
  119. package/node_modules/pi-lean-search/index.ts +4 -21
  120. package/node_modules/pi-lean-search/package.json +2 -2
  121. package/node_modules/pi-lean-search/verify-ship-manifest.ts +6 -92
  122. package/node_modules/pi-lean-search/web-search-tool.ts +19 -13
  123. package/package.json +4 -4
  124. package/node_modules/pi-lean-portal/__tests__/helpers/reddit-fixture.ts +0 -264
  125. package/node_modules/pi-lean-portal/__tests__/helpers/toggle-test-utils.ts +0 -31
  126. package/node_modules/pi-lean-portal/__tests__/reddit-dialog.test.ts +0 -302
  127. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/occlusion.cpython-313.pyc +0 -0
  128. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_bot_detection.cpython-313-pytest-9.1.0.pyc +0 -0
  129. package/node_modules/pi-lean-portal/backends/python-base/tests/test_chromium_py_bridge.py +0 -281
  130. package/node_modules/pi-lean-portal/backends/python-base/tests/test_firefox_py_bridge.py +0 -212
@@ -23,16 +23,11 @@ import {
23
23
  beforeEach,
24
24
  afterEach,
25
25
  } from "vitest";
26
- import type { BrowserPlugin, PluginCapabilities } from "../../core/plugin-api";
26
+ import type {
27
+ BrowserPlugin,
28
+ PluginCapabilities,
29
+ } from "../../core/plugin-api.js";
27
30
  import { startTestServer, type TestServer } from "./test-server.js";
28
- import {
29
- REDDIT_DIALOG_HTML,
30
- REDDIT_STACKED_HTML,
31
- REDDIT_ASYNC_HTML,
32
- REDDIT_FEED_ONLY_HTML,
33
- findRef,
34
- dialogCount,
35
- } from "./reddit-fixture.js";
36
31
 
37
32
  // ─── Options ──────────────────────────────────────────────────────
38
33
 
@@ -60,6 +55,23 @@ export interface ContractTestOptions {
60
55
  * Default: false.
61
56
  */
62
57
  navigationSettle?: boolean;
58
+
59
+ /**
60
+ * Vitest test timeout (ms) for every `it()` in this suite.
61
+ * When set, each test gets this timeout instead of vitest's default
62
+ * (15s). Useful for real-browser plugins (e.g. Camoufox) where
63
+ * the first-navigate bridge launch can exceed the default.
64
+ * Default: undefined (vitest default: 15_000).
65
+ */
66
+ testTimeout?: number;
67
+
68
+ /**
69
+ * Set true to run the cache-survives-eval-failure parity test.
70
+ * Used by stealth backends (Camoufox) where eval can fail on
71
+ * JS-active pages but the cache path must still work.
72
+ * Default: false.
73
+ */
74
+ runCacheSurvivesEvalFailure?: boolean;
63
75
  }
64
76
 
65
77
  // ─── HTML Fixtures ────────────────────────────────────────────────
@@ -95,15 +107,6 @@ const INTERACTIVE_HTML = `<!DOCTYPE html>
95
107
  </script>
96
108
  </body></html>`;
97
109
 
98
- /** Page with images. */
99
- const IMAGES_HTML = `<!DOCTYPE html>
100
- <html><head><title>Contract Test — Images</title></head>
101
- <body>
102
- <h1>Image Page</h1>
103
- <img src="/img/logo.png" alt="Logo" width="200" height="50" />
104
- <img src="/img/photo.jpg" alt="Photo" width="640" height="480" />
105
- </body></html>`;
106
-
107
110
  /** Long page that requires scrolling. */
108
111
  const SCROLL_HTML = `<!DOCTYPE html>
109
112
  <html><head><title>Contract Test — Scroll</title></head>
@@ -183,17 +186,10 @@ const NOT_FOUND_HTML = `<!DOCTYPE html>
183
186
  <html><head><title>Not Found</title></head>
184
187
  <body><h1>404 — Page Not Found</h1></body></html>`;
185
188
 
186
- /** 1x1 transparent PNG, pre-decoded for image serving. */
187
- const PIXEL_PNG_BUFFER = Buffer.from(
188
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
189
- "base64",
190
- );
191
-
192
189
  // ─── Test Server Router ──────────────────────────────────────────
193
190
 
194
191
  /**
195
192
  * Build an HTTP request handler that serves the contract test fixtures.
196
- * Image paths return a tiny PNG; HTML paths return the fixture.
197
193
  */
198
194
  function contractTestHandler(
199
195
  req: import("node:http").IncomingMessage,
@@ -201,21 +197,10 @@ function contractTestHandler(
201
197
  ): void {
202
198
  const url = new URL(req.url ?? "/", "http://localhost");
203
199
 
204
- // Image endpoint
205
- if (url.pathname.startsWith("/img/")) {
206
- res.writeHead(200, {
207
- "Content-Type": "image/png",
208
- "Content-Length": PIXEL_PNG_BUFFER.length,
209
- });
210
- res.end(PIXEL_PNG_BUFFER);
211
- return;
212
- }
213
-
214
200
  // HTML pages
215
201
  const pages: Record<string, string> = {
216
202
  "/simple": SIMPLE_HTML,
217
203
  "/interactive": INTERACTIVE_HTML,
218
- "/images": IMAGES_HTML,
219
204
  "/scroll": SCROLL_HTML,
220
205
  "/console": CONSOLE_HTML,
221
206
  "/page-a": PAGE_A_HTML,
@@ -223,10 +208,6 @@ function contractTestHandler(
223
208
  "/duplicates": DUPLICATE_HTML,
224
209
  "/slow-nav": SLOW_NAV_HTML,
225
210
  "/slow-nav-destination": SLOW_NAV_TARGET_HTML,
226
- "/reddit-dialog": REDDIT_DIALOG_HTML,
227
- "/reddit-stacked": REDDIT_STACKED_HTML,
228
- "/reddit-async": REDDIT_ASYNC_HTML,
229
- "/reddit-feed": REDDIT_FEED_ONLY_HTML,
230
211
  };
231
212
 
232
213
  const html = pages[url.pathname];
@@ -268,9 +249,34 @@ export function runContractTests(
268
249
  realBrowser = false,
269
250
  navigateTimeout = 15_000,
270
251
  navigationSettle = false,
252
+ testTimeout,
253
+ runCacheSurvivesEvalFailure = false,
271
254
  } = options;
272
255
  const TASK_ID = "contract-test";
273
256
 
257
+ // Scoped test creator: when testTimeout is set, every `it()` call in this
258
+ // suite uses that timeout, so slow real-browser plugins (e.g. Camoufox)
259
+ // don't hit the default 15s vitest timeout on first-navigate bridge launch.
260
+ //
261
+ // Typed as simple callables (not vitest's TestAPI / AfterEachAPI) because
262
+ // arrow functions lack the chainable .only/.skip/.runIf properties that
263
+ // the vitest types include. Chainable variants are never needed here —
264
+ // the one .runIf call in this file uses the original `it` directly.
265
+ const _it = testTimeout
266
+ ? (name: string, fn: (...args: unknown[]) => unknown): void => {
267
+ it(name, fn, testTimeout);
268
+ }
269
+ : it;
270
+
271
+ // Scoped afterEach: when testTimeout is set, apply it to the cleanup
272
+ // hook too — plugin.cleanupAll() may exceed the default 10s hook timeout
273
+ // for real-browser plugins with expensive teardown.
274
+ const _afterEach = testTimeout
275
+ ? (fn: (...args: unknown[]) => unknown): void => {
276
+ afterEach(fn, testTimeout);
277
+ }
278
+ : afterEach;
279
+
274
280
  // ═══════════════════════════════════════════════════════════
275
281
  // Structural tests — work with any BrowserPlugin (including MockPlugin)
276
282
  // ═══════════════════════════════════════════════════════════
@@ -282,26 +288,22 @@ export function runContractTests(
282
288
  plugin = await createPlugin();
283
289
  });
284
290
 
285
- afterEach(async () => {
291
+ _afterEach(async () => {
286
292
  await plugin.cleanupAll().catch(() => {});
287
293
  });
288
294
 
289
295
  // ─── Identity & Capabilities ───────────────────────────
290
296
 
291
297
  describe("identity", () => {
292
- it("has a non-empty name", () => {
298
+ _it("has a non-empty name", () => {
293
299
  expect(plugin.name).toBeTruthy();
294
300
  expect(typeof plugin.name).toBe("string");
295
301
  });
296
302
 
297
- it("has complete capabilities", () => {
303
+ _it("has complete capabilities", () => {
298
304
  const caps: PluginCapabilities = plugin.capabilities;
299
305
  expect(typeof caps.supportsFullPageScreenshot).toBe("boolean");
300
- expect(typeof caps.supportsConsoleCapture).toBe("boolean");
301
306
  expect(typeof caps.supportsJavaScriptEvaluate).toBe("boolean");
302
- expect(typeof caps.supportsBotDetection).toBe("boolean");
303
- expect(typeof caps.supportsDialogAutoDismissal).toBe("boolean");
304
- expect(typeof caps.supportsAbortSignal).toBe("boolean");
305
307
  expect(typeof caps.engine).toBe("string");
306
308
  });
307
309
  });
@@ -309,7 +311,7 @@ export function runContractTests(
309
311
  // ─── Navigate ─────────────────────────────────────────
310
312
 
311
313
  describe("navigate()", () => {
312
- it("returns a NavigateResult with required fields", async () => {
314
+ _it("returns a NavigateResult with required fields", async () => {
313
315
  const result = await plugin.navigate(
314
316
  "https://example.com/",
315
317
  TASK_ID,
@@ -332,17 +334,20 @@ export function runContractTests(
332
334
  }
333
335
  });
334
336
 
335
- it("returns a well-formed result for any URL (success or error)", async () => {
336
- const result = await plugin.navigate("not-a-url", TASK_ID, 30_000);
337
+ _it(
338
+ "returns a well-formed result for any URL (success or error)",
339
+ async () => {
340
+ const result = await plugin.navigate("not-a-url", TASK_ID, 30_000);
337
341
 
338
- expect(result).toBeDefined();
339
- expect(typeof result.success).toBe("boolean");
340
- if (!result.success) {
341
- expect(result.error).toBeTruthy();
342
- }
343
- });
342
+ expect(result).toBeDefined();
343
+ expect(typeof result.success).toBe("boolean");
344
+ if (!result.success) {
345
+ expect(result.error).toBeTruthy();
346
+ }
347
+ },
348
+ );
344
349
 
345
- it("does not set botDetected for normal pages", async () => {
350
+ _it("does not set botDetected for normal pages", async () => {
346
351
  const result = await plugin.navigate(
347
352
  "https://example.com/",
348
353
  TASK_ID,
@@ -358,7 +363,7 @@ export function runContractTests(
358
363
  // ─── Snapshot ─────────────────────────────────────────
359
364
 
360
365
  describe("snapshot()", () => {
361
- it("returns a SnapshotResult with required fields", async () => {
366
+ _it("returns a SnapshotResult with required fields", async () => {
362
367
  // Ensure a session exists first
363
368
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
364
369
 
@@ -381,7 +386,7 @@ export function runContractTests(
381
386
  // ─── Click ────────────────────────────────────────────
382
387
 
383
388
  describe("click()", () => {
384
- it("returns an InteractionResult with required fields", async () => {
389
+ _it("returns an InteractionResult with required fields", async () => {
385
390
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
386
391
 
387
392
  // Use a ref that might exist; structural test just checks shape
@@ -407,7 +412,7 @@ export function runContractTests(
407
412
  // ─── Type ────────────────────────────────────────────
408
413
 
409
414
  describe("type()", () => {
410
- it("returns an InteractionResult with required fields", async () => {
415
+ _it("returns an InteractionResult with required fields", async () => {
411
416
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
412
417
 
413
418
  const result = await plugin.type(TASK_ID, "@e1", "hello");
@@ -431,7 +436,7 @@ export function runContractTests(
431
436
  // ─── Scroll ──────────────────────────────────────────
432
437
 
433
438
  describe("scroll()", () => {
434
- it("returns an InteractionResult with required fields", async () => {
439
+ _it("returns an InteractionResult with required fields", async () => {
435
440
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
436
441
 
437
442
  const result = await plugin.scroll(TASK_ID, "down");
@@ -448,7 +453,7 @@ export function runContractTests(
448
453
  }
449
454
  });
450
455
 
451
- it("accepts 'up' direction", async () => {
456
+ _it("accepts 'up' direction", async () => {
452
457
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
453
458
 
454
459
  const result = await plugin.scroll(TASK_ID, "up");
@@ -461,7 +466,7 @@ export function runContractTests(
461
466
  // ─── GoBack ──────────────────────────────────────────
462
467
 
463
468
  describe("goBack()", () => {
464
- it("returns an InteractionResult with required fields", async () => {
469
+ _it("returns an InteractionResult with required fields", async () => {
465
470
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
466
471
 
467
472
  const result = await plugin.goBack(TASK_ID);
@@ -489,7 +494,7 @@ export function runContractTests(
489
494
  // ─── Press ───────────────────────────────────────────
490
495
 
491
496
  describe("press()", () => {
492
- it("returns an InteractionResult with required fields", async () => {
497
+ _it("returns an InteractionResult with required fields", async () => {
493
498
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
494
499
 
495
500
  const result = await plugin.press(TASK_ID, "Enter");
@@ -510,7 +515,7 @@ export function runContractTests(
510
515
  // ─── Screenshot ──────────────────────────────────────
511
516
 
512
517
  describe("screenshot()", () => {
513
- it("returns a ScreenshotResult with required fields", async () => {
518
+ _it("returns a ScreenshotResult with required fields", async () => {
514
519
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
515
520
 
516
521
  const result = await plugin.screenshot(TASK_ID);
@@ -528,7 +533,7 @@ export function runContractTests(
528
533
  }
529
534
  });
530
535
 
531
- it("accepts fullPage option when supported", async () => {
536
+ _it("accepts fullPage option when supported", async () => {
532
537
  if (!plugin.capabilities.supportsFullPageScreenshot) return;
533
538
 
534
539
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
@@ -545,7 +550,7 @@ export function runContractTests(
545
550
  // ─── Console ─────────────────────────────────────────
546
551
 
547
552
  describe("getConsoleMessages()", () => {
548
- it("returns a ConsoleMessagesResult with required fields", async () => {
553
+ _it("returns a ConsoleMessagesResult with required fields", async () => {
549
554
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
550
555
 
551
556
  const result = await plugin.getConsoleMessages(TASK_ID);
@@ -568,7 +573,7 @@ export function runContractTests(
568
573
  // ─── ClearConsole ────────────────────────────────────
569
574
 
570
575
  describe("clearConsole()", () => {
571
- it("does not throw", async () => {
576
+ _it("does not throw", async () => {
572
577
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
573
578
 
574
579
  // Should resolve without error
@@ -579,7 +584,7 @@ export function runContractTests(
579
584
  // ─── Evaluate ────────────────────────────────────────
580
585
 
581
586
  describe("evaluate()", () => {
582
- it("returns an EvaluateResult with required fields", async () => {
587
+ _it("returns an EvaluateResult with required fields", async () => {
583
588
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
584
589
 
585
590
  if (!plugin.capabilities.supportsJavaScriptEvaluate) {
@@ -606,7 +611,7 @@ export function runContractTests(
606
611
  // ─── Cookies & storage state ─────────────────────────
607
612
 
608
613
  describe("getCookies()", () => {
609
- it("returns a CookieResult with required fields", async () => {
614
+ _it("returns a CookieResult with required fields", async () => {
610
615
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
611
616
 
612
617
  const result = await plugin.getCookies(TASK_ID);
@@ -625,7 +630,7 @@ export function runContractTests(
625
630
  }
626
631
  });
627
632
 
628
- it("accepts optional urls filter", async () => {
633
+ _it("accepts optional urls filter", async () => {
629
634
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
630
635
 
631
636
  const result = await plugin.getCookies(TASK_ID, [
@@ -639,7 +644,7 @@ export function runContractTests(
639
644
  });
640
645
 
641
646
  describe("addCookies()", () => {
642
- it("returns a ResultBase with required fields", async () => {
647
+ _it("returns a ResultBase with required fields", async () => {
643
648
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
644
649
 
645
650
  const result = await plugin.addCookies(TASK_ID, [
@@ -658,7 +663,7 @@ export function runContractTests(
658
663
  }
659
664
  });
660
665
 
661
- it("rejects cookies without required fields", async () => {
666
+ _it("rejects cookies without required fields", async () => {
662
667
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
663
668
 
664
669
  // Missing domain and path — may fail at Playwright level
@@ -673,7 +678,7 @@ export function runContractTests(
673
678
  });
674
679
 
675
680
  describe("clearCookies()", () => {
676
- it("returns a ResultBase with required fields", async () => {
681
+ _it("returns a ResultBase with required fields", async () => {
677
682
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
678
683
 
679
684
  const result = await plugin.clearCookies(TASK_ID);
@@ -682,7 +687,7 @@ export function runContractTests(
682
687
  expect(typeof result.success).toBe("boolean");
683
688
  });
684
689
 
685
- it("accepts optional filter params", async () => {
690
+ _it("accepts optional filter params", async () => {
686
691
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
687
692
 
688
693
  const result = await plugin.clearCookies(TASK_ID, {
@@ -695,7 +700,7 @@ export function runContractTests(
695
700
  });
696
701
 
697
702
  describe("getStorageState()", () => {
698
- it("returns a StorageStateResult with required fields", async () => {
703
+ _it("returns a StorageStateResult with required fields", async () => {
699
704
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
700
705
 
701
706
  const result = await plugin.getStorageState(TASK_ID);
@@ -723,7 +728,7 @@ export function runContractTests(
723
728
  // ─── Cleanup ────────────────────────────────────────
724
729
 
725
730
  describe("cleanup()", () => {
726
- it("does not throw for an active session", async () => {
731
+ _it("does not throw for an active session", async () => {
727
732
  await plugin.navigate("https://example.com/", TASK_ID, 30_000);
728
733
 
729
734
  await expect(plugin.cleanup(TASK_ID)).resolves.toBeUndefined();
@@ -733,7 +738,7 @@ export function runContractTests(
733
738
  // ─── CleanupAll ──────────────────────────────────────
734
739
 
735
740
  describe("cleanupAll()", () => {
736
- it("does not throw", async () => {
741
+ _it("does not throw", async () => {
737
742
  await expect(plugin.cleanupAll()).resolves.toBeUndefined();
738
743
  });
739
744
  });
@@ -761,14 +766,14 @@ export function runContractTests(
761
766
  plugin = await createPlugin();
762
767
  });
763
768
 
764
- afterEach(async () => {
769
+ _afterEach(async () => {
765
770
  await plugin.cleanupAll().catch(() => {});
766
771
  });
767
772
 
768
773
  // ─── Navigate ─────────────────────────────────────────
769
774
 
770
775
  describe("navigate() — real pages", () => {
771
- it("loads a page and returns correct title", async () => {
776
+ _it("loads a page and returns correct title", async () => {
772
777
  const result = await plugin.navigate(
773
778
  `${server.url}/simple`,
774
779
  TASK_ID,
@@ -780,20 +785,23 @@ export function runContractTests(
780
785
  expect(result.url).toContain("/simple");
781
786
  });
782
787
 
783
- it("returns a snapshot with @e refs for interactive elements", async () => {
784
- const result = await plugin.navigate(
785
- `${server.url}/interactive`,
786
- TASK_ID,
787
- navigateTimeout,
788
- );
788
+ _it(
789
+ "returns a snapshot with @e refs for interactive elements",
790
+ async () => {
791
+ const result = await plugin.navigate(
792
+ `${server.url}/interactive`,
793
+ TASK_ID,
794
+ navigateTimeout,
795
+ );
789
796
 
790
- expect(result.success).toBe(true);
791
- expect(result.snapshot).toBeTruthy();
792
- // Interactive page should have at least some elements
793
- expect(result.elementCount).toBeGreaterThan(0);
794
- });
797
+ expect(result.success).toBe(true);
798
+ expect(result.snapshot).toBeTruthy();
799
+ // Interactive page should have at least some elements
800
+ expect(result.elementCount).toBeGreaterThan(0);
801
+ },
802
+ );
795
803
 
796
- it("follows redirects", async () => {
804
+ _it("follows redirects", async () => {
797
805
  const result = await plugin.navigate(
798
806
  server.url,
799
807
  TASK_ID,
@@ -804,7 +812,7 @@ export function runContractTests(
804
812
  expect(result.url).toContain("/simple");
805
813
  });
806
814
 
807
- it("handles 404 pages", async () => {
815
+ _it("handles 404 pages", async () => {
808
816
  const result = await plugin.navigate(
809
817
  `${server.url}/nonexistent`,
810
818
  TASK_ID,
@@ -820,64 +828,42 @@ export function runContractTests(
820
828
  // ─── Click ────────────────────────────────────────────
821
829
 
822
830
  describe("click() — real interaction", () => {
823
- it("clicks a link and navigates", async () => {
824
- await plugin.navigate(
825
- `${server.url}/interactive`,
826
- TASK_ID,
827
- navigateTimeout,
828
- );
829
-
830
- // Find the link @e ref from the snapshot
831
- const snap = await plugin.snapshot(TASK_ID);
832
- expect(snap.success).toBe(true);
833
-
834
- // Extract a link ref from the snapshot
835
- const linkMatch = snap.snapshot.match(/@e(\d+)/);
836
- expect(linkMatch).toBeTruthy();
837
-
838
- const ref = `@e${linkMatch![1]}`;
839
- const result = await plugin.click(TASK_ID, ref);
840
-
841
- expect(result.success).toBe(true);
842
- // After clicking a link, the page may have changed
843
- if (result.snapshot) {
844
- expect(typeof result.snapshot).toBe("string");
845
- }
846
- });
847
-
848
- it("clicks duplicate-named links without strict-mode violation", async () => {
849
- await plugin.navigate(
850
- `${server.url}/duplicates`,
851
- TASK_ID,
852
- navigateTimeout,
853
- );
854
-
855
- const snap = await plugin.snapshot(TASK_ID);
856
- expect(snap.success).toBe(true);
857
-
858
- // Extract all link @e refs from the snapshot
859
- const linkRefs: string[] = [];
860
- const linkMatches = snap.snapshot.matchAll(/@(e\d+)\s.*?link/g);
861
- for (const m of linkMatches) {
862
- linkRefs.push(`@${m[1]!}`);
863
- }
864
-
865
- expect(linkRefs.length).toBeGreaterThanOrEqual(3);
866
-
867
- // Click each duplicate link in sequence — should not throw strict mode
868
- // Each link with the same name "Same Link" must be clickable via its @e ref
869
- for (const ref of linkRefs) {
870
- const result = await plugin.click(TASK_ID, ref);
871
- expect(result.success).toBe(true);
872
-
873
- // Navigate back to duplicates page for next click
831
+ _it(
832
+ "clicks duplicate-named links without strict-mode violation",
833
+ async () => {
874
834
  await plugin.navigate(
875
835
  `${server.url}/duplicates`,
876
836
  TASK_ID,
877
837
  navigateTimeout,
878
838
  );
879
- }
880
- });
839
+
840
+ const snap = await plugin.snapshot(TASK_ID);
841
+ expect(snap.success).toBe(true);
842
+
843
+ // Extract all link @e refs from the snapshot
844
+ const linkRefs: string[] = [];
845
+ const linkMatches = snap.snapshot.matchAll(/@(e\d+)\s.*?link/g);
846
+ for (const m of linkMatches) {
847
+ linkRefs.push(`@${m[1]!}`);
848
+ }
849
+
850
+ expect(linkRefs.length).toBeGreaterThanOrEqual(3);
851
+
852
+ // Click each duplicate link in sequence — should not throw strict mode
853
+ // Each link with the same name "Same Link" must be clickable via its @e ref
854
+ for (const ref of linkRefs) {
855
+ const result = await plugin.click(TASK_ID, ref);
856
+ expect(result.success).toBe(true);
857
+
858
+ // Navigate back to duplicates page for next click
859
+ await plugin.navigate(
860
+ `${server.url}/duplicates`,
861
+ TASK_ID,
862
+ navigateTimeout,
863
+ );
864
+ }
865
+ },
866
+ );
881
867
  });
882
868
 
883
869
  it.runIf(navigationSettle)(
@@ -911,37 +897,10 @@ export function runContractTests(
911
897
  },
912
898
  );
913
899
 
914
- // ─── Type ────────────────────────────────────────────
915
-
916
- describe("type() — real interaction", () => {
917
- it("types into an input field", async () => {
918
- await plugin.navigate(
919
- `${server.url}/interactive`,
920
- TASK_ID,
921
- navigateTimeout,
922
- );
923
-
924
- // Find an input ref
925
- const snap = await plugin.snapshot(TASK_ID);
926
- expect(snap.success).toBe(true);
927
-
928
- // Look for textbox in the snapshot (input fields are role=textbox)
929
- const inputMatch = snap.snapshot.match(/textbox[^\n]*@e(\d+)/);
930
- if (!inputMatch) {
931
- return;
932
- }
933
-
934
- const ref = `@e${inputMatch[1]}`;
935
- const result = await plugin.type(TASK_ID, ref, "hello world");
936
-
937
- expect(result.success).toBe(true);
938
- });
939
- });
940
-
941
900
  // ─── Scroll ──────────────────────────────────────────
942
901
 
943
902
  describe("scroll() — real interaction", () => {
944
- it("scrolls down on a long page", async () => {
903
+ _it("scrolls down on a long page", async () => {
945
904
  await plugin.navigate(`${server.url}/scroll`, TASK_ID, navigateTimeout);
946
905
 
947
906
  const result = await plugin.scroll(TASK_ID, "down");
@@ -949,7 +908,7 @@ export function runContractTests(
949
908
  expect(result.success).toBe(true);
950
909
  });
951
910
 
952
- it("scrolls back up", async () => {
911
+ _it("scrolls back up", async () => {
953
912
  await plugin.navigate(`${server.url}/scroll`, TASK_ID, navigateTimeout);
954
913
 
955
914
  await plugin.scroll(TASK_ID, "down");
@@ -957,12 +916,35 @@ export function runContractTests(
957
916
 
958
917
  expect(result.success).toBe(true);
959
918
  });
919
+
920
+ _it("scrolls to the bottom after repeated scrolls", async () => {
921
+ await plugin.navigate(`${server.url}/scroll`, TASK_ID, navigateTimeout);
922
+
923
+ let bottomReached = false;
924
+ const MAX_SCROLLS = 100;
925
+ for (let i = 0; i < MAX_SCROLLS; i++) {
926
+ const scroll = await plugin.scroll(TASK_ID, "down");
927
+ expect(scroll.success).toBe(true);
928
+
929
+ const evalResult = await plugin.evaluate(
930
+ TASK_ID,
931
+ "document.getElementById('bottom-marker').getBoundingClientRect().top < window.innerHeight",
932
+ );
933
+
934
+ if (evalResult.success && evalResult.result === true) {
935
+ bottomReached = true;
936
+ break;
937
+ }
938
+ }
939
+
940
+ expect(bottomReached).toBe(true);
941
+ });
960
942
  });
961
943
 
962
944
  // ─── GoBack ──────────────────────────────────────────
963
945
 
964
946
  describe("goBack() — real navigation", () => {
965
- it("navigates back to the previous page", async () => {
947
+ _it("navigates back to the previous page", async () => {
966
948
  // Navigate to page A
967
949
  await plugin.navigate(`${server.url}/page-a`, TASK_ID, navigateTimeout);
968
950
 
@@ -993,7 +975,7 @@ export function runContractTests(
993
975
  // ─── Press ───────────────────────────────────────────
994
976
 
995
977
  describe("press() — real interaction", () => {
996
- it("presses Enter key", async () => {
978
+ _it("presses Enter key", async () => {
997
979
  await plugin.navigate(
998
980
  `${server.url}/interactive`,
999
981
  TASK_ID,
@@ -1009,7 +991,7 @@ export function runContractTests(
1009
991
  // ─── Screenshot ──────────────────────────────────────
1010
992
 
1011
993
  describe("screenshot() — real capture", () => {
1012
- it("returns a valid JPEG data URI", async () => {
994
+ _it("returns a valid JPEG data URI", async () => {
1013
995
  await plugin.navigate(`${server.url}/simple`, TASK_ID, navigateTimeout);
1014
996
 
1015
997
  const result = await plugin.screenshot(TASK_ID);
@@ -1027,9 +1009,7 @@ export function runContractTests(
1027
1009
  // ─── Console ─────────────────────────────────────────
1028
1010
 
1029
1011
  describe("console — real capture", () => {
1030
- it("captures console messages from the page", async () => {
1031
- if (!plugin.capabilities.supportsConsoleCapture) return;
1032
-
1012
+ _it("captures console messages from the page", async () => {
1033
1013
  await plugin.navigate(
1034
1014
  `${server.url}/console`,
1035
1015
  TASK_ID,
@@ -1041,13 +1021,13 @@ export function runContractTests(
1041
1021
  expect(result.success).toBe(true);
1042
1022
  expect(result.messages.length).toBeGreaterThan(0);
1043
1023
 
1044
- const texts = result.messages.map((m) => m.text);
1045
- expect(texts.some((t) => t.includes("hello from console"))).toBe(true);
1024
+ const texts = result.messages.map((m: { text: string }) => m.text);
1025
+ expect(
1026
+ texts.some((t: string) => t.includes("hello from console")),
1027
+ ).toBe(true);
1046
1028
  });
1047
1029
 
1048
- it("clears console messages", async () => {
1049
- if (!plugin.capabilities.supportsConsoleCapture) return;
1050
-
1030
+ _it("clears console messages", async () => {
1051
1031
  await plugin.navigate(
1052
1032
  `${server.url}/console`,
1053
1033
  TASK_ID,
@@ -1065,7 +1045,7 @@ export function runContractTests(
1065
1045
  // ─── Evaluate ────────────────────────────────────────
1066
1046
 
1067
1047
  describe("evaluate() — real execution", () => {
1068
- it("evaluates a JavaScript expression", async () => {
1048
+ _it("evaluates a JavaScript expression", async () => {
1069
1049
  if (!plugin.capabilities.supportsJavaScriptEvaluate) return;
1070
1050
 
1071
1051
  await plugin.navigate(`${server.url}/simple`, TASK_ID, navigateTimeout);
@@ -1076,7 +1056,7 @@ export function runContractTests(
1076
1056
  expect(result.result).toBe("Contract Test — Simple");
1077
1057
  });
1078
1058
 
1079
- it("evaluates arithmetic", async () => {
1059
+ _it("evaluates arithmetic", async () => {
1080
1060
  if (!plugin.capabilities.supportsJavaScriptEvaluate) return;
1081
1061
 
1082
1062
  await plugin.navigate(`${server.url}/simple`, TASK_ID, navigateTimeout);
@@ -1087,7 +1067,7 @@ export function runContractTests(
1087
1067
  expect(result.result).toBe(5);
1088
1068
  });
1089
1069
 
1090
- it("returns error for invalid expressions", async () => {
1070
+ _it("returns error for invalid expressions", async () => {
1091
1071
  if (!plugin.capabilities.supportsJavaScriptEvaluate) return;
1092
1072
 
1093
1073
  await plugin.navigate(`${server.url}/simple`, TASK_ID, navigateTimeout);
@@ -1105,7 +1085,7 @@ export function runContractTests(
1105
1085
  // ─── Lifecycle ───────────────────────────────────────
1106
1086
 
1107
1087
  describe("lifecycle — real sessions", () => {
1108
- it("creates and cleans up a session", async () => {
1088
+ _it("creates and cleans up a session", async () => {
1109
1089
  await plugin.navigate(`${server.url}/simple`, TASK_ID, navigateTimeout);
1110
1090
 
1111
1091
  // Session should be working
@@ -1120,7 +1100,7 @@ export function runContractTests(
1120
1100
  expect(afterCleanup.success).toBe(false);
1121
1101
  });
1122
1102
 
1123
- it("isolates sessions by taskId", async () => {
1103
+ _it("isolates sessions by taskId", async () => {
1124
1104
  const TASK_A = "task-a";
1125
1105
  const TASK_B = "task-b";
1126
1106
 
@@ -1148,143 +1128,43 @@ export function runContractTests(
1148
1128
  });
1149
1129
  });
1150
1130
 
1151
- // ─── Reddit dialog fixtures ─────────────────────────
1152
-
1153
- describe("reddit dialog fixtures", () => {
1154
- it("dialog appears in snapshot after navigate", async () => {
1155
- const result = await plugin.navigate(
1156
- `${server.url}/reddit-dialog`,
1157
- TASK_ID,
1158
- navigateTimeout,
1159
- );
1160
-
1161
- expect(result.success).toBe(true);
1162
- expect(result.elementCount).toBeGreaterThan(0);
1163
- expect(result.snapshot).toContain("Consent");
1164
-
1165
- const snap = await plugin.snapshot(TASK_ID);
1166
- expect(snap.success).toBe(true);
1167
- expect(dialogCount(snap.snapshot)).toBeGreaterThanOrEqual(1);
1168
- });
1169
-
1170
- it("'Reject All' (nested SVG) is clickable", async () => {
1171
- await plugin.navigate(
1172
- `${server.url}/reddit-dialog`,
1173
- TASK_ID,
1174
- navigateTimeout,
1175
- );
1176
-
1177
- const snap = await plugin.snapshot(TASK_ID);
1178
- expect(snap.success).toBe(true);
1179
-
1180
- const info = findRef(snap.snapshot, "Reject All");
1181
- if (!info) {
1182
- // If the element is beyond the cap, skip
1183
- return;
1184
- }
1185
-
1186
- const result = await plugin.click(TASK_ID, info.ref);
1187
- expect(result.success).toBe(true);
1188
-
1189
- const after = await plugin.snapshot(TASK_ID);
1190
- expect(after.success).toBe(true);
1191
- expect(dialogCount(after.snapshot)).toBe(0);
1192
- });
1131
+ // ─── Cache survives eval failure (parity invariant) ─
1132
+
1133
+ describe("cache survives eval failure", () => {
1134
+ // ponytail: This test asserts a structural invariant — the cache
1135
+ // is not invalidated by an eval call, even one that fails. It does
1136
+ // NOT force an actual eval failure (which is engine-dependent and
1137
+ // not reproducible in CI without a challenge page). A passing eval
1138
+ // call must not invalidate the cache either — the invariant is that
1139
+ // cache-query and eval are independent paths.
1140
+ //
1141
+ // Full regression guard for the eval-failure class: if navigate
1142
+ // populated an N-element a11y tree, the cache-query path works even
1143
+ // when the eval path fails (e.g. on Camoufox challenge pages where
1144
+ // the main-world context is destroyed).
1145
+ _it("cache query works after evaluate call", async () => {
1146
+ if (!runCacheSurvivesEvalFailure) return;
1193
1147
 
1194
- it("'Accept All' (plain button) is clickable", async () => {
1195
1148
  await plugin.navigate(
1196
- `${server.url}/reddit-dialog`,
1149
+ `${server.url}/interactive`,
1197
1150
  TASK_ID,
1198
1151
  navigateTimeout,
1199
1152
  );
1200
1153
 
1201
- const snap = await plugin.snapshot(TASK_ID);
1202
- expect(snap.success).toBe(true);
1203
-
1204
- const info = findRef(snap.snapshot, "Accept All");
1205
- if (!info) {
1206
- return;
1207
- }
1208
-
1209
- const result = await plugin.click(TASK_ID, info.ref);
1210
- expect(result.success).toBe(true);
1211
-
1212
- const after = await plugin.snapshot(TASK_ID);
1213
- expect(after.success).toBe(true);
1214
- expect(dialogCount(after.snapshot)).toBe(0);
1215
- });
1216
-
1217
- it("stacked dialogs close in sequence", async () => {
1218
- const STACKED_TASK = "stacked-test";
1219
-
1220
- await plugin.navigate(
1221
- `${server.url}/reddit-stacked`,
1222
- STACKED_TASK,
1223
- navigateTimeout,
1224
- );
1225
-
1226
- // First snapshot — consent dialog should be visible
1227
- const snap1 = await plugin.snapshot(STACKED_TASK);
1154
+ // Snapshot → a11y tree populated
1155
+ const snap1 = await plugin.snapshot(TASK_ID);
1228
1156
  expect(snap1.success).toBe(true);
1229
- expect(dialogCount(snap1.snapshot)).toBeGreaterThanOrEqual(1);
1157
+ expect(snap1.elementCount).toBeGreaterThan(0);
1230
1158
 
1231
- // Click "Reject All" to dismiss consent dialog
1232
- const info1 = findRef(snap1.snapshot, "Reject All");
1233
- if (!info1) {
1234
- await plugin.cleanup(STACKED_TASK);
1235
- return;
1236
- }
1237
- const result1 = await plugin.click(STACKED_TASK, info1.ref);
1238
- expect(result1.success).toBe(true);
1159
+ // Evaluate (may succeed or fail — not forced). The
1160
+ // invariant: an eval call does not invalidate the cache.
1161
+ await plugin.evaluate(TASK_ID, "document.title");
1239
1162
 
1240
- // Second snapshot — "Welcome Back" dialog should now be visible
1241
- const snap2 = await plugin.snapshot(STACKED_TASK);
1163
+ // Re-run snapshot — must still succeed with same content
1164
+ const snap2 = await plugin.snapshot(TASK_ID);
1242
1165
  expect(snap2.success).toBe(true);
1243
- expect(snap2.snapshot).toContain("Welcome");
1244
-
1245
- // Click "Dismiss" to close the welcome dialog
1246
- const info2 = findRef(snap2.snapshot, "Dismiss");
1247
- if (!info2) {
1248
- await plugin.cleanup(STACKED_TASK);
1249
- return;
1250
- }
1251
- const result2 = await plugin.click(STACKED_TASK, info2.ref);
1252
- expect(result2.success).toBe(true);
1253
-
1254
- // Third snapshot — no dialogs should remain
1255
- const snap3 = await plugin.snapshot(STACKED_TASK);
1256
- expect(snap3.success).toBe(true);
1257
- expect(dialogCount(snap3.snapshot)).toBe(0);
1258
-
1259
- await plugin.cleanup(STACKED_TASK);
1260
- });
1261
-
1262
- it("async dialog eventually appears", async () => {
1263
- const ASYNC_TASK = "async-test";
1264
-
1265
- await plugin.navigate(
1266
- `${server.url}/reddit-async`,
1267
- ASYNC_TASK,
1268
- navigateTimeout,
1269
- );
1270
-
1271
- // Wait for the async dialog to appear (setTimeout 500ms)
1272
- await new Promise((r) => setTimeout(r, 1000));
1273
-
1274
- const snap = await plugin.snapshot(ASYNC_TASK);
1275
- expect(snap.success).toBe(true);
1276
- expect(snap.snapshot).toContain("Consent");
1277
-
1278
- const info = findRef(snap.snapshot, "Reject All");
1279
- if (!info) {
1280
- await plugin.cleanup(ASYNC_TASK);
1281
- return;
1282
- }
1283
-
1284
- const result = await plugin.click(ASYNC_TASK, info.ref);
1285
- expect(result.success).toBe(true);
1286
-
1287
- await plugin.cleanup(ASYNC_TASK);
1166
+ expect(snap2.elementCount).toBeGreaterThan(0);
1167
+ expect(snap2.snapshot).toBe(snap1.snapshot);
1288
1168
  });
1289
1169
  });
1290
1170
  });