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.
- package/package.json +4 -4
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +233 -0
- package/src/__tests__/cli-live.test.ts +384 -0
- package/src/__tests__/cli-update-view.test.ts +286 -0
- package/src/__tests__/gitignore-fixer.test.ts +20 -1
- package/src/__tests__/marketplace-refresh.test.ts +63 -0
- package/src/cli/ansi.ts +512 -0
- package/src/cli/bootstrap.ts +0 -6
- package/src/cli/install.ts +4 -1
- package/src/cli/live.ts +374 -0
- package/src/cli/profile.ts +1 -1
- package/src/cli/router.ts +7 -3
- package/src/cli/update-view.ts +438 -0
- package/src/cli/update.ts +457 -129
- package/src/services/doctor-bins.ts +9 -1
- package/src/services/marketplace-refresh.ts +39 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "
|
|
68
|
-
"claudeup-darwin-x64": "
|
|
69
|
-
"claudeup-linux-x64": "
|
|
67
|
+
"claudeup-darwin-arm64": "6.0.0",
|
|
68
|
+
"claudeup-darwin-x64": "6.0.0",
|
|
69
|
+
"claudeup-linux-x64": "6.0.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the CLI's colour layer.
|
|
3
|
+
*
|
|
4
|
+
* Two things are worth locking here, and neither is "does it look nice":
|
|
5
|
+
*
|
|
6
|
+
* 1. **Width arithmetic**, because the live region's cursor-up repaint is built
|
|
7
|
+
* on it. A padding or truncation function that counts escape bytes as cells
|
|
8
|
+
* makes every animated frame drift down the screen, and the symptom looks
|
|
9
|
+
* like a rendering bug rather than an off-by-N in `width`.
|
|
10
|
+
* 2. **Colour capability**, because getting the precedence wrong ships escape
|
|
11
|
+
* sequences into a log file. `NO_COLOR` must win.
|
|
12
|
+
*
|
|
13
|
+
* The gradient assertions check that a ramp really is a ramp — the failure mode
|
|
14
|
+
* the skill warns about is a "gradient" that paints one flat colour, which no
|
|
15
|
+
* character-level assertion can see.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import {
|
|
20
|
+
badge,
|
|
21
|
+
blend1D,
|
|
22
|
+
blendStops,
|
|
23
|
+
colorEnabled,
|
|
24
|
+
darken,
|
|
25
|
+
elapsed,
|
|
26
|
+
fg,
|
|
27
|
+
inkOn,
|
|
28
|
+
lighten,
|
|
29
|
+
meter,
|
|
30
|
+
mix,
|
|
31
|
+
padEnd,
|
|
32
|
+
padStart,
|
|
33
|
+
setColorEnabled,
|
|
34
|
+
spinner,
|
|
35
|
+
stackedBar,
|
|
36
|
+
stripAnsi,
|
|
37
|
+
truncate,
|
|
38
|
+
width,
|
|
39
|
+
} from "../cli/ansi.js";
|
|
40
|
+
|
|
41
|
+
afterEach(() => setColorEnabled(null));
|
|
42
|
+
|
|
43
|
+
describe("colour capability", () => {
|
|
44
|
+
const env = { ...process.env };
|
|
45
|
+
// `delete` is the only way to make an env var absent: assigning undefined
|
|
46
|
+
// stores the literal string "undefined", and these tests turn on the
|
|
47
|
+
// difference between unset and set-to-something.
|
|
48
|
+
const unset = (key: string) => delete process.env[key];
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
for (const key of ["NO_COLOR", "FORCE_COLOR", "TERM"] as const) {
|
|
51
|
+
const original = env[key];
|
|
52
|
+
if (original === undefined) unset(key);
|
|
53
|
+
else process.env[key] = original;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("NO_COLOR beats FORCE_COLOR", () => {
|
|
58
|
+
// A user who exported NO_COLOR in their shell profile must not get colour
|
|
59
|
+
// back merely because the CI they are in sets FORCE_COLOR.
|
|
60
|
+
process.env.NO_COLOR = "1";
|
|
61
|
+
process.env.FORCE_COLOR = "1";
|
|
62
|
+
expect(colorEnabled()).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("an empty NO_COLOR is not set, per the spec", () => {
|
|
66
|
+
process.env.NO_COLOR = "";
|
|
67
|
+
process.env.FORCE_COLOR = "1";
|
|
68
|
+
expect(colorEnabled()).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("FORCE_COLOR=0 disables even on a TTY", () => {
|
|
72
|
+
unset("NO_COLOR");
|
|
73
|
+
process.env.FORCE_COLOR = "0";
|
|
74
|
+
expect(colorEnabled()).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("with colour off, every styling call is the identity", () => {
|
|
78
|
+
setColorEnabled(false);
|
|
79
|
+
expect(fg("#ff0000", "hi")).toBe("hi");
|
|
80
|
+
expect(badge("UPDATE", "#ff0000")).toBe(" UPDATE ");
|
|
81
|
+
expect(stripAnsi(meter(50, 10))).toHaveLength(10);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("colour maths", () => {
|
|
86
|
+
test("blend1D includes both endpoints", () => {
|
|
87
|
+
const out = blend1D(5, "#000000", "#ffffff");
|
|
88
|
+
expect(out).toHaveLength(5);
|
|
89
|
+
expect(out[0]).toBe("#000000");
|
|
90
|
+
expect(out[4]).toBe("#ffffff");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("blend1D(1) takes the from end rather than dividing by zero", () => {
|
|
94
|
+
expect(blend1D(1, "#123456", "#ffffff")).toEqual(["#123456"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("degenerate step counts give an empty ramp, never a throw", () => {
|
|
98
|
+
expect(blend1D(0, "#000", "#fff")).toEqual([]);
|
|
99
|
+
expect(blend1D(-3, "#000", "#fff")).toEqual([]);
|
|
100
|
+
expect(blend1D(2.5, "#000", "#fff")).toEqual([]);
|
|
101
|
+
expect(blendStops(4)).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("blendStops passes through its midtone stop", () => {
|
|
105
|
+
// The midtone is the whole reason blendStops exists: a two-stop blend
|
|
106
|
+
// between distant hues desaturates through the middle, so the control
|
|
107
|
+
// point has to survive into the output.
|
|
108
|
+
const out = blendStops(5, "#ff0000", "#00ff00", "#0000ff");
|
|
109
|
+
expect(out[2]).toBe("#00ff00");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("mix clamps t rather than extrapolating past an endpoint", () => {
|
|
113
|
+
expect(mix("#000000", "#ffffff", -1)).toBe("#000000");
|
|
114
|
+
expect(mix("#000000", "#ffffff", 2)).toBe("#ffffff");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("short hex expands, and junk resolves to black instead of NaN", () => {
|
|
118
|
+
expect(mix("#f00", "#f00", 0)).toBe("#ff0000");
|
|
119
|
+
expect(mix("not-a-colour", "not-a-colour", 0)).toBe("#000000");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("darken and lighten move toward black and white", () => {
|
|
123
|
+
expect(darken("#808080", 1)).toBe("#000000");
|
|
124
|
+
expect(lighten("#808080", 1)).toBe("#ffffff");
|
|
125
|
+
expect(darken("#808080", 0)).toBe("#808080");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("ink flips to dark on a bright fill", () => {
|
|
129
|
+
expect(inkOn("#FFE066")).toBe("#101014");
|
|
130
|
+
expect(inkOn("#15803D")).toBe("#FFFFFF");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("width, padding and truncation", () => {
|
|
135
|
+
test("escape sequences cost no cells", () => {
|
|
136
|
+
expect(width(fg("#ff0000", "abc"))).toBe(3);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("padding measures visible cells, not string length", () => {
|
|
140
|
+
setColorEnabled(true);
|
|
141
|
+
const painted = fg("#ff0000", "ab");
|
|
142
|
+
expect(width(padEnd(painted, 6))).toBe(6);
|
|
143
|
+
expect(width(padStart(painted, 6))).toBe(6);
|
|
144
|
+
expect(padStart("7", 3)).toBe(" 7");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("padding never truncates something already too wide", () => {
|
|
148
|
+
expect(padEnd("abcdef", 3)).toBe("abcdef");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("truncate clips to visible cells and keeps the colour intact", () => {
|
|
152
|
+
setColorEnabled(true);
|
|
153
|
+
const clipped = truncate(fg("#ff0000", "abcdefgh"), 4);
|
|
154
|
+
expect(width(clipped)).toBe(4);
|
|
155
|
+
expect(stripAnsi(clipped)).toBe("abcd");
|
|
156
|
+
// The reset must survive, or the clip bleeds its colour into the next line.
|
|
157
|
+
expect(clipped).toContain("\x1b[0m");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("truncate is a no-op when it already fits", () => {
|
|
161
|
+
expect(truncate("abc", 10)).toBe("abc");
|
|
162
|
+
expect(truncate("abc", 0)).toBe("");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a double-width glyph costs two cells", () => {
|
|
166
|
+
expect(width("漢字")).toBe(4);
|
|
167
|
+
expect(width(truncate("漢字", 3))).toBe(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("emoji that render wide are counted wide", () => {
|
|
171
|
+
// Under-counting is the dangerous direction: a line measured narrower
|
|
172
|
+
// than it renders reaches the last column, wraps, and the live region's
|
|
173
|
+
// cursor-up erase — which counted it as one row — smears the block.
|
|
174
|
+
// These three blocks were all counted as width 1 before.
|
|
175
|
+
expect(width("🚀")).toBe(2); // U+1F680 transport
|
|
176
|
+
expect(width("✅")).toBe(2); // U+2705 dingbat
|
|
177
|
+
expect(width("🇺🇸")).toBe(4); // regional indicator pair
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a zero-width joiner costs nothing", () => {
|
|
181
|
+
// Without this a three-person family measures as three separate wide
|
|
182
|
+
// glyphs (6 cells) rather than the one grapheme it renders as.
|
|
183
|
+
expect(width("")).toBe(0);
|
|
184
|
+
expect(width("👨👩👧")).toBe(6);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("ambiguous-width glyphs the report actually uses stay narrow", () => {
|
|
188
|
+
// The arrows and box glyphs in the plan rows are EAW-Ambiguous. Widening
|
|
189
|
+
// them would misalign the common case, which is the one that matters.
|
|
190
|
+
for (const ch of ["↑", "→", "█", "░", "·", "✓", "⚠", "◆", "⟳"])
|
|
191
|
+
expect(width(ch)).toBe(1);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("widgets", () => {
|
|
196
|
+
test("a meter is exactly `cells` wide at every fill level", () => {
|
|
197
|
+
for (const pct of [0, 1, 37, 50, 99, 100]) {
|
|
198
|
+
expect(width(meter(pct, 20))).toBe(20);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a meter takes 0..100, and clamps rather than overflowing", () => {
|
|
203
|
+
expect(stripAnsi(meter(0, 10))).toBe("░".repeat(10));
|
|
204
|
+
expect(stripAnsi(meter(100, 10))).toBe("█".repeat(10));
|
|
205
|
+
expect(stripAnsi(meter(-50, 10))).toBe("░".repeat(10));
|
|
206
|
+
expect(stripAnsi(meter(400, 10))).toBe("█".repeat(10));
|
|
207
|
+
// The classic mistake: passing a 0..1 ratio. It reads as 1%, i.e. empty —
|
|
208
|
+
// which is exactly why this asserts the 0..100 contract explicitly.
|
|
209
|
+
expect(stripAnsi(meter(1, 100))).toBe(`${"█"}${"░".repeat(99)}`);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("NaN does not silently paint a full bar", () => {
|
|
213
|
+
expect(stripAnsi(meter(Number.NaN, 10))).toBe("░".repeat(10));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a full meter really is a gradient, not one flat colour", () => {
|
|
217
|
+
// The failure this catches: a "gradient" that emits the same escape
|
|
218
|
+
// sequence for every cell. Character assertions cannot see it.
|
|
219
|
+
setColorEnabled(true);
|
|
220
|
+
const painted = meter(100, 24);
|
|
221
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
|
|
222
|
+
const distinct = new Set(painted.match(/\x1b\[38;2;[0-9;]+m/g) ?? []);
|
|
223
|
+
expect(distinct.size).toBeGreaterThanOrEqual(12);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a stacked bar is exactly `cells` wide however the counts split", () => {
|
|
227
|
+
for (const segs of [
|
|
228
|
+
[{ value: 1, color: "#f00" }],
|
|
229
|
+
[
|
|
230
|
+
{ value: 9, color: "#f00" },
|
|
231
|
+
{ value: 1, color: "#0f0" },
|
|
232
|
+
],
|
|
233
|
+
[
|
|
234
|
+
{ value: 1, color: "#f00" },
|
|
235
|
+
{ value: 1, color: "#0f0" },
|
|
236
|
+
{ value: 1, color: "#00f" },
|
|
237
|
+
{ value: 97, color: "#ff0" },
|
|
238
|
+
],
|
|
239
|
+
]) {
|
|
240
|
+
expect(width(stackedBar(24, segs))).toBe(24);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a tiny segment still gets a cell rather than vanishing", () => {
|
|
245
|
+
setColorEnabled(true);
|
|
246
|
+
const bar = stackedBar(20, [
|
|
247
|
+
{ value: 999, color: "#ff0000" },
|
|
248
|
+
{ value: 1, color: "#00ff00" },
|
|
249
|
+
]);
|
|
250
|
+
expect(bar).toContain("38;2;0;255;0m");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("an all-zero distribution renders an empty track, not nothing", () => {
|
|
254
|
+
expect(width(stackedBar(12, [{ value: 0, color: "#f00" }]))).toBe(12);
|
|
255
|
+
expect(stackedBar(0, [{ value: 3, color: "#f00" }])).toBe("");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("more segments than cells still yields exactly `cells`", () => {
|
|
259
|
+
// The width guarantee outranks "every segment gets a cell". The earlier
|
|
260
|
+
// shrink loop gave up once every segment was down to one and returned a
|
|
261
|
+
// row WIDER than asked for — 5 cells for cells=3 — silently breaking the
|
|
262
|
+
// alignment every caller depends on.
|
|
263
|
+
setColorEnabled(false);
|
|
264
|
+
const many = Array.from({ length: 5 }, (_, i) => ({
|
|
265
|
+
value: i + 1,
|
|
266
|
+
color: "#ff0000",
|
|
267
|
+
}));
|
|
268
|
+
expect(width(stackedBar(3, many))).toBe(3);
|
|
269
|
+
expect(width(stackedBar(1, many))).toBe(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("when segments are dropped, the largest survive", () => {
|
|
273
|
+
setColorEnabled(true);
|
|
274
|
+
const bar = stackedBar(2, [
|
|
275
|
+
{ value: 1, color: "#010101" },
|
|
276
|
+
{ value: 500, color: "#00ff00" },
|
|
277
|
+
{ value: 400, color: "#0000ff" },
|
|
278
|
+
]);
|
|
279
|
+
expect(width(bar)).toBe(2);
|
|
280
|
+
expect(bar).toContain("38;2;0;255;0m");
|
|
281
|
+
expect(bar).toContain("38;2;0;0;255m");
|
|
282
|
+
expect(bar).not.toContain("38;2;1;1;1m");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("the spinner cycles and never indexes out of bounds", () => {
|
|
286
|
+
expect(spinner(0)).toBe(spinner(10));
|
|
287
|
+
expect(spinner(-1)).toBeTruthy();
|
|
288
|
+
expect(width(spinner(7))).toBe(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("elapsed picks a unit per magnitude", () => {
|
|
292
|
+
expect(elapsed(340)).toBe("340ms");
|
|
293
|
+
expect(elapsed(1400)).toBe("1.4s");
|
|
294
|
+
expect(elapsed(124_000)).toBe("2m04s");
|
|
295
|
+
expect(elapsed(-1)).toBe("--");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the three progress seams the animated report depends on.
|
|
3
|
+
*
|
|
4
|
+
* All three shipped untested: `ApplyReporter`, `checkBinaries`'s `onProbe`, and
|
|
5
|
+
* the coupling between the apply meter's denominator and the items the apply
|
|
6
|
+
* loop actually touches. A review found that last one hand-duplicated
|
|
7
|
+
* `applyPlugins`'s skip conditions and replicated only one of the two, so a
|
|
8
|
+
* plan containing an actionable plugin with no scopes would leave the bar stuck
|
|
9
|
+
* short of full at the end of a successful run. It was unreachable then and
|
|
10
|
+
* nothing pinned it, which is the definition of a latent bug.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
14
|
+
import {
|
|
15
|
+
type ApplyReporter,
|
|
16
|
+
type PluginApplyDeps,
|
|
17
|
+
applyPlugins,
|
|
18
|
+
parseArgs,
|
|
19
|
+
pluginNeedsWork,
|
|
20
|
+
} from "../cli/update.js";
|
|
21
|
+
import { checkBinaries } from "../services/doctor-bins.js";
|
|
22
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
23
|
+
import type { PluginUpdateItem } from "../services/update-plan.js";
|
|
24
|
+
import type { ResolvedBin } from "../types/index.js";
|
|
25
|
+
|
|
26
|
+
const PROJECT = "/tmp/project";
|
|
27
|
+
|
|
28
|
+
function item(over: Partial<PluginUpdateItem> = {}): PluginUpdateItem {
|
|
29
|
+
return {
|
|
30
|
+
pluginId: "dev@magus",
|
|
31
|
+
pinned: "latest",
|
|
32
|
+
installed: "1.0.0",
|
|
33
|
+
available: "2.0.0",
|
|
34
|
+
target: "2.0.0",
|
|
35
|
+
action: "update",
|
|
36
|
+
scopes: ["project"],
|
|
37
|
+
...over,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function deps(): PluginApplyDeps {
|
|
42
|
+
return {
|
|
43
|
+
update: mock(async () => {}),
|
|
44
|
+
repair: mock(async () => {}),
|
|
45
|
+
readInstalled: mock(async () => "2.0.0"),
|
|
46
|
+
saveInstalled: mock(async () => {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Records the reporter callbacks in the order they fire. */
|
|
51
|
+
function recorder() {
|
|
52
|
+
const events: string[] = [];
|
|
53
|
+
const reporter: ApplyReporter = {
|
|
54
|
+
begin: (name) => events.push(`begin:${name}`),
|
|
55
|
+
finish: (ok, name) => events.push(`${ok ? "ok" : "fail"}:${name}`),
|
|
56
|
+
detach: async (work) => {
|
|
57
|
+
events.push("detach:start");
|
|
58
|
+
try {
|
|
59
|
+
return await work();
|
|
60
|
+
} finally {
|
|
61
|
+
events.push("detach:end");
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return { reporter, events };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("ApplyReporter", () => {
|
|
69
|
+
test("begin fires before the work and finish after it", async () => {
|
|
70
|
+
const { reporter, events } = recorder();
|
|
71
|
+
await applyPlugins([item()], PROJECT, deps(), reporter);
|
|
72
|
+
expect(events).toEqual(["begin:dev@magus", "ok:dev@magus"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a failure is reported through finish, not thrown", async () => {
|
|
76
|
+
const { reporter, events } = recorder();
|
|
77
|
+
const d = deps();
|
|
78
|
+
d.update = mock(async () => {
|
|
79
|
+
throw new Error("network down");
|
|
80
|
+
});
|
|
81
|
+
const result = await applyPlugins([item()], PROJECT, d, reporter);
|
|
82
|
+
expect(events).toEqual(["begin:dev@magus", "fail:dev@magus"]);
|
|
83
|
+
expect(result.failed).toEqual(["dev@magus"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a skipped item produces no events at all", async () => {
|
|
87
|
+
// The meter counts begin/finish pairs. An item that emits `begin` and
|
|
88
|
+
// never finishes would hang the bar; one that emits neither is correct.
|
|
89
|
+
const { reporter, events } = recorder();
|
|
90
|
+
await applyPlugins(
|
|
91
|
+
[
|
|
92
|
+
item({ action: "current" }),
|
|
93
|
+
item({ action: "unknown" }),
|
|
94
|
+
item({ action: "update", scopes: [] }),
|
|
95
|
+
],
|
|
96
|
+
PROJECT,
|
|
97
|
+
deps(),
|
|
98
|
+
reporter,
|
|
99
|
+
);
|
|
100
|
+
expect(events).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("the apply meter's denominator", () => {
|
|
105
|
+
test("pluginNeedsWork agrees with what applyPlugins actually runs", async () => {
|
|
106
|
+
// The property that matters: for ANY plan, the number of items the
|
|
107
|
+
// predicate selects equals the number the loop reports. Drift between
|
|
108
|
+
// them is what leaves the bar short of full.
|
|
109
|
+
const plan = [
|
|
110
|
+
item({ pluginId: "a", action: "current" }),
|
|
111
|
+
item({ pluginId: "b", action: "unknown" }),
|
|
112
|
+
item({ pluginId: "c", action: "update", scopes: ["project"] }),
|
|
113
|
+
item({ pluginId: "d", action: "update", scopes: [] }),
|
|
114
|
+
item({ pluginId: "e", action: "install", scopes: [] }),
|
|
115
|
+
item({ pluginId: "f", action: "repair", scopes: ["user", "project"] }),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const { reporter, events } = recorder();
|
|
119
|
+
await applyPlugins(plan, PROJECT, deps(), reporter);
|
|
120
|
+
|
|
121
|
+
const predicted = plan.filter(pluginNeedsWork).map((p) => p.pluginId);
|
|
122
|
+
const actual = events
|
|
123
|
+
.filter((e) => e.startsWith("begin:"))
|
|
124
|
+
.map((e) => e.slice("begin:".length));
|
|
125
|
+
|
|
126
|
+
expect(actual).toEqual(predicted);
|
|
127
|
+
// And specifically: an install with no scopes IS work (it goes in at
|
|
128
|
+
// project scope), while an update with no scopes is not.
|
|
129
|
+
expect(predicted).toEqual(["c", "e", "f"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("an item touching two scopes still counts once", async () => {
|
|
133
|
+
const { reporter, events } = recorder();
|
|
134
|
+
await applyPlugins(
|
|
135
|
+
[item({ scopes: ["user", "project"] })],
|
|
136
|
+
PROJECT,
|
|
137
|
+
deps(),
|
|
138
|
+
reporter,
|
|
139
|
+
);
|
|
140
|
+
expect(events.filter((e) => e.startsWith("begin:"))).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("flags", () => {
|
|
145
|
+
test("`--dry-run` and `--check` are distinct modes", () => {
|
|
146
|
+
// Both print and write nothing, but they answer different questions and
|
|
147
|
+
// exit differently: --check is the CI gate (exit 1 when behind), while
|
|
148
|
+
// --dry-run is a preview and always exits 0.
|
|
149
|
+
expect(parseArgs(["--dry-run"])).toMatchObject({
|
|
150
|
+
dryRun: true,
|
|
151
|
+
check: false,
|
|
152
|
+
});
|
|
153
|
+
expect(parseArgs(["--check"])).toMatchObject({
|
|
154
|
+
dryRun: false,
|
|
155
|
+
check: true,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("`--yes` and `-y` are accepted and ignored, not treated as a profile", () => {
|
|
160
|
+
// The confirmation they skipped no longer exists. An old script or a
|
|
161
|
+
// habit must still run and still do the right thing — and crucially the
|
|
162
|
+
// flag must not be mistaken for a profile name, which would send the run
|
|
163
|
+
// at a profile called "-y" and fail.
|
|
164
|
+
for (const flag of ["--yes", "-y"]) {
|
|
165
|
+
expect(parseArgs([flag]).profile).toBeUndefined();
|
|
166
|
+
expect(parseArgs(["lean", flag]).profile).toBe("lean");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a profile name is picked out from among flags in any order", () => {
|
|
171
|
+
expect(parseArgs(["--check", "lean"]).profile).toBe("lean");
|
|
172
|
+
expect(parseArgs(["lean", "--dry-run"]).profile).toBe("lean");
|
|
173
|
+
expect(parseArgs([]).profile).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("checkBinaries onProbe", () => {
|
|
178
|
+
const bins: ResolvedBin[] = [
|
|
179
|
+
{ name: "tmux", via: "brew", sources: [] } as unknown as ResolvedBin,
|
|
180
|
+
{ name: "tmux-mcp", via: "bun", sources: [] } as unknown as ResolvedBin,
|
|
181
|
+
{ name: "jq", via: "brew", sources: [] } as unknown as ResolvedBin,
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
test("fires once per binary, before its probe, with a stable total", async () => {
|
|
185
|
+
// Before the probe matters: the point is to name the binary the command
|
|
186
|
+
// is currently blocked on, not the one it just finished.
|
|
187
|
+
const seen: Array<[string, number, number]> = [];
|
|
188
|
+
const order: string[] = [];
|
|
189
|
+
await checkBinaries(
|
|
190
|
+
bins,
|
|
191
|
+
async (name) => {
|
|
192
|
+
order.push(`probe:${name}`);
|
|
193
|
+
return true;
|
|
194
|
+
},
|
|
195
|
+
(name, index, total) => {
|
|
196
|
+
order.push(`announce:${name}`);
|
|
197
|
+
seen.push([name, index, total]);
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(seen).toEqual([
|
|
202
|
+
["tmux", 0, 3],
|
|
203
|
+
["tmux-mcp", 1, 3],
|
|
204
|
+
["jq", 2, 3],
|
|
205
|
+
]);
|
|
206
|
+
expect(order.slice(0, 2)).toEqual(["announce:tmux", "probe:tmux"]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("the index is 0-based, so done/total needs index+1", async () => {
|
|
210
|
+
// A meter fed the raw index reads 0/3 while the first probe runs, which
|
|
211
|
+
// is correct — it has finished none of them.
|
|
212
|
+
const first: number[] = [];
|
|
213
|
+
await checkBinaries(bins, async () => true, (_n, index) =>
|
|
214
|
+
first.push(index),
|
|
215
|
+
);
|
|
216
|
+
expect(first[0]).toBe(0);
|
|
217
|
+
expect(first.at(-1)).toBe(bins.length - 1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("no callback means no behaviour change", async () => {
|
|
221
|
+
const results = await checkBinaries(bins, async () => false);
|
|
222
|
+
expect(results.map((r) => r.present)).toEqual([false, false, false]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("an empty bin list announces nothing", async () => {
|
|
226
|
+
const seen: string[] = [];
|
|
227
|
+
const results = await checkBinaries([], async () => true, (n) =>
|
|
228
|
+
seen.push(n),
|
|
229
|
+
);
|
|
230
|
+
expect(results).toEqual([]);
|
|
231
|
+
expect(seen).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
});
|