magus-cli 7.6.0 → 7.8.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__/blackbox-parity.test.ts +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -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 +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +9 -34
- package/src/__tests__/gitignore-profile.test.ts +0 -4
- package/src/__tests__/models-screen-state.test.ts +18 -40
- 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__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -42
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/gitignore-service.ts +0 -19
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +1 -1
- package/src/services/profile-startup.ts +30 -27
- package/src/services/profile-state.ts +81 -2
- package/src/services/resolver.ts +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +186 -123
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +299 -28
- package/src/ui/adapters/startupReports.ts +282 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -13
- 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 +31 -31
- package/src/ui/hooks/useProfileStartupModal.ts +127 -122
- package/src/ui/hooks/useStaleLoadModal.ts +33 -53
- package/src/ui/keymap.ts +14 -1
- 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 -44
- package/src/ui/screens/CliToolsScreen.tsx +65 -51
- package/src/ui/screens/EnvVarsScreen.tsx +21 -6
- package/src/ui/screens/GitignoreScreen.tsx +49 -75
- package/src/ui/screens/McpRegistryScreen.tsx +72 -40
- package/src/ui/screens/McpScreen.tsx +55 -24
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +316 -245
- package/src/ui/screens/ProfilesScreen.tsx +88 -73
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +72 -7
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +25 -14
- package/src/ui/state/types.ts +61 -31
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/version-snapshot.ts +0 -189
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* magus 7.8.0 QA spec, section D — deprecated plugins.
|
|
3
|
+
*
|
|
4
|
+
* Written blind: from the spec and the exported signatures of
|
|
5
|
+
* services/update-plan.ts, services/update-engine.ts,
|
|
6
|
+
* ui/renderers/pluginStatusText.ts and cli/update-view.ts. No function body
|
|
7
|
+
* was read.
|
|
8
|
+
*
|
|
9
|
+
* Coverage map (requirement → describe block):
|
|
10
|
+
* D1 unlisted by a READ marketplace → remove/deprecated → "D1"
|
|
11
|
+
* D2 remove scopes = installed scopes ([] if declared only) → "D2"
|
|
12
|
+
* D3 unread marketplace → unknown/marketplace-unread → "D3"
|
|
13
|
+
* D4 pluginNeedsWork is true for remove → "D4"
|
|
14
|
+
* D5 apply: uninstall each scope, drop from profile, verify → "D5"
|
|
15
|
+
* D6 Plugins row text → "D6"
|
|
16
|
+
* D7 CLI view Deprecated group → "D7"
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { setColorEnabled, stripAnsi } from "../cli/ansi.js";
|
|
21
|
+
import { planReport } from "../cli/update-view.js";
|
|
22
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
23
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
24
|
+
import {
|
|
25
|
+
type ApplyReporter,
|
|
26
|
+
type PluginApplyDeps,
|
|
27
|
+
type PluginRemoveDeps,
|
|
28
|
+
applyPlugins,
|
|
29
|
+
planWorkItems,
|
|
30
|
+
pluginName,
|
|
31
|
+
pluginNeedsWork,
|
|
32
|
+
} from "../services/update-engine.js";
|
|
33
|
+
import {
|
|
34
|
+
type PluginUpdateItem,
|
|
35
|
+
type UpdatePlan,
|
|
36
|
+
classifyPlugin,
|
|
37
|
+
indexCatalog,
|
|
38
|
+
planHasWork,
|
|
39
|
+
planIsBehind,
|
|
40
|
+
planPluginUpdates,
|
|
41
|
+
} from "../services/update-plan.js";
|
|
42
|
+
import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
|
|
43
|
+
|
|
44
|
+
const PROJECT = "/tmp/blind-780-project";
|
|
45
|
+
const SCOPES: PluginScope[] = ["user", "project", "local"];
|
|
46
|
+
|
|
47
|
+
afterEach(() => setColorEnabled(null));
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Builders
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
|
|
54
|
+
return {
|
|
55
|
+
name: over.id.split("@")[0] as string,
|
|
56
|
+
version: null,
|
|
57
|
+
description: "",
|
|
58
|
+
marketplace: over.id.split("@")[1] ?? "magus",
|
|
59
|
+
marketplaceDisplay: "",
|
|
60
|
+
...over,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A plugin the marketplace publishes — which is what makes "magus" a READ marketplace. */
|
|
65
|
+
const LISTED = info({
|
|
66
|
+
id: "alive@magus",
|
|
67
|
+
version: "1.0.0",
|
|
68
|
+
projectScope: { installed: true, version: "1.0.0" },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** Installed here, but its marketplace (which we read) does not list it. */
|
|
72
|
+
function orphan(id: string, scopes: Partial<Record<PluginScope, string>>) {
|
|
73
|
+
return info({
|
|
74
|
+
id,
|
|
75
|
+
version: scopes.project ?? scopes.user ?? scopes.local ?? null,
|
|
76
|
+
isOrphaned: true,
|
|
77
|
+
userScope: scopes.user
|
|
78
|
+
? { installed: true, version: scopes.user }
|
|
79
|
+
: undefined,
|
|
80
|
+
projectScope: scopes.project
|
|
81
|
+
? { installed: true, version: scopes.project }
|
|
82
|
+
: undefined,
|
|
83
|
+
localScope: scopes.local
|
|
84
|
+
? { installed: true, version: scopes.local }
|
|
85
|
+
: undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function itemFor(
|
|
90
|
+
items: PluginUpdateItem[],
|
|
91
|
+
pluginId: string,
|
|
92
|
+
): PluginUpdateItem {
|
|
93
|
+
const found = items.find((i) => i.pluginId === pluginId);
|
|
94
|
+
if (!found) throw new Error(`${pluginId} not planned`);
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function removeItem(over: Partial<PluginUpdateItem> = {}): PluginUpdateItem {
|
|
99
|
+
return {
|
|
100
|
+
pluginId: "old@magus",
|
|
101
|
+
pinned: "latest",
|
|
102
|
+
installed: "0.9.0",
|
|
103
|
+
loaded: "0.9.0",
|
|
104
|
+
available: null,
|
|
105
|
+
target: null,
|
|
106
|
+
action: "remove",
|
|
107
|
+
reason: "deprecated",
|
|
108
|
+
scopes: ["project"],
|
|
109
|
+
beforeVersions: { project: "0.9.0" },
|
|
110
|
+
...over,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A machine: which scopes each plugin is installed at, and which plugins the
|
|
116
|
+
* profile lists. The remove deps act on it; the other deps must not be called.
|
|
117
|
+
*/
|
|
118
|
+
function machine(
|
|
119
|
+
installed: Record<string, PluginScope[]>,
|
|
120
|
+
profile: string[],
|
|
121
|
+
behaviour: {
|
|
122
|
+
uninstallThrows?: boolean;
|
|
123
|
+
uninstallIsNoop?: boolean;
|
|
124
|
+
dropReturns?: boolean;
|
|
125
|
+
} = {},
|
|
126
|
+
) {
|
|
127
|
+
const state = {
|
|
128
|
+
installed: new Map(
|
|
129
|
+
Object.entries(installed).map(([k, v]) => [k, new Set(v)]),
|
|
130
|
+
),
|
|
131
|
+
profile: new Set(profile),
|
|
132
|
+
uninstalls: [] as Array<{ id: string; scope: PluginScope }>,
|
|
133
|
+
drops: [] as Array<{ id: string; profile: string | null | undefined }>,
|
|
134
|
+
otherCalls: [] as string[],
|
|
135
|
+
};
|
|
136
|
+
const remove: PluginRemoveDeps = {
|
|
137
|
+
uninstall: async (id, scope) => {
|
|
138
|
+
state.uninstalls.push({ id, scope });
|
|
139
|
+
if (behaviour.uninstallThrows) throw new Error("uninstall exploded");
|
|
140
|
+
if (!behaviour.uninstallIsNoop) state.installed.get(id)?.delete(scope);
|
|
141
|
+
},
|
|
142
|
+
isInstalled: async (id, scope) =>
|
|
143
|
+
state.installed.get(id)?.has(scope) ?? false,
|
|
144
|
+
dropFromProfile: async (id, _project, profile) => {
|
|
145
|
+
state.drops.push({ id, profile });
|
|
146
|
+
if (behaviour.dropReturns === false) return false;
|
|
147
|
+
state.profile.delete(id);
|
|
148
|
+
return !state.profile.has(id);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const unexpected = (name: string) => async () => {
|
|
152
|
+
state.otherCalls.push(name);
|
|
153
|
+
throw new Error(`${name} must not be called for a remove`);
|
|
154
|
+
};
|
|
155
|
+
const deps: PluginApplyDeps = {
|
|
156
|
+
install: unexpected("install"),
|
|
157
|
+
update: unexpected("update"),
|
|
158
|
+
repair: unexpected("repair"),
|
|
159
|
+
readInstalled: async () => null,
|
|
160
|
+
saveInstalled: unexpected("saveInstalled"),
|
|
161
|
+
repairStaleLoad: unexpected("repairStaleLoad"),
|
|
162
|
+
remove,
|
|
163
|
+
};
|
|
164
|
+
return { deps, state };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function recordingReporter() {
|
|
168
|
+
const finished: Array<{ ok: boolean; name: string; detail: string }> = [];
|
|
169
|
+
const reporter: ApplyReporter = {
|
|
170
|
+
begin: () => {},
|
|
171
|
+
finish: (ok, name, detail) => finished.push({ ok, name, detail }),
|
|
172
|
+
detach: (work) => work(),
|
|
173
|
+
};
|
|
174
|
+
return { reporter, finished };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// D1 / D2 / D3 — the planner
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe("D1 a plugin its READ marketplace does not list is deprecated", () => {
|
|
182
|
+
test("D1 installed at project scope, unlisted → remove / deprecated", () => {
|
|
183
|
+
const items = planPluginUpdates({}, [
|
|
184
|
+
LISTED,
|
|
185
|
+
orphan("old@magus", { project: "0.9.0" }),
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
const old = itemFor(items, "old@magus");
|
|
189
|
+
expect({ action: old.action, reason: old.reason }).toEqual({
|
|
190
|
+
action: "remove",
|
|
191
|
+
reason: "deprecated",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("D1 declared by the profile only, installed nowhere, unlisted → remove / deprecated", () => {
|
|
196
|
+
const items = planPluginUpdates({ "gone@magus": "latest" }, [LISTED]);
|
|
197
|
+
|
|
198
|
+
const gone = itemFor(items, "gone@magus");
|
|
199
|
+
expect({ action: gone.action, reason: gone.reason }).toEqual({
|
|
200
|
+
action: "remove",
|
|
201
|
+
reason: "deprecated",
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("D1 declared by the profile under an exact pin and unlisted → still deprecated", () => {
|
|
206
|
+
const items = planPluginUpdates({ "gone@magus": "1.2.3" }, [LISTED]);
|
|
207
|
+
|
|
208
|
+
expect(itemFor(items, "gone@magus").reason).toBe("deprecated");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("D1 declared AND installed, unlisted → remove / deprecated", () => {
|
|
212
|
+
const items = planPluginUpdates({ "old@magus": "latest" }, [
|
|
213
|
+
LISTED,
|
|
214
|
+
orphan("old@magus", { user: "0.9.0" }),
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
expect(itemFor(items, "old@magus").action).toBe("remove");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("D1 the same through classifyPlugin over indexCatalog", () => {
|
|
221
|
+
const index = indexCatalog([
|
|
222
|
+
LISTED,
|
|
223
|
+
orphan("old@magus", { local: "0.9.0" }),
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
const old = classifyPlugin("old@magus", "latest", index);
|
|
227
|
+
|
|
228
|
+
expect({ action: old.action, reason: old.reason }).toEqual({
|
|
229
|
+
action: "remove",
|
|
230
|
+
reason: "deprecated",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("D1 the listed plugin beside it is not deprecated", () => {
|
|
235
|
+
const items = planPluginUpdates({}, [
|
|
236
|
+
LISTED,
|
|
237
|
+
orphan("old@magus", { project: "0.9.0" }),
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
expect(itemFor(items, "alive@magus").action).not.toBe("remove");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("D2 scopes of a remove item", () => {
|
|
245
|
+
test("D2 one installed scope → exactly that scope", () => {
|
|
246
|
+
const items = planPluginUpdates({}, [
|
|
247
|
+
LISTED,
|
|
248
|
+
orphan("old@magus", { project: "0.9.0" }),
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
expect(itemFor(items, "old@magus").scopes).toEqual(["project"]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("D2 installed at user and local → both, and nothing else", () => {
|
|
255
|
+
const items = planPluginUpdates({}, [
|
|
256
|
+
LISTED,
|
|
257
|
+
orphan("old@magus", { user: "0.9.0", local: "0.8.0" }),
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
expect([...itemFor(items, "old@magus").scopes].sort()).toEqual([
|
|
261
|
+
"local",
|
|
262
|
+
"user",
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("D2 installed at all three scopes → all three", () => {
|
|
267
|
+
const items = planPluginUpdates({}, [
|
|
268
|
+
LISTED,
|
|
269
|
+
orphan("old@magus", { user: "0.9.0", project: "0.9.0", local: "0.9.0" }),
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
expect([...itemFor(items, "old@magus").scopes].sort()).toEqual([
|
|
273
|
+
"local",
|
|
274
|
+
"project",
|
|
275
|
+
"user",
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("D2 declared only → scopes is empty", () => {
|
|
280
|
+
const items = planPluginUpdates({ "gone@magus": "latest" }, [LISTED]);
|
|
281
|
+
|
|
282
|
+
expect(itemFor(items, "gone@magus").scopes).toEqual([]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("D3 a plugin whose marketplace was NOT read", () => {
|
|
287
|
+
test("D3 installed, its marketplace contributes no listed id → unknown / marketplace-unread", () => {
|
|
288
|
+
const items = planPluginUpdates({}, [
|
|
289
|
+
LISTED,
|
|
290
|
+
orphan("thing@unread-market", { project: "1.0.0" }),
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
const thing = itemFor(items, "thing@unread-market");
|
|
294
|
+
expect({ action: thing.action, reason: thing.reason }).toEqual({
|
|
295
|
+
action: "unknown",
|
|
296
|
+
reason: "marketplace-unread",
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("D3 declared by the profile and installed, marketplace unread → unknown, not remove", () => {
|
|
301
|
+
const items = planPluginUpdates({ "thing@unread-market": "latest" }, [
|
|
302
|
+
LISTED,
|
|
303
|
+
orphan("thing@unread-market", { user: "1.0.0" }),
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
const thing = itemFor(items, "thing@unread-market");
|
|
307
|
+
expect({ action: thing.action, reason: thing.reason }).toEqual({
|
|
308
|
+
action: "unknown",
|
|
309
|
+
reason: "marketplace-unread",
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("D3 declared only, marketplace unread → never planned as a removal", () => {
|
|
314
|
+
const items = planPluginUpdates({ "thing@unread-market": "latest" }, [
|
|
315
|
+
LISTED,
|
|
316
|
+
]);
|
|
317
|
+
|
|
318
|
+
const thing = itemFor(items, "thing@unread-market");
|
|
319
|
+
expect(thing.action).not.toBe("remove");
|
|
320
|
+
expect(thing.reason).not.toBe("deprecated");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("D3 a marketplace whose only entries are orphans counts as unread", () => {
|
|
324
|
+
const items = planPluginUpdates({}, [
|
|
325
|
+
orphan("a@ghost-market", { project: "1.0.0" }),
|
|
326
|
+
orphan("b@ghost-market", { project: "1.0.0" }),
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
for (const id of ["a@ghost-market", "b@ghost-market"]) {
|
|
330
|
+
expect(itemFor(items, id).action).toBe("unknown");
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// D4 — pluginNeedsWork
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe("D4 pluginNeedsWork", () => {
|
|
340
|
+
test("D4 true for a remove item installed at a scope", () => {
|
|
341
|
+
expect(pluginNeedsWork(removeItem())).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("D4 true for a remove item with no scopes (declared only)", () => {
|
|
345
|
+
expect(pluginNeedsWork(removeItem({ scopes: [], installed: null }))).toBe(
|
|
346
|
+
true,
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("D4 true for the remove items the planner produces", () => {
|
|
351
|
+
const items = planPluginUpdates({ "gone@magus": "latest" }, [
|
|
352
|
+
LISTED,
|
|
353
|
+
orphan("old@magus", { project: "0.9.0" }),
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
expect(pluginNeedsWork(itemFor(items, "old@magus"))).toBe(true);
|
|
357
|
+
expect(pluginNeedsWork(itemFor(items, "gone@magus"))).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// D5 — applying a remove
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
describe("D5 applying a remove item", () => {
|
|
366
|
+
test("D5 uninstalls at every scope, drops it from the profile, counts it removed", async () => {
|
|
367
|
+
const m = machine({ "old@magus": ["user", "project"] }, ["old@magus"]);
|
|
368
|
+
|
|
369
|
+
const result = await applyPlugins(
|
|
370
|
+
[removeItem({ scopes: ["user", "project"] })],
|
|
371
|
+
PROJECT,
|
|
372
|
+
m.deps,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(m.state.uninstalls.map((u) => `${u.id}:${u.scope}`).sort()).toEqual([
|
|
376
|
+
"old@magus:project",
|
|
377
|
+
"old@magus:user",
|
|
378
|
+
]);
|
|
379
|
+
expect(m.state.drops.map((d) => d.id)).toEqual(["old@magus"]);
|
|
380
|
+
expect(result.removed.map(pluginName)).toEqual(["old"]);
|
|
381
|
+
expect(result.failed).toEqual([]);
|
|
382
|
+
expect(m.state.otherCalls).toEqual([]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("D5 afterwards it is installed nowhere and the profile no longer lists it", async () => {
|
|
386
|
+
const m = machine({ "old@magus": ["local"] }, ["old@magus", "keep@magus"]);
|
|
387
|
+
|
|
388
|
+
await applyPlugins([removeItem({ scopes: ["local"] })], PROJECT, m.deps);
|
|
389
|
+
|
|
390
|
+
expect(
|
|
391
|
+
SCOPES.filter((s) => m.state.installed.get("old@magus")?.has(s)),
|
|
392
|
+
).toEqual([]);
|
|
393
|
+
expect([...m.state.profile]).toEqual(["keep@magus"]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("D5 declared only (no scopes): no uninstall, profile edited, counted removed", async () => {
|
|
397
|
+
const m = machine({}, ["gone@magus"]);
|
|
398
|
+
|
|
399
|
+
const result = await applyPlugins(
|
|
400
|
+
[
|
|
401
|
+
removeItem({
|
|
402
|
+
pluginId: "gone@magus",
|
|
403
|
+
scopes: [],
|
|
404
|
+
installed: null,
|
|
405
|
+
loaded: null,
|
|
406
|
+
beforeVersions: {},
|
|
407
|
+
}),
|
|
408
|
+
],
|
|
409
|
+
PROJECT,
|
|
410
|
+
m.deps,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
expect(m.state.uninstalls).toEqual([]);
|
|
414
|
+
expect(m.state.drops.map((d) => d.id)).toEqual(["gone@magus"]);
|
|
415
|
+
expect(result.removed.map(pluginName)).toEqual(["gone"]);
|
|
416
|
+
expect(result.failed).toEqual([]);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("D5 the uninstall throws → failed, not removed", async () => {
|
|
420
|
+
const m = machine({ "old@magus": ["project"] }, ["old@magus"], {
|
|
421
|
+
uninstallThrows: true,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const result = await applyPlugins([removeItem()], PROJECT, m.deps);
|
|
425
|
+
|
|
426
|
+
expect(result.removed).toEqual([]);
|
|
427
|
+
expect(result.failed.map(pluginName)).toEqual(["old"]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("D5 the uninstall returns but it is still installed → failed, not removed", async () => {
|
|
431
|
+
const m = machine({ "old@magus": ["project"] }, ["old@magus"], {
|
|
432
|
+
uninstallIsNoop: true,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const result = await applyPlugins([removeItem()], PROJECT, m.deps);
|
|
436
|
+
|
|
437
|
+
expect(result.removed).toEqual([]);
|
|
438
|
+
expect(result.failed.map(pluginName)).toEqual(["old"]);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("D5 the profile still lists it afterwards → failed, not removed", async () => {
|
|
442
|
+
const m = machine({ "old@magus": ["project"] }, ["old@magus"], {
|
|
443
|
+
dropReturns: false,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const result = await applyPlugins([removeItem()], PROJECT, m.deps);
|
|
447
|
+
|
|
448
|
+
expect(result.removed).toEqual([]);
|
|
449
|
+
expect(result.failed.map(pluginName)).toEqual(["old"]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("D5 still installed at a scope the item did not name → failed (installed nowhere is the bar)", async () => {
|
|
453
|
+
const m = machine({ "old@magus": ["project", "user"] }, ["old@magus"]);
|
|
454
|
+
|
|
455
|
+
const result = await applyPlugins(
|
|
456
|
+
[removeItem({ scopes: ["project"] })],
|
|
457
|
+
PROJECT,
|
|
458
|
+
m.deps,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
expect(result.removed).toEqual([]);
|
|
462
|
+
expect(result.failed.map(pluginName)).toEqual(["old"]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("D5 a remove item applied with no remove deps fails rather than claiming a removal", async () => {
|
|
466
|
+
const m = machine({ "old@magus": ["project"] }, ["old@magus"]);
|
|
467
|
+
const { remove: _ignored, ...withoutRemove } = m.deps;
|
|
468
|
+
|
|
469
|
+
const result = await applyPlugins([removeItem()], PROJECT, withoutRemove);
|
|
470
|
+
|
|
471
|
+
expect(result.removed).toEqual([]);
|
|
472
|
+
expect(result.failed.map(pluginName)).toEqual(["old"]);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("D5 the reporter hears a failure for a failed removal and a success for a removal", async () => {
|
|
476
|
+
const m = machine({ "old@magus": ["project"], "dead@magus": ["user"] }, [
|
|
477
|
+
"old@magus",
|
|
478
|
+
"dead@magus",
|
|
479
|
+
]);
|
|
480
|
+
const failing = machine({ "dead@magus": ["user"] }, ["dead@magus"], {
|
|
481
|
+
uninstallThrows: true,
|
|
482
|
+
});
|
|
483
|
+
const ok = recordingReporter();
|
|
484
|
+
const bad = recordingReporter();
|
|
485
|
+
|
|
486
|
+
await applyPlugins([removeItem()], PROJECT, m.deps, ok.reporter);
|
|
487
|
+
await applyPlugins(
|
|
488
|
+
[removeItem({ pluginId: "dead@magus", scopes: ["user"] })],
|
|
489
|
+
PROJECT,
|
|
490
|
+
failing.deps,
|
|
491
|
+
bad.reporter,
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
expect(ok.finished.map((f) => f.ok)).toEqual([true]);
|
|
495
|
+
expect(bad.finished.map((f) => f.ok)).toEqual([false]);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// D6 — the Plugins row text
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
describe("D6 Plugins row text", () => {
|
|
504
|
+
test("D6 a remove item reads ' deprecated — press a to remove' in the danger tone", () => {
|
|
505
|
+
expect(pluginStatusText(removeItem())).toEqual({
|
|
506
|
+
suffix: " deprecated — press a to remove",
|
|
507
|
+
tone: "danger",
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("D6 the same for a declared-only remove item", () => {
|
|
512
|
+
expect(
|
|
513
|
+
pluginStatusText(removeItem({ scopes: [], installed: null })),
|
|
514
|
+
).toEqual({ suffix: " deprecated — press a to remove", tone: "danger" });
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("D6 the planner's own remove item renders the same text", () => {
|
|
518
|
+
const items = planPluginUpdates({}, [
|
|
519
|
+
LISTED,
|
|
520
|
+
orphan("old@magus", { project: "0.9.0" }),
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
expect(pluginStatusText(itemFor(items, "old@magus"))).toEqual({
|
|
524
|
+
suffix: " deprecated — press a to remove",
|
|
525
|
+
tone: "danger",
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("D6 never contains 'marketplace', even when the item's note does", () => {
|
|
530
|
+
const text = pluginStatusText(
|
|
531
|
+
removeItem({ note: "no longer listed by its marketplace" }),
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
expect(text.suffix.toLowerCase()).not.toContain("marketplace");
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// D7 — the CLI view
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
function planWith(plugins: PluginUpdateItem[]): UpdatePlan {
|
|
543
|
+
return {
|
|
544
|
+
profileId: "blind",
|
|
545
|
+
plugins,
|
|
546
|
+
bins: [],
|
|
547
|
+
skills: [],
|
|
548
|
+
binSpecs: new Map(),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const CURRENT: PluginUpdateItem = {
|
|
553
|
+
pluginId: "alive@magus",
|
|
554
|
+
pinned: "latest",
|
|
555
|
+
installed: "1.0.0",
|
|
556
|
+
loaded: "1.0.0",
|
|
557
|
+
available: "1.0.0",
|
|
558
|
+
target: null,
|
|
559
|
+
action: "current",
|
|
560
|
+
reason: "up-to-date",
|
|
561
|
+
scopes: ["project"],
|
|
562
|
+
beforeVersions: { project: "1.0.0" },
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
describe("D7 CLI view", () => {
|
|
566
|
+
test("D7 remove items are listed under a 'Deprecated' group with a DEPRECATED chip", () => {
|
|
567
|
+
setColorEnabled(false);
|
|
568
|
+
|
|
569
|
+
const lines = planReport(planWith([CURRENT, removeItem()])).map((l) =>
|
|
570
|
+
stripAnsi(l),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const heading = lines.findIndex((l) => /^\s*Deprecated\b/.test(l));
|
|
574
|
+
const row = lines.findIndex((l) => l.includes("old@magus"));
|
|
575
|
+
expect(heading).toBeGreaterThanOrEqual(0);
|
|
576
|
+
expect(row).toBeGreaterThan(heading);
|
|
577
|
+
expect(lines[row]).toContain("DEPRECATED");
|
|
578
|
+
const nextHeading = lines.findIndex(
|
|
579
|
+
(l, i) => i > heading && /^\s{0,2}[A-Z][a-z]+\s+\d+\s*$/.test(l),
|
|
580
|
+
);
|
|
581
|
+
if (nextHeading !== -1) expect(row).toBeLessThan(nextHeading);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("D7 the Deprecated group counts its items", () => {
|
|
585
|
+
setColorEnabled(false);
|
|
586
|
+
|
|
587
|
+
const lines = planReport(
|
|
588
|
+
planWith([
|
|
589
|
+
CURRENT,
|
|
590
|
+
removeItem(),
|
|
591
|
+
removeItem({ pluginId: "gone@magus", scopes: [], installed: null }),
|
|
592
|
+
]),
|
|
593
|
+
).map((l) => stripAnsi(l));
|
|
594
|
+
|
|
595
|
+
expect(lines.some((l) => /^\s*Deprecated\s+2\s*$/.test(l))).toBe(true);
|
|
596
|
+
expect(lines.filter((l) => l.includes("DEPRECATED"))).toHaveLength(2);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("D7 the current plugin is not under the Deprecated group", () => {
|
|
600
|
+
setColorEnabled(false);
|
|
601
|
+
|
|
602
|
+
const lines = planReport(planWith([CURRENT, removeItem()])).map((l) =>
|
|
603
|
+
stripAnsi(l),
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const aliveRow = lines.find((l) => l.includes("alive@magus")) ?? "";
|
|
607
|
+
expect(aliveRow).not.toContain("DEPRECATED");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("D7 remove items count as items to apply", () => {
|
|
611
|
+
const plan = planWith([
|
|
612
|
+
CURRENT,
|
|
613
|
+
removeItem(),
|
|
614
|
+
removeItem({ pluginId: "gone@magus", scopes: [], installed: null }),
|
|
615
|
+
]);
|
|
616
|
+
|
|
617
|
+
const work = planWorkItems(plan);
|
|
618
|
+
|
|
619
|
+
expect(work).toHaveLength(2);
|
|
620
|
+
expect(work.map(pluginName).sort()).toEqual(["gone", "old"]);
|
|
621
|
+
expect(planHasWork(plan)).toBe(true);
|
|
622
|
+
expect(planIsBehind(plan)).toBe(true);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("D7 a plan of current plugins only has nothing to apply (control)", () => {
|
|
626
|
+
const plan = planWith([CURRENT]);
|
|
627
|
+
|
|
628
|
+
expect(planWorkItems(plan)).toEqual([]);
|
|
629
|
+
expect(planHasWork(plan)).toBe(false);
|
|
630
|
+
});
|
|
631
|
+
});
|