claudeup 5.0.1 → 6.0.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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Tests for the live region.
3
+ *
4
+ * The invariant worth defending is the ORDER of writes around a permanent line.
5
+ * `note()` must erase the animated block, write, then repaint. Writing first
6
+ * puts the line inside the block, where the next repaint's cursor-up counts it
7
+ * as a frame row and starts eating the scrollback record — a data-loss bug that
8
+ * looks like a flicker.
9
+ *
10
+ * The console-capture suite exists because that ordering was documented as a
11
+ * rule and the rule was broken in production by code two call layers away
12
+ * (`plugin-manager`'s stale-marketplace advisory, reached through
13
+ * `getAvailablePlugins`). A rule spanning a call graph this module does not own
14
+ * has to be a mechanism, so these tests pin the mechanism.
15
+ *
16
+ * The clamps are the other half. A frame line that reaches the terminal's last
17
+ * column auto-wraps into two rows while the repainter still counts it as one,
18
+ * so the block walks down the screen a row per frame.
19
+ */
20
+
21
+ import { afterEach, describe, expect, test } from "bun:test";
22
+ import { LiveRegion, withPaused } from "../cli/live.js";
23
+
24
+ /** A write stream that records instead of drawing. */
25
+ function fakeStream(
26
+ opts: { isTTY?: boolean; columns?: number; rows?: number } = {},
27
+ ) {
28
+ const writes: string[] = [];
29
+ const listeners = new Map<string, Array<() => void>>();
30
+ const stream = {
31
+ write: (chunk: string) => {
32
+ writes.push(chunk);
33
+ return true;
34
+ },
35
+ on(event: string, fn: () => void) {
36
+ const list = listeners.get(event) ?? [];
37
+ list.push(fn);
38
+ listeners.set(event, list);
39
+ return this;
40
+ },
41
+ off(event: string, fn: () => void) {
42
+ const list = (listeners.get(event) ?? []).filter((f) => f !== fn);
43
+ listeners.set(event, list);
44
+ return this;
45
+ },
46
+ isTTY: opts.isTTY ?? true,
47
+ columns: opts.columns ?? 80,
48
+ rows: opts.rows ?? 24,
49
+ } as unknown as NodeJS.WriteStream & {
50
+ columns: number;
51
+ rows: number;
52
+ };
53
+ const emit = (event: string) => {
54
+ for (const fn of listeners.get(event) ?? []) fn();
55
+ };
56
+ return { stream, writes, emit, all: () => writes.join("") };
57
+ }
58
+
59
+ /**
60
+ * An animated region that needs no timer: paint via `refresh()`.
61
+ *
62
+ * `captureConsole` is OFF here. These tests assert on stream writes, and a
63
+ * region that hijacks the global console would also hijack the test runner's
64
+ * own reporting. The capture suite below opts back in deliberately.
65
+ */
66
+ function region(opts: Parameters<typeof fakeStream>[0] = {}) {
67
+ const fake = fakeStream(opts);
68
+ const live = new LiveRegion({
69
+ stream: fake.stream,
70
+ animate: true,
71
+ fps: 1000,
72
+ captureConsole: false,
73
+ });
74
+ return { ...fake, live };
75
+ }
76
+
77
+ describe("non-animated mode — a pipe, CI, or NO_COLOR", () => {
78
+ test("the transient block is never painted, but the record still prints", () => {
79
+ const fake = fakeStream({ isTTY: false });
80
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
81
+ live.start(() => ["spinning"]);
82
+ live.refresh();
83
+ live.note("permanent one");
84
+ live.stop(["final"]);
85
+
86
+ expect(fake.writes).toEqual(["permanent one\n", "final\n"]);
87
+ });
88
+
89
+ test("no escape sequence of any kind reaches a pipe", () => {
90
+ const fake = fakeStream({ isTTY: false });
91
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
92
+ live.start(() => ["frame"]);
93
+ live.note("line");
94
+ live.pause();
95
+ live.resume();
96
+ live.stop(["done"]);
97
+ expect(fake.all()).not.toContain("\x1b");
98
+ });
99
+
100
+ test("console is never captured when nothing is being painted", () => {
101
+ const fake = fakeStream({ isTTY: false });
102
+ const original = console.log;
103
+ const live = new LiveRegion({ stream: fake.stream, animate: false });
104
+ live.start(() => ["frame"]);
105
+ try {
106
+ expect(console.log).toBe(original);
107
+ } finally {
108
+ live.stop();
109
+ }
110
+ });
111
+ });
112
+
113
+ describe("animated mode", () => {
114
+ test("start hides the cursor and paints the frame", () => {
115
+ const { live, all } = region();
116
+ live.start(() => ["row one", "row two"]);
117
+ expect(all()).toContain("\x1b[?25l");
118
+ expect(all()).toContain("row one\nrow two\n");
119
+ live.stop();
120
+ });
121
+
122
+ test("a repaint erases exactly the rows it painted", () => {
123
+ const { live, writes } = region();
124
+ live.start(() => ["a", "b", "c"]);
125
+ writes.length = 0;
126
+ live.refresh();
127
+ // Three rows painted, so three rows up and erase below.
128
+ expect(writes[0]).toBe("\x1b[3A\x1b[0J");
129
+ live.stop();
130
+ });
131
+
132
+ test("note erases BEFORE writing, then repaints", () => {
133
+ const { live, writes } = region();
134
+ live.start(() => ["frame"]);
135
+ writes.length = 0;
136
+ live.note("kept");
137
+
138
+ expect(writes[0]).toBe("\x1b[1A\x1b[0J");
139
+ expect(writes[1]).toBe("kept\n");
140
+ expect(writes[2]).toBe("frame\n");
141
+ });
142
+
143
+ test("a frame that shrinks does not leave orphaned rows", () => {
144
+ let rows = ["a", "b", "c"];
145
+ const { live, writes } = region();
146
+ live.start(() => rows);
147
+ rows = ["a"];
148
+ writes.length = 0;
149
+ live.refresh();
150
+ // Erase-below clears the two rows the shorter frame no longer covers.
151
+ expect(writes[0]).toBe("\x1b[3A\x1b[0J");
152
+ expect(writes[1]).toBe("a\n");
153
+ live.stop();
154
+ });
155
+
156
+ test("a line is clipped one column short of the terminal width", () => {
157
+ const { live, writes } = region({ columns: 20 });
158
+ live.start(() => ["x".repeat(40)]);
159
+ const painted = writes.at(-1) ?? "";
160
+ expect(painted.replace(/\n$/, "")).toHaveLength(19);
161
+ live.stop();
162
+ });
163
+
164
+ test("the block is capped one row short of the terminal height", () => {
165
+ const { live, writes } = region({ rows: 6 });
166
+ live.start(() => Array.from({ length: 20 }, (_, i) => `row ${i}`));
167
+ const painted = (writes.at(-1) ?? "").split("\n").filter(Boolean);
168
+ expect(painted).toHaveLength(5);
169
+ live.stop();
170
+ });
171
+
172
+ test("stop erases the frame and restores the cursor", () => {
173
+ const { live, writes } = region();
174
+ live.start(() => ["frame"]);
175
+ writes.length = 0;
176
+ live.stop(["record"]);
177
+ expect(writes[0]).toBe("\x1b[1A\x1b[0J");
178
+ expect(writes[1]).toBe("record\n");
179
+ expect(writes[2]).toBe("\x1b[?25h");
180
+ });
181
+
182
+ test("stop with nothing painted still shows the cursor", () => {
183
+ const { live, all } = region();
184
+ live.start(() => []);
185
+ live.stop();
186
+ expect(all()).toContain("\x1b[?25h");
187
+ });
188
+ });
189
+
190
+ describe("resize", () => {
191
+ test("a resize abandons the old block instead of mis-erasing it", () => {
192
+ // `painted` is a row count measured at the OLD width. After a narrowing,
193
+ // the block occupies more rows than it says, so a cursor-up of `painted`
194
+ // lands inside it and smears. The only safe move is to stop claiming
195
+ // ownership of those rows.
196
+ const { live, writes, emit, stream } = region({ columns: 80 });
197
+ live.start(() => ["x".repeat(70)]);
198
+ writes.length = 0;
199
+ (stream as unknown as { columns: number }).columns = 40;
200
+ emit("resize");
201
+
202
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
203
+ const CURSOR_UP = /\x1b\[\d+A/;
204
+ expect(writes.some((w) => CURSOR_UP.test(w))).toBe(false);
205
+ expect(writes.at(-1)?.replace(/\n$/, "")).toHaveLength(39);
206
+ live.stop();
207
+ });
208
+
209
+ test("the resize listener is removed on stop", () => {
210
+ const { live, writes, emit } = region();
211
+ live.start(() => ["frame"]);
212
+ live.stop();
213
+ writes.length = 0;
214
+ emit("resize");
215
+ expect(writes).toEqual([]);
216
+ });
217
+ });
218
+
219
+ describe("handing the terminal over", () => {
220
+ test("pause erases the block and shows the cursor before the subprocess", () => {
221
+ const { live, writes } = region();
222
+ live.start(() => ["frame"]);
223
+ writes.length = 0;
224
+ live.pause();
225
+ expect(writes).toEqual(["\x1b[1A\x1b[0J", "\x1b[?25h"]);
226
+ });
227
+
228
+ test("note does NOT repaint while paused", () => {
229
+ // A repaint here draws the block underneath output from whatever now owns
230
+ // the terminal — corrupt, and in the way of a readline prompt.
231
+ const { live, writes } = region();
232
+ live.start(() => ["FRAME"]);
233
+ live.pause();
234
+ writes.length = 0;
235
+ live.note("subprocess said something");
236
+ expect(writes).toEqual(["subprocess said something\n"]);
237
+ });
238
+
239
+ test("resume repaints and re-hides", () => {
240
+ const { live, writes } = region();
241
+ live.start(() => ["frame"]);
242
+ live.pause();
243
+ writes.length = 0;
244
+ live.resume();
245
+ expect(writes[0]).toBe("\x1b[?25l");
246
+ expect(writes[1]).toBe("frame\n");
247
+ live.stop();
248
+ });
249
+
250
+ test("withPaused resumes even when the work throws", async () => {
251
+ const { live, writes } = region();
252
+ live.start(() => ["frame"]);
253
+ await expect(
254
+ withPaused(live, async () => {
255
+ throw new Error("installer failed");
256
+ }),
257
+ ).rejects.toThrow("installer failed");
258
+ // A resume repaint after the throw proves the region was handed back.
259
+ expect(writes.at(-1)).toBe("frame\n");
260
+ live.stop();
261
+ });
262
+
263
+ test("withPaused returns the work's value", async () => {
264
+ const { live } = region();
265
+ live.start(() => ["frame"]);
266
+ expect(await withPaused(live, async () => 42)).toBe(42);
267
+ live.stop();
268
+ });
269
+ });
270
+
271
+ describe("console capture — the invariant as a mechanism", () => {
272
+ /** Console is process-global: every test here must put it back. */
273
+ let restore: (() => void) | null = null;
274
+ afterEach(() => {
275
+ restore?.();
276
+ restore = null;
277
+ });
278
+
279
+ function capturing(opts: Parameters<typeof fakeStream>[0] = {}) {
280
+ const out = fakeStream(opts);
281
+ const err = fakeStream(opts);
282
+ const live = new LiveRegion({
283
+ stream: out.stream,
284
+ errorStream: err.stream,
285
+ animate: true,
286
+ fps: 1000,
287
+ });
288
+ const original = {
289
+ log: console.log,
290
+ info: console.info,
291
+ warn: console.warn,
292
+ error: console.error,
293
+ };
294
+ restore = () => {
295
+ live.stop();
296
+ Object.assign(console, original);
297
+ };
298
+ return { live, out, err };
299
+ }
300
+
301
+ test("a bare console.log from anywhere is routed, not left inside the block", () => {
302
+ // This is the production bug: plugin-manager's stale-marketplace advisory
303
+ // prints from two call layers below the catalog step. Without capture it
304
+ // lands under the block, the next cursor-up is short by a row, and the
305
+ // advisory is erased unread.
306
+ const { live, out } = capturing();
307
+ live.start(() => ["FRAME"]);
308
+ out.writes.length = 0;
309
+ console.log("ℹ Marketplace magus is stale");
310
+
311
+ expect(out.writes).toEqual([
312
+ "\x1b[1A\x1b[0J",
313
+ "ℹ Marketplace magus is stale\n",
314
+ "FRAME\n",
315
+ ]);
316
+ });
317
+
318
+ test("without capture the same call corrupts the block", () => {
319
+ // The negative control. This is what the code did before the fix, and it
320
+ // is why documenting the rule was not enough.
321
+ const out = fakeStream();
322
+ const live = new LiveRegion({
323
+ stream: out.stream,
324
+ animate: true,
325
+ fps: 1000,
326
+ captureConsole: false,
327
+ });
328
+ live.start(() => ["FRAME"]);
329
+ out.writes.length = 0;
330
+ out.stream.write("ℹ Marketplace magus is stale\n"); // an uncaptured write
331
+ live.refresh();
332
+ // One row up from a cursor now two rows below the block's start: the
333
+ // erase begins inside the frame rather than at its top.
334
+ expect(out.writes[1]).toBe("\x1b[1A\x1b[0J");
335
+ live.stop();
336
+ });
337
+
338
+ test("stderr stays on stderr", () => {
339
+ // Silently moving warn/error to stdout would break `2>err.log`.
340
+ const { live, out, err } = capturing();
341
+ live.start(() => ["FRAME"]);
342
+ out.writes.length = 0;
343
+ err.writes.length = 0;
344
+ console.warn("a warning");
345
+ console.error("an error");
346
+
347
+ expect(err.writes.filter((w) => !w.startsWith("\x1b"))).toEqual([
348
+ "a warning\n",
349
+ "an error\n",
350
+ ]);
351
+ expect(out.writes.some((w) => w.includes("a warning"))).toBe(false);
352
+ });
353
+
354
+ test("format specifiers and multi-line writes survive", () => {
355
+ // A newline inside one write is a row the erase arithmetic cannot see,
356
+ // so it has to be split into separate notes.
357
+ const { live, out } = capturing();
358
+ live.start(() => ["FRAME"]);
359
+ out.writes.length = 0;
360
+ console.log("%s has %d plugins\nsecond line", "magus", 12);
361
+
362
+ expect(out.writes).toContain("magus has 12 plugins\n");
363
+ expect(out.writes).toContain("second line\n");
364
+ });
365
+
366
+ test("console is released on stop", () => {
367
+ const original = console.log;
368
+ const { live } = capturing();
369
+ live.start(() => ["FRAME"]);
370
+ expect(console.log).not.toBe(original);
371
+ live.stop();
372
+ expect(console.log).toBe(original);
373
+ });
374
+
375
+ test("console is released while paused, so a subprocess writes directly", () => {
376
+ const original = console.log;
377
+ const { live } = capturing();
378
+ live.start(() => ["FRAME"]);
379
+ live.pause();
380
+ expect(console.log).toBe(original);
381
+ live.resume();
382
+ expect(console.log).not.toBe(original);
383
+ });
384
+ });
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Tests for the `claudeup update` report.
3
+ *
4
+ * These assert the things a screenshot cannot: that every row lines up at every
5
+ * data shape, that turning colour off leaves a report someone can still read,
6
+ * and that no bar lies about what it measures.
7
+ *
8
+ * The alignment tests exist because the old report built each row with string
9
+ * concatenation and `String.padEnd`, which counts escape bytes as columns. Add
10
+ * one colour to one cell and every column below it shifts — invisible to a
11
+ * character-level assertion, obvious and ugly on screen.
12
+ */
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import { setColorEnabled, stripAnsi, width } from "../cli/ansi.js";
16
+ import type { UpdatePlan } from "../services/update-plan.js";
17
+ import {
18
+ type ApplyState,
19
+ type Step,
20
+ applyFrame,
21
+ applyRow,
22
+ header,
23
+ planReport,
24
+ stepFrame,
25
+ stepRecord,
26
+ summaryLine,
27
+ } from "../cli/update-view.js";
28
+
29
+ afterEach(() => setColorEnabled(null));
30
+
31
+ // -- fixtures -----------------------------------------------------------------
32
+
33
+ function plan(over: Partial<UpdatePlan> = {}): UpdatePlan {
34
+ return {
35
+ profileId: "default",
36
+ plugins: [
37
+ {
38
+ pluginId: "agentdev@magus",
39
+ pinned: "latest",
40
+ installed: "1.7.0",
41
+ available: "1.7.0",
42
+ target: null,
43
+ action: "current",
44
+ scopes: ["project"],
45
+ },
46
+ {
47
+ pluginId: "dev@magus",
48
+ pinned: "latest",
49
+ installed: "4.6.1",
50
+ available: "5.0.0",
51
+ target: "5.0.0",
52
+ action: "update",
53
+ scopes: ["project"],
54
+ },
55
+ {
56
+ pluginId: "feature-dev@claude-plugins-official",
57
+ pinned: "latest",
58
+ installed: null,
59
+ available: null,
60
+ target: null,
61
+ action: "install",
62
+ scopes: [],
63
+ },
64
+ {
65
+ pluginId: "seo@magus",
66
+ pinned: "latest",
67
+ installed: "1.8.0",
68
+ available: null,
69
+ target: null,
70
+ action: "unknown",
71
+ scopes: ["project"],
72
+ note: "catalog unreachable",
73
+ },
74
+ ],
75
+ bins: [
76
+ { name: "tmux-mcp", action: "current", version: "v1.7.1", sources: [] },
77
+ { name: "tmux", action: "upgrade", sources: [] },
78
+ ],
79
+ skills: [
80
+ {
81
+ name: "release",
82
+ action: "install",
83
+ ref: { name: "release", repo: "MadAppGang/magus", path: "s/SKILL.md" },
84
+ },
85
+ ],
86
+ binSpecs: new Map([
87
+ ["tmux-mcp", { name: "tmux-mcp", via: "bun", sources: [] }],
88
+ ["tmux", { name: "tmux", via: "brew", formula: "tmux", sources: [] }],
89
+ ]),
90
+ ...over,
91
+ } as UpdatePlan;
92
+ }
93
+
94
+ function steps(): Step[] {
95
+ return [
96
+ {
97
+ label: "profile",
98
+ state: "done",
99
+ detail: "4 plugins",
100
+ startedAt: 0,
101
+ endedAt: 100,
102
+ },
103
+ {
104
+ label: "marketplaces",
105
+ state: "running",
106
+ detail: "pulling magus",
107
+ startedAt: 100,
108
+ progress: { done: 1, total: 4 },
109
+ },
110
+ { label: "catalog", state: "pending", detail: "" },
111
+ ];
112
+ }
113
+
114
+ // -- alignment ----------------------------------------------------------------
115
+
116
+ describe("alignment", () => {
117
+ test("every plugin row shares one column layout, colour or not", () => {
118
+ setColorEnabled(true);
119
+ const rows = planReport(plan())
120
+ .filter((l) => /@/.test(stripAnsi(l)))
121
+ .map((l) => stripAnsi(l));
122
+ // Every row's version column starts at the same visible offset, whatever
123
+ // mix of badges, dim text and notes the rows carry.
124
+ const offsets = rows.map((r) => r.indexOf("1.") + r.indexOf("latest"));
125
+ expect(offsets.length).toBeGreaterThan(0);
126
+ const versionCol = rows.map((r) =>
127
+ r.search(/(1\.7\.0|4\.6\.1|latest|1\.8\.0)/),
128
+ );
129
+ expect(new Set(versionCol).size).toBe(1);
130
+ });
131
+
132
+ test("the same row is the same visible width with colour on and off", () => {
133
+ setColorEnabled(false);
134
+ const plain = planReport(plan()).map((l) => width(l));
135
+ setColorEnabled(true);
136
+ const painted = planReport(plan()).map((l) => width(l));
137
+ expect(painted).toEqual(plain);
138
+ });
139
+
140
+ test("a very long plugin id widens the column for every row", () => {
141
+ setColorEnabled(false);
142
+ const long = "a-very-long-plugin-name-indeed@some-marketplace";
143
+ const p = plan();
144
+ p.plugins[0]!.pluginId = long;
145
+ const rows = planReport(p).filter((l) => l.includes("@"));
146
+ const cols = rows.map((r) => r.search(/(1\.7\.0|4\.6\.1|latest|1\.8\.0)/));
147
+ expect(new Set(cols).size).toBe(1);
148
+ expect(rows[0]).toContain(long);
149
+ });
150
+ });
151
+
152
+ // -- readability without colour ----------------------------------------------
153
+
154
+ describe("NO_COLOR", () => {
155
+ test("the report emits no escape sequence at all", () => {
156
+ setColorEnabled(false);
157
+ const out = [
158
+ ...header("default"),
159
+ ...stepFrame(steps(), 3, 500),
160
+ ...stepRecord(steps(), 500),
161
+ ...planReport(plan()),
162
+ ...applyFrame({ done: 1, total: 4, current: "dev@magus", startedAt: 0 }, 2, 900),
163
+ applyRow(true, "dev@magus", 20, "project", 400),
164
+ ...summaryLine(2, 5, 1, 0, 9000),
165
+ ].join("\n");
166
+ expect(out).not.toContain("\x1b");
167
+ });
168
+
169
+ test("every action stays distinguishable without colour", () => {
170
+ setColorEnabled(false);
171
+ const out = planReport(plan()).join("\n");
172
+ // Colour never carries meaning alone: each action has its own word AND
173
+ // its own glyph, so the report survives a pipe and colour blindness.
174
+ for (const label of ["CURRENT", "UPDATE", "INSTALL", "UNKNOWN", "UPGRADE"])
175
+ expect(out).toContain(label);
176
+ for (const glyph of ["=", "↑", "+", "?"]) expect(out).toContain(glyph);
177
+ });
178
+
179
+ test("a note rides along on the row it explains", () => {
180
+ setColorEnabled(false);
181
+ const row = planReport(plan()).find((l) => l.includes("seo@magus"))!;
182
+ expect(row).toContain("catalog unreachable");
183
+ });
184
+ });
185
+
186
+ // -- the bars tell the truth --------------------------------------------------
187
+
188
+ describe("bars", () => {
189
+ test("a running step with a denominator gets a determinate meter", () => {
190
+ setColorEnabled(false);
191
+ const frame = stepFrame(steps(), 0, 500);
192
+ // 1 of 4 → a quarter of the 18-cell bar filled.
193
+ const bar = frame[1]!.match(/[█░]+/)?.[0] ?? "";
194
+ expect(bar).toHaveLength(18);
195
+ expect(bar.split("█").length - 1).toBe(5);
196
+ });
197
+
198
+ test("a running step with no denominator gets a sweep, not a fake meter", () => {
199
+ setColorEnabled(false);
200
+ const s = steps();
201
+ s[1]!.progress = undefined;
202
+ const bar = stepFrame(s, 3, 500)[1]!.match(/[█░]+/)?.[0] ?? "";
203
+ expect(bar).toHaveLength(18);
204
+ // A sweep is partly filled at every tick; a "0%" meter would be all track.
205
+ expect(bar).toContain("█");
206
+ expect(bar).toContain("░");
207
+ });
208
+
209
+ test("only the running row carries a bar in the live frame", () => {
210
+ setColorEnabled(false);
211
+ const frame = stepFrame(steps(), 0, 500);
212
+ expect(frame[0]).not.toMatch(/[█░]/);
213
+ expect(frame[1]).toMatch(/[█░]/);
214
+ expect(frame[2]).not.toMatch(/[█░]/);
215
+ });
216
+
217
+ test("the record scales each step against the slowest, not the total", () => {
218
+ setColorEnabled(false);
219
+ const record = stepRecord(
220
+ [
221
+ { label: "fast", state: "done", detail: "", startedAt: 0, endedAt: 100 },
222
+ { label: "slow", state: "done", detail: "", startedAt: 0, endedAt: 1000 },
223
+ ],
224
+ 1000,
225
+ );
226
+ const bars = record.map((r) => r.match(/[█░]+/)![0]);
227
+ expect(bars[1]!.split("█").length - 1).toBe(18);
228
+ expect(bars[0]!.split("█").length - 1).toBe(2);
229
+ });
230
+
231
+ test("a step that never ran does not divide by zero", () => {
232
+ setColorEnabled(false);
233
+ const record = stepRecord([{ label: "x", state: "pending", detail: "" }], 0);
234
+ expect(record[0]).toContain("░");
235
+ });
236
+
237
+ test("the apply meter is full exactly when the work is done", () => {
238
+ setColorEnabled(false);
239
+ const state: ApplyState = { done: 4, total: 4, current: "", startedAt: 0 };
240
+ const bar = applyFrame(state, 0, 100)[1]!.match(/[█░]+/)![0];
241
+ expect(bar).toBe("█".repeat(36));
242
+ });
243
+
244
+ test("an empty apply reads as complete rather than as stuck at zero", () => {
245
+ setColorEnabled(false);
246
+ const state: ApplyState = { done: 0, total: 0, current: "", startedAt: 0 };
247
+ expect(applyFrame(state, 0, 100)[1]).toContain("█".repeat(36));
248
+ });
249
+
250
+ test("the summary bar survives an all-zero run", () => {
251
+ setColorEnabled(false);
252
+ const line = summaryLine(0, 0, 0, 0, 100)[1]!;
253
+ expect(line.match(/[█░]+/)![0]).toHaveLength(36);
254
+ });
255
+ });
256
+
257
+ // -- misc ---------------------------------------------------------------------
258
+
259
+ describe("sections", () => {
260
+ test("an empty section is omitted entirely, not printed as a heading", () => {
261
+ setColorEnabled(false);
262
+ const out = planReport(
263
+ plan({ bins: [], skills: [] }) as UpdatePlan,
264
+ ).join("\n");
265
+ expect(out).toContain("Plugins");
266
+ expect(out).not.toContain("CLI");
267
+ expect(out).not.toContain("Skills");
268
+ });
269
+
270
+ test("a section legend counts every action it contains", () => {
271
+ setColorEnabled(false);
272
+ const line = planReport(plan()).find((l) => l.includes("Plugins"))!;
273
+ expect(line).toContain("1 current");
274
+ expect(line).toContain("1 update");
275
+ expect(line).toContain("1 install");
276
+ expect(line).toContain("1 unknown");
277
+ });
278
+
279
+ test("a CLI upgrade shows the command that will run", () => {
280
+ setColorEnabled(false);
281
+ const row = planReport(plan()).find(
282
+ (l) => l.includes("tmux ") && l.includes("UPGRADE"),
283
+ )!;
284
+ expect(row).toContain("brew upgrade tmux");
285
+ });
286
+ });
@@ -10,8 +10,27 @@ import {
10
10
  } from "../services/gitignore-fixer";
11
11
  import type { Violation } from "../types/gitignore";
12
12
 
13
+ // `commit.gpgsign=false` is not tidiness — without it these tests HANG.
14
+ //
15
+ // Two tests here run `git commit` in a temp repo, which inherits the
16
+ // developer's global config. On any machine with commit signing enabled and a
17
+ // GUI signer (`gpg.format=ssh` pointing at 1Password's `op-ssh-sign` is the
18
+ // common setup here), the commit blocks waiting for an approval dialog nobody
19
+ // is watching, and both tests fail on a 5s timeout. It presents as a flake
20
+ // because the approval is cached once granted, so the suite goes green until
21
+ // the cache expires. `marketplace-refresh.test.ts` already guards this the same
22
+ // way; this file did not.
23
+ const GIT_CFG = [
24
+ "-c",
25
+ "core.excludesFile=/dev/null",
26
+ "-c",
27
+ "commit.gpgsign=false",
28
+ "-c",
29
+ "init.defaultBranch=main",
30
+ ];
31
+
13
32
  function git(cwd: string, ...args: string[]): { stdout: string; status: number } {
14
- const r = spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], {
33
+ const r = spawnSync("git", [...GIT_CFG, ...args], {
15
34
  cwd,
16
35
  encoding: "utf8",
17
36
  });