magus-cli 7.3.1 → 7.4.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 (123) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Blind tests — contract C11 (requirement R2.7, with R2.6 for `doctor`).
3
+ *
4
+ * Written from requirements.md and architecture.md only.
5
+ *
6
+ * Every magus run is a child `bun --no-env-file src/main.tsx …` launched with
7
+ * HOME set to a temp dir and cwd set to a temp project (C11, and the C2
8
+ * correction: HOME must be set at launch, never changed at runtime). Before
9
+ * any magus run, ./child-run.ts proves the same env really redirects
10
+ * `os.homedir()`; if it does not, magus is never launched.
11
+ *
12
+ * Only side-effect-free invocations from C11 are used: `install --check`,
13
+ * `profile list`, `doctor` (no `--fix`). Never `upgrade`.
14
+ *
15
+ * Nothing the child prints is surfaced verbatim. Assertions compare summary
16
+ * objects (booleans, exit code, redacted `.json` path tokens), so no failure
17
+ * can echo the contents of a user file.
18
+ */
19
+ import { afterAll, describe, expect, test } from "bun:test";
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readFileSync,
25
+ realpathSync,
26
+ rmSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import { runInChild } from "./child-run.js";
32
+
33
+ const MAIN = path.resolve(import.meta.dir, "../../main.tsx");
34
+ const SPAWN_TIMEOUT = 45_000; // a child that runs longer is killed and reported as timedOut
35
+ const T = 120_000; // per-test timeout: the first test in a block pays for guard + magus run
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // temp dirs
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const dirs: string[] = [];
42
+ afterAll(() => {
43
+ for (const dir of dirs.splice(0))
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ function tempDir(tag: string): string {
48
+ const dir = mkdtempSync(path.join(os.tmpdir(), `magus-blind-c11-${tag}-`));
49
+ dirs.push(dir);
50
+ return dir;
51
+ }
52
+
53
+ function write(file: string, content: string): void {
54
+ mkdirSync(path.dirname(file), { recursive: true });
55
+ writeFileSync(file, content);
56
+ }
57
+
58
+ interface Case {
59
+ home: string;
60
+ project: string;
61
+ /** Both spellings of each temp dir: as created, and resolved (macOS /var -> /private/var). */
62
+ homeForms: string[];
63
+ projectForms: string[];
64
+ }
65
+
66
+ function newCase(): Case {
67
+ const home = tempDir("home");
68
+ const project = tempDir("proj");
69
+ const forms = (d: string) => [...new Set([d, realpathSync(d)])];
70
+ return {
71
+ home,
72
+ project,
73
+ homeForms: forms(home),
74
+ projectForms: forms(project),
75
+ };
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // testdata
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /** Cut off mid-object: present on disk, not valid JSON. */
83
+ const TRUNCATED_SETTINGS = '{\n "enabledPlugins": {\n "a@b": true,\n';
84
+ const TRUNCATED_PROFILES =
85
+ '{\n "profiles": {\n "blind": {\n "plugins": [\n';
86
+ const VALID_SETTINGS = '{\n "enabledPlugins": {}\n}\n';
87
+ /** Minimal valid JSON. The profiles.json schema is not in the contract, so the control only needs JSON validity. */
88
+ const VALID_PROFILES = "{}\n";
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // output classification
92
+ // ---------------------------------------------------------------------------
93
+
94
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
95
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
96
+ const ERROR_STARTING = /Error starting magus/i;
97
+ /** Either wording the requirements use for the refusal (R2.4 "not valid JSON", R2.7 "fix the JSON by hand"). */
98
+ const NOT_VALID_JSON = /not valid JSON|invalid JSON|fix the JSON by hand/i;
99
+ /** R2.7 quotes this note. */
100
+ const FIX_BY_HAND = /fix the JSON by hand/i;
101
+ /** Bun's source dump: `12 | const x = …`. */
102
+ const LINE_NUMBER_PIPE = /^\s*\d+\s*\|/m;
103
+ /** A stack frame: ` at fn (file:1:2)`. */
104
+ const AT_FRAME = /^[ \t]{2,}at \S/m;
105
+ /** Bun's uncaught-error banner: `Bun v1.4.0 (…)`. */
106
+ const BUN_CRASH_BANNER = /^Bun v\d+\.\d+\.\d+/m;
107
+
108
+ interface CliRun {
109
+ exitCode: number | null;
110
+ signal: string | null;
111
+ timedOut: boolean;
112
+ stdout: string;
113
+ stderr: string;
114
+ }
115
+
116
+ function combined(r: CliRun): string {
117
+ return `${r.stdout}\n${r.stderr}`;
118
+ }
119
+
120
+ function escapeRe(s: string): string {
121
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
122
+ }
123
+
124
+ /** `.json` path tokens in the output, with temp dirs replaced by labels. Paths only — never file contents. */
125
+ function jsonPathsSeen(text: string, c: Case): string[] {
126
+ const tokens = text.match(/[~\w./-]*\.json\b/g) ?? [];
127
+ const redact = (t: string): string => {
128
+ for (const d of c.homeForms)
129
+ if (t.startsWith(d)) return `<HOME>${t.slice(d.length)}`;
130
+ for (const d of c.projectForms)
131
+ if (t.startsWith(d)) return `<PROJECT>${t.slice(d.length)}`;
132
+ if (t.startsWith("/")) return `<outside-temp>/${path.basename(t)}`;
133
+ return t;
134
+ };
135
+ return [...new Set(tokens.map(redact))];
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // the damaged file, and every spelling that counts as naming it
140
+ // ---------------------------------------------------------------------------
141
+
142
+ type Damaged = "project-settings" | "profiles" | "global-settings";
143
+
144
+ interface Target {
145
+ file: (c: Case) => string;
146
+ label: string;
147
+ truncated: string;
148
+ valid: string;
149
+ /** True when `text` names this file by an absolute path, `~/…`, or a project-relative path. */
150
+ named: (text: string, c: Case) => boolean;
151
+ }
152
+
153
+ function projectFile(rel: string, truncated: string, valid: string): Target {
154
+ // A bare relative `.claude/x.json` counts, but not when it is the tail of
155
+ // `~/.claude/x.json` or of another absolute path.
156
+ const relRe = new RegExp(`(^|[^\\w~/.-])(\\./)?${escapeRe(rel)}`, "m");
157
+ return {
158
+ file: (c) => path.join(c.project, rel),
159
+ label: rel,
160
+ truncated,
161
+ valid,
162
+ named: (text, c) =>
163
+ c.projectForms.some((d) => text.includes(path.join(d, rel))) ||
164
+ relRe.test(text),
165
+ };
166
+ }
167
+
168
+ const TARGETS: Record<Damaged, Target> = {
169
+ "project-settings": projectFile(
170
+ ".claude/settings.json",
171
+ TRUNCATED_SETTINGS,
172
+ VALID_SETTINGS,
173
+ ),
174
+ profiles: projectFile(
175
+ ".claude/profiles.json",
176
+ TRUNCATED_PROFILES,
177
+ VALID_PROFILES,
178
+ ),
179
+ "global-settings": {
180
+ file: (c) => path.join(c.home, ".claude", "settings.json"),
181
+ label: "~/.claude/settings.json",
182
+ truncated: TRUNCATED_SETTINGS,
183
+ valid: VALID_SETTINGS,
184
+ named: (text, c) =>
185
+ text.includes("~/.claude/settings.json") ||
186
+ c.homeForms.some((d) =>
187
+ text.includes(path.join(d, ".claude", "settings.json")),
188
+ ),
189
+ },
190
+ };
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // running magus
194
+ // ---------------------------------------------------------------------------
195
+
196
+ function cliEnv(home: string): Record<string, string> {
197
+ const env: Record<string, string> = {};
198
+ for (const [k, v] of Object.entries(process.env)) {
199
+ if (v === undefined) continue;
200
+ // Nothing inherited may point magus at real config or a real project.
201
+ if (
202
+ k === "CLAUDE_CONFIG_DIR" ||
203
+ k === "CLAUDE_PROJECT_DIR" ||
204
+ k === "FORCE_COLOR"
205
+ )
206
+ continue;
207
+ if (k.startsWith("MAGUS_")) continue;
208
+ env[k] = v;
209
+ }
210
+ env.HOME = home;
211
+ env.CLAUDE_CONFIG_DIR = path.join(home, ".claude");
212
+ env.XDG_CONFIG_HOME = path.join(home, ".config");
213
+ env.NO_COLOR = "1";
214
+ return env;
215
+ }
216
+
217
+ /** Proves HOME set at launch redirects `os.homedir()` in a child bun for this case. Throws if not. */
218
+ function guardHome(c: Case): void {
219
+ const r = runInChild({
220
+ home: c.home,
221
+ cwd: c.project,
222
+ body: 'await out({ stage: "done", ok: true });',
223
+ });
224
+ if (r.stage !== "done") {
225
+ throw new Error(
226
+ `HOME guard failed (stage ${r.stage}): magus was not launched`,
227
+ );
228
+ }
229
+ }
230
+
231
+ function runMagus(c: Case, args: string[]): CliRun {
232
+ guardHome(c);
233
+ const proc = Bun.spawnSync(
234
+ [process.execPath, "--no-env-file", MAIN, ...args],
235
+ {
236
+ cwd: c.project,
237
+ env: cliEnv(c.home),
238
+ stdin: "ignore",
239
+ stdout: "pipe",
240
+ stderr: "pipe",
241
+ timeout: SPAWN_TIMEOUT,
242
+ },
243
+ );
244
+ const timedOut =
245
+ (proc as { exitedDueToTimeout?: boolean }).exitedDueToTimeout === true ||
246
+ proc.exitCode === null;
247
+ return {
248
+ exitCode: proc.exitCode,
249
+ signal: (proc as { signalCode?: string | null }).signalCode ?? null,
250
+ timedOut,
251
+ stdout: proc.stdout.toString().replace(ANSI, ""),
252
+ stderr: proc.stderr.toString().replace(ANSI, ""),
253
+ };
254
+ }
255
+
256
+ function once<V>(fn: () => V): () => V {
257
+ let memo: { v: V } | undefined;
258
+ return () => {
259
+ memo ??= { v: fn() };
260
+ return memo.v;
261
+ };
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // scenarios
266
+ // ---------------------------------------------------------------------------
267
+
268
+ interface Scenario {
269
+ name: string;
270
+ args: string[];
271
+ damaged: Damaged;
272
+ /** R2.6: doctor reports the file and exits normally; exit 1 is not asserted for it. */
273
+ isDoctor: boolean;
274
+ }
275
+
276
+ const SCENARIOS: Scenario[] = [
277
+ {
278
+ name: "(a) no .claude/profiles.json, truncated .claude/settings.json — `install --check`",
279
+ args: ["install", "--check"],
280
+ damaged: "project-settings",
281
+ isDoctor: false,
282
+ },
283
+ {
284
+ name: "(b) truncated .claude/profiles.json — `profile list`",
285
+ args: ["profile", "list"],
286
+ damaged: "profiles",
287
+ isDoctor: false,
288
+ },
289
+ {
290
+ name: "(b) truncated .claude/profiles.json — `install --check`",
291
+ args: ["install", "--check"],
292
+ damaged: "profiles",
293
+ isDoctor: false,
294
+ },
295
+ {
296
+ name: "(c) truncated global ~/.claude/settings.json (temp HOME) — `doctor`",
297
+ args: ["doctor"],
298
+ damaged: "global-settings",
299
+ isDoctor: true,
300
+ },
301
+ ];
302
+
303
+ describe("C11 — child-process harness", () => {
304
+ test(
305
+ "R2.7 control: a child bun launched with the test env reports os.homedir() as the temp HOME",
306
+ () => {
307
+ const c = newCase();
308
+ const r = runInChild({
309
+ home: c.home,
310
+ cwd: c.project,
311
+ body: 'await out({ stage: "done", ok: true });',
312
+ });
313
+ expect(r).toEqual({ stage: "done", ok: true });
314
+ },
315
+ T,
316
+ );
317
+
318
+ test("R2.7 control: the CLI entry point named by C11 exists", () => {
319
+ expect(existsSync(MAIN)).toBe(true);
320
+ });
321
+ });
322
+
323
+ for (const s of SCENARIOS) {
324
+ const target = TARGETS[s.damaged];
325
+ const cmd = `magus ${s.args.join(" ")}`;
326
+
327
+ describe(`C11 — ${s.name}`, () => {
328
+ const damaged = once(() => {
329
+ const c = newCase();
330
+ const file = target.file(c);
331
+ write(file, target.truncated);
332
+ const before = readFileSync(file);
333
+ const run = runMagus(c, s.args);
334
+ const after = existsSync(file) ? readFileSync(file) : null;
335
+ return { c, run, before, after };
336
+ });
337
+
338
+ if (s.isDoctor) {
339
+ test(
340
+ `R2.7 ${cmd} with ${target.label} truncated does not crash with "Error starting magus"`,
341
+ () => {
342
+ const { run } = damaged();
343
+ expect({
344
+ timedOut: run.timedOut,
345
+ exitedOnItsOwn: run.exitCode !== null && run.signal === null,
346
+ printsErrorStartingMagus: ERROR_STARTING.test(combined(run)),
347
+ }).toEqual({
348
+ timedOut: false,
349
+ exitedOnItsOwn: true,
350
+ printsErrorStartingMagus: false,
351
+ });
352
+ },
353
+ T,
354
+ );
355
+ } else {
356
+ test(
357
+ `R2.7 ${cmd} with ${target.label} truncated exits 1`,
358
+ () => {
359
+ const { run } = damaged();
360
+ expect({ exitCode: run.exitCode, timedOut: run.timedOut }).toEqual({
361
+ exitCode: 1,
362
+ timedOut: false,
363
+ });
364
+ },
365
+ T,
366
+ );
367
+ }
368
+
369
+ test(
370
+ `R2.7 ${cmd} with ${target.label} truncated names the damaged file's path`,
371
+ () => {
372
+ const { c, run } = damaged();
373
+ const text = combined(run);
374
+ expect({
375
+ namesDamagedFile: target.named(text, c),
376
+ jsonPathsSeen: jsonPathsSeen(text, c),
377
+ }).toMatchObject({ namesDamagedFile: true });
378
+ },
379
+ T,
380
+ );
381
+
382
+ test(
383
+ `R2.7 ${cmd} with ${target.label} truncated prints no "Error starting magus" and no stack or source dump`,
384
+ () => {
385
+ const { run } = damaged();
386
+ const text = combined(run);
387
+ expect({
388
+ printsErrorStartingMagus: ERROR_STARTING.test(text),
389
+ lineNumberPipeSourceDump: LINE_NUMBER_PIPE.test(text),
390
+ stackFrameAtLine: AT_FRAME.test(text),
391
+ bunCrashBanner: BUN_CRASH_BANNER.test(text),
392
+ }).toEqual({
393
+ printsErrorStartingMagus: false,
394
+ lineNumberPipeSourceDump: false,
395
+ stackFrameAtLine: false,
396
+ bunCrashBanner: false,
397
+ });
398
+ },
399
+ T,
400
+ );
401
+
402
+ test(
403
+ `R2.7 ${cmd} leaves the truncated ${target.label} byte-identical`,
404
+ () => {
405
+ const { before, after } = damaged();
406
+ expect({
407
+ stillExists: after !== null,
408
+ byteIdentical: after?.equals(before) === true,
409
+ }).toEqual({ stillExists: true, byteIdentical: true });
410
+ },
411
+ T,
412
+ );
413
+
414
+ if (s.isDoctor) {
415
+ test(
416
+ `R2.7 ${cmd} with ${target.label} truncated says the file is not valid JSON`,
417
+ () => {
418
+ const { run } = damaged();
419
+ expect({
420
+ saysNotValidJson: NOT_VALID_JSON.test(combined(run)),
421
+ }).toEqual({ saysNotValidJson: true });
422
+ },
423
+ T,
424
+ );
425
+ } else {
426
+ test(
427
+ `R2.7 ${cmd} with ${target.label} truncated prints the "fix the JSON by hand" note on stderr`,
428
+ () => {
429
+ const { run } = damaged();
430
+ expect({
431
+ fixByHandNoteOnStderr: FIX_BY_HAND.test(run.stderr),
432
+ }).toEqual({
433
+ fixByHandNoteOnStderr: true,
434
+ });
435
+ },
436
+ T,
437
+ );
438
+ }
439
+
440
+ test(
441
+ `R2.7 control: ${cmd} with a valid ${target.label} does not print the not-valid-JSON message`,
442
+ () => {
443
+ const c = newCase();
444
+ write(target.file(c), target.valid);
445
+ const run = runMagus(c, s.args);
446
+ expect({
447
+ timedOut: run.timedOut,
448
+ printsNotValidJson: NOT_VALID_JSON.test(combined(run)),
449
+ }).toEqual({ timedOut: false, printsNotValidJson: false });
450
+ },
451
+ T,
452
+ );
453
+ });
454
+ }