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
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared browser-data validation tests.
3
+ *
4
+ * Verifies that ``browser-data.json`` is well-formed and contains all
5
+ * expected keys with the correct types. Both the TypeScript loader
6
+ * (browser-data.ts) and the Python bridge's JSON loader depend on this
7
+ * file, so a break here means both sides are broken in the same way.
8
+ *
9
+ * These tests are browser-free — no Chromium/Firefox needed.
10
+ */
11
+
12
+ import { describe, it, expect } from "vitest";
13
+ import {
14
+ BOT_SIGNALS,
15
+ ACCESSIBILITY,
16
+ NAV_SETTLE,
17
+ } from "../core/shared/browser-data.js";
18
+
19
+ // ─── Bot signals ─────────────────────────────────────────────────────
20
+
21
+ describe("shared browser-data.json — bot signals", () => {
22
+ it("has all four signal categories as arrays of strings", () => {
23
+ expect(Array.isArray(BOT_SIGNALS.blockSignals)).toBe(true);
24
+ expect(BOT_SIGNALS.blockSignals.length).toBeGreaterThanOrEqual(10);
25
+ for (const s of BOT_SIGNALS.blockSignals) {
26
+ expect(typeof s).toBe("string");
27
+ }
28
+
29
+ expect(Array.isArray(BOT_SIGNALS.bodyOnlySignals)).toBe(true);
30
+ expect(BOT_SIGNALS.bodyOnlySignals.length).toBeGreaterThanOrEqual(1);
31
+ for (const s of BOT_SIGNALS.bodyOnlySignals) {
32
+ expect(typeof s).toBe("string");
33
+ }
34
+
35
+ expect(Array.isArray(BOT_SIGNALS.bodyOnlyPatterns)).toBe(true);
36
+ expect(BOT_SIGNALS.bodyOnlyPatterns.length).toBeGreaterThanOrEqual(1);
37
+ for (const s of BOT_SIGNALS.bodyOnlyPatterns) {
38
+ expect(typeof s).toBe("string");
39
+ }
40
+
41
+ expect(Array.isArray(BOT_SIGNALS.htmlSignals)).toBe(true);
42
+ expect(BOT_SIGNALS.htmlSignals.length).toBeGreaterThanOrEqual(4);
43
+ for (const s of BOT_SIGNALS.htmlSignals) {
44
+ expect(typeof s).toBe("string");
45
+ }
46
+ });
47
+
48
+ it("includes known block signals", () => {
49
+ expect(BOT_SIGNALS.blockSignals).toContain("please verify you are human");
50
+ expect(BOT_SIGNALS.blockSignals).toContain("cf-challenge");
51
+ expect(BOT_SIGNALS.blockSignals).toContain("cdn-cgi/challenge");
52
+ });
53
+
54
+ it("includes known body-only signals", () => {
55
+ expect(BOT_SIGNALS.bodyOnlySignals).toContain("errors.edgesuite.net");
56
+ });
57
+
58
+ it("includes known html signals", () => {
59
+ expect(BOT_SIGNALS.htmlSignals).toContain("recaptcha");
60
+ expect(BOT_SIGNALS.htmlSignals).toContain("hcaptcha");
61
+ expect(BOT_SIGNALS.htmlSignals).toContain("data-sitekey");
62
+ });
63
+ });
64
+
65
+ // ─── Accessibility ───────────────────────────────────────────────────
66
+
67
+ describe("shared browser-data.json — accessibility", () => {
68
+ it("has interactiveRoles as a non-empty array of strings", () => {
69
+ expect(Array.isArray(ACCESSIBILITY.interactiveRoles)).toBe(true);
70
+ expect(ACCESSIBILITY.interactiveRoles.length).toBeGreaterThanOrEqual(40);
71
+ for (const r of ACCESSIBILITY.interactiveRoles) {
72
+ expect(typeof r).toBe("string");
73
+ }
74
+ });
75
+
76
+ it("has informationalRoles as a non-empty array of strings", () => {
77
+ expect(Array.isArray(ACCESSIBILITY.informationalRoles)).toBe(true);
78
+ expect(ACCESSIBILITY.informationalRoles.length).toBeGreaterThanOrEqual(15);
79
+ for (const r of ACCESSIBILITY.informationalRoles) {
80
+ expect(typeof r).toBe("string");
81
+ }
82
+ });
83
+
84
+ it("has roleIcons as a map with known entries", () => {
85
+ expect(typeof ACCESSIBILITY.roleIcons).toBe("object");
86
+ expect(ACCESSIBILITY.roleIcons).not.toBeNull();
87
+ expect(Object.keys(ACCESSIBILITY.roleIcons).length).toBeGreaterThanOrEqual(
88
+ 60,
89
+ );
90
+ expect(ACCESSIBILITY.roleIcons["button"]).toBe("🔘 ");
91
+ expect(ACCESSIBILITY.roleIcons["link"]).toBe("🔗 ");
92
+ });
93
+
94
+ it("includes common interactive roles like button and link", () => {
95
+ expect(ACCESSIBILITY.interactiveRoles).toContain("button");
96
+ expect(ACCESSIBILITY.interactiveRoles).toContain("link");
97
+ expect(ACCESSIBILITY.interactiveRoles).toContain("textbox");
98
+ expect(ACCESSIBILITY.interactiveRoles).toContain("checkbox");
99
+ });
100
+
101
+ it("includes informational roles like paragraph and text", () => {
102
+ expect(ACCESSIBILITY.informationalRoles).toContain("paragraph");
103
+ expect(ACCESSIBILITY.informationalRoles).toContain("text");
104
+ expect(ACCESSIBILITY.informationalRoles).toContain("group");
105
+ });
106
+ });
107
+
108
+ // ─── Nav-settle ────────────────────────────────────────────────────
109
+
110
+ describe("shared browser-data.json — nav-settle", () => {
111
+ it("has expected numeric timeout values", () => {
112
+ expect(NAV_SETTLE.navTimeoutMs).toBe(5000);
113
+ expect(NAV_SETTLE.settleTimeoutMs).toBe(400);
114
+ expect(NAV_SETTLE.settleRaceMs).toBe(150);
115
+ });
116
+
117
+ it("has a non-empty domStabilizationJs string", () => {
118
+ expect(typeof NAV_SETTLE.domStabilizationJs).toBe("string");
119
+ expect(NAV_SETTLE.domStabilizationJs.length).toBeGreaterThan(50);
120
+ expect(NAV_SETTLE.domStabilizationJs).toContain(
121
+ "document.querySelectorAll",
122
+ );
123
+ });
124
+ });
@@ -21,6 +21,7 @@ import {
21
21
  queryElementCache,
22
22
  formatElementList,
23
23
  formatRoleCountSummary,
24
+ type QueryStatus,
24
25
  } from "../core/shared/dom-extractor.js";
