magus-cli 7.5.3 → 7.6.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-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin's installed version comes only from scopes where it is INSTALLED —
|
|
3
|
+
* and "does this project hold a row?" reads only this project's own row.
|
|
4
|
+
*
|
|
5
|
+
* `getAvailablePlugins` took `installedVersion` from the project lookup whenever
|
|
6
|
+
* the plugin was installed anywhere, and that lookup resolves a row inherited
|
|
7
|
+
* from another checkout, installed here or not. So with the plugin installed at
|
|
8
|
+
* user scope at 0.9.0 beside an inherited project row at 0.8.1, the panel said
|
|
9
|
+
* "(v0.8.1 installed)" next to a status of current at 0.9.0; with its user files
|
|
10
|
+
* missing, the header said "● Installed" above a status saying files missing.
|
|
11
|
+
*
|
|
12
|
+
* The same kind of fallback sat under the apply engine's row check: the
|
|
13
|
+
* read-back fell back to the user row, so a plugin installed only at user scope,
|
|
14
|
+
* and turned on in project settings, ran `update --scope project` from a folder
|
|
15
|
+
* holding no project row.
|
|
16
|
+
*
|
|
17
|
+
* The pure halves — which row each question reads — are in
|
|
18
|
+
* scope-row-lookup.test.ts.
|
|
19
|
+
*
|
|
20
|
+
* The real `getAvailablePlugins` and the real registry read-back run in a CHILD
|
|
21
|
+
* process that starts with HOME, CLAUDE_CONFIG_DIR and XDG_* inside a temp dir:
|
|
22
|
+
* Bun's `os.homedir()` ignores a HOME changed at runtime, and the registry path
|
|
23
|
+
* is built from it. The child's `fetch` serves one catalog and 404s the rest, and
|
|
24
|
+
* its PATH holds only `git`, so no network and no `claude` CLI is reachable.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import {
|
|
30
|
+
mkdir,
|
|
31
|
+
mkdtemp,
|
|
32
|
+
readFile,
|
|
33
|
+
realpath,
|
|
34
|
+
rm,
|
|
35
|
+
symlink,
|
|
36
|
+
writeFile,
|
|
37
|
+
} from "node:fs/promises";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
41
|
+
import { type ReactNode, act } from "react";
|
|
42
|
+
import {
|
|
43
|
+
type PluginInfo,
|
|
44
|
+
hasMissingFiles,
|
|
45
|
+
} from "../services/plugin-manager.js";
|
|
46
|
+
import {
|
|
47
|
+
type PluginUpdateItem,
|
|
48
|
+
classifyPluginRows,
|
|
49
|
+
} from "../services/update-plan.js";
|
|
50
|
+
import type { InstalledPluginEntry, Marketplace } from "../types/index.js";
|
|
51
|
+
import {
|
|
52
|
+
type PluginPluginItem,
|
|
53
|
+
buildPluginBrowserItems,
|
|
54
|
+
} from "../ui/adapters/pluginsAdapter.js";
|
|
55
|
+
import { renderPluginDetail } from "../ui/renderers/pluginRenderers.js";
|
|
56
|
+
import { pluginRowText } from "../ui/renderers/pluginRowText.js";
|
|
57
|
+
|
|
58
|
+
const ID = "probe@magus";
|
|
59
|
+
const SRC = join(import.meta.dir, "..");
|
|
60
|
+
|
|
61
|
+
const MAGUS: Marketplace = {
|
|
62
|
+
name: "magus",
|
|
63
|
+
displayName: "Magus",
|
|
64
|
+
source: { source: "github", repo: "MadAppGang/magus" },
|
|
65
|
+
description: "",
|
|
66
|
+
owned: true,
|
|
67
|
+
} as Marketplace;
|
|
68
|
+
|
|
69
|
+
// ── the child ────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Loads the plugin list with the real `getAvailablePlugins`, and — when the job
|
|
73
|
+
* asks — plans it and applies it with the real engine and the real registry
|
|
74
|
+
* read-back, the Claude CLI's three commands recorded instead of run.
|
|
75
|
+
*/
|
|
76
|
+
const CHILD = `
|
|
77
|
+
const job = JSON.parse(await Bun.file(process.argv[2]).text());
|
|
78
|
+
const os = await import("node:os");
|
|
79
|
+
if (os.homedir() !== job.home) {
|
|
80
|
+
await Bun.write(job.out, JSON.stringify({ guard: false }));
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
globalThis.fetch = async (input) => {
|
|
84
|
+
const url = String(input instanceof Request ? input.url : input);
|
|
85
|
+
if (url === job.catalogUrl) {
|
|
86
|
+
return new Response(JSON.stringify(job.catalog), {
|
|
87
|
+
status: 200,
|
|
88
|
+
headers: { "content-type": "application/json" },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return new Response("", { status: 404, statusText: "Not Found" });
|
|
92
|
+
};
|
|
93
|
+
console.log = () => {};
|
|
94
|
+
const pm = await import(job.modules.pluginManager);
|
|
95
|
+
const plugins = await pm.getAvailablePlugins(job.project);
|
|
96
|
+
let apply = null;
|
|
97
|
+
if (job.apply) {
|
|
98
|
+
const plan = await import(job.modules.updatePlan);
|
|
99
|
+
const engine = await import(job.modules.updateEngine);
|
|
100
|
+
const item = plan.planPluginUpdates({}, plugins).find((i) => i.pluginId === job.id);
|
|
101
|
+
const calls = [];
|
|
102
|
+
const record = (command) => async (_id, scope) => { calls.push([command, scope]); };
|
|
103
|
+
await engine.applyPlugins([item], job.project, {
|
|
104
|
+
...engine.REAL_PLUGIN_DEPS,
|
|
105
|
+
install: record("install"),
|
|
106
|
+
update: record("update"),
|
|
107
|
+
repair: record("repair"),
|
|
108
|
+
saveInstalled: async () => {},
|
|
109
|
+
repairStaleLoad: async () => { throw new Error("no stale-load repair expected"); },
|
|
110
|
+
loadedVersion: undefined,
|
|
111
|
+
registryRows: undefined,
|
|
112
|
+
});
|
|
113
|
+
apply = { action: item.action, scopes: item.scopes, calls };
|
|
114
|
+
}
|
|
115
|
+
await Bun.write(job.out, JSON.stringify({ guard: true, plugins, apply }));
|
|
116
|
+
process.exit(0);
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
interface World {
|
|
120
|
+
base: string;
|
|
121
|
+
home: string;
|
|
122
|
+
/** A git repository: its root's registry rows are inherited by `project`. */
|
|
123
|
+
repo: string;
|
|
124
|
+
/** `<repo>/sub` — the directory magus runs in. */
|
|
125
|
+
project: string;
|
|
126
|
+
env: Record<string, string>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface Seed {
|
|
130
|
+
/** `~/.claude/settings.json` enabledPlugins. */
|
|
131
|
+
userEnabled?: Record<string, boolean>;
|
|
132
|
+
/** `<project>/.claude/settings.json` enabledPlugins. */
|
|
133
|
+
projectEnabled?: Record<string, boolean>;
|
|
134
|
+
/** The plugin's registry rows; `onDisk` creates the row's cache directory. */
|
|
135
|
+
rows: Array<{
|
|
136
|
+
scope: "user" | "project" | "local";
|
|
137
|
+
projectPath?: string;
|
|
138
|
+
version: string;
|
|
139
|
+
onDisk: boolean;
|
|
140
|
+
}>;
|
|
141
|
+
/** The version the magus catalog publishes for the plugin. */
|
|
142
|
+
catalogVersion: string;
|
|
143
|
+
/** Plan and apply the plugin, recording the Claude CLI calls. */
|
|
144
|
+
apply?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface ChildResult {
|
|
148
|
+
plugins: PluginInfo[];
|
|
149
|
+
apply: {
|
|
150
|
+
action: string;
|
|
151
|
+
scopes: string[];
|
|
152
|
+
calls: Array<[string, string]>;
|
|
153
|
+
} | null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let root: string;
|
|
157
|
+
let binDir: string;
|
|
158
|
+
let childScript: string;
|
|
159
|
+
|
|
160
|
+
beforeAll(async () => {
|
|
161
|
+
root = await realpath(await mkdtemp(join(tmpdir(), "installed-version-")));
|
|
162
|
+
binDir = join(root, "bin");
|
|
163
|
+
await mkdir(binDir);
|
|
164
|
+
const git = Bun.which("git");
|
|
165
|
+
if (!git) throw new Error("git is required: inherited rows are found by it");
|
|
166
|
+
await symlink(git, join(binDir, "git"));
|
|
167
|
+
childScript = join(root, "child.ts");
|
|
168
|
+
await writeFile(childScript, CHILD);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
afterAll(async () => {
|
|
172
|
+
await rm(root, { recursive: true, force: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
let worlds = 0;
|
|
176
|
+
async function world(): Promise<World> {
|
|
177
|
+
const base = join(root, `world-${++worlds}`);
|
|
178
|
+
const home = join(base, "home");
|
|
179
|
+
const xdg = join(base, "xdg");
|
|
180
|
+
const repo = join(base, "repo");
|
|
181
|
+
const project = join(repo, "sub");
|
|
182
|
+
await mkdir(join(home, ".claude", "plugins"), { recursive: true });
|
|
183
|
+
for (const kind of ["config", "cache", "data", "state"]) {
|
|
184
|
+
await mkdir(join(xdg, kind), { recursive: true });
|
|
185
|
+
}
|
|
186
|
+
await mkdir(join(project, ".claude"), { recursive: true });
|
|
187
|
+
const env = {
|
|
188
|
+
PATH: `${binDir}:/usr/bin:/bin`,
|
|
189
|
+
HOME: home,
|
|
190
|
+
CLAUDE_CONFIG_DIR: join(home, ".claude"),
|
|
191
|
+
XDG_CONFIG_HOME: join(xdg, "config"),
|
|
192
|
+
XDG_CACHE_HOME: join(xdg, "cache"),
|
|
193
|
+
XDG_DATA_HOME: join(xdg, "data"),
|
|
194
|
+
XDG_STATE_HOME: join(xdg, "state"),
|
|
195
|
+
TMPDIR: tmpdir(),
|
|
196
|
+
NO_COLOR: "1",
|
|
197
|
+
TERM: "dumb",
|
|
198
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
199
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
200
|
+
};
|
|
201
|
+
const init = spawnSync("git", ["init", "-q", repo], { env });
|
|
202
|
+
if (init.status !== 0) throw new Error(`git init failed: ${init.stderr}`);
|
|
203
|
+
return { base, home, repo, project, env };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function writeJson(file: string, value: unknown): Promise<void> {
|
|
207
|
+
await mkdir(dirname(file), { recursive: true });
|
|
208
|
+
await writeFile(file, JSON.stringify(value, null, 2));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function run(w: World, seed: Seed): Promise<ChildResult> {
|
|
212
|
+
const claude = join(w.home, ".claude");
|
|
213
|
+
await writeJson(join(claude, "settings.json"), {
|
|
214
|
+
enabledPlugins: seed.userEnabled ?? {},
|
|
215
|
+
});
|
|
216
|
+
await writeJson(join(w.project, ".claude", "settings.json"), {
|
|
217
|
+
enabledPlugins: seed.projectEnabled ?? {},
|
|
218
|
+
});
|
|
219
|
+
const rows: InstalledPluginEntry[] = [];
|
|
220
|
+
for (const row of seed.rows) {
|
|
221
|
+
const installPath = join(
|
|
222
|
+
claude,
|
|
223
|
+
"plugins",
|
|
224
|
+
"cache",
|
|
225
|
+
"magus",
|
|
226
|
+
"probe",
|
|
227
|
+
row.version,
|
|
228
|
+
);
|
|
229
|
+
if (row.onDisk) await mkdir(installPath, { recursive: true });
|
|
230
|
+
rows.push({
|
|
231
|
+
scope: row.scope,
|
|
232
|
+
...(row.projectPath ? { projectPath: row.projectPath } : {}),
|
|
233
|
+
installPath,
|
|
234
|
+
version: row.version,
|
|
235
|
+
installedAt: "2026-09-01T00:00:00.000Z",
|
|
236
|
+
lastUpdated: "2026-09-01T00:00:00.000Z",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
await writeJson(join(claude, "plugins", "installed_plugins.json"), {
|
|
240
|
+
version: 2,
|
|
241
|
+
plugins: { [ID]: rows },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const out = join(w.base, "result.json");
|
|
245
|
+
const jobFile = join(w.base, "job.json");
|
|
246
|
+
await writeJson(jobFile, {
|
|
247
|
+
home: w.home,
|
|
248
|
+
project: w.project,
|
|
249
|
+
id: ID,
|
|
250
|
+
out,
|
|
251
|
+
apply: seed.apply ?? false,
|
|
252
|
+
catalogUrl:
|
|
253
|
+
"https://raw.githubusercontent.com/MadAppGang/magus/main/.claude-plugin/marketplace.json",
|
|
254
|
+
catalog: {
|
|
255
|
+
name: "magus",
|
|
256
|
+
plugins: [
|
|
257
|
+
{
|
|
258
|
+
name: "probe",
|
|
259
|
+
version: seed.catalogVersion,
|
|
260
|
+
description: "Probe plugin.",
|
|
261
|
+
source: "./plugins/probe",
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
modules: {
|
|
266
|
+
pluginManager: join(SRC, "services", "plugin-manager.ts"),
|
|
267
|
+
updatePlan: join(SRC, "services", "update-plan.ts"),
|
|
268
|
+
updateEngine: join(SRC, "services", "update-engine.ts"),
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const proc = spawnSync(
|
|
273
|
+
process.execPath,
|
|
274
|
+
["--no-env-file", childScript, jobFile],
|
|
275
|
+
{ cwd: w.project, env: w.env, timeout: 60_000 },
|
|
276
|
+
);
|
|
277
|
+
let raw: string;
|
|
278
|
+
try {
|
|
279
|
+
raw = await readFile(out, "utf8");
|
|
280
|
+
} catch {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`child produced no result (exit ${proc.status}):\n${proc.stderr}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const result = JSON.parse(raw) as ChildResult & { guard: boolean };
|
|
286
|
+
if (!result.guard) throw new Error("the child's home was not redirected");
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── the surfaces, rendered the way the Plugins screen renders them ───────────
|
|
291
|
+
|
|
292
|
+
async function detailText(item: PluginPluginItem): Promise<string> {
|
|
293
|
+
const { captureCharFrame, renderOnce, renderer } = await testRender(
|
|
294
|
+
<box flexDirection="column">
|
|
295
|
+
{renderPluginDetail(item, new Set<string>()) as ReactNode}
|
|
296
|
+
</box>,
|
|
297
|
+
{ width: 120, height: 80 },
|
|
298
|
+
);
|
|
299
|
+
await act(async () => {
|
|
300
|
+
await renderOnce();
|
|
301
|
+
await renderOnce();
|
|
302
|
+
});
|
|
303
|
+
const frame = captureCharFrame();
|
|
304
|
+
await act(async () => renderer.destroy());
|
|
305
|
+
return frame;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
interface Surfaces {
|
|
309
|
+
plugin: PluginInfo;
|
|
310
|
+
status: PluginUpdateItem;
|
|
311
|
+
row: string;
|
|
312
|
+
detail: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The screen's own path: classify, build the browser items, render. */
|
|
316
|
+
async function surfaces(plugins: PluginInfo[]): Promise<Surfaces> {
|
|
317
|
+
const items = buildPluginBrowserItems({
|
|
318
|
+
marketplaces: [MAGUS],
|
|
319
|
+
plugins,
|
|
320
|
+
collapsedMarketplaces: new Set(),
|
|
321
|
+
statuses: classifyPluginRows(plugins, {}),
|
|
322
|
+
});
|
|
323
|
+
const item = items.find(
|
|
324
|
+
(i): i is PluginPluginItem => i.kind === "plugin" && i.plugin.id === ID,
|
|
325
|
+
);
|
|
326
|
+
if (!item?.status) throw new Error(`no classified row for ${ID}`);
|
|
327
|
+
return {
|
|
328
|
+
plugin: item.plugin,
|
|
329
|
+
status: item.status,
|
|
330
|
+
row: pluginRowText(item).text,
|
|
331
|
+
detail: await detailText(item),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Finding 2 ────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe("installedVersion comes only from installed scopes", () => {
|
|
338
|
+
test("case B: installed at user v0.9.0 beside an inherited project row v0.8.1 — every surface says 0.9.0", async () => {
|
|
339
|
+
const w = await world();
|
|
340
|
+
const { plugins } = await run(w, {
|
|
341
|
+
userEnabled: { [ID]: true },
|
|
342
|
+
rows: [
|
|
343
|
+
{ scope: "user", version: "0.9.0", onDisk: true },
|
|
344
|
+
{
|
|
345
|
+
scope: "project",
|
|
346
|
+
projectPath: w.repo,
|
|
347
|
+
version: "0.8.1",
|
|
348
|
+
onDisk: false,
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
catalogVersion: "0.9.0",
|
|
352
|
+
});
|
|
353
|
+
const s = await surfaces(plugins);
|
|
354
|
+
|
|
355
|
+
// The reported shape: user installed at 0.9.0, the inherited row not on here.
|
|
356
|
+
expect(s.plugin.userScope).toEqual({ installed: true, version: "0.9.0" });
|
|
357
|
+
expect(s.plugin.projectScope).toEqual({
|
|
358
|
+
installed: false,
|
|
359
|
+
version: "0.8.1",
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
expect(s.status.action).toBe("current");
|
|
363
|
+
expect(s.status.installed).toBe("0.9.0");
|
|
364
|
+
expect(s.plugin.installedVersion).toBe("0.9.0");
|
|
365
|
+
expect(s.plugin.installedVersion ?? null).toBe(s.status.installed);
|
|
366
|
+
expect(hasMissingFiles(s.plugin)).toBe(false);
|
|
367
|
+
expect(s.row).toBe(" v0.9.0");
|
|
368
|
+
expect(s.detail).toContain("● Installed");
|
|
369
|
+
expect(s.detail).not.toContain("files missing");
|
|
370
|
+
// Not "(v0.8.1 installed)", and not anywhere else either.
|
|
371
|
+
expect(s.detail).not.toContain("0.8.1");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("case A: files missing at user scope beside the same inherited row — every surface says files missing", async () => {
|
|
375
|
+
const w = await world();
|
|
376
|
+
const { plugins } = await run(w, {
|
|
377
|
+
userEnabled: { [ID]: true },
|
|
378
|
+
rows: [
|
|
379
|
+
{
|
|
380
|
+
scope: "project",
|
|
381
|
+
projectPath: w.repo,
|
|
382
|
+
version: "0.8.1",
|
|
383
|
+
onDisk: false,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
catalogVersion: "0.9.0",
|
|
387
|
+
});
|
|
388
|
+
const s = await surfaces(plugins);
|
|
389
|
+
|
|
390
|
+
expect(s.plugin.userScope).toEqual({ installed: true });
|
|
391
|
+
expect(s.plugin.projectScope).toEqual({
|
|
392
|
+
installed: false,
|
|
393
|
+
version: "0.8.1",
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(s.status.action).toBe("install");
|
|
397
|
+
expect(s.status.reason).toBe("files-missing");
|
|
398
|
+
expect(s.status.installed).toBeNull();
|
|
399
|
+
expect(s.plugin.installedVersion).toBeUndefined();
|
|
400
|
+
expect(hasMissingFiles(s.plugin)).toBe(true);
|
|
401
|
+
// No version beside "files missing" — the row has none to show.
|
|
402
|
+
expect(s.row).toBe(" files missing");
|
|
403
|
+
expect(s.detail).toContain("● Installed — files missing");
|
|
404
|
+
expect(s.detail).not.toContain("0.8.1");
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ── Finding 3 ────────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
describe("no row for this project → install, not update", () => {
|
|
411
|
+
test("a user row only, turned on in project settings: `install --scope project`, never `update`", async () => {
|
|
412
|
+
const w = await world();
|
|
413
|
+
const { apply } = await run(w, {
|
|
414
|
+
projectEnabled: { [ID]: true },
|
|
415
|
+
rows: [{ scope: "user", version: "1.0.0", onDisk: true }],
|
|
416
|
+
catalogVersion: "2.0.0",
|
|
417
|
+
apply: true,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Planned as project scope behind — the project resolves to the user row.
|
|
421
|
+
expect(apply?.action).toBe("update");
|
|
422
|
+
expect(apply?.scopes).toEqual(["project"]);
|
|
423
|
+
expect(apply?.calls).toEqual([["install", "project"]]);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key focus rule: "A key goes only to the input that had focus when the key was
|
|
3
|
+
* pressed."
|
|
4
|
+
*
|
|
5
|
+
* Written blind — from the spec and the exported signatures of KeyRouter,
|
|
6
|
+
* AppProvider/useModal, DimensionsProvider and ModalContainer only. The real
|
|
7
|
+
* providers and the real ModalContainer are mounted; keys are sent through the
|
|
8
|
+
* test renderer's mock keyboard, and the outcome is read from what the user
|
|
9
|
+
* sees (the frame) and what the caller receives (onSubmit).
|
|
10
|
+
*
|
|
11
|
+
* REQ-A1 a select modal's onSelect opens an input modal in the same Enter:
|
|
12
|
+
* that Enter shows the input and does NOT submit it.
|
|
13
|
+
* REQ-A2 the next Enter submits the input's default value ("default-2").
|
|
14
|
+
* REQ-A3 keys typed after the input opened do reach it.
|
|
15
|
+
* REQ-A4 an input modal that was already open receives typed text: "abc" +
|
|
16
|
+
* Enter submits "abc".
|
|
17
|
+
* REQ-A5 a screen-level key that opens an input modal is not typed into it:
|
|
18
|
+
* the default value is unchanged after the key.
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
21
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
25
|
+
import { act } from "react";
|
|
26
|
+
import { ModalContainer } from "../ui/components/modals/ModalContainer.js";
|
|
27
|
+
import { KeyRouter, useKeyLayer } from "../ui/input/KeyRouter.js";
|
|
28
|
+
import { AppProvider, useModal } from "../ui/state/AppContext.js";
|
|
29
|
+
import { DimensionsProvider } from "../ui/state/DimensionsContext.js";
|
|
30
|
+
import type { ModalState } from "../ui/state/types.js";
|
|
31
|
+
|
|
32
|
+
type ModalApi = ReturnType<typeof useModal>;
|
|
33
|
+
|
|
34
|
+
const SELECT_TITLE = "Pick a setup";
|
|
35
|
+
const INPUT_TITLE = "Name the new profile";
|
|
36
|
+
const INPUT_LABEL = "Profile name";
|
|
37
|
+
|
|
38
|
+
let cleanup: Array<() => Promise<void> | void> = [];
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
for (const fn of cleanup.reverse()) await fn();
|
|
42
|
+
cleanup = [];
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
interface Mounted {
|
|
46
|
+
modal: () => ModalApi;
|
|
47
|
+
frame: () => Promise<string>;
|
|
48
|
+
/** Run input actions, then let React and the renderer settle. */
|
|
49
|
+
send: (fn: () => void | Promise<void>) => Promise<void>;
|
|
50
|
+
mockInput: Awaited<ReturnType<typeof testRender>>["mockInput"];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A screen that opens an input modal on its own key `e`. */
|
|
54
|
+
function ScreenWithKey({
|
|
55
|
+
onKey,
|
|
56
|
+
}: {
|
|
57
|
+
onKey: () => void;
|
|
58
|
+
}) {
|
|
59
|
+
useKeyLayer("screen", true, (key) => {
|
|
60
|
+
if (key.name === "e" && !key.ctrl && !key.meta) {
|
|
61
|
+
onKey();
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
return <text>screen body</text>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function mount(
|
|
70
|
+
opts: { screenKey?: (modal: ModalApi) => void } = {},
|
|
71
|
+
): Promise<Mounted> {
|
|
72
|
+
// A throwaway project path: nothing here reads or writes a real project.
|
|
73
|
+
const project = mkdtempSync(join(tmpdir(), "key-focus-guard-"));
|
|
74
|
+
cleanup.push(() => rmSync(project, { recursive: true, force: true }));
|
|
75
|
+
|
|
76
|
+
let api: ModalApi | null = null;
|
|
77
|
+
function Harness() {
|
|
78
|
+
const modal = useModal();
|
|
79
|
+
api = modal;
|
|
80
|
+
return (
|
|
81
|
+
<box flexDirection="column" width="100%" height="100%">
|
|
82
|
+
{opts.screenKey ? (
|
|
83
|
+
<ScreenWithKey onKey={() => opts.screenKey?.(modal)} />
|
|
84
|
+
) : (
|
|
85
|
+
<text>screen body</text>
|
|
86
|
+
)}
|
|
87
|
+
<ModalContainer />
|
|
88
|
+
</box>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
93
|
+
await testRender(
|
|
94
|
+
<AppProvider initialProjectPath={project}>
|
|
95
|
+
<DimensionsProvider>
|
|
96
|
+
<KeyRouter>
|
|
97
|
+
<Harness />
|
|
98
|
+
</KeyRouter>
|
|
99
|
+
</DimensionsProvider>
|
|
100
|
+
</AppProvider>,
|
|
101
|
+
{ width: 100, height: 30 },
|
|
102
|
+
);
|
|
103
|
+
cleanup.push(async () => {
|
|
104
|
+
await act(async () => renderer.destroy());
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const settle = async () => {
|
|
108
|
+
for (let i = 0; i < 3; i++) {
|
|
109
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
110
|
+
await renderOnce();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
await act(settle);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
mockInput,
|
|
117
|
+
modal: () => {
|
|
118
|
+
if (!api) throw new Error("useModal was never reached");
|
|
119
|
+
return api;
|
|
120
|
+
},
|
|
121
|
+
frame: async () => {
|
|
122
|
+
await act(settle);
|
|
123
|
+
return captureCharFrame();
|
|
124
|
+
},
|
|
125
|
+
send: async (fn) => {
|
|
126
|
+
await act(async () => {
|
|
127
|
+
await fn();
|
|
128
|
+
await settle();
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function inputModal(
|
|
135
|
+
defaultValue: string | undefined,
|
|
136
|
+
onSubmit: (value: string) => void,
|
|
137
|
+
): ModalState {
|
|
138
|
+
return {
|
|
139
|
+
type: "input",
|
|
140
|
+
title: INPUT_TITLE,
|
|
141
|
+
label: INPUT_LABEL,
|
|
142
|
+
...(defaultValue !== undefined ? { defaultValue } : {}),
|
|
143
|
+
onSubmit,
|
|
144
|
+
onCancel: () => {},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Open a select whose onSelect opens the input modal, in the same handler. */
|
|
149
|
+
async function openSelectThatOpensInput(
|
|
150
|
+
m: Mounted,
|
|
151
|
+
onSelect: (value: string) => void,
|
|
152
|
+
onSubmit: (value: string) => void,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
await m.send(() => {
|
|
155
|
+
m.modal().showModal({
|
|
156
|
+
type: "select",
|
|
157
|
+
title: SELECT_TITLE,
|
|
158
|
+
message: "Which setup should this project start from?",
|
|
159
|
+
options: [
|
|
160
|
+
{ label: "Default setup", value: "default" },
|
|
161
|
+
{ label: "Empty setup", value: "empty" },
|
|
162
|
+
],
|
|
163
|
+
defaultIndex: 0,
|
|
164
|
+
onSelect: (value) => {
|
|
165
|
+
onSelect(value);
|
|
166
|
+
m.modal().showModal(inputModal("default-2", onSubmit));
|
|
167
|
+
},
|
|
168
|
+
onCancel: () => {},
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
const frame = await m.frame();
|
|
172
|
+
// Precondition: the select is on screen, the input is not.
|
|
173
|
+
expect(frame).toContain(SELECT_TITLE);
|
|
174
|
+
expect(frame).not.toContain(INPUT_TITLE);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("key focus: a key goes only to the input that had focus when it was pressed", () => {
|
|
178
|
+
test("REQ-A1 one Enter on a select whose onSelect opens an input shows the input and does not submit it", async () => {
|
|
179
|
+
const m = await mount();
|
|
180
|
+
const onSelect = mock((_v: string) => {});
|
|
181
|
+
const onSubmit = mock((_v: string) => {});
|
|
182
|
+
await openSelectThatOpensInput(m, onSelect, onSubmit);
|
|
183
|
+
|
|
184
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
185
|
+
|
|
186
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(onSelect).toHaveBeenCalledWith("default");
|
|
188
|
+
const frame = await m.frame();
|
|
189
|
+
expect(frame).toContain(INPUT_TITLE);
|
|
190
|
+
expect(frame).toContain("default-2");
|
|
191
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('REQ-A2 a second Enter submits the input\'s default value "default-2"', async () => {
|
|
195
|
+
const m = await mount();
|
|
196
|
+
const onSubmit = mock((_v: string) => {});
|
|
197
|
+
await openSelectThatOpensInput(m, () => {}, onSubmit);
|
|
198
|
+
|
|
199
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
200
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
201
|
+
|
|
202
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
203
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(onSubmit).toHaveBeenCalledWith("default-2");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('REQ-A3 keys typed after the input opened reach it: "x" then Enter submits "default-2x"', async () => {
|
|
208
|
+
const m = await mount();
|
|
209
|
+
const onSubmit = mock((_v: string) => {});
|
|
210
|
+
await openSelectThatOpensInput(m, () => {}, onSubmit);
|
|
211
|
+
|
|
212
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
213
|
+
await m.send(() => m.mockInput.typeText("x"));
|
|
214
|
+
expect(await m.frame()).toContain("default-2x");
|
|
215
|
+
|
|
216
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
217
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
218
|
+
expect(onSubmit).toHaveBeenCalledWith("default-2x");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('REQ-A4 an input modal that was already open receives typing: "abc" then Enter submits "abc"', async () => {
|
|
222
|
+
const m = await mount();
|
|
223
|
+
const onSubmit = mock((_v: string) => {});
|
|
224
|
+
await m.send(() => m.modal().showModal(inputModal(undefined, onSubmit)));
|
|
225
|
+
expect(await m.frame()).toContain(INPUT_TITLE);
|
|
226
|
+
|
|
227
|
+
await m.send(() => m.mockInput.typeText("abc"));
|
|
228
|
+
expect(await m.frame()).toContain("abc");
|
|
229
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
230
|
+
|
|
231
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
232
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(onSubmit).toHaveBeenCalledWith("abc");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('REQ-A5 a screen key that opens an input modal is not typed into it: default "cfg" stays "cfg"', async () => {
|
|
237
|
+
const onSubmit = mock((_v: string) => {});
|
|
238
|
+
const opened = mock(() => {});
|
|
239
|
+
const m = await mount({
|
|
240
|
+
screenKey: (modal) => {
|
|
241
|
+
opened();
|
|
242
|
+
modal.showModal(inputModal("cfg", onSubmit));
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
expect(await m.frame()).not.toContain(INPUT_TITLE);
|
|
246
|
+
|
|
247
|
+
await m.send(() => m.mockInput.pressKey("e"));
|
|
248
|
+
|
|
249
|
+
expect(opened).toHaveBeenCalledTimes(1);
|
|
250
|
+
const frame = await m.frame();
|
|
251
|
+
expect(frame).toContain(INPUT_TITLE);
|
|
252
|
+
expect(frame).toContain("cfg");
|
|
253
|
+
expect(frame).not.toContain("cfge");
|
|
254
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
255
|
+
|
|
256
|
+
await m.send(() => m.mockInput.pressEnter());
|
|
257
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(onSubmit).toHaveBeenCalledWith("cfg");
|
|
259
|
+
});
|
|
260
|
+
});
|