magus-cli 7.3.1 → 7.4.1
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__/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/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -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__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- 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__/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 +120 -15
- package/src/cli/install.ts +35 -4
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- 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 +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- 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/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 +107 -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 +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- 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 +173 -101
- 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 +129 -129
- 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,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C7 (requirements R7.1–R7.4).
|
|
3
|
+
*
|
|
4
|
+
* Written from requirements.md and architecture.md only.
|
|
5
|
+
*
|
|
6
|
+
* Widths are computed with a reference formula taken from the contract:
|
|
7
|
+
* barWidth(texts, padded) = Σ (cells(text) + (padded ? 2 : 0)) + (n - 1)
|
|
8
|
+
* so a wrong barWidth in the module cannot make a wrong layout look right.
|
|
9
|
+
* barWidth itself is checked against the same formula below.
|
|
10
|
+
*
|
|
11
|
+
* For every tab screen the tests sweep every available width from 0 to a
|
|
12
|
+
* little past the full padded bar, classify the returned layout into tiers
|
|
13
|
+
* 1–4, and assert the tier is the first one that fits, that the tier changes
|
|
14
|
+
* at exactly the three computed boundaries, and that R7.2/R7.3 hold at every
|
|
15
|
+
* width.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
|
|
19
|
+
const load = () => import("../../ui/components/TabBar.js");
|
|
20
|
+
|
|
21
|
+
const TAB_SCREENS = [
|
|
22
|
+
"plugins",
|
|
23
|
+
"mcp",
|
|
24
|
+
"settings",
|
|
25
|
+
"cli-tools",
|
|
26
|
+
"profiles",
|
|
27
|
+
"skills",
|
|
28
|
+
"styles",
|
|
29
|
+
"gitignore",
|
|
30
|
+
"alias",
|
|
31
|
+
"models",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
interface Tab {
|
|
35
|
+
key: string;
|
|
36
|
+
label: string;
|
|
37
|
+
screen: string;
|
|
38
|
+
}
|
|
39
|
+
interface Layout {
|
|
40
|
+
texts: string[];
|
|
41
|
+
padded: boolean;
|
|
42
|
+
}
|
|
43
|
+
type Tier = 1 | 2 | 3 | 4;
|
|
44
|
+
|
|
45
|
+
const cells = (s: string) => [...s].length; // tab labels are single-width text
|
|
46
|
+
function refBar(texts: readonly string[], padded = true): number {
|
|
47
|
+
if (texts.length === 0) return 0;
|
|
48
|
+
return (
|
|
49
|
+
texts.reduce((sum, t) => sum + cells(t) + (padded ? 2 : 0), 0) +
|
|
50
|
+
(texts.length - 1)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const full = (t: Tab) => `${t.key}:${t.label}`;
|
|
54
|
+
|
|
55
|
+
/** Narrowest form of each tier for `screen`. */
|
|
56
|
+
function tierForms(tabs: readonly Tab[], screen: string) {
|
|
57
|
+
const fullTexts = tabs.map(full);
|
|
58
|
+
const min3 = tabs.map((t) =>
|
|
59
|
+
t.screen === screen ? full(t) : `${t.key}:${t.label.slice(0, 3)}`,
|
|
60
|
+
);
|
|
61
|
+
const min4 = tabs.map((t) => (t.screen === screen ? full(t) : t.key));
|
|
62
|
+
return {
|
|
63
|
+
fullTexts,
|
|
64
|
+
min4,
|
|
65
|
+
W1: refBar(fullTexts, true),
|
|
66
|
+
W2: refBar(fullTexts, false),
|
|
67
|
+
W3: refBar(min3, false),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** R7.1: the first tier that fits `available`. */
|
|
72
|
+
function expectedTier(
|
|
73
|
+
tabs: readonly Tab[],
|
|
74
|
+
screen: string,
|
|
75
|
+
available: number,
|
|
76
|
+
): Tier {
|
|
77
|
+
const { W1, W2, W3 } = tierForms(tabs, screen);
|
|
78
|
+
if (W1 <= available) return 1;
|
|
79
|
+
if (W2 <= available) return 2;
|
|
80
|
+
if (W3 <= available) return 3;
|
|
81
|
+
return 4;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function layoutAt(screen: string, available: number): Promise<Layout> {
|
|
85
|
+
const { TABS, layoutTabs } = await load();
|
|
86
|
+
const out = layoutTabs(TABS, screen as never, available) as unknown;
|
|
87
|
+
const o = out as Partial<Layout> | null;
|
|
88
|
+
if (!o || !Array.isArray(o.texts) || typeof o.padded !== "boolean") {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`layoutTabs(TABS, "${screen}", ${available}) did not return { texts: string[]; padded: boolean }; got ${JSON.stringify(out)}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return o as Layout;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Classify a layout into a tier, or explain why it matches none. */
|
|
97
|
+
function classify(
|
|
98
|
+
tabs: readonly Tab[],
|
|
99
|
+
screen: string,
|
|
100
|
+
layout: Layout,
|
|
101
|
+
): Tier | string {
|
|
102
|
+
const { texts, padded } = layout;
|
|
103
|
+
if (texts.length !== tabs.length) {
|
|
104
|
+
return `has ${texts.length} texts for ${tabs.length} tabs`;
|
|
105
|
+
}
|
|
106
|
+
const fullTexts = tabs.map(full);
|
|
107
|
+
if (texts.every((t, i) => t === fullTexts[i])) return padded ? 1 : 2;
|
|
108
|
+
const active = tabs.findIndex((t) => t.screen === screen);
|
|
109
|
+
if (texts[active] !== fullTexts[active]) {
|
|
110
|
+
return `active tab shows ${JSON.stringify(texts[active])}, not ${JSON.stringify(fullTexts[active])}`;
|
|
111
|
+
}
|
|
112
|
+
const inactive = tabs.map((_, i) => i).filter((i) => i !== active);
|
|
113
|
+
if (inactive.every((i) => texts[i] === (tabs[i] as Tab).key)) return 4;
|
|
114
|
+
const bad = inactive.filter(
|
|
115
|
+
(i) => !hasLabelPrefix(tabs[i] as Tab, texts[i] as string, 3),
|
|
116
|
+
);
|
|
117
|
+
if (bad.length === 0) return 3;
|
|
118
|
+
return `mixed or invalid inactive texts: ${bad.map((i) => JSON.stringify(texts[i])).join(", ")}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `key:` followed by a prefix of the label at least `min` chars long (or the whole label). */
|
|
122
|
+
function hasLabelPrefix(tab: Tab, text: string, min: number): boolean {
|
|
123
|
+
const head = `${tab.key}:`;
|
|
124
|
+
if (!text.startsWith(head)) return false;
|
|
125
|
+
let prefix = text.slice(head.length);
|
|
126
|
+
if (prefix.endsWith("…")) prefix = prefix.slice(0, -1);
|
|
127
|
+
return (
|
|
128
|
+
tab.label.startsWith(prefix) &&
|
|
129
|
+
prefix.length >= Math.min(min, tab.label.length)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("C7 — barWidth follows the contract formula", () => {
|
|
134
|
+
test("R7.3 barWidth pads each cell by one space each side and joins cells with one column (default padded)", async () => {
|
|
135
|
+
const { barWidth } = await load();
|
|
136
|
+
expect(barWidth(["ab", "c"])).toBe(8);
|
|
137
|
+
expect(barWidth(["ab", "c"], true)).toBe(8);
|
|
138
|
+
expect(barWidth(["abc"])).toBe(5);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("R7.3 barWidth unpadded is the text widths plus one separator between cells", async () => {
|
|
142
|
+
const { barWidth } = await load();
|
|
143
|
+
expect(barWidth(["ab", "c"], false)).toBe(4);
|
|
144
|
+
expect(barWidth(["abc"], false)).toBe(3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("R7.3 barWidth of the full tab bar matches the reference formula, both modes", async () => {
|
|
148
|
+
const { TABS, barWidth } = await load();
|
|
149
|
+
const texts = (TABS as readonly Tab[]).map(full);
|
|
150
|
+
expect(barWidth(texts)).toBe(refBar(texts, true));
|
|
151
|
+
expect(barWidth(texts, false)).toBe(refBar(texts, false));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
for (const screen of TAB_SCREENS) {
|
|
156
|
+
describe(`C7 — layoutTabs with "${screen}" active`, () => {
|
|
157
|
+
test(`R7.1 ${screen}: tier boundaries are ordered (tier3 min < unpadded full < padded full)`, async () => {
|
|
158
|
+
const { TABS } = await load();
|
|
159
|
+
const { W1, W2, W3 } = tierForms(TABS as readonly Tab[], screen);
|
|
160
|
+
expect(W3).toBeLessThan(W2);
|
|
161
|
+
expect(W2).toBeLessThan(W1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test(`R7.1 ${screen}: at every width 0..full+5 the chosen tier is the first that fits`, async () => {
|
|
165
|
+
const { TABS } = await load();
|
|
166
|
+
const tabs = TABS as readonly Tab[];
|
|
167
|
+
const { W1 } = tierForms(tabs, screen);
|
|
168
|
+
const wrong: string[] = [];
|
|
169
|
+
for (let w = 0; w <= W1 + 5; w++) {
|
|
170
|
+
const layout = await layoutAt(screen, w);
|
|
171
|
+
const got = classify(tabs, screen, layout);
|
|
172
|
+
const want = expectedTier(tabs, screen, w);
|
|
173
|
+
if (got !== want) {
|
|
174
|
+
wrong.push(
|
|
175
|
+
`available=${w}: want tier ${want}, got ${typeof got === "number" ? `tier ${got}` : got} ${JSON.stringify(layout)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
expect(wrong.slice(0, 5)).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test(`R7.1 ${screen}: the tier changes at exactly the three boundaries`, async () => {
|
|
183
|
+
const { TABS } = await load();
|
|
184
|
+
const tabs = TABS as readonly Tab[];
|
|
185
|
+
const { W1, W2, W3 } = tierForms(tabs, screen);
|
|
186
|
+
const changes: number[] = [];
|
|
187
|
+
let prev = classify(tabs, screen, await layoutAt(screen, 0));
|
|
188
|
+
for (let w = 1; w <= W1 + 5; w++) {
|
|
189
|
+
const cur = classify(tabs, screen, await layoutAt(screen, w));
|
|
190
|
+
if (cur !== prev) changes.push(w);
|
|
191
|
+
prev = cur;
|
|
192
|
+
}
|
|
193
|
+
expect(changes).toEqual([W3, W2, W1]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test(`R7.1 ${screen}: at available = padded full width, tier 1 (every tab key:label, padded)`, async () => {
|
|
197
|
+
const { TABS } = await load();
|
|
198
|
+
const tabs = TABS as readonly Tab[];
|
|
199
|
+
const { W1, fullTexts } = tierForms(tabs, screen);
|
|
200
|
+
expect(await layoutAt(screen, W1)).toEqual({
|
|
201
|
+
texts: fullTexts,
|
|
202
|
+
padded: true,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test(`R7.1 ${screen}: one column below padded full width, tier 2 (every tab key:label, unpadded)`, async () => {
|
|
207
|
+
const { TABS } = await load();
|
|
208
|
+
const tabs = TABS as readonly Tab[];
|
|
209
|
+
const { W1, fullTexts } = tierForms(tabs, screen);
|
|
210
|
+
expect(await layoutAt(screen, W1 - 1)).toEqual({
|
|
211
|
+
texts: fullTexts,
|
|
212
|
+
padded: false,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test(`R7.1 ${screen}: at available = unpadded full width, still tier 2`, async () => {
|
|
217
|
+
const { TABS } = await load();
|
|
218
|
+
const tabs = TABS as readonly Tab[];
|
|
219
|
+
const { W2, fullTexts } = tierForms(tabs, screen);
|
|
220
|
+
expect(await layoutAt(screen, W2)).toEqual({
|
|
221
|
+
texts: fullTexts,
|
|
222
|
+
padded: false,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test(`R7.1 ${screen}: one column below unpadded full width, tier 3 (inactive tabs keep a >=3-char label prefix)`, async () => {
|
|
227
|
+
const { TABS } = await load();
|
|
228
|
+
const tabs = TABS as readonly Tab[];
|
|
229
|
+
const { W2 } = tierForms(tabs, screen);
|
|
230
|
+
expect(classify(tabs, screen, await layoutAt(screen, W2 - 1))).toBe(3);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test(`R7.1 ${screen}: at the narrowest tier-3 width, still tier 3`, async () => {
|
|
234
|
+
const { TABS } = await load();
|
|
235
|
+
const tabs = TABS as readonly Tab[];
|
|
236
|
+
const { W3 } = tierForms(tabs, screen);
|
|
237
|
+
expect(classify(tabs, screen, await layoutAt(screen, W3))).toBe(3);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test(`R7.1 ${screen}: one column below the narrowest tier-3 width, tier 4 (inactive tabs show key only)`, async () => {
|
|
241
|
+
const { TABS } = await load();
|
|
242
|
+
const tabs = TABS as readonly Tab[];
|
|
243
|
+
const { W3, min4 } = tierForms(tabs, screen);
|
|
244
|
+
const layout = await layoutAt(screen, W3 - 1);
|
|
245
|
+
expect(classify(tabs, screen, layout)).toBe(4);
|
|
246
|
+
expect(layout.texts).toEqual(min4);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test(`R7.1 ${screen}: at available = 0, tier 4`, async () => {
|
|
250
|
+
const { TABS } = await load();
|
|
251
|
+
const tabs = TABS as readonly Tab[];
|
|
252
|
+
expect(classify(tabs, screen, await layoutAt(screen, 0))).toBe(4);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test(`R7.2 ${screen}: at every width the active tab shows its full key:label and every tab shows its key, in TABS order`, async () => {
|
|
256
|
+
const { TABS } = await load();
|
|
257
|
+
const tabs = TABS as readonly Tab[];
|
|
258
|
+
const { W1 } = tierForms(tabs, screen);
|
|
259
|
+
const active = tabs.findIndex((t) => t.screen === screen);
|
|
260
|
+
const wrong: string[] = [];
|
|
261
|
+
for (let w = 0; w <= W1 + 5; w++) {
|
|
262
|
+
const { texts } = await layoutAt(screen, w);
|
|
263
|
+
if (texts.length !== tabs.length) {
|
|
264
|
+
wrong.push(`available=${w}: ${texts.length} texts`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (texts[active] !== full(tabs[active] as Tab)) {
|
|
268
|
+
wrong.push(
|
|
269
|
+
`available=${w}: active shows ${JSON.stringify(texts[active])}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
tabs.forEach((t, i) => {
|
|
273
|
+
const s = texts[i] as string;
|
|
274
|
+
if (s !== t.key && !s.startsWith(`${t.key}:`)) {
|
|
275
|
+
wrong.push(
|
|
276
|
+
`available=${w}: tab ${t.key} shows ${JSON.stringify(s)}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
expect(wrong.slice(0, 5)).toEqual([]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test(`R7.3 ${screen}: at every width the rendered bar fits, unless even tier 4 cannot`, async () => {
|
|
285
|
+
const { TABS } = await load();
|
|
286
|
+
const tabs = TABS as readonly Tab[];
|
|
287
|
+
const { W1, min4 } = tierForms(tabs, screen);
|
|
288
|
+
const tier4Floor = refBar(min4, false);
|
|
289
|
+
const over: string[] = [];
|
|
290
|
+
for (let w = 0; w <= W1 + 5; w++) {
|
|
291
|
+
const layout = await layoutAt(screen, w);
|
|
292
|
+
const width = refBar(layout.texts, layout.padded);
|
|
293
|
+
if (width <= w) continue;
|
|
294
|
+
const tier = classify(tabs, screen, layout);
|
|
295
|
+
if (tier === 4 && tier4Floor > w) continue; // nothing can fit
|
|
296
|
+
over.push(
|
|
297
|
+
`available=${w}: rendered ${width} (tier ${tier}) ${JSON.stringify(layout)}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
expect(over.slice(0, 5)).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test(`R7.4 ${screen}: at available = 96 (100-column terminal minus 4) every inactive tab shows at least 3 label characters`, async () => {
|
|
304
|
+
const { TABS } = await load();
|
|
305
|
+
const tabs = TABS as readonly Tab[];
|
|
306
|
+
const layout = await layoutAt(screen, 96);
|
|
307
|
+
const short = tabs
|
|
308
|
+
.map((t, i) => ({ t, text: layout.texts[i] as string }))
|
|
309
|
+
.filter(({ t }) => t.screen !== screen)
|
|
310
|
+
.filter(({ t, text }) => !hasLabelPrefix(t, text ?? "", 3))
|
|
311
|
+
.map(({ text }) => text);
|
|
312
|
+
expect(short).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test(`R7.4 ${screen}: at available = 96 the bar fits`, async () => {
|
|
316
|
+
const layout = await layoutAt(screen, 96);
|
|
317
|
+
expect(refBar(layout.texts, layout.padded)).toBeLessThanOrEqual(96);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C8 (requirements R8.1, R8.2 as far as the contract
|
|
3
|
+
* reaches: measuring, truncating and padding in terminal cells).
|
|
4
|
+
*
|
|
5
|
+
* Written from requirements.md and architecture.md only.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
const load = () => import("../../ui/text-width.js");
|
|
10
|
+
|
|
11
|
+
const E_ACUTE = "é"; // e + COMBINING ACUTE ACCENT
|
|
12
|
+
|
|
13
|
+
describe("C8 — displayWidth counts terminal cells", () => {
|
|
14
|
+
const cases: [string, string, number][] = [
|
|
15
|
+
["empty string", "", 0],
|
|
16
|
+
["ASCII", "hello", 5],
|
|
17
|
+
["ASCII with spaces and punctuation", "a b-c:d", 7],
|
|
18
|
+
["CJK (Japanese)", "日本語", 6],
|
|
19
|
+
["CJK (Chinese)", "中文", 4],
|
|
20
|
+
["Hangul", "한글", 4],
|
|
21
|
+
["emoji", "🎉", 2],
|
|
22
|
+
["emoji (U+2705)", "✅", 2],
|
|
23
|
+
["emoji in text", "a🔌b", 4],
|
|
24
|
+
["combining mark adds 0", E_ACUTE, 1],
|
|
25
|
+
["two combining marks add 0", "à́", 1],
|
|
26
|
+
["mixed row", `Plugins 🔌 日本 ${E_ACUTE}`, 7 + 1 + 2 + 1 + 4 + 1 + 1],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const [name, input, want] of cases) {
|
|
30
|
+
test(`R8.2 displayWidth(${name}) is ${want}`, async () => {
|
|
31
|
+
const { displayWidth } = await load();
|
|
32
|
+
expect(displayWidth(input)).toBe(want);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("C8 — truncate", () => {
|
|
38
|
+
test("R8.1 a string that fits is returned unchanged, with no ellipsis", async () => {
|
|
39
|
+
const { truncate } = await load();
|
|
40
|
+
expect(truncate("abc", 5)).toBe("abc");
|
|
41
|
+
expect(truncate("abcde", 5)).toBe("abcde");
|
|
42
|
+
expect(truncate("日本", 4)).toBe("日本");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const cuts: [string, string, number][] = [
|
|
46
|
+
["ASCII", "Enable auto-update checks", 10],
|
|
47
|
+
["CJK", "日本語テキスト", 5],
|
|
48
|
+
["CJK even width", "日本語テキスト", 6],
|
|
49
|
+
["emoji", "🎉🎉🎉🎉", 5],
|
|
50
|
+
["combining marks", `${E_ACUTE}${E_ACUTE}${E_ACUTE}${E_ACUTE}`, 3],
|
|
51
|
+
["mixed", "Plugins 🔌 日本語", 9],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const [name, input, width] of cuts) {
|
|
55
|
+
test(`R8.1 truncate(${name}, ${width}) fits the width and ends with …`, async () => {
|
|
56
|
+
const { truncate, displayWidth } = await load();
|
|
57
|
+
const out = truncate(input, width);
|
|
58
|
+
expect(displayWidth(out)).toBeLessThanOrEqual(width);
|
|
59
|
+
expect(out.endsWith("…")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test(`R8.1 truncate(${name}, ${width}) keeps a prefix of the original before the …`, async () => {
|
|
63
|
+
const { truncate } = await load();
|
|
64
|
+
const out = truncate(input, width);
|
|
65
|
+
expect(input.startsWith(out.slice(0, -1))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test(`R8.1 truncate(${name}, ${width}) does not over-cut (uses at least width-1 cells)`, async () => {
|
|
69
|
+
// A wide glyph may leave one cell unused; more than that throws text away.
|
|
70
|
+
const { truncate, displayWidth } = await load();
|
|
71
|
+
expect(displayWidth(truncate(input, width))).toBeGreaterThanOrEqual(
|
|
72
|
+
width - 1,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("R8.1 truncate to width 1 leaves just the ellipsis", async () => {
|
|
78
|
+
const { truncate } = await load();
|
|
79
|
+
expect(truncate("abcdef", 1)).toBe("…");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("R8.1 truncate of an ASCII string one cell too long ends in …, width exact", async () => {
|
|
83
|
+
const { truncate } = await load();
|
|
84
|
+
expect(truncate("abcdef", 5)).toBe("abcd…");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("C8 — padTo pads to exactly width cells", () => {
|
|
89
|
+
const pads: [string, string, number][] = [
|
|
90
|
+
["ASCII", "ab", 5],
|
|
91
|
+
["empty", "", 3],
|
|
92
|
+
["exact", "abc", 3],
|
|
93
|
+
["CJK", "日本", 6],
|
|
94
|
+
["emoji", "🎉", 4],
|
|
95
|
+
["combining", E_ACUTE, 4],
|
|
96
|
+
["too wide ASCII", "abcdefgh", 5],
|
|
97
|
+
["too wide CJK at an odd width", "日本語", 5],
|
|
98
|
+
["too wide emoji at an odd width", "🎉🎉🎉", 5],
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
for (const [name, input, width] of pads) {
|
|
102
|
+
test(`R8.2 padTo(${name}, ${width}) is exactly ${width} cells`, async () => {
|
|
103
|
+
const { padTo, displayWidth } = await load();
|
|
104
|
+
expect(displayWidth(padTo(input, width))).toBe(width);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("R8.2 padTo pads with spaces on the right", async () => {
|
|
109
|
+
const { padTo } = await load();
|
|
110
|
+
expect(padTo("ab", 5)).toBe("ab ");
|
|
111
|
+
expect(padTo("", 3)).toBe(" ");
|
|
112
|
+
expect(padTo("日本", 6)).toBe("日本 ");
|
|
113
|
+
expect(padTo("🎉", 4)).toBe("🎉 ");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("R8.2 padTo leaves an exact-width string unchanged", async () => {
|
|
117
|
+
const { padTo } = await load();
|
|
118
|
+
expect(padTo("abc", 3)).toBe("abc");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("R8.2 rows with emoji, CJK and combining marks padded to one width put a divider in one column", async () => {
|
|
122
|
+
const { padTo, displayWidth } = await load();
|
|
123
|
+
const rows = [
|
|
124
|
+
"plain row",
|
|
125
|
+
"Plugins 🔌",
|
|
126
|
+
"日本語の行",
|
|
127
|
+
`${E_ACUTE}clair`,
|
|
128
|
+
"✅ installed",
|
|
129
|
+
"a much longer row that must be cut to fit",
|
|
130
|
+
];
|
|
131
|
+
const lines = rows.map((r) => `${padTo(r, 20)}│value`);
|
|
132
|
+
const dividerCol = lines.map((l) =>
|
|
133
|
+
displayWidth(l.slice(0, l.indexOf("│"))),
|
|
134
|
+
);
|
|
135
|
+
expect(dividerCol).toEqual(rows.map(() => 20));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C9 (requirement R9.3).
|
|
3
|
+
*
|
|
4
|
+
* Written from requirements.md and architecture.md only. Each test builds the
|
|
5
|
+
* on-disk shape a package manager leaves behind (a bin symlink into a global
|
|
6
|
+
* node_modules package) inside its own temp dir.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
symlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
const load = () => import("../../services/cli-tool-version.js");
|
|
22
|
+
|
|
23
|
+
const dirs: string[] = [];
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const dir of dirs.splice(0))
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function temp(): string {
|
|
30
|
+
// realpath so relative symlinks are computed against the real location.
|
|
31
|
+
const dir = realpathSync(
|
|
32
|
+
mkdtempSync(path.join(os.tmpdir(), "magus-blind-c9-")),
|
|
33
|
+
);
|
|
34
|
+
dirs.push(dir);
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeJson(file: string, value: unknown): void {
|
|
39
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
40
|
+
writeFileSync(file, JSON.stringify(value, null, 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeBin(file: string): void {
|
|
44
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
45
|
+
writeFileSync(file, "#!/usr/bin/env node\nconsole.log('hi');\n");
|
|
46
|
+
chmodSync(file, 0o755);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function link(target: string, at: string, relative = false): void {
|
|
50
|
+
mkdirSync(path.dirname(at), { recursive: true });
|
|
51
|
+
symlinkSync(relative ? path.relative(path.dirname(at), target) : target, at);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("C9 — readPackageVersionForBin", () => {
|
|
55
|
+
test("R9.3 npm global layout: bin/magus -> ../lib/node_modules/magus-cli/bin/magus.js reads magus-cli's version", async () => {
|
|
56
|
+
const { readPackageVersionForBin } = await load();
|
|
57
|
+
const prefix = temp();
|
|
58
|
+
const pkg = path.join(prefix, "lib", "node_modules", "magus-cli");
|
|
59
|
+
writeJson(path.join(pkg, "package.json"), {
|
|
60
|
+
name: "magus-cli",
|
|
61
|
+
version: "7.3.0",
|
|
62
|
+
});
|
|
63
|
+
writeBin(path.join(pkg, "bin", "magus.js"));
|
|
64
|
+
link(
|
|
65
|
+
path.join(pkg, "bin", "magus.js"),
|
|
66
|
+
path.join(prefix, "bin", "magus"),
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
expect(readPackageVersionForBin(path.join(prefix, "bin", "magus"))).toBe(
|
|
70
|
+
"7.3.0",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("R9.3 bun global layout with a scoped package (Claude Code) reads its version", async () => {
|
|
75
|
+
const { readPackageVersionForBin } = await load();
|
|
76
|
+
const root = temp();
|
|
77
|
+
const pkg = path.join(
|
|
78
|
+
root,
|
|
79
|
+
".bun",
|
|
80
|
+
"install",
|
|
81
|
+
"global",
|
|
82
|
+
"node_modules",
|
|
83
|
+
"@anthropic-ai",
|
|
84
|
+
"claude-code",
|
|
85
|
+
);
|
|
86
|
+
writeJson(path.join(pkg, "package.json"), {
|
|
87
|
+
name: "@anthropic-ai/claude-code",
|
|
88
|
+
version: "2.1.300",
|
|
89
|
+
});
|
|
90
|
+
writeBin(path.join(pkg, "cli.js"));
|
|
91
|
+
link(path.join(pkg, "cli.js"), path.join(root, ".bun", "bin", "claude"));
|
|
92
|
+
expect(
|
|
93
|
+
readPackageVersionForBin(path.join(root, ".bun", "bin", "claude")),
|
|
94
|
+
).toBe("2.1.300");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("R9.3 resolves a two-hop symlink chain before walking up", async () => {
|
|
98
|
+
const { readPackageVersionForBin } = await load();
|
|
99
|
+
const root = temp();
|
|
100
|
+
const pkg = path.join(root, "store", "node_modules", "@charmland", "crush");
|
|
101
|
+
writeJson(path.join(pkg, "package.json"), {
|
|
102
|
+
name: "@charmland/crush",
|
|
103
|
+
version: "0.9.1",
|
|
104
|
+
});
|
|
105
|
+
writeBin(path.join(pkg, "bin", "crush"));
|
|
106
|
+
link(path.join(pkg, "bin", "crush"), path.join(root, "hop", "crush"));
|
|
107
|
+
link(
|
|
108
|
+
path.join(root, "hop", "crush"),
|
|
109
|
+
path.join(root, "bin", "crush"),
|
|
110
|
+
true,
|
|
111
|
+
);
|
|
112
|
+
expect(readPackageVersionForBin(path.join(root, "bin", "crush"))).toBe(
|
|
113
|
+
"0.9.1",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("R9.3 walks up from the binary's REAL location, not from where the symlink sits", async () => {
|
|
118
|
+
const { readPackageVersionForBin } = await load();
|
|
119
|
+
const root = temp();
|
|
120
|
+
// The symlink lives inside an unrelated package; that package must not answer.
|
|
121
|
+
writeJson(path.join(root, "host", "package.json"), {
|
|
122
|
+
name: "host",
|
|
123
|
+
version: "0.0.1",
|
|
124
|
+
});
|
|
125
|
+
const pkg = path.join(
|
|
126
|
+
root,
|
|
127
|
+
"global",
|
|
128
|
+
"node_modules",
|
|
129
|
+
"@charmland",
|
|
130
|
+
"crush",
|
|
131
|
+
);
|
|
132
|
+
writeJson(path.join(pkg, "package.json"), {
|
|
133
|
+
name: "@charmland/crush",
|
|
134
|
+
version: "0.9.1",
|
|
135
|
+
});
|
|
136
|
+
writeBin(path.join(pkg, "bin", "crush"));
|
|
137
|
+
link(
|
|
138
|
+
path.join(pkg, "bin", "crush"),
|
|
139
|
+
path.join(root, "host", "bin", "crush"),
|
|
140
|
+
);
|
|
141
|
+
expect(
|
|
142
|
+
readPackageVersionForBin(path.join(root, "host", "bin", "crush")),
|
|
143
|
+
).toBe("0.9.1");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("R9.3 a binary given by its real path (no symlink) reads its package's version", async () => {
|
|
147
|
+
const { readPackageVersionForBin } = await load();
|
|
148
|
+
const root = temp();
|
|
149
|
+
const pkg = path.join(root, "node_modules", "tool");
|
|
150
|
+
writeJson(path.join(pkg, "package.json"), {
|
|
151
|
+
name: "tool",
|
|
152
|
+
version: "1.2.3",
|
|
153
|
+
});
|
|
154
|
+
writeBin(path.join(pkg, "dist", "bin", "tool.js"));
|
|
155
|
+
expect(
|
|
156
|
+
readPackageVersionForBin(path.join(pkg, "dist", "bin", "tool.js")),
|
|
157
|
+
).toBe("1.2.3");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("R9.3 skips package.json files missing name or version and keeps walking up", async () => {
|
|
161
|
+
const { readPackageVersionForBin } = await load();
|
|
162
|
+
const root = temp();
|
|
163
|
+
const pkg = path.join(root, "node_modules", "tool");
|
|
164
|
+
writeJson(path.join(pkg, "package.json"), {
|
|
165
|
+
name: "tool",
|
|
166
|
+
version: "4.5.6",
|
|
167
|
+
});
|
|
168
|
+
// Nearer package.json files that do not qualify:
|
|
169
|
+
writeJson(path.join(pkg, "dist", "package.json"), { type: "module" });
|
|
170
|
+
writeJson(path.join(pkg, "dist", "esm", "package.json"), {
|
|
171
|
+
name: "tool-esm",
|
|
172
|
+
});
|
|
173
|
+
writeJson(path.join(pkg, "dist", "esm", "bin", "package.json"), {
|
|
174
|
+
version: "0.0.0",
|
|
175
|
+
});
|
|
176
|
+
writeBin(path.join(pkg, "dist", "esm", "bin", "tool.js"));
|
|
177
|
+
link(
|
|
178
|
+
path.join(pkg, "dist", "esm", "bin", "tool.js"),
|
|
179
|
+
path.join(root, "bin", "tool"),
|
|
180
|
+
);
|
|
181
|
+
expect(readPackageVersionForBin(path.join(root, "bin", "tool"))).toBe(
|
|
182
|
+
"4.5.6",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("R9.3 the nearest qualifying package.json wins over an enclosing project's", async () => {
|
|
187
|
+
const { readPackageVersionForBin } = await load();
|
|
188
|
+
const root = temp();
|
|
189
|
+
writeJson(path.join(root, "package.json"), {
|
|
190
|
+
name: "workspace",
|
|
191
|
+
version: "9.9.9",
|
|
192
|
+
});
|
|
193
|
+
const pkg = path.join(root, "node_modules", "tool");
|
|
194
|
+
writeJson(path.join(pkg, "package.json"), {
|
|
195
|
+
name: "tool",
|
|
196
|
+
version: "1.0.0",
|
|
197
|
+
});
|
|
198
|
+
writeBin(path.join(pkg, "bin", "tool.js"));
|
|
199
|
+
expect(readPackageVersionForBin(path.join(pkg, "bin", "tool.js"))).toBe(
|
|
200
|
+
"1.0.0",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("R9.3 returns null when no package.json with name and version is found", async () => {
|
|
205
|
+
const { readPackageVersionForBin } = await load();
|
|
206
|
+
const root = temp();
|
|
207
|
+
writeBin(path.join(root, "bin", "lonely"));
|
|
208
|
+
expect(
|
|
209
|
+
readPackageVersionForBin(path.join(root, "bin", "lonely")),
|
|
210
|
+
).toBeNull();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("R9.3 returns null (does not throw) for a path that does not exist", async () => {
|
|
214
|
+
const { readPackageVersionForBin } = await load();
|
|
215
|
+
const root = temp();
|
|
216
|
+
expect(
|
|
217
|
+
readPackageVersionForBin(path.join(root, "bin", "missing")),
|
|
218
|
+
).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("R9.3 returns null (does not throw) for a dangling symlink", async () => {
|
|
222
|
+
const { readPackageVersionForBin } = await load();
|
|
223
|
+
const root = temp();
|
|
224
|
+
link(path.join(root, "gone", "tool.js"), path.join(root, "bin", "tool"));
|
|
225
|
+
expect(readPackageVersionForBin(path.join(root, "bin", "tool"))).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("R9.3 returns null (does not throw) when the only package.json is unreadable JSON", async () => {
|
|
229
|
+
const { readPackageVersionForBin } = await load();
|
|
230
|
+
const root = temp();
|
|
231
|
+
const pkg = path.join(root, "node_modules", "broken");
|
|
232
|
+
mkdirSync(pkg, { recursive: true });
|
|
233
|
+
writeFileSync(path.join(pkg, "package.json"), "{ not json");
|
|
234
|
+
writeBin(path.join(pkg, "bin", "broken.js"));
|
|
235
|
+
expect(
|
|
236
|
+
readPackageVersionForBin(path.join(pkg, "bin", "broken.js")),
|
|
237
|
+
).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
});
|