pi-lean-dimension 0.5.0 → 0.6.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 (104) hide show
  1. package/README.md +5 -3
  2. package/node_modules/pi-lean-host/AGENTS.md +13 -6
  3. package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
  4. package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
  5. package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
  6. package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
  7. package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
  8. package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
  9. package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
  10. package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
  11. package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
  12. package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
  13. package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
  14. package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
  15. package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
  16. package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
  17. package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
  18. package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
  19. package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
  20. package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
  21. package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
  22. package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
  23. package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
  24. package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
  25. package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
  26. package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
  27. package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
  28. package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
  29. package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
  30. package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
  31. package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
  32. package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
  33. package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
  34. package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
  35. package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
  36. package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
  37. package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
  38. package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
  39. package/node_modules/pi-lean-host/core/auth.ts +2 -1
  40. package/node_modules/pi-lean-host/core/helpers.ts +29 -35
  41. package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
  42. package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
  43. package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
  44. package/node_modules/pi-lean-host/core/transport.ts +1 -1
  45. package/node_modules/pi-lean-host/package.json +1 -1
  46. package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
  47. package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
  48. package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
  49. package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
  50. package/node_modules/pi-lean-host/tools/utils.ts +69 -0
  51. package/node_modules/pi-lean-portal/AGENTS.md +4 -3
  52. package/node_modules/pi-lean-portal/README.md +9 -9
  53. package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
  54. package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
  55. package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
  56. package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
  57. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
  58. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
  59. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
  60. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  62. package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
  63. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
  64. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
  65. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
  66. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
  67. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
  68. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
  69. package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
  70. package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
  71. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  72. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
  77. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
  84. package/node_modules/pi-lean-portal/browser-install.ts +454 -0
  85. package/node_modules/pi-lean-portal/browser-status.ts +21 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
  87. package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
  88. package/node_modules/pi-lean-portal/core/router.ts +7 -32
  89. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
  90. package/node_modules/pi-lean-portal/index.ts +1 -18
  91. package/node_modules/pi-lean-portal/package.json +2 -1
  92. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
  93. package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
  94. package/node_modules/pi-lean-search/AGENTS.md +5 -3
  95. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
  96. package/node_modules/pi-lean-search/index.ts +0 -3
  97. package/node_modules/pi-lean-search/package.json +1 -1
  98. package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
  99. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  100. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  101. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  102. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  103. package/node_modules/yaml/package.json +1 -1
  104. package/package.json +4 -4