25
26
 
26
27
  // ─── Helpers ───────────────────────────────────────────────────────
@@ -171,15 +172,16 @@ describe("runExtractor()", () => {
171
172
  result: MOCK_EXTRACTOR_RESPONSE,
172
173
  };
173
174
 
174
- const result = await runExtractor("test-task", mockPlugin);
175
- expect(result).not.toBeNull();
176
- expect(result!.title).toBe("Test Page");
177
- expect(result!.headings).toHaveLength(1);
178
- expect(result!.headings[0]!.text).toBe("Heading One");
179
- expect(result!.paragraphs).toHaveLength(1);
180
- expect(result!.links).toHaveLength(1);
181
- expect(result!.images).toHaveLength(1);
182
- expect(result!.interactive).toHaveLength(1);
175
+ const outcome = await runExtractor("test-task", mockPlugin);
176
+ expect(outcome.ok).toBe(true);
177
+ if (!outcome.ok) return;
178
+ expect(outcome.result.title).toBe("Test Page");
179
+ expect(outcome.result.headings).toHaveLength(1);
180
+ expect(outcome.result.headings[0]!.text).toBe("Heading One");
181
+ expect(outcome.result.paragraphs).toHaveLength(1);
182
+ expect(outcome.result.links).toHaveLength(1);
183
+ expect(outcome.result.images).toHaveLength(1);
184
+ expect(outcome.result.interactive).toHaveLength(1);
183
185
  });
184
186
 
