magus-cli 7.3.0 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/services/version-check.ts +20 -10
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teardown tests for `installShutdown`. Every one fires on a SPECIFIC regression in the
|
|
3
|
+
* quit path, because that path is the one an app cannot recover from: a shutdown that
|
|
4
|
+
* throws half-way leaves the terminal in raw mode inside the alternate screen, and the
|
|
5
|
+
* reader's next prompt needs `reset` to come back.
|
|
6
|
+
*
|
|
7
|
+
* THE EXIT-PATH TESTS RUN IN SUBPROCESSES AND SEND REAL SIGNALS. They have to. This file
|
|
8
|
+
* has been believed correct and been wrong three times, and each time the belief rested
|
|
9
|
+
* on mocks: a stubbed `process.exit` proves the function was reached, not that the process
|
|
10
|
+
* ended; a hand-called handler proves nothing about WHO the kernel delivers a signal to
|
|
11
|
+
* when two owners registered for it. So the child sends itself a real signal, records
|
|
12
|
+
* each step to a file with `appendFileSync` (synchronous, so `process.exit` cannot
|
|
13
|
+
* truncate it, and immune to the renderer's stdout capture), and the parent asserts the
|
|
14
|
+
* exact order and the exact exit code.
|
|
15
|
+
*
|
|
16
|
+
* Every child arms a REF'D 4s guard timer that exits 99. That is the negative control: a
|
|
17
|
+
* teardown that never reaches `process.exit` reports 99 instead of drifting out with a
|
|
18
|
+
* misleading 0, so a green result here cannot be vacuous.
|
|
19
|
+
*
|
|
20
|
+
* The in-process stubs are `Pick<CliRenderer, "destroy">` / `Pick<Root, "unmount">`, which
|
|
21
|
+
* the signature admits directly — no `as unknown as` cast, and a cast is exactly what
|
|
22
|
+
* stops anyone writing these tests. */
|
|
23
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
24
|
+
import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
25
|
+
import { constants, tmpdir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { OWNED_SIGNALS, installShutdown } from "../ui/runtime/shutdown.js";
|
|
28
|
+
|
|
29
|
+
const noop = (): void => {};
|
|
30
|
+
const boom = (): never => {
|
|
31
|
+
throw new Error("boom");
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const SHUTDOWN = Bun.fileURLToPath(
|
|
35
|
+
new URL("../ui/runtime/shutdown.ts", import.meta.url),
|
|
36
|
+
);
|
|
37
|
+
/** `bun -e` resolves bare specifiers from cwd, and the real-renderer children import
|
|
38
|
+
* `@opentui/core` — so every child runs with the package root (the package boundary) as
|
|
39
|
+
* its cwd. */
|
|
40
|
+
const PACKAGE_ROOT = Bun.fileURLToPath(new URL("../../", import.meta.url));
|
|
41
|
+
const WORK = mkdtempSync(join(tmpdir(), "opentui-shutdown-"));
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
rmSync(WORK, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** 128 + the OS's own signal number. Restated here from the policy, NOT read back from
|
|
47
|
+
* the implementation, and resolved through `node:os` because the numbers are
|
|
48
|
+
* platform-specific (SIGBUS is 10 on darwin, 7 on Linux). */
|
|
49
|
+
const expectedCode = (sig: NodeJS.Signals): number => {
|
|
50
|
+
const signum = (
|
|
51
|
+
constants.signals as unknown as Record<string, number | undefined>
|
|
52
|
+
)[sig];
|
|
53
|
+
if (typeof signum !== "number")
|
|
54
|
+
throw new Error(`${sig} has no number on ${process.platform}`);
|
|
55
|
+
return 128 + signum;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Subprocess harness
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** How a teardown step fails. `plain` is an ordinary Error; `hostile` and `nasty` are the
|
|
63
|
+
* throwables whose FORMATTING throws — the defect that skipped `destroy()` entirely. */
|
|
64
|
+
type Fault = "none" | "plain" | "hostile" | "nasty";
|
|
65
|
+
|
|
66
|
+
const THROWABLES = `
|
|
67
|
+
const plainBoom = () => { throw new Error("boom") }
|
|
68
|
+
// The reported repro: an object whose toString() throws. String(err) reaches
|
|
69
|
+
// Symbol.toPrimitive and the formatter itself throws.
|
|
70
|
+
const hostileBoom = () => {
|
|
71
|
+
const o = {}
|
|
72
|
+
Object.defineProperty(o, "toString", { value: () => { throw new Error("toString boom") } })
|
|
73
|
+
Object.defineProperty(o, Symbol.toPrimitive, { value: () => { throw new Error("toPrimitive boom") } })
|
|
74
|
+
throw o
|
|
75
|
+
}
|
|
76
|
+
// The other half of the same defect: an Error whose OWN stack/message getters throw, so
|
|
77
|
+
// the \`err instanceof Error\` branch is the one that explodes.
|
|
78
|
+
const nastyBoom = () => {
|
|
79
|
+
const e = new Error("nasty")
|
|
80
|
+
Object.defineProperty(e, "stack", { get() { throw new Error("stack getter boom") }, configurable: true })
|
|
81
|
+
Object.defineProperty(e, "message", { get() { throw new Error("message getter boom") }, configurable: true })
|
|
82
|
+
throw e
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const throwFor = (f: Fault): string =>
|
|
87
|
+
f === "none"
|
|
88
|
+
? ""
|
|
89
|
+
: f === "plain"
|
|
90
|
+
? "plainBoom()"
|
|
91
|
+
: f === "hostile"
|
|
92
|
+
? "hostileBoom()"
|
|
93
|
+
: "nastyBoom()";
|
|
94
|
+
|
|
95
|
+
interface ChildOpts {
|
|
96
|
+
/** How the child ends: a real signal it sends itself, the returned quit function, that
|
|
97
|
+
* function called concurrently, or an uncaught throw. */
|
|
98
|
+
readonly via: NodeJS.Signals | "quit" | "quit-twice" | "crash";
|
|
99
|
+
readonly unmount?: Fault;
|
|
100
|
+
readonly destroy?: Fault;
|
|
101
|
+
/** `undefined` = two-method stubs. Otherwise a REAL `createCliRenderer` with those
|
|
102
|
+
* `exitSignals`, to measure who actually owns the signal. */
|
|
103
|
+
readonly rendererExitSignals?: NodeJS.Signals[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ChildResult {
|
|
107
|
+
readonly code: number | null;
|
|
108
|
+
readonly signal: string | null;
|
|
109
|
+
/** Teardown steps in the order the child actually performed them. */
|
|
110
|
+
readonly log: string[];
|
|
111
|
+
readonly stderr: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runChild(opts: ChildOpts): Promise<ChildResult> {
|
|
115
|
+
const logPath = join(WORK, `${crypto.randomUUID()}.log`);
|
|
116
|
+
const trigger =
|
|
117
|
+
opts.via === "quit"
|
|
118
|
+
? 'await quit(); rec("resolved"); clearTimeout(guard)'
|
|
119
|
+
: opts.via === "quit-twice"
|
|
120
|
+
? 'await Promise.all([quit(), quit(), quit()]); await quit(); rec("resolved"); clearTimeout(guard)'
|
|
121
|
+
: opts.via === "crash"
|
|
122
|
+
? 'setTimeout(() => { throw new Error("app boom") }, 1)'
|
|
123
|
+
: `process.kill(process.pid, ${JSON.stringify(opts.via)})`;
|
|
124
|
+
|
|
125
|
+
const renderer =
|
|
126
|
+
opts.rendererExitSignals === undefined
|
|
127
|
+
? `const renderer = { destroy: () => { rec("destroy"); ${throwFor(opts.destroy ?? "none")} } }`
|
|
128
|
+
: // An own `destroy` property SHADOWS the prototype method, so the renderer's own
|
|
129
|
+
// internal `this.destroy()` — the one its exitHandler calls — is recorded too. That
|
|
130
|
+
// is how a second owner becomes visible as a second "destroy" line.
|
|
131
|
+
`import { createCliRenderer } from "@opentui/core"
|
|
132
|
+
const renderer = await createCliRenderer({
|
|
133
|
+
exitSignals: ${JSON.stringify(opts.rendererExitSignals)},
|
|
134
|
+
exitOnCtrlC: false,
|
|
135
|
+
screenMode: "main-screen",
|
|
136
|
+
})
|
|
137
|
+
const realDestroy = renderer.destroy.bind(renderer)
|
|
138
|
+
renderer.destroy = () => { rec("destroy"); realDestroy() }`;
|
|
139
|
+
|
|
140
|
+
const src = [
|
|
141
|
+
'import { appendFileSync } from "node:fs"',
|
|
142
|
+
`import { installShutdown } from ${JSON.stringify(SHUTDOWN)}`,
|
|
143
|
+
`const rec = (s) => appendFileSync(${JSON.stringify(logPath)}, s + "\\n")`,
|
|
144
|
+
THROWABLES,
|
|
145
|
+
renderer,
|
|
146
|
+
`const root = { unmount: () => { rec("unmount"); ${throwFor(opts.unmount ?? "none")} } }`,
|
|
147
|
+
'const quit = installShutdown(renderer, root, [() => rec("disposer")])',
|
|
148
|
+
// REF'D on purpose: it is the only thing keeping the loop alive on the quit path, so
|
|
149
|
+
// a teardown that hangs reports 99 rather than looking like a clean exit.
|
|
150
|
+
'const guard = setTimeout(() => { rec("GUARD-FIRED"); process.exit(99) }, 4000)',
|
|
151
|
+
trigger,
|
|
152
|
+
].join("\n");
|
|
153
|
+
|
|
154
|
+
const p = Bun.spawn(["bun", "-e", src], {
|
|
155
|
+
cwd: PACKAGE_ROOT,
|
|
156
|
+
stdin: "ignore",
|
|
157
|
+
stdout: "ignore",
|
|
158
|
+
stderr: "pipe",
|
|
159
|
+
});
|
|
160
|
+
const [, stderr] = await Promise.all([
|
|
161
|
+
p.exited,
|
|
162
|
+
new Response(p.stderr).text(),
|
|
163
|
+
]);
|
|
164
|
+
let log: string[] = [];
|
|
165
|
+
try {
|
|
166
|
+
log = readFileSync(logPath, "utf8").split("\n").filter(Boolean);
|
|
167
|
+
} catch {
|
|
168
|
+
/* child died before recording */
|
|
169
|
+
}
|
|
170
|
+
return { code: p.exitCode, signal: p.signalCode, log, stderr };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The contract, in one place: stop the app's own work, take the tree down, then restore
|
|
174
|
+
* the terminal. */
|
|
175
|
+
const ORDER = ["disposer", "unmount", "destroy"];
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
describe("OWNED_SIGNALS", () => {
|
|
180
|
+
// It used to be "OpenTUI's default set MINUS SIGINT", because it was handed to
|
|
181
|
+
// `createCliRenderer` — which is what made the renderer a second teardown owner. The
|
|
182
|
+
// renderer is now given `exitSignals: []`, so this module registers the complete set.
|
|
183
|
+
test("is the complete default set, SIGINT included, because nothing else registers any", () => {
|
|
184
|
+
expect(OWNED_SIGNALS).toEqual([
|
|
185
|
+
"SIGINT",
|
|
186
|
+
"SIGTERM",
|
|
187
|
+
"SIGQUIT",
|
|
188
|
+
"SIGABRT",
|
|
189
|
+
"SIGHUP",
|
|
190
|
+
"SIGBREAK",
|
|
191
|
+
"SIGPIPE",
|
|
192
|
+
"SIGBUS",
|
|
193
|
+
]);
|
|
194
|
+
expect(OWNED_SIGNALS).toContain("SIGINT");
|
|
195
|
+
expect(OWNED_SIGNALS).toContain("SIGBUS");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("every signal it claims is one it can actually register and remove", () => {
|
|
199
|
+
const before = OWNED_SIGNALS.map((s) => process.listenerCount(s));
|
|
200
|
+
const shutdown = installShutdown({ destroy: noop }, { unmount: noop });
|
|
201
|
+
// SIGBREAK is Windows-only: `process.on` accepts it on darwin, it just never fires.
|
|
202
|
+
expect(OWNED_SIGNALS.map((s) => process.listenerCount(s))).toEqual(
|
|
203
|
+
before.map((n) => n + 1),
|
|
204
|
+
);
|
|
205
|
+
void shutdown();
|
|
206
|
+
expect(OWNED_SIGNALS.map((s) => process.listenerCount(s))).toEqual(before);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("exit-code policy — 128 + signum, never 0", () => {
|
|
211
|
+
test("a signal never maps to a success code, and no two collide", () => {
|
|
212
|
+
const codes = OWNED_SIGNALS.filter((s) => s !== "SIGBREAK").map(
|
|
213
|
+
expectedCode,
|
|
214
|
+
);
|
|
215
|
+
for (const c of codes) expect(c).toBeGreaterThan(128);
|
|
216
|
+
expect(new Set(codes).size).toBe(codes.length);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("the numbers a shell would report", () => {
|
|
220
|
+
if (process.platform !== "darwin") return; // signal numbers are platform-specific
|
|
221
|
+
expect({
|
|
222
|
+
SIGINT: expectedCode("SIGINT"),
|
|
223
|
+
SIGQUIT: expectedCode("SIGQUIT"),
|
|
224
|
+
SIGABRT: expectedCode("SIGABRT"),
|
|
225
|
+
SIGHUP: expectedCode("SIGHUP"),
|
|
226
|
+
SIGPIPE: expectedCode("SIGPIPE"),
|
|
227
|
+
SIGBUS: expectedCode("SIGBUS"),
|
|
228
|
+
SIGTERM: expectedCode("SIGTERM"),
|
|
229
|
+
}).toEqual({
|
|
230
|
+
SIGINT: 130,
|
|
231
|
+
SIGQUIT: 131,
|
|
232
|
+
SIGABRT: 134,
|
|
233
|
+
SIGHUP: 129,
|
|
234
|
+
SIGPIPE: 141,
|
|
235
|
+
SIGBUS: 138,
|
|
236
|
+
SIGTERM: 143,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("installShutdown — teardown always completes", () => {
|
|
242
|
+
// THE DEFECT: `root.unmount()` and `renderer.destroy()` ran unguarded, so a throw from
|
|
243
|
+
// the first skipped the second. Restoring the terminal is the step that must not be
|
|
244
|
+
// skipped.
|
|
245
|
+
test("a throwing unmount still reaches renderer.destroy(), and does not reject", async () => {
|
|
246
|
+
let destroyed = false;
|
|
247
|
+
const shutdown = installShutdown(
|
|
248
|
+
{
|
|
249
|
+
destroy: () => {
|
|
250
|
+
destroyed = true;
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{ unmount: boom },
|
|
254
|
+
);
|
|
255
|
+
await shutdown();
|
|
256
|
+
expect(destroyed).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("a throwing destroy does not reject either — the app can still await the end", async () => {
|
|
260
|
+
let unmounted = false;
|
|
261
|
+
const shutdown = installShutdown(
|
|
262
|
+
{ destroy: boom },
|
|
263
|
+
{
|
|
264
|
+
unmount: () => {
|
|
265
|
+
unmounted = true;
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
await shutdown();
|
|
270
|
+
expect(unmounted).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// THE FORMATTER DEFECT, in process. `String(err)` inside the catch reached a throwing
|
|
274
|
+
// `Symbol.toPrimitive`, so the catch block itself threw and took the rest of teardown
|
|
275
|
+
// with it.
|
|
276
|
+
test("an unmount that throws an unprintable object still reaches destroy()", async () => {
|
|
277
|
+
const hostile: Record<PropertyKey, unknown> = {};
|
|
278
|
+
Object.defineProperty(hostile, "toString", {
|
|
279
|
+
value: () => {
|
|
280
|
+
throw new Error("toString boom");
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
Object.defineProperty(hostile, Symbol.toPrimitive, {
|
|
284
|
+
value: () => {
|
|
285
|
+
throw new Error("toPrimitive boom");
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
let destroyed = false;
|
|
289
|
+
const shutdown = installShutdown(
|
|
290
|
+
{
|
|
291
|
+
destroy: () => {
|
|
292
|
+
destroyed = true;
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
unmount: () => {
|
|
297
|
+
throw hostile;
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
await shutdown();
|
|
302
|
+
expect(destroyed).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("an Error whose own stack getter throws does not stop teardown either", async () => {
|
|
306
|
+
const nasty = new Error("nasty");
|
|
307
|
+
Object.defineProperty(nasty, "stack", {
|
|
308
|
+
get() {
|
|
309
|
+
throw new Error("stack getter boom");
|
|
310
|
+
},
|
|
311
|
+
configurable: true,
|
|
312
|
+
});
|
|
313
|
+
Object.defineProperty(nasty, "message", {
|
|
314
|
+
get() {
|
|
315
|
+
throw new Error("message getter boom");
|
|
316
|
+
},
|
|
317
|
+
configurable: true,
|
|
318
|
+
});
|
|
319
|
+
let destroyed = false;
|
|
320
|
+
const shutdown = installShutdown(
|
|
321
|
+
{
|
|
322
|
+
destroy: () => {
|
|
323
|
+
destroyed = true;
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
unmount: () => {
|
|
328
|
+
throw nasty;
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
await shutdown();
|
|
333
|
+
expect(destroyed).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// DISPOSERS. `installShutdown(renderer, root)` took no extra teardown, while this file
|
|
337
|
+
// forbids `process.exit()` everywhere else — so an app-owned `setInterval` had nowhere
|
|
338
|
+
// to be cleared and kept the loop alive past the shutdown that was meant to end it.
|
|
339
|
+
test("disposers run, once each, BEFORE the tree comes down", async () => {
|
|
340
|
+
const seen: string[] = [];
|
|
341
|
+
const shutdown = installShutdown(
|
|
342
|
+
{ destroy: () => seen.push("destroy") },
|
|
343
|
+
{ unmount: () => seen.push("unmount") },
|
|
344
|
+
[() => seen.push("disposer-a"), () => seen.push("disposer-b")],
|
|
345
|
+
);
|
|
346
|
+
await shutdown();
|
|
347
|
+
expect(seen).toEqual(["disposer-a", "disposer-b", "unmount", "destroy"]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("a throwing disposer stops neither the unmount nor the destroy", async () => {
|
|
351
|
+
const seen: string[] = [];
|
|
352
|
+
const shutdown = installShutdown(
|
|
353
|
+
{ destroy: () => seen.push("destroy") },
|
|
354
|
+
{ unmount: () => seen.push("unmount") },
|
|
355
|
+
[boom, () => seen.push("disposer")],
|
|
356
|
+
);
|
|
357
|
+
await shutdown();
|
|
358
|
+
expect(seen).toEqual(["disposer", "unmount", "destroy"]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// The three steps are guarded INDEPENDENTLY and at the same level, so not even a
|
|
362
|
+
// pathological disposers collection can skip the terminal restore.
|
|
363
|
+
test("a disposers collection that throws on iteration still leaves the terminal restored", async () => {
|
|
364
|
+
const exploding = new Proxy([] as Array<() => void>, {
|
|
365
|
+
get() {
|
|
366
|
+
throw new Error("array boom");
|
|
367
|
+
},
|
|
368
|
+
}) as ReadonlyArray<() => void>;
|
|
369
|
+
const seen: string[] = [];
|
|
370
|
+
const shutdown = installShutdown(
|
|
371
|
+
{ destroy: () => seen.push("destroy") },
|
|
372
|
+
{ unmount: () => seen.push("unmount") },
|
|
373
|
+
exploding,
|
|
374
|
+
);
|
|
375
|
+
await shutdown();
|
|
376
|
+
expect(seen).toEqual(["unmount", "destroy"]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("an app-owned interval is actually cleared, so nothing survives teardown", async () => {
|
|
380
|
+
let ticks = 0;
|
|
381
|
+
const t = setInterval(() => {
|
|
382
|
+
ticks++;
|
|
383
|
+
}, 1);
|
|
384
|
+
const shutdown = installShutdown({ destroy: noop }, { unmount: noop }, [
|
|
385
|
+
() => clearInterval(t),
|
|
386
|
+
]);
|
|
387
|
+
await shutdown();
|
|
388
|
+
const after = ticks;
|
|
389
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
390
|
+
expect(ticks).toBe(after);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("teardown is idempotent however many times it is called", async () => {
|
|
394
|
+
let n = 0;
|
|
395
|
+
const shutdown = installShutdown(
|
|
396
|
+
{
|
|
397
|
+
destroy: () => {
|
|
398
|
+
n += 1;
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{ unmount: noop },
|
|
402
|
+
[
|
|
403
|
+
() => {
|
|
404
|
+
n += 10;
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
);
|
|
408
|
+
await Promise.all([shutdown(), shutdown(), shutdown()]);
|
|
409
|
+
await shutdown();
|
|
410
|
+
expect(n).toBe(11);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Registration is not the same as listing: SIGBUS has to be a real listener, and every
|
|
414
|
+
// listener has to come off again or a second renderer in the same process (a server
|
|
415
|
+
// taking the stream again) inherits the first one's handlers.
|
|
416
|
+
test("it registers SIGBUS, and removes every listener it added", async () => {
|
|
417
|
+
const count = () => ({
|
|
418
|
+
SIGINT: process.listenerCount("SIGINT"),
|
|
419
|
+
SIGBUS: process.listenerCount("SIGBUS"),
|
|
420
|
+
SIGTERM: process.listenerCount("SIGTERM"),
|
|
421
|
+
uncaught: process.listenerCount("uncaughtException"),
|
|
422
|
+
rejection: process.listenerCount("unhandledRejection"),
|
|
423
|
+
});
|
|
424
|
+
const before = count();
|
|
425
|
+
const shutdown = installShutdown({ destroy: noop }, { unmount: noop });
|
|
426
|
+
const during = count();
|
|
427
|
+
expect(during.SIGBUS).toBe(before.SIGBUS + 1);
|
|
428
|
+
expect(during.SIGINT).toBe(before.SIGINT + 1);
|
|
429
|
+
expect(during.uncaught).toBe(before.uncaught + 1);
|
|
430
|
+
await shutdown();
|
|
431
|
+
expect(count()).toEqual(before);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* REAL SIGNALS, REAL PROCESSES. Everything below spawns a child, sends it an actual
|
|
437
|
+
* signal, and reads back the order it tore down in and the code it died with.
|
|
438
|
+
*/
|
|
439
|
+
describe("installShutdown — real signals, real processes", () => {
|
|
440
|
+
// THE MASKING DEFECT: every signal used to exit 0. A SIGABRT or SIGBUS — a fatal
|
|
441
|
+
// termination — reported success to whatever supervises the app.
|
|
442
|
+
test.each([
|
|
443
|
+
"SIGTERM",
|
|
444
|
+
"SIGINT",
|
|
445
|
+
"SIGQUIT",
|
|
446
|
+
"SIGABRT",
|
|
447
|
+
"SIGHUP",
|
|
448
|
+
"SIGPIPE",
|
|
449
|
+
"SIGBUS",
|
|
450
|
+
] as NodeJS.Signals[])(
|
|
451
|
+
"%s tears down in order and exits 128+signum",
|
|
452
|
+
async (sig) => {
|
|
453
|
+
const r = await runChild({ via: sig });
|
|
454
|
+
expect(r.log).toEqual(ORDER);
|
|
455
|
+
expect(r.code).toBe(expectedCode(sig));
|
|
456
|
+
},
|
|
457
|
+
30_000,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
test("a normal quit tears down in the same order and the process ends 0", async () => {
|
|
461
|
+
// No `process.exit` on this path: the returned function tears down and hands control
|
|
462
|
+
// back, and the program ends by letting the loop drain. `resolved` proves the promise
|
|
463
|
+
// settled — an unguarded step used to reject it instead.
|
|
464
|
+
const r = await runChild({ via: "quit" });
|
|
465
|
+
expect(r.log).toEqual([...ORDER, "resolved"]);
|
|
466
|
+
expect(r.code).toBe(0);
|
|
467
|
+
}, 30_000);
|
|
468
|
+
|
|
469
|
+
test("a fatal signal is distinguishable from a clean quit and from every other signal", async () => {
|
|
470
|
+
const [abrt, bus, term, quit] = await Promise.all([
|
|
471
|
+
runChild({ via: "SIGABRT" }),
|
|
472
|
+
runChild({ via: "SIGBUS" }),
|
|
473
|
+
runChild({ via: "SIGTERM" }),
|
|
474
|
+
runChild({ via: "quit" }),
|
|
475
|
+
]);
|
|
476
|
+
const codes = [abrt.code, bus.code, term.code, quit.code];
|
|
477
|
+
expect(new Set(codes).size).toBe(4);
|
|
478
|
+
expect(quit.code).toBe(0);
|
|
479
|
+
for (const c of [abrt.code, bus.code, term.code]) expect(c).not.toBe(0);
|
|
480
|
+
}, 60_000);
|
|
481
|
+
|
|
482
|
+
test("concurrent calls run every step exactly once and still end the process", async () => {
|
|
483
|
+
const r = await runChild({ via: "quit-twice" });
|
|
484
|
+
expect(r.log).toEqual([...ORDER, "resolved"]);
|
|
485
|
+
expect(r.code).toBe(0);
|
|
486
|
+
}, 30_000);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("installShutdown — a throwing step never strands the process", () => {
|
|
490
|
+
// 99 is the guard timer, i.e. "never exited at all". These four are the exact shapes
|
|
491
|
+
// that produced it.
|
|
492
|
+
test("a throwing unmount still reaches destroy() and still exits", async () => {
|
|
493
|
+
const r = await runChild({ via: "SIGTERM", unmount: "plain" });
|
|
494
|
+
expect(r.log).toEqual(ORDER);
|
|
495
|
+
expect(r.code).toBe(expectedCode("SIGTERM"));
|
|
496
|
+
}, 30_000);
|
|
497
|
+
|
|
498
|
+
// THE CASE THAT HUNG. `unmount` throws an object whose `toString()` also throws, so the
|
|
499
|
+
// catch block's own `String(err)` threw: `renderer.destroy()` never ran, the exit never
|
|
500
|
+
// ran, and `done` was already latched so no retry could clean up. Measured exit: 99.
|
|
501
|
+
test("an unmount throwing an unprintable object still reaches destroy() and still exits", async () => {
|
|
502
|
+
const r = await runChild({ via: "SIGTERM", unmount: "hostile" });
|
|
503
|
+
expect(r.log).toEqual(ORDER);
|
|
504
|
+
expect(r.code).toBe(expectedCode("SIGTERM"));
|
|
505
|
+
expect(r.code).not.toBe(99); // 99 is the guard: the process hung
|
|
506
|
+
}, 30_000);
|
|
507
|
+
|
|
508
|
+
test("an unmount throwing an Error with a throwing stack getter behaves the same", async () => {
|
|
509
|
+
const r = await runChild({ via: "SIGTERM", unmount: "nasty" });
|
|
510
|
+
expect(r.log).toEqual(ORDER);
|
|
511
|
+
expect(r.code).toBe(expectedCode("SIGTERM"));
|
|
512
|
+
}, 30_000);
|
|
513
|
+
|
|
514
|
+
test("a throwing destroy still exits — the terminal restore was attempted", async () => {
|
|
515
|
+
const r = await runChild({ via: "SIGTERM", destroy: "plain" });
|
|
516
|
+
expect(r.log).toEqual(ORDER);
|
|
517
|
+
expect(r.code).toBe(expectedCode("SIGTERM"));
|
|
518
|
+
}, 30_000);
|
|
519
|
+
|
|
520
|
+
test("every step throwing unprintably still exits, and the failures are reported after destroy()", async () => {
|
|
521
|
+
const r = await runChild({
|
|
522
|
+
via: "SIGTERM",
|
|
523
|
+
unmount: "hostile",
|
|
524
|
+
destroy: "nasty",
|
|
525
|
+
});
|
|
526
|
+
expect(r.log).toEqual(ORDER);
|
|
527
|
+
expect(r.code).toBe(expectedCode("SIGTERM"));
|
|
528
|
+
expect(r.stderr).toContain("shutdown: root.unmount");
|
|
529
|
+
expect(r.stderr).toContain("shutdown: renderer.destroy");
|
|
530
|
+
expect(r.stderr).toContain("<unprintable throwable>"); // formatted, not thrown
|
|
531
|
+
}, 30_000);
|
|
532
|
+
|
|
533
|
+
test("a throwing unmount does not strand the normal-quit path either", async () => {
|
|
534
|
+
const r = await runChild({
|
|
535
|
+
via: "quit",
|
|
536
|
+
unmount: "hostile",
|
|
537
|
+
destroy: "plain",
|
|
538
|
+
});
|
|
539
|
+
expect(r.log).toEqual([...ORDER, "resolved"]);
|
|
540
|
+
expect(r.code).toBe(0);
|
|
541
|
+
}, 30_000);
|
|
542
|
+
|
|
543
|
+
test("the crash path tears down first, then reports, then exits 1", async () => {
|
|
544
|
+
const r = await runChild({ via: "crash", unmount: "hostile" });
|
|
545
|
+
expect(r.log).toEqual(ORDER);
|
|
546
|
+
expect(r.code).toBe(1);
|
|
547
|
+
expect(r.stderr).toContain("app boom");
|
|
548
|
+
}, 30_000);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* THE SECOND OWNER. These two run a REAL `createCliRenderer` from the pinned version and
|
|
553
|
+
* send it a REAL SIGTERM. Nothing here is stubbed, because the whole question is which
|
|
554
|
+
* handler the process calls first — and that is decided by registration order inside
|
|
555
|
+
* OpenTUI, which no mock reproduces.
|
|
556
|
+
*/
|
|
557
|
+
describe("installShutdown — the renderer must not be a second teardown owner", () => {
|
|
558
|
+
test("with exitSignals: [] there is exactly ONE destroy(), in the documented order", async () => {
|
|
559
|
+
const r = await runChild({ via: "SIGTERM", rendererExitSignals: [] });
|
|
560
|
+
expect(r.log).toEqual(ORDER);
|
|
561
|
+
expect(r.log.filter((l) => l === "destroy")).toHaveLength(1);
|
|
562
|
+
expect(r.code).toBe(expectedCode("SIGTERM"));
|
|
563
|
+
}, 60_000);
|
|
564
|
+
|
|
565
|
+
// THE REGRESSION WITNESS, and the negative control for the test above: hand the renderer
|
|
566
|
+
// the same signal and it wins the race, because it was constructed first. The tree comes
|
|
567
|
+
// down AFTER the terminal was restored, and destroy() runs twice. This is what the
|
|
568
|
+
// bootstrap used to do, and it is why the bootstrap must now pass `[]`.
|
|
569
|
+
test("handing the renderer the same signal reproduces the double-destroy in the wrong order", async () => {
|
|
570
|
+
const r = await runChild({
|
|
571
|
+
via: "SIGTERM",
|
|
572
|
+
rendererExitSignals: ["SIGTERM"],
|
|
573
|
+
});
|
|
574
|
+
expect(r.log).toEqual(["destroy", ...ORDER]);
|
|
575
|
+
expect(r.log.filter((l) => l === "destroy")).toHaveLength(2);
|
|
576
|
+
expect(r.code).toBe(expectedCode("SIGTERM"));
|
|
577
|
+
}, 60_000);
|
|
578
|
+
});
|
|
@@ -1080,9 +1080,9 @@ describe("the Community section", () => {
|
|
|
1080
1080
|
});
|
|
1081
1081
|
|
|
1082
1082
|
test("wears the same tone as Anthropic captures, never the danger tone", () => {
|
|
1083
|
-
// Both sections are "text captured from somewhere else onto this disk"
|
|
1084
|
-
//
|
|
1085
|
-
// section must not wear it.
|
|
1083
|
+
// Both sections are "text captured from somewhere else onto this disk",
|
|
1084
|
+
// a fact about the source, so both wear `info`. `danger` means broken —
|
|
1085
|
+
// a normal, working section must not wear it.
|
|
1086
1086
|
const items = buildStyleBrowserItems({
|
|
1087
1087
|
presets: [],
|
|
1088
1088
|
imports: [],
|
|
@@ -1092,7 +1092,7 @@ describe("the Community section", () => {
|
|
|
1092
1092
|
registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
|
|
1093
1093
|
});
|
|
1094
1094
|
const category = items.find((i) => i.kind === "category");
|
|
1095
|
-
expect(category?.kind === "category" && category.tone).toBe("
|
|
1095
|
+
expect(category?.kind === "category" && category.tone).toBe("info");
|
|
1096
1096
|
expect(category?.kind === "category" && category.badge).toBe("from GitHub");
|
|
1097
1097
|
});
|
|
1098
1098
|
});
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { parseKeypress } from "@opentui/core";
|
|
5
6
|
import fs from "fs-extra";
|
|
6
7
|
import {
|
|
7
8
|
applyStyles,
|
|
@@ -9,6 +10,8 @@ import {
|
|
|
9
10
|
communityCacheDirOrNull,
|
|
10
11
|
loadStyles,
|
|
11
12
|
} from "../services/styles-manager.js";
|
|
13
|
+
import type { KeyPress } from "../ui/input/key-dispatch.js";
|
|
14
|
+
import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
|
|
12
15
|
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
13
16
|
import type { AppAction, AppState } from "../ui/state/types.js";
|
|
14
17
|
|
|
@@ -428,28 +431,39 @@ describe("a project that committed a community style", () => {
|
|
|
428
431
|
|
|
429
432
|
// ─── Keybindings ──────────────────────────────────────────────────────────────
|
|
430
433
|
|
|
434
|
+
/** A real parsed keypress, in the shape the keymap matches. */
|
|
435
|
+
function press(raw: string): KeyPress {
|
|
436
|
+
const k = parseKeypress(raw);
|
|
437
|
+
return {
|
|
438
|
+
name: k?.name ?? "",
|
|
439
|
+
sequence: k?.sequence ?? "",
|
|
440
|
+
ctrl: k?.ctrl === true,
|
|
441
|
+
meta: k?.meta === true,
|
|
442
|
+
shift: k?.shift === true,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
431
446
|
describe("StylesScreen keybindings", () => {
|
|
432
|
-
test("Shift+U
|
|
447
|
+
test("Shift+U and plain u reach different actions — neither swallows the other", () => {
|
|
433
448
|
// OpenTUI reports Shift+U as `{name: "u", shift: true}`, so a lowercase
|
|
434
|
-
// branch placed first
|
|
435
|
-
// PluginsScreen and made "update" install at user scope instead.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
"
|
|
449
|
+
// branch placed first once matched it too. That exact shape shipped in
|
|
450
|
+
// PluginsScreen and made "update" install at user scope instead. Keys are
|
|
451
|
+
// matched through the keymap now, shift flag included.
|
|
452
|
+
expect(matchBinding(SCREEN_BINDINGS.styles, press("U"))?.id).toBe(
|
|
453
|
+
"check-all",
|
|
439
454
|
);
|
|
440
|
-
|
|
441
|
-
const plain = src.indexOf('event.name === "u") {');
|
|
442
|
-
expect(shifted).toBeGreaterThan(-1);
|
|
443
|
-
expect(plain).toBeGreaterThan(-1);
|
|
444
|
-
expect(shifted).toBeLessThan(plain);
|
|
455
|
+
expect(matchBinding(SCREEN_BINDINGS.styles, press("u"))?.id).toBe("check");
|
|
445
456
|
});
|
|
446
457
|
|
|
447
458
|
test("f is bound, and it is what the sync status tells the user to press", async () => {
|
|
459
|
+
expect(matchBinding(SCREEN_BINDINGS.styles, press("f"))?.id).toBe("fetch");
|
|
448
460
|
const src = await fs.readFile(
|
|
449
461
|
join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
|
|
450
462
|
"utf8",
|
|
451
463
|
);
|
|
452
|
-
|
|
464
|
+
const fetch = src.indexOf("fetch: () =>");
|
|
465
|
+
expect(fetch).toBeGreaterThan(-1);
|
|
466
|
+
expect(src.slice(fetch, fetch + 120)).toContain("handleFetch()");
|
|
453
467
|
// The falls-through-to-the-declaration branch. Without it, "press f" is a
|
|
454
468
|
// lie whenever the cursor is not parked on a Community row.
|
|
455
469
|
expect(src).toContain("status.fetchable");
|