@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.11-beta.3

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 (68) hide show
  1. package/clients/dsh-remote/agent/README.md +412 -0
  2. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  3. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  4. package/clients/dsh-remote/agent/codex.mjs +736 -0
  5. package/clients/dsh-remote/agent/detect.mjs +399 -0
  6. package/clients/dsh-remote/agent/events.mjs +511 -0
  7. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  8. package/clients/dsh-remote/agent/index.mjs +339 -0
  9. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  10. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  11. package/clients/dsh-remote/agent/proc.mjs +179 -0
  12. package/clients/dsh-remote/agent/version.mjs +148 -0
  13. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  14. package/clients/dsh-remote/decision-poller.mjs +235 -0
  15. package/clients/dsh-remote/dsh-bridge.mjs +214 -1
  16. package/clients/dsh-remote/push-reporter.mjs +54 -0
  17. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  18. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  19. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  20. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  21. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  22. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  23. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  24. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  25. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  26. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  27. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  28. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  29. package/clients/dsh-remote/test/wecom-bot.test.mjs +1438 -0
  30. package/clients/dsh-remote/wecom-bot.mjs +2444 -0
  31. package/dsh-setup.mjs +18 -3
  32. package/package.json +2 -2
  33. package/packages/dsh-remote-web/lib/client.js +1048 -223
  34. package/packages/dsh-remote-web/lib/index.js +299 -2
  35. package/packages/dsh-remote-web/package.json +1 -1
  36. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  37. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  38. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  39. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +258 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
  53. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
  54. package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
  55. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
  56. package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
  57. package/packages/dsh-remote-web/test/im-bots.test.mjs +359 -0
  58. package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
  59. package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +13 -5
  60. package/packages/dsh-remote-web/test/panel-ia.test.mjs +798 -0
  61. package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
  62. package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
  63. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +10 -1
  64. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +20 -8
  65. package/packages/dsh-remote-web/test/settings-entry.test.mjs +30 -21
  66. package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
  67. package/packages/dsh-remote-web/test/telemetry.test.mjs +5 -3
  68. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 能力探测契约测试:找 CLI / 问版本 / 看既有钩子。