185
187
  it("returns null on evaluate failure", async () => {
@@ -189,8 +191,10 @@ describe("runExtractor()", () => {
189
191
  error: "evaluate failed",
190
192
  };
191
193
 
192
- const result = await runExtractor("test-task", mockPlugin);
193
- expect(result).toBeNull();
194
+ const outcome = await runExtractor("test-task", mockPlugin);
195
+ expect(outcome.ok).toBe(false);
196
+ if (outcome.ok) return;
197
+ expect(outcome.error).toContain("evaluate failed");
194
198
  });
195
199
 
196
200
  it("returns null on invalid JSON from evaluate", async () => {
@@ -200,8 +204,10 @@ describe("runExtractor()", () => {
200
204
  result: "not valid json {{{",
201
205
  };
202
206
 
203
- const result = await runExtractor("test-task", mockPlugin);
204
- expect(result).toBeNull();
207
+ const outcome = await runExtractor("test-task", mockPlugin);
208
+ expect(outcome.ok).toBe(false);
209
+ if (outcome.ok) return;
210
+ expect(outcome.error).toContain("invalid JSON");
205
211
  });
206
212
 
207
213
  it("returns null when script returns error field", async () => {
@@ -211,8 +217,26 @@ describe("runExtractor()", () => {
211
217
  result: JSON.stringify({ error: "Something went wrong" }),
212
218
  };
213
219
 
214
- const result = await runExtractor("test-task", mockPlugin);
215
- expect(result).toBeNull();
220
+ const outcome = await runExtractor("test-task", mockPlugin);
221
+ expect(outcome.ok).toBe(false);
222
+ if (outcome.ok) return;
223
+ expect(outcome.error).toBe("Something went wrong");
224
+ });
225
+
226
+ it("forwards readOnly: true to plugin.evaluate", async () => {
227
+ const mockPlugin = new MockPlugin("test");
228
+ mockPlugin.evalResult = {
229
+ success: true,
230
+ result: MOCK_EXTRACTOR_RESPONSE,
231
+ };
232
+
233
+ await runExtractor("test-task", mockPlugin);
234
+ const evaluateCalls = mockPlugin.calls.get("evaluate");
235
+ expect(evaluateCalls).toBeDefined();
236
+ expect(evaluateCalls!.length).toBe(1);
237
+ // Third arg must be true (readOnly)
238
+ const args = evaluateCalls![0] as unknown[];
239
+ expect(args[2]).toBe(true);
216
240
  });
217
241
  });
218
242
 
@@ -491,6 +515,55 @@ describe("queryElementCache()", () => {
491
515
  expect(results).toHaveLength(2); // Two Submit buttons
492
516
  expect(results.every((n) => n.role === "button")).toBe(true);
493
517
  });
518
+
519
+ describe("QueryStatus out-param", () => {
520
+ it("ref present + extra role filter that does not match", () => {
521
+ const status: QueryStatus = {};
522
+ const results = queryElementCache(
523
+ cache,
524
+ { ref: "@e6", role: "button" },
525
+ status,
526
+ );
527
+ expect(results).toHaveLength(0);
528
+ expect(status.refFilteredOut).toBeDefined();
529
+ expect(status.refFilteredOut!.filter).toBe("role");
530
+ expect(status.refFilteredOut!.value).toBe("button");
531
+ expect(status.refFilteredOut!.node.role).toBe("dialog");
532
+ });
533
+
534
+ it("ref present + extra name filter that does not match", () => {
535
+ const status: QueryStatus = {};
536
+ const results = queryElementCache(
537
+ cache,
538
+ { ref: "@e6", name: "close" },
539
+ status,
540
+ );
541
+ expect(results).toHaveLength(0);
542
+ expect(status.refFilteredOut).toBeDefined();
543
+ expect(status.refFilteredOut!.filter).toBe("name");
544
+ expect(status.refFilteredOut!.value).toBe("close");
545
+ expect(status.refFilteredOut!.node.name).toBe("Sign Up");
546
+ });
547
+
548
+ it("ref genuinely absent", () => {
549
+ const status: QueryStatus = {};
550
+ const results = queryElementCache(cache, { ref: "@e99" }, status);
551
+ expect(results).toHaveLength(0);
552
+ expect(status.refFilteredOut).toBeUndefined();
553
+ });
554
+
555
+ it("ref present + matching extra filter", () => {
556
+ const status: QueryStatus = {};
557
+ const results = queryElementCache(
558
+ cache,
559
+ { ref: "@e6", role: "dialog" },
560
+ status,
561
+ );
562
+ expect(results).toHaveLength(1);
563
+ expect(results[0]!.ref).toBe("e6");
564
+ expect(status.refFilteredOut).toBeUndefined();
565
+ });
566
+ });
494
567
  });