@@ -0,0 +1,737 @@
1
+ /**
2
+ * Structural tests for browser-install.ts (/web install) — no real browser.
3
+ *
4
+ * Covers:
5
+ * - bundled-playwright resolution: success → spawn path; failure → generic
6
+ * manual command, no throw
7
+ * - node-interpreter selection (bun-compiled pi vs npm-shim node; Windows .exe)
8
+ * - shell-aware chromium detection + firefox marker detection (real fs on
9
+ * temp dirs; browsers.json content controlled via a mocked createRequire)
10
+ * - form routing: direct form spawns in UI modes, prints in print mode;
11
+ * bare form prints the manual command outside TUI
12
+ * - checklist dialog: pre-check, toggle, install-row confirm, empty guard,
13
+ * Esc cancel (driven through the captured ctx.ui.custom factory)
14
+ * - spawn streaming: success notify, host-validation warning surfacing,
15
+ * spawn `error` event fallback
16
+ */
17
+
18
+ import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
19
+ import { spawn } from "node:child_process";
20
+ import { EventEmitter } from "node:events";
21
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import {
25
+ handleInstallSubcommand,
26
+ pickBrowsersToInstall,
27
+ detectInstalledBrowsers,
28
+ resolveNodeInterpreter,
29
+ } from "../browser-install.js";
30
+ import { mockCtx, captureWebHandler } from "./helpers/mock-pi.js";
31
+
32
+ // ─── Module mocks ────────────────────────────────────────────────
33
+
34
+ // createRequire is wrapped so tests can stub what "playwright" resolves to
35
+ // (or make resolution throw); impl === null delegates to the real resolver.
36
+ const requireState = vi.hoisted(() => ({
37
+ impl: null as null | ((id: string) => unknown),
38
+ }));
39
+
40
+ vi.mock("node:module", async (importOriginal) => {
41
+ const actual = await importOriginal<typeof import("node:module")>();
42
+ return {
43
+ ...actual,
44
+ createRequire: (from: string) => {
45
+ if (requireState.impl) {
46
+ const impl = requireState.impl;
47
+ const fake = ((id: string) => impl(id)) as NodeRequire;
48
+ fake.resolve = ((id: string) => impl(id)) as NodeRequire["resolve"];
49
+ return fake;
50
+ }
51
+ return actual.createRequire(from);
52
+ },
53
+ };
54
+ });
55
+
56
+ vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
57
+
58
+ const spawnMock = vi.mocked(spawn);
59
+
60
+ beforeEach(() => {
61
+ requireState.impl = null;
62
+ spawnMock.mockReset();
63
+ });
64
+
65
+ afterAll(() => {
66
+ requireState.impl = null;
67
+ });
68
+
69
+ /** Stub the module resolution so browser-install sees a fake playwright. */
70
+ function stubPlaywrightResolution(
71
+ corePackageJson?: string,
72
+ execPaths?: { chromiumExec: string; firefoxExec: string },
73
+ ) {
74
+ const pw = execPaths
75
+ ? {
76
+ chromium: { executablePath: () => execPaths.chromiumExec },
77
+ firefox: { executablePath: () => execPaths.firefoxExec },
78
+ }
79
+ : { chromium: {}, firefox: {} };
80
+ requireState.impl = (id: string) => {
81
+ if (id === "playwright") return pw;
82
+ if (id === "playwright/package.json")
83
+ return "/fake/node_modules/playwright/package.json";
84
+ if (corePackageJson && id === "playwright-core/package.json")
85
+ return corePackageJson;
86
+ throw new Error(`unmocked module id: ${id}`);
87
+ };
88
+ }
89
+
90
+ function fakeChild() {
91
+ const child = new EventEmitter() as any;
92
+ child.stdout = new EventEmitter();
93
+ child.stderr = new EventEmitter();
94
+ return child;
95
+ }
96
+
97
+ /** pw mock whose executablePath reports the given path. */
98
+ function makePw(chromiumExec?: string, firefoxExec?: string): any {
99
+ return {
100
+ chromium: {
101
+ executablePath: () => {
102
+ if (chromiumExec === undefined) throw new Error("unknown browser");
103
+ return chromiumExec;
104
+ },
105
+ },
106
+ firefox: {
107
+ executablePath: () => {
108
+ if (firefoxExec === undefined) throw new Error("unknown browser");
109
+ return firefoxExec;
110
+ },
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Build a fake cache layout in a temp dir and point the mocked
117
+ * playwright-core resolution at a browsers.json with the given entries.
118
+ * Returns the executablePath values chromium/firefox should report.
119
+ */
120
+ function fakeCacheLayout(
121
+ browsers: Array<{ name: string; revision: number | string }>,
122
+ {
123
+ chromiumShellMarker = false,
124
+ firefoxMarker = false,
125
+ }: { chromiumShellMarker?: boolean; firefoxMarker?: boolean } = {},
126
+ ): {
127
+ chromiumExec: string;
128
+ firefoxExec: string;
129
+ corePackageJson: string;
130
+ cleanup: () => void;
131
+ } {
132
+ const root = mkdtempSync(join(tmpdir(), "pw-install-test-"));
133
+ const coreDir = join(root, "playwright-core");
134
+ mkdirSync(coreDir, { recursive: true });
135
+ writeFileSync(join(coreDir, "browsers.json"), JSON.stringify({ browsers }));
136
+
137
+ const chromiumRev = 1243;
138
+ const chromiumExec = join(
139
+ root,
140
+ "ms-playwright",
141
+ `chromium-${chromiumRev}`,
142
+ "chrome-linux64",
143
+ "chrome",
144
+ );
145
+ if (chromiumShellMarker) {
146
+ const shellDir = join(root, "ms-playwright", `chromium_headless_shell-4242`);
147
+ mkdirSync(shellDir, { recursive: true });
148
+ writeFileSync(join(shellDir, "INSTALLATION_COMPLETE"), "");
149
+ }
150
+ const firefoxRev = 1234;
151
+ const firefoxExec = join(
152
+ root,
153
+ "ms-playwright",
154
+ `firefox-${firefoxRev}`,
155
+ "firefox",
156
+ "firefox",
157
+ );
158
+ if (firefoxMarker) {
159
+ mkdirSync(join(root, "ms-playwright", `firefox-${firefoxRev}`), {
160
+ recursive: true,
161
+ });
162
+ writeFileSync(
163
+ join(
164
+ root,
165
+ "ms-playwright",
166
+ `firefox-${firefoxRev}`,
167
+ "INSTALLATION_COMPLETE",
168
+ ),
169
+ "",
170
+ );
171
+ }
172
+
173
+ requireState.impl = (id: string) => {
174
+ if (id === "playwright-core/package.json")
175
+ return join(coreDir, "package.json");
176
+ throw new Error(`unmocked module: ${id}`);
177
+ };
178
+
179
+ return {
180
+ chromiumExec,
181
+ firefoxExec,
182
+ corePackageJson: join(coreDir, "package.json"),
183
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
184
+ };
185
+ }
186
+
187
+ // ==================================================================
188
+ // Node interpreter selection
189
+ // ==================================================================
190
+
191
+ describe("resolveNodeInterpreter", () => {
192
+ const realExecPath = process.execPath;
193
+
194
+ function stubExecPath(value: string): () => void {
195
+ Object.defineProperty(process, "execPath", { value, configurable: true });
196
+ return () =>
197
+ Object.defineProperty(process, "execPath", {
198
+ value: realExecPath,
199
+ configurable: true,
200
+ });
201
+ }
202
+
203
+ it("returns process.execPath when it is a node binary (npm-shim install)", () => {
204
+ expect(resolveNodeInterpreter()).toBe(realExecPath);
205
+ });
206
+
207
+ it("falls back to PATH `node` under a non-node runtime (bun-compiled pi)", () => {
208
+ const restore = stubExecPath("/usr/local/bin/pi");
209
+ try {
210
+ expect(resolveNodeInterpreter()).toBe("node");
211
+ } finally {
212
+ restore();
213
+ }
214
+ });
215
+
216
+ it("strips a .exe suffix so a Windows node.exe still selects the exact interpreter", () => {
217
+ // Platform-independent exercise of the .exe strip via a POSIX path
218
+ // (basename is platform-dependent, but the strip itself is not).
219
+ const restore = stubExecPath("/usr/local/bin/node.exe");
220
+ try {
221
+ expect(resolveNodeInterpreter()).toBe("/usr/local/bin/node.exe");
222
+ } finally {
223
+ restore();
224
+ }
225
+ });
226
+ });
227
+
228
+ // ==================================================================
229
+ // Detection
230
+ // ==================================================================
231
+
232
+ describe("detectInstalledBrowsers", () => {
233
+ it("headless-shell marker present → chromium downloaded", () => {
234
+ const layout = fakeCacheLayout(
235
+ [
236
+ { name: "chromium", revision: 1243 },
237
+ { name: "chromium-headless-shell", revision: 4242 },
238
+ { name: "firefox", revision: 1234 },
239
+ ],
240
+ { chromiumShellMarker: true },
241
+ );
242
+ try {
243
+ expect(
244
+ detectInstalledBrowsers(makePw(layout.chromiumExec, layout.firefoxExec)),
245
+ ).toEqual({
246
+ chromium: true,
247
+ firefox: false,
248
+ });
249
+ } finally {
250
+ layout.cleanup();
251
+ }
252
+ });
253
+
254
+ it("full-chromium-only cache (--no-shell-style) → chromium missing, not downloaded", () => {
255
+ const layout = fakeCacheLayout(
256
+ [
257
+ { name: "chromium", revision: 1243 },
258
+ { name: "chromium-headless-shell", revision: 4242 },
259
+ { name: "firefox", revision: 1234 },
260
+ ],
261
+ { firefoxMarker: true },
262
+ );
263
+ try {
264
+ expect(
265
+ detectInstalledBrowsers(makePw(layout.chromiumExec, layout.firefoxExec)),
266
+ ).toEqual({
267
+ chromium: false,
268
+ firefox: true,
269
+ });
270
+ } finally {
271
+ layout.cleanup();
272
+ }
273
+ });
274
+
275
+ it("unparseable executable layout degrades to missing (safe direction)", () => {
276
+ const layout = fakeCacheLayout(
277
+ [{ name: "chromium-headless-shell", revision: 4242 }],
278
+ { chromiumShellMarker: true },
279
+ );
280
+ try {
281
+ // No chromium-<rev> segment → regex never matches.
282
+ expect(
283
+ detectInstalledBrowsers(
284
+ makePw("/opt/google/chrome/chrome", "/opt/firefox/firefox"),
285
+ ),
286
+ ).toEqual({
287
+ chromium: false,
288
+ firefox: false,
289
+ });
290
+ } finally {
291
+ layout.cleanup();
292
+ }
293
+ });
294
+
295
+ it("browsers.json read failure falls back to the chromium revision", () => {
296
+ const root = mkdtempSync(join(tmpdir(), "pw-install-test-"));
297
+ try {
298
+ // Mocked resolution throws → fallback rev (1243) is used.
299
+ requireState.impl = () => {
300
+ throw new Error("resolution broken");
301
+ };
302
+ const shellDir = join(root, "chromium_headless_shell-1243");
303
+ mkdirSync(shellDir, { recursive: true });
304
+ writeFileSync(join(shellDir, "INSTALLATION_COMPLETE"), "");
305
+ const exec = join(root, "chromium-1243", "chrome-linux64", "chrome");
306
+ expect(detectInstalledBrowsers(makePw(exec)).chromium).toBe(true);
307
+ } finally {
308
+ rmSync(root, { recursive: true, force: true });
309
+ requireState.impl = null;
310
+ }
311
+ });
312
+
313
+ it("missing chromium-headless-shell entry falls back (never the string 'undefined')", () => {
314
+ const root = mkdtempSync(join(tmpdir(), "pw-install-fb-"));
315
+ try {
316
+ // browsers.json lists only the full browser — no shell entry.
317
+ const coreDir = join(root, "core");
318
+ mkdirSync(coreDir, { recursive: true });
319
+ writeFileSync(
320
+ join(coreDir, "browsers.json"),
321
+ JSON.stringify({ browsers: [{ name: "chromium", revision: 1243 }] }),
322
+ );
323
+ requireState.impl = (id: string) => {
324
+ if (id === "playwright-core/package.json")
325
+ return join(coreDir, "package.json");
326
+ throw new Error(`unmocked module: ${id}`);
327
+ };
328
+ const msRoot = join(root, "ms-playwright");
329
+ const exec = join(msRoot, "chromium-1243", "chrome-linux64", "chrome");
330
+ // A stale marker at an unrelated revision must not satisfy the check.
331
+ mkdirSync(join(msRoot, "chromium_headless_shell-4242"), { recursive: true });
332
+ writeFileSync(
333
+ join(msRoot, "chromium_headless_shell-4242", "INSTALLATION_COMPLETE"),
334
+ "",
335
+ );
336
+ expect(detectInstalledBrowsers(makePw(exec)).chromium).toBe(false);
337
+ // Marker at the chromium revision → the fallback resolves it.
338
+ mkdirSync(join(msRoot, "chromium_headless_shell-1243"), { recursive: true });
339
+ writeFileSync(
340
+ join(msRoot, "chromium_headless_shell-1243", "INSTALLATION_COMPLETE"),
341
+ "",
342
+ );
343
+ expect(detectInstalledBrowsers(makePw(exec)).chromium).toBe(true);
344
+ } finally {
345
+ rmSync(root, { recursive: true, force: true });
346
+ requireState.impl = null;
347
+ }
348
+ });
349
+ });
350
+
351
+ // ==================================================================
352
+ // Form routing (handler)
353
+ // ==================================================================
354
+
355
+ describe("handleInstallSubcommand — routing", () => {
356
+ it("unknown engine name → usage warning, no spawn", async () => {
357
+ const ctx = mockCtx();
358
+ await handleInstallSubcommand("webkit", ctx);
359
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
360
+ expect.stringContaining("Usage: /web install [chromium|firefox]"),
361
+ "warning",
362
+ );
363
+ expect(spawnMock).not.toHaveBeenCalled();
364
+ });
365
+
366
+ it("resolution failure → generic manual command, no throw, no spawn", async () => {
367
+ requireState.impl = () => {
368
+ throw new Error("ERR_MODULE_NOT_FOUND");
369
+ };
370
+ const ctx = mockCtx();
371
+ await handleInstallSubcommand("", ctx);
372
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
373
+ expect.stringContaining("npx playwright install chromium firefox"),
374
+ "info",
375
+ );
376
+ expect(spawnMock).not.toHaveBeenCalled();
377
+ });
378
+
379
+ it("direct form in print mode (no feedback channel) → manual command on stderr + notify, no spawn", async () => {
380
+ stubPlaywrightResolution();
381
+ const stderr = vi
382
+ .spyOn(process.stderr, "write")
383
+ .mockImplementation(() => true);
384
+ try {
385
+ const ctx = mockCtx({ mode: "print", hasUI: false });
386
+ await handleInstallSubcommand("chromium", ctx);
387
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
388
+ expect.stringContaining(
389
+ `Install: ${process.execPath} /fake/node_modules/playwright/cli.js install chromium`,
390
+ ),
391
+ "info",
392
+ );
393
+ // print mode's notify is a no-op stub — stderr is the real channel.
394
+ expect(stderr).toHaveBeenCalledWith(
395
+ expect.stringContaining("install chromium"),
396
+ );
397
+ } finally {
398
+ stderr.mockRestore();
399
+ }
400
+ expect(spawnMock).not.toHaveBeenCalled();
401
+ });
402
+
403
+ it("bare form in RPC mode → manual command, no spawn (no dialog outside TUI)", async () => {
404
+ stubPlaywrightResolution();
405
+ const ctx = mockCtx({ mode: "rpc", hasUI: true });
406
+ await handleInstallSubcommand("", ctx);
407
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
408
+ expect.stringContaining(
409
+ `Install: ${process.execPath} /fake/node_modules/playwright/cli.js install chromium firefox`,
410
+ ),
411
+ "info",
412
+ );
413
+ expect(spawnMock).not.toHaveBeenCalled();
414
+ });
415
+
416
+ it("direct form in RPC mode spawns (feedback channel exists)", async () => {
417
+ stubPlaywrightResolution();
418
+ const child = fakeChild();
419
+ spawnMock.mockImplementation(() => child);
420
+ const ctx = mockCtx({ mode: "rpc", hasUI: true });
421
+ const pending = handleInstallSubcommand("firefox", ctx);
422
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
423
+ expect(spawnMock).toHaveBeenCalledWith(
424
+ process.execPath,
425
+ ["/fake/node_modules/playwright/cli.js", "install", "firefox"],
426
+ { stdio: "pipe" },
427
+ );
428
+ child.emit("close", 0);
429
+ await pending;
430
+ });
431
+ });
432
+
433
+ // ==================================================================
434
+ // Spawn & stream
435
+ // ==================================================================
436
+
437
+ describe("handleInstallSubcommand — spawn streaming", () => {
438
+ it("success → completion notify; stdout tail reaches the progress widget", async () => {
439
+ stubPlaywrightResolution();
440
+ const child = fakeChild();
441
+ spawnMock.mockImplementation(() => child);
442
+ const ctx = mockCtx();
443
+ const pending = handleInstallSubcommand("chromium", ctx);
444
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
445
+ child.stdout.emit("data", Buffer.from("Downloading chromium 1243\n"));
446
+ child.emit("close", 0);
447
+ await pending;
448
+ expect(ctx.ui.setWidget).toHaveBeenCalledWith(
449
+ "install",
450
+ expect.arrayContaining([
451
+ expect.stringContaining("Downloading chromium 1243"),
452
+ ]),
453
+ );
454
+ expect(ctx.ui.setWidget).toHaveBeenCalledWith("install", undefined);
455
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
456
+ expect.stringContaining("✅ Installed: chromium"),
457
+ "info",
458
+ );
459
+ });
460
+
461
+ it("Playwright Host validation warning is surfaced in the completion notify", async () => {
462
+ stubPlaywrightResolution();
463
+ const child = fakeChild();
464
+ spawnMock.mockImplementation(() => child);
465
+ const ctx = mockCtx();
466
+ const pending = handleInstallSubcommand("chromium", ctx);
467
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
468
+ child.stderr.emit(
469
+ "data",
470
+ Buffer.from("Playwright Host validation warning: Missing dependencies.\n"),
471
+ );
472
+ child.emit("close", 0);
473
+ await pending;
474
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
475
+ expect.stringContaining("missing host dependencies"),
476
+ "info",
477
+ );
478
+ });
479
+
480
+ it("non-zero exit → manual-command fallback, no throw", async () => {
481
+ stubPlaywrightResolution();
482
+ const child = fakeChild();
483
+ spawnMock.mockImplementation(() => child);
484
+ const ctx = mockCtx();
485
+ const pending = handleInstallSubcommand("chromium", ctx);
486
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
487
+ child.emit("close", 1);
488
+ await pending;
489
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
490
+ expect.stringContaining("Browser install failed (exit code 1)"),
491
+ "warning",
492
+ );
493
+ });
494
+
495
+ it("spawn error event (ENOENT etc.) → manual-command fallback, no hang", async () => {
496
+ stubPlaywrightResolution();
497
+ const child = fakeChild();
498
+ spawnMock.mockImplementation(() => child);
499
+ const ctx = mockCtx();
500
+ const pending = handleInstallSubcommand("chromium", ctx);
501
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
502
+ child.emit("error", new Error("spawn node ENOENT"));
503
+ await pending;
504
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
505
+ expect.stringContaining("Browser install failed: spawn node ENOENT"),
506
+ "warning",
507
+ );
508
+ });
509
+
510
+ it("error followed by close settles once — single failure notify", async () => {
511
+ // Node can fire both `error` and `close` for one failed spawn.
512
+ stubPlaywrightResolution();
513
+ const child = fakeChild();
514
+ spawnMock.mockImplementation(() => child);
515
+ const ctx = mockCtx();
516
+ const pending = handleInstallSubcommand("chromium", ctx);
517
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
518
+ child.emit("error", new Error("spawn node ENOENT"));
519
+ child.emit("close", 1);
520
+ await pending;
521
+ const failures = ctx.ui.notify.mock.calls.filter((c: unknown[]) =>
522
+ String(c[0]).includes("Browser install failed"),
523
+ );
524
+ expect(failures).toHaveLength(1);
525
+ });
526
+ });
527
+
528
+ // ==================================================================
529
+ // Bare form in TUI: dialog row states
530
+ // ==================================================================
531
+
532
+ describe("handleInstallSubcommand — dialog", () => {
533
+ it("dialog resolution feeds the spawn: checked engines install", async () => {
534
+ const layout = fakeCacheLayout(
535
+ [
536
+ { name: "chromium", revision: 1243 },
537
+ { name: "chromium-headless-shell", revision: 4242 },
538
+ { name: "firefox", revision: 1234 },
539
+ ],
540
+ { firefoxMarker: true }, // firefox present → only chromium pre-checked
541
+ );
542
+ try {
543
+ stubPlaywrightResolution(layout.corePackageJson, layout);
544
+ const child = fakeChild();
545
+ spawnMock.mockImplementation(() => child);
546
+ const ctx = mockCtx({
547
+ ui: {
548
+ notify: vi.fn(),
549
+ setWidget: vi.fn(),
550
+ custom: vi.fn(async () => ["chromium"]),
551
+ },
552
+ });
553
+ const pending = handleInstallSubcommand("", ctx);
554
+ await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
555
+ (spawnMock.mock.results[0]!.value as EventEmitter).emit("close", 0);
556
+ await pending;
557
+ expect(spawnMock).toHaveBeenCalledWith(
558
+ process.execPath,
559
+ ["/fake/node_modules/playwright/cli.js", "install", "chromium"],
560
+ { stdio: "pipe" },
561
+ );
562
+ } finally {
563
+ layout.cleanup();
564
+ }
565
+ });
566
+ });
567
+
568
+ // ==================================================================
569
+ // pickBrowsersToInstall (driven through the ctx.ui.custom factory)
570
+ // ==================================================================
571
+
572
+ const mockTheme = {
573
+ fg: (_color: string, text: string) => text,
574
+ bold: (text: string) => text,
575
+ } as any;
576
+
577
+ function captureComponent(
578
+ rows: Parameters<typeof pickBrowsersToInstall>[1],
579
+ preChecked: string[],
580
+ ) {
581
+ let factory: any;
582
+ const ctx = {
583
+ mode: "tui",
584
+ ui: {
585
+ notify: vi.fn(),
586
+ custom: vi.fn(async (f: any) => {
587
+ factory = f;
588
+ return new Promise<string[] | undefined>(() => {});
589
+ }),
590
+ },
591
+ } as any;
592
+ void pickBrowsersToInstall(ctx, rows, preChecked);
593
+ let resolved: string[] | undefined;
594
+ const component = factory(
595
+ { requestRender: vi.fn() },
596
+ mockTheme,
597
+ {},
598
+ (v: string[] | undefined) => {
599
+ resolved = v;
600
+ },
601
+ );
602
+ return { component, resolved: () => resolved, notify: ctx.ui.notify };
603
+ }
604
+
605
+ describe("pickBrowsersToInstall", () => {
606
+ const rows = [
607
+ { value: "chromium", label: "Chromium (~170 MB) — missing" },
608
+ { value: "firefox", label: "Firefox (~90 MB) — downloaded" },
609
+ ];
610
+
611
+ it("pre-checked rows resolve unchanged via the Install row", async () => {
612
+ const { component, resolved } = captureComponent(rows, ["chromium"]);
613
+ component.handleInput("\x1b[B"); // down → firefox
614
+ component.handleInput("\x1b[B"); // down → Install row
615
+ component.handleInput("\r"); // Install
616
+ await Promise.resolve();
617
+ expect(resolved()).toEqual(["chromium"]);
618
+ });
619
+
620
+ it("Enter toggles a row; toggling before Install is picked up", async () => {
621
+ const { component, resolved } = captureComponent(rows, []);
622
+ component.handleInput("\r"); // toggle chromium on
623
+ component.handleInput("\x1b[B");
624
+ component.handleInput("\x1b[B");
625
+ component.handleInput("\r"); // Install
626
+ await Promise.resolve();
627
+ expect(resolved()).toEqual(["chromium"]);
628
+ });
629
+
630
+ it("confirming with nothing checked notifies and stays open (guard)", async () => {
631
+ const { component, resolved, notify } = captureComponent(rows, []);
632
+ component.handleInput("\x1b[B");
633
+ component.handleInput("\x1b[B");
634
+ component.handleInput("\r"); // Install with nothing checked
635
+ await Promise.resolve();
636
+ expect(resolved()).toBeUndefined();
637
+ expect(notify).toHaveBeenCalledWith(
638
+ expect.stringContaining("Nothing selected"),
639
+ "warning",
640
+ );
641
+ });
642
+
643
+ it("Esc cancels (resolves undefined)", async () => {
644
+ const { component, resolved } = captureComponent(rows, ["chromium"]);
645
+ component.handleInput("\x1b");
646
+ await Promise.resolve();
647
+ expect(resolved()).toBeUndefined();
648
+ });
649
+ });
650
+
651
+ // ==================================================================
652
+ // /web dispatcher wiring
653
+ // ==================================================================
654
+
655
+ describe("/web install dispatcher wiring", () => {
656
+ const REGISTRY_KEY = "__piToolMaskingRegistry";
657
+ const RESTORE_EVENT_KEY = "__piToolMaskingLastRestoreEvent";
658
+ const MODULE_STATE_KEY = "__piToolMaskingModuleState";
659
+
660
+ const ALL_TOOLS = [
661
+ { name: "web-fetch", description: "fetch" },
662
+ { name: "browser-navigate", description: "navigate" },
663
+ { name: "web-learn", description: "learn" },
664
+ ];
665
+
666
+ function mockPi(): any {
667
+ const emitter = new EventEmitter();
668
+ const active: any[] = [...ALL_TOOLS];
669
+ return {
670
+ getAllTools: vi.fn(() => ALL_TOOLS),
671
+ getActiveTools: vi.fn(() => [...active]),
672
+ setActiveTools: vi.fn((names: string[]) => {
673
+ active.length = 0;
674
+ active.push(...names);
675
+ }),
676
+ appendEntry: vi.fn(),
677
+ registerCommand: vi.fn(),
678
+ on: vi.fn(),
679
+ get events() {
680
+ return {
681
+ emit: (channel: string, data: unknown) => emitter.emit(channel, data),
682
+ on: (channel: string, handler: (data: unknown) => void) => {
683
+ emitter.on(channel, handler);
684
+ return () => emitter.off(channel, handler);
685
+ },
686
+ };
687
+ },
688
+ };
689
+ }
690
+
691
+ beforeEach(() => {
692
+ delete (globalThis as any)[REGISTRY_KEY];
693
+ delete (globalThis as any)[RESTORE_EVENT_KEY];
694
+ delete (globalThis as any)[MODULE_STATE_KEY];
695
+ });
696
+
697
+ it("'/web install chromium' routes the engine to the install handler", async () => {
698
+ vi.doMock("../browser-install.js", () => ({
699
+ handleInstallSubcommand: vi.fn(async () => {}),
700
+ }));
701
+ const mod = await import("../browser-toggle.js");
702
+ const pi = mockPi();
703
+ mod.default(pi);
704
+ const ctx = mockCtx();
705
+ await captureWebHandler(pi)("install chromium", ctx);
706
+ const { handleInstallSubcommand } = await import("../browser-install.js");
707
+ expect(handleInstallSubcommand).toHaveBeenCalledWith("chromium", ctx);
708
+ vi.doUnmock("../browser-install.js");
709
+ vi.resetModules();
710
+ });
711
+
712
+ it("bare '/web install' routes an empty sub", async () => {
713
+ vi.doMock("../browser-install.js", () => ({
714
+ handleInstallSubcommand: vi.fn(async () => {}),
715
+ }));
716
+ const mod = await import("../browser-toggle.js");
717
+ const pi = mockPi();
718
+ mod.default(pi);
719
+ const ctx = mockCtx();
720
+ await captureWebHandler(pi)("install", ctx);
721
+ const { handleInstallSubcommand } = await import("../browser-install.js");
722
+ expect(handleInstallSubcommand).toHaveBeenCalledWith("", ctx);
723
+ vi.doUnmock("../browser-install.js");
724
+ vi.resetModules();
725
+ });
726
+
727
+ it("usage line advertises the install subcommand", () => {
728
+ const pi = mockPi();
729
+ // Re-import: browser-toggle must be re-importable after resetModules.
730
+ return import("../browser-toggle.js").then((mod) => {
731
+ mod.default(pi);
732
+ const [name, opts] = (pi.registerCommand as Mock).mock.calls[0]!;
733
+ expect(name).toBe("web");
734
+ expect(opts.description).toContain("install");
735
+ });
736
+ });
737
+ });