4
+ *
5
+ * 为什么这些点值得测(每条都对应一个真实翻车方式):
6
+ * ① **只认一种装法** → 换装法的用户直接不可用(npm 全局 / 原生安装器 / postinstall 没跑);
7
+ * ② **Windows 上用了 `claude.cmd`** → Node 无 shell spawn 会 ENOENT/EINVAL,且带 shell 有注入面;
8
+ * ③ **不扫既有审批钩子** → 我们的 allow 会抢先于用户的 deny(第 1 周实测),绕过企业策略;
9
+ * ④ **探测期抛错** → bridge 启动路径多一个崩溃面(探测必须永远返回状态,不抛)。
10
+ *
11
+ * 全部外部动作注入假实现:不读真 `~/.claude`、不跑真 `claude`、不碰真实 PATH。
12
+ *
13
+ * 用法: node --test clients/dsh-remote/test/agent-detect.test.mjs
14
+ */
15
+ import assert from "node:assert/strict";
16
+ import path from "node:path";
17
+ import test from "node:test";
18
+
19
+ import {
20
+ CLAUDE_BIN_ENV,
21
+ NPM_ROOT_ENV,
22
+ buildClaudeCandidates,
23
+ detectClaude,
24
+ probeCandidateVersion,
25
+ probeNpmRoot,
26
+ scanExistingPermissionHooks,
27
+ splitPathList
28
+ } from "../agent/detect.mjs";
29
+
30
+ /** ENOENT 风格的 fs 假实现(默认"什么都没有")。 */
31
+ function makeFs(overrides = {}) {
32
+ const enoent = () => {
33
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
34
+ };
35
+ return {
36
+ existsSync: () => false,
37
+ statSync: () => enoent(),
38
+ readFileSync: () => enoent(),
39
+ realpathSync: (p) => p,
40
+ constants: {},
41
+ ...overrides
42
+ };
43
+ }
44
+
45
+ const okVersion = (text) => () => ({ status: 0, stdout: `${text} (Claude Code)\n`, stderr: "" });
46
+
47
+ test("PATH 拆分:posix 用冒号、Windows 用分号", () => {
48
+ assert.deepEqual(splitPathList("/a:/b::/c", "linux"), ["/a", "/b", "/c"]);
49
+ assert.deepEqual(splitPathList("C:\\a;C:\\b", "win32"), ["C:\\a", "C:\\b"]);
50
+ assert.deepEqual(splitPathList("", "linux"), []);
51
+ });
52
+
53
+ test("CLI 候选①:显式 env 最高优先(.cjs 走 node wrapper 形态)", () => {
54
+ const c = buildClaudeCandidates({
55
+ env: { [CLAUDE_BIN_ENV]: "/opt/claude" },
56
+ platform: "linux",
57
+ fs: makeFs(),
58
+ npmRoot: ""
59
+ });
60
+ assert.equal(c[0].kind, "explicit");
61
+ assert.equal(c[0].command, "/opt/claude");
62
+
63
+ const w = buildClaudeCandidates({
64
+ env: { [CLAUDE_BIN_ENV]: "/pkg/cli-wrapper.cjs" },
65
+ platform: "linux",
66
+ execPath: "/usr/bin/node",
67
+ fs: makeFs(),
68
+ npmRoot: ""
69
+ });
70
+ assert.equal(w[0].kind, "explicit-wrapper");
71
+ assert.equal(w[0].command, "/usr/bin/node");
72
+ assert.deepEqual(w[0].argsPrefix, ["/pkg/cli-wrapper.cjs"]);
73
+ });
74
+
75
+ test("CLI 候选②:posix 从 PATH 找 claude,并解软链到真实可执行", () => {
76
+ const c = buildClaudeCandidates({
77
+ env: { PATH: "/usr/local/bin:/opt/homebrew/bin" },
78
+ platform: "linux",
79
+ fs: makeFs(),
80
+ npmRoot: "",
81
+ isExecutable: (p) => p === "/opt/homebrew/bin/claude",
82
+ realpath: (p) => (p === "/opt/homebrew/bin/claude" ? "/pkg/bin/claude.exe" : p)
83
+ });
84
+ assert.equal(c.length, 1);
85
+ assert.equal(c[0].kind, "path");
86
+ assert.equal(c[0].command, "/pkg/bin/claude.exe", "软链必须解开:真正可执行的是包内文件");
87
+ assert.equal(c[0].pathHint, "/opt/homebrew/bin/claude");
88
+ });
89
+
90
+ test("CLI 候选②:Windows 只认 claude.exe,**绝不把 claude.cmd 当候选**", () => {
91
+ const seen = [];
92
+ const c = buildClaudeCandidates({
93
+ env: { PATH: "C:\\Users\\x\\AppData\\Roaming\\npm" },
94
+ platform: "win32",
95
+ fs: makeFs(),
96
+ npmRoot: "",
97
+ isExecutable: (p) => {
98
+ seen.push(p);
99
+ return /claude\.exe$/i.test(p);
100
+ }
101
+ });
102
+ assert.deepEqual(
103
+ seen.map((p) => path.basename(p)),
104
+ ["claude.exe"],
105
+ "只探测 .exe,不该去看 .cmd(Node 无 shell 跑不了它)"
106
+ );
107
+ assert.equal(c.length, 1);
108
+ assert.equal(c[0].kind, "path");
109
+ });
110
+
111
+ test("CLI 候选③④:npm 全局包内 exe 优先于 wrapper 兜底", () => {
112
+ const npmRoot = "/usr/lib/node_modules";
113
+ const exe = path.join(npmRoot, "@anthropic-ai", "claude-code", "bin", "claude.exe");
114
+ const wrapper = path.join(npmRoot, "@anthropic-ai", "claude-code", "cli-wrapper.cjs");
115
+ const c = buildClaudeCandidates({
116
+ env: {},
117
+ platform: "linux",
118
+ execPath: "/usr/bin/node",
119
+ fs: makeFs({ statSync: () => ({ isFile: () => true }) }),
120
+ npmRoot,
121
+ isExecutable: (p) => p === exe
122
+ });
123
+ assert.deepEqual(c.map((x) => x.kind), ["npm-global-exe", "npm-wrapper"]);
124
+ assert.equal(c[0].command, exe);
125
+ assert.equal(c[1].command, "/usr/bin/node");
126
+ assert.deepEqual(c[1].argsPrefix, [wrapper]);
127
+ });
128
+
129
+ test("CLI 候选:同一路径不重复入列(显式 + PATH 命中也只有一条)", () => {
130
+ const c = buildClaudeCandidates({
131
+ env: { [CLAUDE_BIN_ENV]: "/same/claude", PATH: "/same" },
132
+ platform: "linux",
133
+ fs: makeFs(),
134
+ npmRoot: "",
135
+ isExecutable: () => true,
136
+ realpath: (p) => p
137
+ });
138
+ assert.equal(c.length, 1);
139
+ });
140
+
141
+ test("npm root:env 覆盖优先,且不调外部命令", () => {
142
+ let called = false;
143
+ const r = probeNpmRoot({
144
+ env: { [NPM_ROOT_ENV]: "/custom/root" },
145
+ execFileSync: () => {
146
+ called = true;
147
+ return "/should/not/use";
148
+ }
149
+ });
150
+ assert.equal(r, "/custom/root");
151
+ assert.equal(called, false);
152
+ });
153
+
154
+ test("npm root:外部命令失败 → 不抛错,回落到启发式候选(可能是猜测路径)", () => {
155
+ const r = probeNpmRoot({
156
+ execFileSync: () => {
157
+ throw new Error("npm 不存在");
158
+ },
159
+ fs: makeFs(), // 什么都不存在 → 命中第一个猜测路径,由后续 isExecutable 兜底
160
+ execPath: "/usr/bin/node"
161
+ });
162
+ assert.equal(typeof r, "string");
163
+ assert.ok(r.length > 0, "至少要给出一个候选根,让 buildClaudeCandidates 去探");
164
+ });
165
+
166
+ test("npm root:Windows 上 npm.cmd 跑不通时,回落到 %APPDATA%\\npm\\node_modules 启发式", () => {
167
+ const appData = "C:\\Users\\x\\AppData\\Roaming";
168
+ const expected = path.join(appData, "npm", "node_modules");
169
+ const pkgDir = path.join(expected, "@anthropic-ai", "claude-code");
170
+ const r = probeNpmRoot({
171
+ platform: "win32",
172
+ env: { APPDATA: appData },
173
+ execFileSync: () => {
174
+ // 真实行为:Node 在 shell:false 下执行 .cmd 会抛 EINVAL
175
+ throw Object.assign(new Error("spawn EINVAL"), { code: "EINVAL" });
176
+ },
177
+ fs: makeFs({ existsSync: (p) => p === pkgDir }),
178
+ execPath: "C:\\Program Files\\nodejs\\node.exe"
179
+ });
180
+ assert.equal(r, expected, "Windows 必须靠启发式找到全局根,否则只剩跑不了的 .cmd");
181
+ });
182
+
183
+ test("npm root:posix 上 npm 跑不通时回落到常见全局目录(存在包才选它)", () => {
184
+ const expected = path.join("/usr/lib/node_modules");
185
+ const pkgDir = path.join(expected, "@anthropic-ai", "claude-code");
186
+ const r = probeNpmRoot({
187
+ platform: "linux",
188
+ env: {},
189
+ execFileSync: () => {
190
+ throw new Error("npm not found");
191
+ },
192
+ fs: makeFs({ existsSync: (p) => p === pkgDir }),
193
+ execPath: "/usr/bin/node"
194
+ });
195
+ assert.equal(r, expected);
196
+ });
197
+
198
+ test("探测版本:成功解析 / 非零退出 / 输出不含版本 / 启动异常,四种都不抛", () => {
199
+ const ok = probeCandidateVersion({ command: "claude", argsPrefix: [] }, { spawnSync: okVersion("2.1.318") });
200
+ assert.equal(ok.ok, true);
201
+ assert.equal(ok.version.patch, 318);
202
+
203
+ const exit1 = probeCandidateVersion(
204
+ { command: "claude", argsPrefix: [] },
205
+ { spawnSync: () => ({ status: 1, stdout: "", stderr: "boom" }) }
206
+ );
207
+ assert.equal(exit1.ok, false);
208
+ assert.match(exit1.reason, /退出码 1/);
209
+
210
+ const noVersion = probeCandidateVersion(
211
+ { command: "claude", argsPrefix: [] },
212
+ { spawnSync: () => ({ status: 0, stdout: "hello", stderr: "" }) }
213
+ );
214
+ assert.equal(noVersion.ok, false);
215
+ assert.match(noVersion.reason, /没有版本号/);
216
+
217
+ const err = probeCandidateVersion(
218
+ { command: "claude", argsPrefix: [] },
219
+ { spawnSync: () => ({ error: { code: "ENOENT", message: "no such file" } }) }
220
+ );
221
+ assert.equal(err.ok, false);
222
+ assert.match(err.reason, /ENOENT/);
223
+ });
224
+
225
+ test("既有钩子扫描:user / project / local 三处都能发现", () => {
226
+ const home = "/home/u";
227
+ const cwd = "/work/p";
228
+ const files = {
229
+ [path.join(home, ".claude", "settings.json")]: JSON.stringify({
230
+ hooks: { PermissionRequest: [{ hooks: [{ type: "command", command: "deny.sh" }] }] }
231
+ }),
232
+ [path.join(cwd, ".claude", "settings.local.json")]: JSON.stringify({
233
+ hooks: { PermissionRequest: [{ hooks: [{ type: "http", url: "http://127.0.0.1:1/x" }] }] }
234
+ })
235
+ };
236
+ const fs = makeFs({
237
+ readFileSync: (p) => {
238
+ if (p in files) return files[p];
239
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
240
+ }
241
+ });
242
+ const r = scanExistingPermissionHooks({ homeDir: home, cwd, fs });
243
+ assert.equal(r.present, true);
244
+ assert.deepEqual(r.sources.map((s) => s.scope).sort(), ["local", "user"]);
245
+ assert.equal(r.warnings.length, 0);
246
+ });
247
+
248
+ test("既有钩子扫描:空数组 / 缺字段 / 坏 JSON 的语义", () => {
249
+ const home = "/home/u";
250
+ const cwd = "/work/p";
251
+ const settings = path.join(cwd, ".claude", "settings.json");
252
+ const bad = path.join(home, ".claude", "settings.json");
253
+ const fs = makeFs({
254
+ readFileSync: (p) => {
255
+ if (p === settings) return JSON.stringify({ hooks: { PermissionRequest: [] } }); // 空数组 ≠ 有钩子
256
+ if (p === bad) return "{ not json";
257
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
258
+ }
259
+ });
260
+ const r = scanExistingPermissionHooks({ homeDir: home, cwd, fs });
261
+ assert.equal(r.present, false, "空数组不算既有钩子");
262
+ assert.deepEqual(r.warnings, [{ path: bad, error: "invalid-json" }], "坏 JSON 要告警但不能崩");
263
+ });
264
+
265
+ test("既有钩子扫描:文件都不存在是正常情况(不告警)", () => {
266
+ const r = scanExistingPermissionHooks({ homeDir: "/nope", cwd: "/nope", fs: makeFs() });
267
+ assert.deepEqual(r, { present: false, sources: [], warnings: [] });
268
+ });
269
+
270
+ test("detectClaude 正常路径:找到 CLI + 版本达标 + 无既有钩子 → ok/approval", () => {
271
+ const r = detectClaude({
272
+ env: { [CLAUDE_BIN_ENV]: "/fake/claude" },
273
+ platform: "linux",
274
+ cwd: "/work/p",
275
+ homeDir: "/home/u",
276
+ fs: makeFs(),
277
+ spawnSync: okVersion("2.1.318"),
278
+ execFileSync: () => {
279
+ throw new Error("no npm");
280
+ }
281
+ });
282
+ assert.equal(r.ok, true);
283
+ assert.equal(r.status, "ok");
284
+ assert.equal(r.mode, "approval");
285
+ assert.equal(r.version.text, "2.1.318");
286
+ assert.equal(r.cli.kind, "explicit");
287
+ });
288
+
289
+ test("detectClaude:版本低于阈值 → 不启用,reason 说明要升级", () => {
290
+ const r = detectClaude({
291
+ env: { [CLAUDE_BIN_ENV]: "/fake/claude" },
292
+ platform: "linux",
293
+ cwd: "/work/p",
294
+ homeDir: "/home/u",
295
+ fs: makeFs(),
296
+ spawnSync: okVersion("2.1.195"),
297
+ execFileSync: () => {
298
+ throw new Error("no npm");
299
+ }
300
+ });
301
+ assert.equal(r.ok, false);
302
+ assert.equal(r.status, "version-unsupported");
303
+ assert.equal(r.mode, "notify-only");
304
+ assert.match(r.reason, /2\.1\.195/);
305
+ assert.match(r.reason, /2\.1\.318/);
306
+ });
307
+
308
+ test("detectClaude:env 覆盖阈值后,2.1.195 也能被认为达标(二分版本用)", () => {
309
+ const r = detectClaude({
310
+ env: { [CLAUDE_BIN_ENV]: "/fake/claude", DSH_AGENT_MIN_CLAUDE_VERSION: "2.1.100" },
311
+ platform: "linux",
312
+ cwd: "/work/p",
313
+ homeDir: "/home/u",
314
+ fs: makeFs(),
315
+ spawnSync: okVersion("2.1.195"),
316
+ execFileSync: () => {
317
+ throw new Error("no npm");
318
+ }
319
+ });
320
+ assert.equal(r.ok, true);
321
+ assert.equal(r.min.source, "env");
322
+ });
323
+
324
+ test("detectClaude:既有钩子 → 让位(notify-only),不启用审批", () => {
325
+ const home = "/home/u";
326
+ const fs = makeFs({
327
+ readFileSync: (p) => {
328
+ if (p === path.join(home, ".claude", "settings.json")) {
329
+ return JSON.stringify({ hooks: { PermissionRequest: [{ hooks: [{ type: "command", command: "x" }] }] } });
330
+ }
331
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
332
+ }
333
+ });
334
+ const r = detectClaude({
335
+ env: { [CLAUDE_BIN_ENV]: "/fake/claude" },
336
+ platform: "linux",
337
+ cwd: "/work/p",
338
+ homeDir: home,
339
+ fs,
340
+ spawnSync: okVersion("2.1.318"),
341
+ execFileSync: () => {
342
+ throw new Error("no npm");
343
+ }
344
+ });
345
+ assert.equal(r.ok, false);
346
+ assert.equal(r.status, "existing-hooks");
347
+ assert.equal(r.mode, "notify-only");
348
+ assert.match(r.reason, /让位/);
349
+ assert.equal(r.hookSources.length, 1);
350
+ // 关键:即使让位,也要把 claude/version 带出去 —— 上层仍可启动 CLI(只是不注入钩子)
351
+ assert.equal(r.cli.path, "/fake/claude");
352
+ assert.equal(r.version.text, "2.1.318");
353
+ });
354
+
355
+ test("detectClaude:一个候选都没有 → cli-missing(不抛错)", () => {
356
+ const r = detectClaude({
357
+ env: { PATH: "" },
358
+ platform: "linux",
359
+ cwd: "/work/p",
360
+ homeDir: "/home/u",
361
+ fs: makeFs(),
362
+ spawnSync: () => ({ status: 0, stdout: "", stderr: "" }),
363
+ execFileSync: () => {
364
+ throw new Error("no npm");
365
+ },
366
+ npmRoot: ""
367
+ });
368
+ assert.equal(r.ok, false);
369
+ assert.equal(r.status, "cli-missing");
370
+ assert.match(r.reason, /没有找到 Claude Code/);
371
+ assert.deepEqual(r.attempts, []);
372
+ });
373
+
374
+ test("detectClaude:候选都探测失败 → version-unknown(带 attempts 便于排障)", () => {
375
+ const r = detectClaude({
376
+ env: { [CLAUDE_BIN_ENV]: "/fake/claude" },
377
+ platform: "linux",
378
+ cwd: "/work/p",
379
+ homeDir: "/home/u",
380
+ fs: makeFs(),
381
+ spawnSync: () => ({ status: 0, stdout: "weird output", stderr: "" }),
382
+ execFileSync: () => {
383
+ throw new Error("no npm");
384
+ }
385
+ });
386
+ assert.equal(r.ok, false);
387
+ assert.equal(r.status, "version-unknown");
388
+ assert.equal(r.attempts.length, 1);
389
+ assert.equal(r.attempts[0].ok, false);
390
+ });