495
568
 
496
569
  // ─── Router dispatch integration tests ─────────────────────────────
@@ -577,6 +650,41 @@ describe("router.browserInspect()", () => {
577
650
  expect(result.content).toContain("Lexical Analysis");
578
651
  });
579
652
 
653
+ it("ref + role mismatch shows found-but-filtered message", async () => {
654
+ await router.navigate("https://example.com", { taskId: "test-5b" });
655
+
656
+ const result = await router.browserInspect("test-5b", {
657
+ ref: "@e6",
658
+ role: "button",
659
+ });
660
+ expect(result.success).toBe(true);
661
+ expect(result.content).toContain("found in cache");
662
+ expect(result.content).toContain("does not match");
663
+ expect(result.content).toContain('filter role="button"');
664
+ expect(result.content).not.toContain("not found in cache");
665
+ });
666
+
667
+ it("ref genuinely absent keeps not-found message", async () => {
668
+ await router.navigate("https://example.com", { taskId: "test-5c" });
669
+
670
+ const result = await router.browserInspect("test-5c", { ref: "@e99" });
671
+ expect(result.success).toBe(true);
672
+ expect(result.content).toContain("not found in cache");
673
+ });
674
+
675
+ it("ref + role match returns element normally", async () => {
676
+ await router.navigate("https://example.com", { taskId: "test-5d" });
677
+
678
+ const result = await router.browserInspect("test-5d", {
679
+ ref: "@e6",
680
+ role: "dialog",
681
+ });
682
+ expect(result.success).toBe(true);
683
+ expect(result.content).toContain("@e6");
684
+ expect(result.content).toContain("Sign Up");
685
+ expect(result.content).not.toContain("found in cache");
686
+ });
687
+
580
688
  it("empty element cache returns 'no elements cached' message", async () => {
581
689
  const emptyMock = new MockPlugin("empty");
582
690
  emptyMock.elementCache = new Map();
@@ -828,7 +936,75 @@ describe("router.browserInspect()", () => {
828
936
 
829
937
  const result = await router.browserInspect("test-10", { text: true });
830
938
  expect(result.success).toBe(false);
831
- expect(result.error).toContain("returned no content");
939
+ expect(result.error).toContain(
940
+ "Text extraction failed: browser disconnected",
941
+ );
942
+ expect(result.error).toContain("browser-inspect with role=");
943
+ expect(result.error).not.toContain("browser-snapshot to inspect visually");
944
+ expect(result.error).toMatch(/\(8 elements\) is available/);
945
+ });
946
+
947
+ describe("Changes 3 & 4 — silent-empty guards", () => {
948
+ it("correlateElements valid-but-empty result appends notice", () => {
949
+ const cache = new Map<string, AriaCachedNode>();
950
+ const result = correlateElements(
951
+ {
952
+ title: "",
953
+ headings: [],
954
+ paragraphs: [],
955
+ links: [],
956
+ images: [],
957
+ interactive: [],
958
+ },
959
+ cache,
960
+ true,
961
+ );
962
+ expect(result.text).toContain("No extractable content");
963
+ expect(result.text.length).toBeGreaterThan(0);
964
+ expect(result.matchedRefs).toBe(0);
965
+ expect(result.staleCache).toBe(false);
966
+ });
967
+
968
+ it("text=true + empty extractor + populated cache returns notice", async () => {
969
+ mock.evalResult = {
970
+ success: true,
971
+ result: JSON.stringify({
972
+ title: "",
973
+ headings: [],
974
+ paragraphs: [],
975
+ links: [],
976
+ images: [],
977
+ interactive: [],
978
+ }),
979
+ };
980
+ await router.navigate("https://example.com", { taskId: "test-11" });
981
+
982
+ const result = await router.browserInspect("test-11", { text: true });
983
+ expect(result.success).toBe(true);
984
+ expect(result.content).toContain("No extractable content");
985
+ });
986
+
987
+ it("text=true + query + empty extractor input appends no-match notice", async () => {
988
+ mock.evalResult = {
989
+ success: true,
990
+ result: JSON.stringify({
991
+ title: "",
992
+ headings: [],
993
+ paragraphs: [],
994
+ links: [],
995
+ images: [],
996
+ interactive: [],
997
+ }),
998
+ };
999
+ await router.navigate("https://example.com", { taskId: "test-12" });
1000
+
1001
+ const result = await router.browserInspect("test-12", {
1002
+ text: true,
1003
+ query: "anything",
1004
+ });
1005
+ expect(result.success).toBe(true);
1006
+ expect(result.content).toContain('No content matched "anything"');
1007
+ });
832
1008
  });
833
1009
  });
