magus-cli 7.7.0 → 7.9.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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +45 -26
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1178 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +896 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +740 -0
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +24 -18
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +320 -54
- package/src/cli/install.ts +55 -71
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +55 -12
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +88 -384
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +35 -37
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +263 -59
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +345 -45
- package/src/services/update-plan.ts +296 -36
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +60 -16
- package/src/ui/App.tsx +81 -171
- package/src/ui/adapters/cliToolsAdapter.ts +252 -12
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +260 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +199 -98
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +315 -259
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/plugin-setup.ts +0 -965
- package/src/services/version-snapshot.ts +0 -189
- package/src/utils/go-module.ts +0 -36
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* magus 7.8.0 QA — CLI Tools failure reports and step failure reports (C1–C6).
|
|
3
|
+
*
|
|
4
|
+
* Written blind: from the caller's C1–C6 behaviour spec and the exported
|
|
5
|
+
* declarations of ui/adapters/cliToolsAdapter.ts, ui/adapters/startupReports.ts,
|
|
6
|
+
* ui/adapters/failureReport.ts, ui/state/types.ts, utils/command-utils.ts and
|
|
7
|
+
* services/settings-file.ts (emitted with `tsc --emitDeclarationOnly`). No
|
|
8
|
+
* function body was read.
|
|
9
|
+
*
|
|
10
|
+
* Coverage map (requirement → describe block):
|
|
11
|
+
* C1 commandFailureReason: stderr first, npm noise skipped, missing
|
|
12
|
+
* installer and timeout said plainly, never "Command failed: <cmd>" → "C1"
|
|
13
|
+
* C2 one tool action: headline verb, "Run it yourself: <cmd>" fix,
|
|
14
|
+
* except timeout / missing claude / network keep their own fix → "C2"
|
|
15
|
+
* C3 update-all: one row per failed tool with its reason → "C3"
|
|
16
|
+
* C4 conflict stuck / partial reports → "C4"
|
|
17
|
+
* C5 stepFailureReport: settings file that does not parse, fallback fix → "C5"
|
|
18
|
+
* C6 no internal word or absolute binary path in headline/reason → "C6"
|
|
19
|
+
*
|
|
20
|
+
* Failures are built in the shape `runArgv` resolves with (`RunResult`):
|
|
21
|
+
* the exit code, whether the timeout killed it, the combined output tail, and
|
|
22
|
+
* for a missing executable a `spawn <cmd> ENOENT` spawn error. Nothing is
|
|
23
|
+
* spawned.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, expect, test } from "bun:test";
|
|
27
|
+
import { SettingsParseError } from "../services/settings-file.js";
|
|
28
|
+
import {
|
|
29
|
+
type CliToolCommandFailure,
|
|
30
|
+
cliToolConflictPartialReport,
|
|
31
|
+
cliToolConflictStuckReport,
|
|
32
|
+
cliToolFailureReport,
|
|
33
|
+
cliToolNotDone,
|
|
34
|
+
cliToolsNotUpdatedReport,
|
|
35
|
+
commandFailureReason,
|
|
36
|
+
} from "../ui/adapters/cliToolsAdapter.js";
|
|
37
|
+
import {
|
|
38
|
+
FIX_BY_HAND,
|
|
39
|
+
stepFailureReport,
|
|
40
|
+
} from "../ui/adapters/startupReports.js";
|
|
41
|
+
import type { FailureReport } from "../ui/state/types.js";
|
|
42
|
+
import type { Command } from "../utils/command-utils.js";
|
|
43
|
+
import type { RunResult } from "../utils/run.js";
|
|
44
|
+
|
|
45
|
+
/** The installer timeout the spec names: 60 seconds. */
|
|
46
|
+
const TIMEOUT_MS = 60_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* "Says it timed out" (C1): the words "timed out", or saying plainly that it
|
|
50
|
+
* did not finish within the limit. The spec fixes the meaning, not the wording.
|
|
51
|
+
*/
|
|
52
|
+
const TIMED_OUT =
|
|
53
|
+
/timed out|time ?out|did not finish (with)?in|took longer than|ran out of time/i;
|
|
54
|
+
|
|
55
|
+
const NPM_INSTALL: Command = {
|
|
56
|
+
cmd: "npm",
|
|
57
|
+
args: ["install", "-g", "claudish"],
|
|
58
|
+
};
|
|
59
|
+
const NPM_UPDATE: Command = {
|
|
60
|
+
cmd: "npm",
|
|
61
|
+
args: ["install", "-g", "claudish@latest"],
|
|
62
|
+
};
|
|
63
|
+
const CLAUDE_UPDATE: Command = { cmd: "claude", args: ["update"] };
|
|
64
|
+
const BREW_UPGRADE: Command = { cmd: "brew", args: ["upgrade", "tmux"] };
|
|
65
|
+
|
|
66
|
+
function line(c: Command): string {
|
|
67
|
+
return [c.cmd, ...c.args].join(" ");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ExecErrorShape {
|
|
71
|
+
command: Command;
|
|
72
|
+
stderr?: string;
|
|
73
|
+
stdout?: string;
|
|
74
|
+
code?: number | string | null;
|
|
75
|
+
killed?: boolean;
|
|
76
|
+
signal?: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What `runArgv` resolves with when the command ran and failed. */
|
|
80
|
+
function execError(o: ExecErrorShape): RunResult {
|
|
81
|
+
return {
|
|
82
|
+
exitCode:
|
|
83
|
+
typeof o.code === "number" ? o.code : o.code === undefined ? 1 : null,
|
|
84
|
+
signal: o.signal ?? null,
|
|
85
|
+
timedOut: o.killed ?? false,
|
|
86
|
+
outputTail: [o.stdout ?? "", o.stderr ?? ""].filter(Boolean).join("\n"),
|
|
87
|
+
durationMs: 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What `runArgv` resolves with when the executable does not exist. */
|
|
92
|
+
function enoent(command: Command): RunResult {
|
|
93
|
+
return {
|
|
94
|
+
exitCode: null,
|
|
95
|
+
signal: null,
|
|
96
|
+
timedOut: false,
|
|
97
|
+
spawnError: `spawn ${command.cmd} ENOENT`,
|
|
98
|
+
outputTail: "",
|
|
99
|
+
durationMs: 0,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What a spawn throws when the executable does not exist (a step's error). */
|
|
104
|
+
function enoentError(command: Command): Error {
|
|
105
|
+
return Object.assign(new Error(`spawn ${command.cmd} ENOENT`), {
|
|
106
|
+
code: "ENOENT",
|
|
107
|
+
syscall: `spawn ${command.cmd}`,
|
|
108
|
+
path: command.cmd,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What `runArgv` resolves with when its timeout killed the child. */
|
|
113
|
+
function timedOut(command: Command, partialStderr = ""): RunResult {
|
|
114
|
+
return execError({
|
|
115
|
+
command,
|
|
116
|
+
code: null,
|
|
117
|
+
killed: true,
|
|
118
|
+
signal: "SIGTERM",
|
|
119
|
+
stderr: partialStderr,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** npm's real E404 stderr, with the bookkeeping lines around the cause. */
|
|
124
|
+
const NPM_404_STDERR = [
|
|
125
|
+
"npm error code E404",
|
|
126
|
+
"npm error 404 Not Found - GET https://npm.example.test/claudish-nope - Not found",
|
|
127
|
+
"npm error 404",
|
|
128
|
+
"npm error 404 'claudish-nope@*' is not in this package index.",
|
|
129
|
+
"npm error A complete log of this run can be found in: /Users/someone/.npm/_logs/2026-09-29T10_00_00_000Z-debug-0.log",
|
|
130
|
+
].join("\n");
|
|
131
|
+
|
|
132
|
+
/** npm's errno-style stderr, EACCES. */
|
|
133
|
+
const NPM_EACCES_STDERR = [
|
|
134
|
+
"npm error code EACCES",
|
|
135
|
+
"npm error syscall mkdir",
|
|
136
|
+
"npm error path /usr/local/lib/node_modules/claudish",
|
|
137
|
+
"npm error errno -13",
|
|
138
|
+
"npm error Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/claudish'",
|
|
139
|
+
"npm error A complete log of this run can be found in: /Users/someone/.npm/_logs/x-debug-0.log",
|
|
140
|
+
].join("\n");
|
|
141
|
+
|
|
142
|
+
/** npm's network stderr (DNS failure). */
|
|
143
|
+
const NPM_NETWORK_STDERR = [
|
|
144
|
+
"npm error code ENOTFOUND",
|
|
145
|
+
"npm error syscall getaddrinfo",
|
|
146
|
+
"npm error errno ENOTFOUND",
|
|
147
|
+
"npm error network request to https://npm.example.test/claudish failed, reason: getaddrinfo ENOTFOUND npm.example.test",
|
|
148
|
+
"npm error network This is a problem related to network connectivity.",
|
|
149
|
+
"npm error network In most cases you are behind a proxy or have bad network settings.",
|
|
150
|
+
"npm error A complete log of this run can be found in: /Users/someone/.npm/_logs/y-debug-0.log",
|
|
151
|
+
].join("\n");
|
|
152
|
+
|
|
153
|
+
const NPM_NOISE: RegExp[] = [
|
|
154
|
+
/npm error code\b/i,
|
|
155
|
+
/npm error errno\b/i,
|
|
156
|
+
/A complete log of this run can be found in/i,
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
/** Words a user-facing headline or reason must never contain (C6). */
|
|
160
|
+
const FORBIDDEN: RegExp[] = [
|
|
161
|
+
/loader/i,
|
|
162
|
+
/registry/i,
|
|
163
|
+
/\brows?\b/i,
|
|
164
|
+
/\brecords?\b/i,
|
|
165
|
+
/installed_plugins/i,
|
|
166
|
+
/** An absolute path to a binary: …/bin/<name>. */
|
|
167
|
+
/(^|[\s'"(])\/[^\s'"]*\/bin\/[\w.-]+/,
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
function leaks(text: string): string[] {
|
|
171
|
+
return FORBIDDEN.filter((re) => re.test(text)).map((re) => re.source);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Every headline and reason in a report, for the C6 sweep. */
|
|
175
|
+
function wordsOf(r: FailureReport | null): string[] {
|
|
176
|
+
if (!r) return [];
|
|
177
|
+
return [r.headline, ...r.rows.map((row) => row.reason)];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Every fix the report carries: the shared one and each row's own. */
|
|
181
|
+
function fixesOf(r: FailureReport): string[] {
|
|
182
|
+
const out: string[] = [];
|
|
183
|
+
if (r.fix) out.push(r.fix);
|
|
184
|
+
for (const row of r.rows) if (row.fix) out.push(row.fix);
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** All the text of a report, for "is X said anywhere" checks. */
|
|
189
|
+
function allText(r: FailureReport): string {
|
|
190
|
+
return [
|
|
191
|
+
r.headline,
|
|
192
|
+
...r.rows.flatMap((row) => [row.subject, row.reason, row.fix ?? ""]),
|
|
193
|
+
r.fix ?? "",
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function failure(
|
|
198
|
+
tool: string,
|
|
199
|
+
command: Command,
|
|
200
|
+
error: unknown,
|
|
201
|
+
): CliToolCommandFailure {
|
|
202
|
+
return {
|
|
203
|
+
tool,
|
|
204
|
+
command,
|
|
205
|
+
reason: commandFailureReason(command, error, TIMEOUT_MS),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// C1 — commandFailureReason
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
describe("C1 commandFailureReason", () => {
|
|
214
|
+
test("C1: returns npm's own cause and skips its code/errno/log bookkeeping lines", () => {
|
|
215
|
+
const reason = commandFailureReason(
|
|
216
|
+
NPM_INSTALL,
|
|
217
|
+
execError({ command: NPM_INSTALL, stderr: NPM_404_STDERR }),
|
|
218
|
+
TIMEOUT_MS,
|
|
219
|
+
);
|
|
220
|
+
expect(reason).toContain("404 Not Found");
|
|
221
|
+
for (const noise of NPM_NOISE) expect(reason).not.toMatch(noise);
|
|
222
|
+
expect(reason).not.toContain("Command failed");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("C1: skips an 'npm error errno' line and reaches the real EACCES cause", () => {
|
|
226
|
+
const reason = commandFailureReason(
|
|
227
|
+
NPM_INSTALL,
|
|
228
|
+
execError({
|
|
229
|
+
command: NPM_INSTALL,
|
|
230
|
+
stderr: NPM_EACCES_STDERR,
|
|
231
|
+
code: 243,
|
|
232
|
+
}),
|
|
233
|
+
TIMEOUT_MS,
|
|
234
|
+
);
|
|
235
|
+
expect(reason).toMatch(/permission denied/i);
|
|
236
|
+
for (const noise of NPM_NOISE) expect(reason).not.toMatch(noise);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("C1: reads stderr before stdout and before the thrown message", () => {
|
|
240
|
+
const reason = commandFailureReason(
|
|
241
|
+
BREW_UPGRADE,
|
|
242
|
+
execError({
|
|
243
|
+
command: BREW_UPGRADE,
|
|
244
|
+
stdout: "==> Fetching downloads for: tmux",
|
|
245
|
+
stderr: 'Error: No available formula with the name "tmux-nope".',
|
|
246
|
+
}),
|
|
247
|
+
TIMEOUT_MS,
|
|
248
|
+
);
|
|
249
|
+
expect(reason).toContain("No available formula");
|
|
250
|
+
expect(reason).not.toContain("Fetching downloads");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("C1: is one line, even when stderr has many", () => {
|
|
254
|
+
const reason = commandFailureReason(
|
|
255
|
+
NPM_INSTALL,
|
|
256
|
+
execError({ command: NPM_INSTALL, stderr: NPM_EACCES_STDERR }),
|
|
257
|
+
TIMEOUT_MS,
|
|
258
|
+
);
|
|
259
|
+
expect(reason).not.toContain("\n");
|
|
260
|
+
expect(reason.trim().length).toBeGreaterThan(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("C1: a missing installer says plainly that it is not there", () => {
|
|
264
|
+
const reason = commandFailureReason(
|
|
265
|
+
NPM_INSTALL,
|
|
266
|
+
enoent(NPM_INSTALL),
|
|
267
|
+
TIMEOUT_MS,
|
|
268
|
+
);
|
|
269
|
+
expect(reason).toContain("npm");
|
|
270
|
+
expect(reason).toMatch(
|
|
271
|
+
/not (installed|found|on (your )?PATH)|missing|cannot be found|could not be found|no npm/i,
|
|
272
|
+
);
|
|
273
|
+
expect(reason).not.toMatch(/ENOENT/);
|
|
274
|
+
expect(reason).not.toMatch(/\bspawn\b/);
|
|
275
|
+
expect(reason).not.toContain("Command failed");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("C1: a missing installer given by absolute path does not print that path", () => {
|
|
279
|
+
const cmd: Command = {
|
|
280
|
+
cmd: "/opt/homebrew/bin/brew",
|
|
281
|
+
args: ["install", "tmux"],
|
|
282
|
+
};
|
|
283
|
+
const reason = commandFailureReason(cmd, enoent(cmd), TIMEOUT_MS);
|
|
284
|
+
expect(reason).not.toContain("/opt/homebrew/bin/brew");
|
|
285
|
+
expect(reason).toContain("brew");
|
|
286
|
+
expect(reason).not.toMatch(/ENOENT/);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("C1: when the 60-second timeout killed it, says it timed out", () => {
|
|
290
|
+
const reason = commandFailureReason(
|
|
291
|
+
NPM_INSTALL,
|
|
292
|
+
timedOut(NPM_INSTALL),
|
|
293
|
+
TIMEOUT_MS,
|
|
294
|
+
);
|
|
295
|
+
expect(reason).toMatch(TIMED_OUT);
|
|
296
|
+
expect(reason).not.toContain("Command failed");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("C1: a timeout wins over whatever partial stderr the installer had written", () => {
|
|
300
|
+
const reason = commandFailureReason(
|
|
301
|
+
NPM_INSTALL,
|
|
302
|
+
timedOut(
|
|
303
|
+
NPM_INSTALL,
|
|
304
|
+
"npm http fetch GET 200 https://npm.example.test/claudish 812ms",
|
|
305
|
+
),
|
|
306
|
+
TIMEOUT_MS,
|
|
307
|
+
);
|
|
308
|
+
expect(reason).toMatch(TIMED_OUT);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("C1: never returns a bare 'Command failed: <cmd>' when stderr and stdout are empty", () => {
|
|
312
|
+
const reason = commandFailureReason(
|
|
313
|
+
NPM_INSTALL,
|
|
314
|
+
execError({ command: NPM_INSTALL, code: 1 }),
|
|
315
|
+
TIMEOUT_MS,
|
|
316
|
+
);
|
|
317
|
+
expect(reason).not.toContain("Command failed");
|
|
318
|
+
expect(reason).not.toBe(line(NPM_INSTALL));
|
|
319
|
+
expect(reason.trim().length).toBeGreaterThan(0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("C1: an installer that printed nothing still yields a non-empty reason", () => {
|
|
323
|
+
const reason = commandFailureReason(
|
|
324
|
+
NPM_INSTALL,
|
|
325
|
+
execError({ command: NPM_INSTALL }),
|
|
326
|
+
TIMEOUT_MS,
|
|
327
|
+
);
|
|
328
|
+
expect(reason).toBe("npm exited with code 1");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// C2 — one tool action
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
describe("C2 cliToolFailureReport (one tool action)", () => {
|
|
337
|
+
test("C2: each action has its own verb — installed, updated, reinstalled", () => {
|
|
338
|
+
expect(cliToolNotDone("install")).toMatch(/\binstalled$/);
|
|
339
|
+
expect(cliToolNotDone("update")).toMatch(/\bupdated$/);
|
|
340
|
+
expect(cliToolNotDone("reinstall")).toMatch(/\breinstalled$/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The declaration documents `cliToolNotDone` as returning the headline's
|
|
345
|
+
* negative phrase: `/** What an Enter action did not do, for the headline:
|
|
346
|
+
* "was not installed". *\/`. This pins that documented contract.
|
|
347
|
+
*/
|
|
348
|
+
// Resolved after the blind run: the doc comment was wrong, not the code. The
|
|
349
|
+
// function returns the verb and the screen builds "<tool> was not <verb>".
|
|
350
|
+
test("C2: cliToolNotDone returns the verb the headline puts after 'was not'", () => {
|
|
351
|
+
expect(cliToolNotDone("install")).toBe("installed");
|
|
352
|
+
expect(cliToolNotDone("update")).toBe("updated");
|
|
353
|
+
expect(cliToolNotDone("reinstall")).toBe("reinstalled");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("C2: an ordinary failure: headline '<tool> was not updated', one row, fix 'Run it yourself: <cmd>'", () => {
|
|
357
|
+
const f = failure(
|
|
358
|
+
"claudish",
|
|
359
|
+
NPM_UPDATE,
|
|
360
|
+
execError({ command: NPM_UPDATE, stderr: NPM_EACCES_STDERR }),
|
|
361
|
+
);
|
|
362
|
+
const headline = "claudish was not updated";
|
|
363
|
+
const report = cliToolFailureReport(headline, [f]);
|
|
364
|
+
expect(report).not.toBeNull();
|
|
365
|
+
if (!report) return;
|
|
366
|
+
expect(report.headline).toBe("claudish was not updated");
|
|
367
|
+
expect(report.rows).toHaveLength(1);
|
|
368
|
+
expect(report.rows[0]?.subject).toContain("claudish");
|
|
369
|
+
expect(report.rows[0]?.reason).toMatch(/permission denied/i);
|
|
370
|
+
expect(report.fix).toBe("Run it yourself: npm install -g claudish@latest");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("C2: an install failure names the install command in the fix", () => {
|
|
374
|
+
const f = failure(
|
|
375
|
+
"claudish",
|
|
376
|
+
NPM_INSTALL,
|
|
377
|
+
execError({ command: NPM_INSTALL, stderr: NPM_404_STDERR }),
|
|
378
|
+
);
|
|
379
|
+
const report = cliToolFailureReport("claudish was not installed", [f]);
|
|
380
|
+
expect(report?.headline).toBe("claudish was not installed");
|
|
381
|
+
expect(report?.rows).toHaveLength(1);
|
|
382
|
+
expect(report?.rows[0]?.reason).toContain("404 Not Found");
|
|
383
|
+
expect(report?.fix).toBe("Run it yourself: npm install -g claudish");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("C2: a timeout keeps its own fix, not 'Run it yourself'", () => {
|
|
387
|
+
const f = failure("claudish", NPM_UPDATE, timedOut(NPM_UPDATE));
|
|
388
|
+
const report = cliToolFailureReport(
|
|
389
|
+
`claudish ${cliToolNotDone("update")}`,
|
|
390
|
+
[f],
|
|
391
|
+
);
|
|
392
|
+
expect(report).not.toBeNull();
|
|
393
|
+
if (!report) return;
|
|
394
|
+
const fixes = fixesOf(report);
|
|
395
|
+
expect(fixes.length).toBeGreaterThan(0);
|
|
396
|
+
for (const fix of fixes) expect(fix).not.toMatch(/^Run it yourself/);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("C2: a missing claude keeps its own fix, not 'Run it yourself: claude update'", () => {
|
|
400
|
+
const f = failure("Claude Code", CLAUDE_UPDATE, enoent(CLAUDE_UPDATE));
|
|
401
|
+
const report = cliToolFailureReport(
|
|
402
|
+
`Claude Code ${cliToolNotDone("update")}`,
|
|
403
|
+
[f],
|
|
404
|
+
);
|
|
405
|
+
expect(report).not.toBeNull();
|
|
406
|
+
if (!report) return;
|
|
407
|
+
const fixes = fixesOf(report);
|
|
408
|
+
expect(fixes.length).toBeGreaterThan(0);
|
|
409
|
+
for (const fix of fixes) {
|
|
410
|
+
expect(fix).not.toMatch(/^Run it yourself/);
|
|
411
|
+
expect(fix).not.toContain("claude update");
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("C2: a network failure keeps its own fix, not 'Run it yourself'", () => {
|
|
416
|
+
const f = failure(
|
|
417
|
+
"claudish",
|
|
418
|
+
NPM_UPDATE,
|
|
419
|
+
execError({ command: NPM_UPDATE, stderr: NPM_NETWORK_STDERR }),
|
|
420
|
+
);
|
|
421
|
+
const report = cliToolFailureReport(
|
|
422
|
+
`claudish ${cliToolNotDone("update")}`,
|
|
423
|
+
[f],
|
|
424
|
+
);
|
|
425
|
+
expect(report).not.toBeNull();
|
|
426
|
+
if (!report) return;
|
|
427
|
+
const fixes = fixesOf(report);
|
|
428
|
+
expect(fixes.length).toBeGreaterThan(0);
|
|
429
|
+
for (const fix of fixes) expect(fix).not.toMatch(/^Run it yourself/);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// C3 — update-all
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
describe("C3 cliToolsNotUpdatedReport (update-all)", () => {
|
|
438
|
+
const TMUX_UPGRADE: Command = { cmd: "brew", args: ["upgrade", "tmux"] };
|
|
439
|
+
const failures: CliToolCommandFailure[] = [
|
|
440
|
+
{
|
|
441
|
+
tool: "claudish",
|
|
442
|
+
command: NPM_UPDATE,
|
|
443
|
+
reason: "permission denied, mkdir '~/.npm-global/lib'",
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
tool: "tmux",
|
|
447
|
+
command: TMUX_UPGRADE,
|
|
448
|
+
reason: 'No available formula with the name "tmux-nope"',
|
|
449
|
+
},
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
test("C3: one row per failed tool, naming the tool and carrying its reason", () => {
|
|
453
|
+
const report = cliToolsNotUpdatedReport(failures);
|
|
454
|
+
expect(report).not.toBeNull();
|
|
455
|
+
if (!report) return;
|
|
456
|
+
expect(report.rows).toHaveLength(2);
|
|
457
|
+
for (const f of failures) {
|
|
458
|
+
const row = report.rows.find((r) => r.subject.includes(f.tool));
|
|
459
|
+
expect(row).toBeDefined();
|
|
460
|
+
expect(row?.reason).toContain(f.reason);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("C3: the headline says the tools were not updated", () => {
|
|
465
|
+
const report = cliToolsNotUpdatedReport(failures);
|
|
466
|
+
expect(report?.headline).toMatch(/not updated/i);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("C3: a tool that succeeded is not listed", () => {
|
|
470
|
+
const report = cliToolsNotUpdatedReport([
|
|
471
|
+
failures[1] as CliToolCommandFailure,
|
|
472
|
+
]);
|
|
473
|
+
expect(report).not.toBeNull();
|
|
474
|
+
if (!report) return;
|
|
475
|
+
expect(report.rows).toHaveLength(1);
|
|
476
|
+
expect(allText(report)).not.toContain("claudish");
|
|
477
|
+
expect(report.rows[0]?.subject).toContain("tmux");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("C3: when every tool updated there is no popup", () => {
|
|
481
|
+
expect(cliToolsNotUpdatedReport([])).toBeNull();
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// C4 — conflicts
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
describe("C4 conflict reports", () => {
|
|
490
|
+
test("C4 stuck: one row per install that could not be removed", () => {
|
|
491
|
+
const report = cliToolConflictStuckReport("claudish", [
|
|
492
|
+
{ method: "npm", reason: "npm is not on PATH" },
|
|
493
|
+
{ method: "Homebrew", reason: "the formula is pinned" },
|
|
494
|
+
]);
|
|
495
|
+
expect(report).not.toBeNull();
|
|
496
|
+
if (!report) return;
|
|
497
|
+
expect(report.rows).toHaveLength(2);
|
|
498
|
+
const npmRow = report.rows.find((r) =>
|
|
499
|
+
`${r.subject} ${r.reason}`.includes("npm"),
|
|
500
|
+
);
|
|
501
|
+
const brewRow = report.rows.find((r) =>
|
|
502
|
+
`${r.subject} ${r.reason}`.includes("Homebrew"),
|
|
503
|
+
);
|
|
504
|
+
expect(npmRow).toBeDefined();
|
|
505
|
+
expect(brewRow).toBeDefined();
|
|
506
|
+
expect(npmRow?.reason).toContain("npm is not on PATH");
|
|
507
|
+
expect(brewRow?.reason).toContain("the formula is pinned");
|
|
508
|
+
expect(report.headline).toContain("claudish");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("C4 stuck: the fix tells the user to remove one by hand, then press r", () => {
|
|
512
|
+
const report = cliToolConflictStuckReport("claudish", [
|
|
513
|
+
{ method: "npm", reason: "npm is not on PATH" },
|
|
514
|
+
{ method: "Homebrew", reason: "the formula is pinned" },
|
|
515
|
+
]);
|
|
516
|
+
expect(report?.fix).toMatch(/remove/i);
|
|
517
|
+
expect(report?.fix).toMatch(/by hand/i);
|
|
518
|
+
expect(report?.fix).toMatch(/press r\b/i);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("C4 partial: a failed removal is listed with the command to run by hand", () => {
|
|
522
|
+
const uninstall: Command = {
|
|
523
|
+
cmd: "npm",
|
|
524
|
+
args: ["uninstall", "-g", "claudish"],
|
|
525
|
+
};
|
|
526
|
+
const report = cliToolConflictPartialReport(
|
|
527
|
+
"claudish",
|
|
528
|
+
[{ method: "npm", command: uninstall, reason: "permission denied" }],
|
|
529
|
+
[],
|
|
530
|
+
);
|
|
531
|
+
expect(report).not.toBeNull();
|
|
532
|
+
if (!report) return;
|
|
533
|
+
expect(report.rows).toHaveLength(1);
|
|
534
|
+
expect(report.rows[0]?.reason).toContain("permission denied");
|
|
535
|
+
expect(allText(report)).toContain("npm uninstall -g claudish");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("C4 partial: failed removals and installs left behind each get a row", () => {
|
|
539
|
+
const uninstall: Command = {
|
|
540
|
+
cmd: "npm",
|
|
541
|
+
args: ["uninstall", "-g", "claudish"],
|
|
542
|
+
};
|
|
543
|
+
const report = cliToolConflictPartialReport(
|
|
544
|
+
"claudish",
|
|
545
|
+
[{ method: "npm", command: uninstall, reason: "permission denied" }],
|
|
546
|
+
[{ method: "a standalone binary", reason: "magus did not install it" }],
|
|
547
|
+
);
|
|
548
|
+
expect(report).not.toBeNull();
|
|
549
|
+
if (!report) return;
|
|
550
|
+
expect(report.rows).toHaveLength(2);
|
|
551
|
+
const failedRow = report.rows.find((r) =>
|
|
552
|
+
r.reason.includes("permission denied"),
|
|
553
|
+
);
|
|
554
|
+
const leftRow = report.rows.find((r) =>
|
|
555
|
+
r.reason.includes("magus did not install it"),
|
|
556
|
+
);
|
|
557
|
+
expect(failedRow).toBeDefined();
|
|
558
|
+
expect(leftRow).toBeDefined();
|
|
559
|
+
expect(`${leftRow?.subject} ${leftRow?.reason}`).toContain(
|
|
560
|
+
"a standalone binary",
|
|
561
|
+
);
|
|
562
|
+
expect(allText(report)).toContain("npm uninstall -g claudish");
|
|
563
|
+
expect(report.headline).toContain("claudish");
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// C5 — stepFailureReport
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
describe("C5 stepFailureReport", () => {
|
|
572
|
+
const SETTINGS = "/tmp/blind-780-cli/.claude/settings.json";
|
|
573
|
+
|
|
574
|
+
function parseError(): SettingsParseError {
|
|
575
|
+
return new SettingsParseError(
|
|
576
|
+
SETTINGS,
|
|
577
|
+
new SyntaxError("JSON Parse error: Expected '}'"),
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
test("C5: a settings file that does not parse → the file is the subject", () => {
|
|
582
|
+
const report = stepFailureReport(
|
|
583
|
+
"The profile was not switched",
|
|
584
|
+
"profile work",
|
|
585
|
+
parseError(),
|
|
586
|
+
);
|
|
587
|
+
expect(report.rows).toHaveLength(1);
|
|
588
|
+
expect(report.rows[0]?.subject).toContain("settings.json");
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("C5: a settings file that does not parse → the problem is the reason", () => {
|
|
592
|
+
const err = parseError();
|
|
593
|
+
const report = stepFailureReport(
|
|
594
|
+
"The profile was not switched",
|
|
595
|
+
"profile work",
|
|
596
|
+
err,
|
|
597
|
+
);
|
|
598
|
+
expect(report.rows[0]?.reason).toContain(err.problem);
|
|
599
|
+
expect(report.rows[0]?.reason).not.toContain("\n");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("C5: a settings file that does not parse → fix 'Fix the file by hand, then try again'", () => {
|
|
603
|
+
const report = stepFailureReport(
|
|
604
|
+
"The profile was not switched",
|
|
605
|
+
"profile work",
|
|
606
|
+
parseError(),
|
|
607
|
+
);
|
|
608
|
+
expect(FIX_BY_HAND).toBe("Fix the file by hand, then try again");
|
|
609
|
+
expect(report.fix).toBe("Fix the file by hand, then try again");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("C5: the fallback fix does not replace the parse error's own fix", () => {
|
|
613
|
+
const report = stepFailureReport(
|
|
614
|
+
"The profile was not switched",
|
|
615
|
+
"profile work",
|
|
616
|
+
parseError(),
|
|
617
|
+
"Run it yourself: magus profile use team",
|
|
618
|
+
);
|
|
619
|
+
expect(report.fix).toBe(FIX_BY_HAND);
|
|
620
|
+
expect(fixesOf(report)).not.toContain(
|
|
621
|
+
"Run it yourself: magus profile use team",
|
|
622
|
+
);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("C5: an unrecognised error takes the fallback fix, the subject and the first line as reason", () => {
|
|
626
|
+
const report = stepFailureReport(
|
|
627
|
+
"Plugins were not refreshed",
|
|
628
|
+
"magus",
|
|
629
|
+
new Error("something odd happened\n at internal (file.ts:1:1)"),
|
|
630
|
+
"Run it yourself: claude plugin marketplace update magus",
|
|
631
|
+
);
|
|
632
|
+
expect(report.headline).toBe("Plugins were not refreshed");
|
|
633
|
+
expect(report.rows).toHaveLength(1);
|
|
634
|
+
expect(report.rows[0]?.subject).toBe("magus");
|
|
635
|
+
expect(report.rows[0]?.reason).toBe("something odd happened");
|
|
636
|
+
expect(report.fix).toBe(
|
|
637
|
+
"Run it yourself: claude plugin marketplace update magus",
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("C5: an unrecognised error with no fallback has no fix line", () => {
|
|
642
|
+
const report = stepFailureReport(
|
|
643
|
+
"Plugins were not refreshed",
|
|
644
|
+
"magus",
|
|
645
|
+
new Error("something odd happened"),
|
|
646
|
+
);
|
|
647
|
+
expect(report.fix).toBeNull();
|
|
648
|
+
expect(report.rows.every((r) => !r.fix)).toBe(true);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("C5: a missing claude takes its own fix, not the fallback", () => {
|
|
652
|
+
const report = stepFailureReport(
|
|
653
|
+
"Plugins were not refreshed",
|
|
654
|
+
"magus",
|
|
655
|
+
enoentError({ cmd: "claude", args: ["plugin", "marketplace", "update"] }),
|
|
656
|
+
"Run it yourself: claude plugin marketplace update magus",
|
|
657
|
+
);
|
|
658
|
+
const fixes = fixesOf(report);
|
|
659
|
+
expect(fixes.length).toBeGreaterThan(0);
|
|
660
|
+
expect(fixes).not.toContain(
|
|
661
|
+
"Run it yourself: claude plugin marketplace update magus",
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("C5: a network failure takes its own fix, not the fallback", () => {
|
|
666
|
+
const err = Object.assign(new Error("getaddrinfo ENOTFOUND github.com"), {
|
|
667
|
+
code: "ENOTFOUND",
|
|
668
|
+
syscall: "getaddrinfo",
|
|
669
|
+
hostname: "github.com",
|
|
670
|
+
});
|
|
671
|
+
const report = stepFailureReport(
|
|
672
|
+
"Plugins were not refreshed",
|
|
673
|
+
"magus",
|
|
674
|
+
err,
|
|
675
|
+
"Run it yourself: claude plugin marketplace update magus",
|
|
676
|
+
);
|
|
677
|
+
const fixes = fixesOf(report);
|
|
678
|
+
expect(fixes.length).toBeGreaterThan(0);
|
|
679
|
+
expect(fixes).not.toContain(
|
|
680
|
+
"Run it yourself: claude plugin marketplace update magus",
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// C6 — no internal words
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
describe("C6 no internal word in any headline or reason", () => {
|
|
690
|
+
const ABS_NPM: Command = {
|
|
691
|
+
cmd: "/usr/local/bin/npm",
|
|
692
|
+
args: ["install", "-g", "claudish"],
|
|
693
|
+
};
|
|
694
|
+
const ABS_CLAUDE: Command = {
|
|
695
|
+
cmd: "/Users/someone/.local/bin/claude",
|
|
696
|
+
args: ["update"],
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const cases: Array<[string, FailureReport | null]> = [
|
|
700
|
+
[
|
|
701
|
+
"npm 404",
|
|
702
|
+
cliToolFailureReport("claudish was not installed", [
|
|
703
|
+
failure(
|
|
704
|
+
"claudish",
|
|
705
|
+
NPM_INSTALL,
|
|
706
|
+
execError({ command: NPM_INSTALL, stderr: NPM_404_STDERR }),
|
|
707
|
+
),
|
|
708
|
+
]),
|
|
709
|
+
],
|
|
710
|
+
[
|
|
711
|
+
"missing installer by absolute path",
|
|
712
|
+
cliToolFailureReport("claudish was not installed", [
|
|
713
|
+
failure("claudish", ABS_NPM, enoent(ABS_NPM)),
|
|
714
|
+
]),
|
|
715
|
+
],
|
|
716
|
+
[
|
|
717
|
+
"missing claude by absolute path",
|
|
718
|
+
cliToolFailureReport("Claude Code was not updated", [
|
|
719
|
+
failure("Claude Code", ABS_CLAUDE, enoent(ABS_CLAUDE)),
|
|
720
|
+
]),
|
|
721
|
+
],
|
|
722
|
+
[
|
|
723
|
+
"timeout by absolute path",
|
|
724
|
+
cliToolFailureReport("claudish was not updated", [
|
|
725
|
+
failure("claudish", ABS_NPM, timedOut(ABS_NPM)),
|
|
726
|
+
]),
|
|
727
|
+
],
|
|
728
|
+
[
|
|
729
|
+
"bare Command failed by absolute path",
|
|
730
|
+
cliToolFailureReport("claudish was not updated", [
|
|
731
|
+
failure("claudish", ABS_NPM, execError({ command: ABS_NPM })),
|
|
732
|
+
]),
|
|
733
|
+
],
|
|
734
|
+
[
|
|
735
|
+
"update-all",
|
|
736
|
+
cliToolsNotUpdatedReport([
|
|
737
|
+
failure(
|
|
738
|
+
"claudish",
|
|
739
|
+
ABS_NPM,
|
|
740
|
+
execError({ command: ABS_NPM, stderr: NPM_EACCES_STDERR }),
|
|
741
|
+
),
|
|
742
|
+
failure("tmux", BREW_UPGRADE, timedOut(BREW_UPGRADE)),
|
|
743
|
+
]),
|
|
744
|
+
],
|
|
745
|
+
[
|
|
746
|
+
"conflict stuck",
|
|
747
|
+
cliToolConflictStuckReport("claudish", [
|
|
748
|
+
{ method: "npm", reason: "npm is not on PATH" },
|
|
749
|
+
{ method: "Homebrew", reason: "the formula is pinned" },
|
|
750
|
+
]),
|
|
751
|
+
],
|
|
752
|
+
[
|
|
753
|
+
"conflict partial",
|
|
754
|
+
cliToolConflictPartialReport(
|
|
755
|
+
"claudish",
|
|
756
|
+
[
|
|
757
|
+
{
|
|
758
|
+
method: "npm",
|
|
759
|
+
command: { cmd: "npm", args: ["uninstall", "-g", "claudish"] },
|
|
760
|
+
reason: "permission denied",
|
|
761
|
+
},
|
|
762
|
+
],
|
|
763
|
+
[{ method: "a standalone binary", reason: "magus did not install it" }],
|
|
764
|
+
),
|
|
765
|
+
],
|
|
766
|
+
[
|
|
767
|
+
"step: settings parse",
|
|
768
|
+
stepFailureReport(
|
|
769
|
+
"The profile was not switched",
|
|
770
|
+
"profile work",
|
|
771
|
+
new SettingsParseError(
|
|
772
|
+
"/tmp/blind-780-cli/.claude/settings.json",
|
|
773
|
+
new SyntaxError("JSON Parse error: Expected '}'"),
|
|
774
|
+
),
|
|
775
|
+
),
|
|
776
|
+
],
|
|
777
|
+
[
|
|
778
|
+
"step: missing claude by absolute path",
|
|
779
|
+
stepFailureReport(
|
|
780
|
+
"Plugins were not refreshed",
|
|
781
|
+
"magus",
|
|
782
|
+
enoentError(ABS_CLAUDE),
|
|
783
|
+
),
|
|
784
|
+
],
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
for (const [name, report] of cases) {
|
|
788
|
+
test(`C6: ${name} — headline and reasons carry no internal word or binary path`, () => {
|
|
789
|
+
expect(report).not.toBeNull();
|
|
790
|
+
const found = wordsOf(report).flatMap((text) =>
|
|
791
|
+
leaks(text).map((w) => `${w} in "${text}"`),
|
|
792
|
+
);
|
|
793
|
+
expect(found).toEqual([]);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
});
|