834
1010
 
@@ -24,8 +24,8 @@ vi.mock("node:fs", () => ({
24
24
  unlinkSync: vi.fn(),
25
25
  }));
26
26
 
27
- import browserToggle from "../browser-toggle";
28
- import { listProfiles, formatProfileList } from "../browser-profile";
27
+ import browserToggle from "../browser-toggle.js";
28
+ import { listProfiles, formatProfileList } from "../browser-profile.js";
29
29
 
30
30
  // ─── Helpers ─────────────────────────────────────────────────────
31
31
 
@@ -113,7 +113,7 @@ describe("listProfiles()", () => {
113
113
  (statSync as Mock).mockReturnValue({ size: 100 });
114
114
 
115
115
  const profiles = listProfiles();
116
- expect(profiles.map((p) => p.name)).toEqual([
116
+ expect(profiles.map((p: { name: string }) => p.name)).toEqual([
117
117
  "default",
118
118
  "shopping",
119
119
  "work",
@@ -3,7 +3,7 @@
3
3
  * /web on | off | status commands for toggling browser automation tools.
4
4
  *
5
5
  * Tests cover:
6
- * - Filtering logic (getRegisteredBrowserTools)
6
+ * - Filtering logic (getRegisteredIn)
7
7
  * - State queries (isBrowserEnabled)
8
8
  * - State mutations (applyBrowserState, persistState)
9
9
  * - Branch-aware restoration (restoreFromBranch)
@@ -19,7 +19,7 @@ import type {
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import browserToggle, { getToggleState } from "../browser-toggle.js";
21
21
  import {
22
- getRegisteredBrowserTools,
22
+ getRegisteredIn,
23
23
  isBrowserEnabled,
24
24
  applyBrowserState,
25
25
  persistState,
@@ -27,12 +27,10 @@ import {
27
27
  _resetToggleStateForTest,
28
28
  readBrowserToggleConfig,
29
29
  applyConfigDefault,
30
- getRegisteredLearnTools,
31
- getRegisteredSiblingTools,
32
30
  isLearnEnabled,
33
31
  applyLearnState,
34
32
  type BrowserToggleState,
35
- } from "./helpers/toggle-test-utils.js";
33
+ } from "../browser-toggle.js";
36
34
 
37
35
  // Mock node:fs so readBrowserToggleConfig and applyConfigDefault can be
38
36
  // tested without touching the real filesystem.
@@ -119,38 +117,52 @@ function mockContext(
119
117
  }
120
118
 
121
119
  // ==================================================================
122
- // getRegisteredBrowserTools
120
+ // getRegisteredIn
123
121
  // ==================================================================
124
- describe("getRegisteredBrowserTools", () => {
122
+ describe("getRegisteredIn", () => {
123
+ const BROWSER_NAMES = new Set([
124
+ "web-fetch",
125
+ "browser-navigate",
126
+ "browser-snapshot",
127
+ "browser-click",
128
+ "browser-type",
129
+ "browser-scroll",
130
+ "browser-back",
131
+ "browser-press",
132
+ "browser-console",
133
+ "browser-inspect",
134
+ "web-guide",
135
+ ]);
136
+
125
137
  it("returns empty array when no tools are registered", () => {
126
138
  const pi = mockPi({ tools: [] });
127
- expect(getRegisteredBrowserTools(pi)).toEqual([]);
139
+ expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual([]);
128
140
  });
129
141
 
130
- it("returns empty array when only non-browser tools are registered", () => {
142
+ it("returns empty array when only non-matching tools are registered", () => {
131
143
  const pi = mockPi({ tools: NON_BROWSER_TOOLS });
132
- expect(getRegisteredBrowserTools(pi)).toEqual([]);
144
+ expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual([]);
133
145
  });
134
146
 
135
- it("returns only browser tools when mixed with other tools", () => {
147
+ it("returns only matching tools when mixed with non-matching tools", () => {
136
148
  const pi = mockPi({
137
149
  tools: [...ALL_BROWSER_TOOLS, ...NON_BROWSER_TOOLS],
138
150
  });
139
- expect(getRegisteredBrowserTools(pi)).toEqual(
151
+ expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual(
140
152
  ALL_BROWSER_TOOLS.map((t) => t.name),
141
153
  );
142
154
  });
143
155
 
144
- it("returns all browser tools when fully loaded", () => {
156
+ it("returns all matching tools when all are present", () => {
145
157
  const pi = mockPi({ tools: ALL_BROWSER_TOOLS });
146
- expect(getRegisteredBrowserTools(pi)).toEqual(
158
+ expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual(
147
159
  ALL_BROWSER_TOOLS.map((t) => t.name),
148
160
  );
149
161
  });
150
162
 
151
- it("returns a subset when only some browser tools are registered", () => {
163
+ it("returns a subset when only some matching tools are registered", () => {
152
164
  const pi = mockPi({ tools: SOME_BROWSER_TOOLS });
153
- expect(getRegisteredBrowserTools(pi)).toEqual(
165
+ expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual(
154
166
  SOME_BROWSER_TOOLS.map((t) => t.name),
155
167
  );
156
168
  });
@@ -164,7 +176,7 @@ describe("getRegisteredBrowserTools", () => {
164
176
  ],
165
177
  });
166
178
  // Only the exact-lowercase match should be returned
167
- expect(getRegisteredBrowserTools(pi)).toEqual(["web-fetch"]);
179
+ expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual(["web-fetch"]);
168
180
  });
169
181
 
170
182
  it("ignores tool metadata beyond the name field", () => {
@@ -173,7 +185,7 @@ describe("getRegisteredBrowserTools", () => {
173
185
  { name: "browser-scroll", description: "b", promptGuidelines: [] },
174
186
  ];
175
187
  const pi = mockPi({ tools: tools as any });
176
- expect(getRegisteredBrowserTools(pi)).toEqual([
188
+ expect(getRegisteredIn(pi, BROWSER_NAMES)).toEqual([
177
189
  "web-fetch",
178
190
  "browser-scroll",
179
191
  ]);
@@ -208,12 +220,12 @@ describe("isBrowserEnabled", () => {
208
220
  expect(isBrowserEnabled(pi)).toBe(true);
209
221
  });
210
222
 
211
- it("returns true when no browser tools exist (vacuously enabled)", () => {
223
+ it("returns false when no browser tools exist", () => {
212
224
  const pi = mockPi({
213
225
  tools: NON_BROWSER_TOOLS,
214
226
  activeTools: NON_BROWSER_TOOLS.map((t) => t.name),
215
227
  });
216
- expect(isBrowserEnabled(pi)).toBe(true);
228
+ expect(isBrowserEnabled(pi)).toBe(false);
217
229
  });
218
230
 
219
231
  it("returns false when active set is empty and browser tools exist", () => {
@@ -825,12 +837,14 @@ describe("command handler dispatch (via factory closure)", () => {
825
837
  });
826
838
 
827
839
  // ==================================================================
828
- // getRegisteredLearnTools / isLearnEnabled / applyLearnState
840
+ // getRegisteredIn (learn) / isLearnEnabled / applyLearnState
829
841
  // ==================================================================
830
- describe("getRegisteredLearnTools", () => {
842
+ describe("getRegisteredIn with learn tool names", () => {
843
+ const LEARN_NAMES = new Set(["web-learn"]);
844
+
831
845
  it("returns empty array when no learn tools registered", () => {
832
846
  const pi = mockPi({ tools: ALL_BROWSER_TOOLS });
833
- expect(getRegisteredLearnTools(pi)).toEqual([]);
847
+ expect(getRegisteredIn(pi, LEARN_NAMES)).toEqual([]);
834
848
  });
835
849
 
836
850
  it("returns web-learn when it's in the tool list", () => {
@@ -839,7 +853,7 @@ describe("getRegisteredLearnTools", () => {
839
853
  { name: "web-learn", description: "learn" },
840
854
  ];
841
855
  const pi = mockPi({ tools });
842
- expect(getRegisteredLearnTools(pi)).toEqual(["web-learn"]);
856
+ expect(getRegisteredIn(pi, LEARN_NAMES)).toEqual(["web-learn"]);
843
857
  });
844
858
 
845
859
  it("is case-sensitive (tool names are always lowercase)", () => {
@@ -849,7 +863,7 @@ describe("getRegisteredLearnTools", () => {
849
863
  { name: "web-learn" },
850
864
  ];
851
865
  const pi = mockPi({ tools });
852
- expect(getRegisteredLearnTools(pi)).toEqual(["web-learn"]);
866
+ expect(getRegisteredIn(pi, LEARN_NAMES)).toEqual(["web-learn"]);
853
867
  });
854
868
  });
855
869
 
@@ -878,9 +892,9 @@ describe("isLearnEnabled", () => {
878
892
  expect(isLearnEnabled(pi)).toBe(false);
879
893
  });
880
894
 
881
- it("returns true when no learn tools exist (vacuously enabled)", () => {
895
+ it("returns false when no learn tools exist", () => {
882
896
  const pi = mockPi({ tools: ALL_BROWSER_TOOLS });
883
- expect(isLearnEnabled(pi)).toBe(true);
897
+ expect(isLearnEnabled(pi)).toBe(false);
884
898
  });
885
899
  });
886
900
 
@@ -924,28 +938,28 @@ describe("applyLearnState", () => {
924
938
  });
925
939
 
926
940
  // ==================================================================
927
- // getRegisteredSiblingTools
941
+ // getRegisteredIn (sibling)
928
942
  // ==================================================================
929
- describe("getRegisteredSiblingTools", () => {
930
- it("returns web-search when it's in the tool list (Sprint 4)", () => {
943
+ describe("getRegisteredIn with sibling tool names", () => {
944
+ const SIBLING_NAMES = new Set(["web-search"]);
945
+
946
+ it("returns web-search when it's in the tool list", () => {
931
947
  const tools = [
932
948
  ...ALL_BROWSER_TOOLS,
933
949
  { name: "web-search", description: "SearXNG search" },
934
950
  ];
935
951
  const pi = mockPi({ tools });
936
- expect(getRegisteredSiblingTools(pi)).toEqual(["web-search"]);
952
+ expect(getRegisteredIn(pi, SIBLING_NAMES)).toEqual(["web-search"]);
937
953
  });
938
954
 
939
955
  it("uses exact-name Set.has() membership — no regex false positives", () => {
940
- // A tool named "web-search-news" should NOT match the "web-search" entry,
941
- // confirming exact-name membership rather than a prefix regex.
942
956
  const tools = [
943
957
  ...ALL_BROWSER_TOOLS,
944
958
  { name: "web-search-news", description: "news" },
945
959
  { name: "web-search-images", description: "images" },
946
960
  ];
947
961
  const pi = mockPi({ tools });
948
- expect(getRegisteredSiblingTools(pi)).toEqual([]);
962
+ expect(getRegisteredIn(pi, SIBLING_NAMES)).toEqual([]);
949
963
  });
950
964
 
951
965
  it("is case-sensitive — only exact lower-case matches", () => {
@@ -955,7 +969,7 @@ describe("getRegisteredSiblingTools", () => {
955
969
  { name: "web-search" },
956
970
  ];
957
971
  const pi = mockPi({ tools });
958
- expect(getRegisteredSiblingTools(pi)).toEqual(["web-search"]);
972
+ expect(getRegisteredIn(pi, SIBLING_NAMES)).toEqual(["web-search"]);
959
973
  });
960
974
  });
961
975