claudeup 4.37.0 → 4.38.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/verify-community-registry.ts +272 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +521 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +101 -7
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +113 -1
- package/src/ui/state/types.ts +60 -2
- package/src/utils/open-file.ts +84 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import {
|
|
7
|
+
applyStyles,
|
|
8
|
+
clearStyle,
|
|
9
|
+
communityCacheDirOrNull,
|
|
10
|
+
loadStyles,
|
|
11
|
+
} from "../services/styles-manager.js";
|
|
12
|
+
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
13
|
+
import type { AppAction, AppState } from "../ui/state/types.js";
|
|
14
|
+
|
|
15
|
+
function run(actions: AppAction[], from: AppState = initialState): AppState {
|
|
16
|
+
return actions.reduce(appReducer, from);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("styles search state", () => {
|
|
20
|
+
test("appending builds up the query one character at a time", () => {
|
|
21
|
+
// The regression this guards: computing `searchQuery + char` in the screen
|
|
22
|
+
// reads a value captured at render time. Keystrokes arriving faster than
|
|
23
|
+
// React re-renders then all start from the same stale string, so typing
|
|
24
|
+
// "slop" left just "p" in the filter.
|
|
25
|
+
const state = run([
|
|
26
|
+
{ type: "STYLES_SEARCH_APPEND", char: "s" },
|
|
27
|
+
{ type: "STYLES_SEARCH_APPEND", char: "l" },
|
|
28
|
+
{ type: "STYLES_SEARCH_APPEND", char: "o" },
|
|
29
|
+
{ type: "STYLES_SEARCH_APPEND", char: "p" },
|
|
30
|
+
]);
|
|
31
|
+
expect(state.styles.searchQuery).toBe("slop");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("appending resets the cursor so it cannot sit past the filtered list", () => {
|
|
35
|
+
const state = run([
|
|
36
|
+
{ type: "STYLES_SELECT", index: 9 },
|
|
37
|
+
{ type: "STYLES_SEARCH_APPEND", char: "a" },
|
|
38
|
+
]);
|
|
39
|
+
expect(state.styles.selectedIndex).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("backspace removes one character at a time", () => {
|
|
43
|
+
const typed = run([
|
|
44
|
+
{ type: "STYLES_SEARCH_APPEND", char: "a" },
|
|
45
|
+
{ type: "STYLES_SEARCH_APPEND", char: "b" },
|
|
46
|
+
{ type: "STYLES_SEARCH_APPEND", char: "c" },
|
|
47
|
+
]);
|
|
48
|
+
expect(
|
|
49
|
+
run([{ type: "STYLES_SEARCH_BACKSPACE" }], typed).styles.searchQuery,
|
|
50
|
+
).toBe("ab");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("backspace on an empty query is harmless", () => {
|
|
54
|
+
expect(run([{ type: "STYLES_SEARCH_BACKSPACE" }]).styles.searchQuery).toBe(
|
|
55
|
+
"",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("template fill state", () => {
|
|
61
|
+
test("survives the screen unmounting, because it lives in app state", () => {
|
|
62
|
+
// A fill runs Claude Code over the whole project for minutes, so switching
|
|
63
|
+
// tabs mid-run is the normal thing to do. Held in the screen's own
|
|
64
|
+
// useState it was lost on unmount, and the returning user — seeing no sign
|
|
65
|
+
// one was running — could launch a second subprocess on top of the first.
|
|
66
|
+
const filling = run([{ type: "STYLES_FILL_START" }]);
|
|
67
|
+
expect(filling.styles.isFilling).toBe(true);
|
|
68
|
+
|
|
69
|
+
// Navigating away and back is what unmounts the screen. App state is
|
|
70
|
+
// untouched by it.
|
|
71
|
+
const navigated = run(
|
|
72
|
+
[
|
|
73
|
+
{ type: "NAVIGATE", route: { screen: "plugins" } },
|
|
74
|
+
{ type: "NAVIGATE", route: { screen: "styles" } },
|
|
75
|
+
],
|
|
76
|
+
filling,
|
|
77
|
+
);
|
|
78
|
+
expect(navigated.styles.isFilling).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("clears when the fill finishes", () => {
|
|
82
|
+
const done = run([
|
|
83
|
+
{ type: "STYLES_FILL_START" },
|
|
84
|
+
{ type: "STYLES_FILL_END" },
|
|
85
|
+
]);
|
|
86
|
+
expect(done.styles.isFilling).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("starts false", () => {
|
|
90
|
+
expect(initialState.styles.isFilling).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("styles selection state", () => {
|
|
95
|
+
test("toggle adds then removes", () => {
|
|
96
|
+
const on = run([{ type: "STYLES_TOGGLE", id: "direct" }]);
|
|
97
|
+
expect([...on.styles.selected]).toEqual(["direct"]);
|
|
98
|
+
const off = run([{ type: "STYLES_TOGGLE", id: "direct" }], on);
|
|
99
|
+
expect([...off.styles.selected]).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("the committed declaration wins over the stale local artifact", () => {
|
|
103
|
+
// The screen tells the user to "press a to re-apply" when the project's
|
|
104
|
+
// style has moved on. Seeding from the local artifact would make `a`
|
|
105
|
+
// re-apply the OLD set and leave the message permanently true.
|
|
106
|
+
const state = run([
|
|
107
|
+
{
|
|
108
|
+
type: "STYLES_DATA_SUCCESS",
|
|
109
|
+
snapshot: {
|
|
110
|
+
presets: [],
|
|
111
|
+
imports: [],
|
|
112
|
+
applied: { presets: ["old"], imports: [], hash: "sha256:old" },
|
|
113
|
+
declaration: {
|
|
114
|
+
version: 1,
|
|
115
|
+
presets: ["pulled"],
|
|
116
|
+
imports: [],
|
|
117
|
+
hash: "sha256:new",
|
|
118
|
+
updatedAt: "",
|
|
119
|
+
},
|
|
120
|
+
status: {
|
|
121
|
+
state: "stale",
|
|
122
|
+
detail: "project style changed",
|
|
123
|
+
missing: [],
|
|
124
|
+
fetchable: [],
|
|
125
|
+
},
|
|
126
|
+
stylePath: "/x/composed.md",
|
|
127
|
+
settingsPath: "/x/settings.json",
|
|
128
|
+
styleName: "composed",
|
|
129
|
+
presetsRoot: "/styles",
|
|
130
|
+
profile: null,
|
|
131
|
+
currentOutputStyle: "composed",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
expect([...state.styles.selected]).toEqual(["pulled"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("loading a snapshot seeds the selection from what is live", () => {
|
|
139
|
+
// Opening the screen on an empty set would read as "nothing is applied"
|
|
140
|
+
// even when a style is active.
|
|
141
|
+
const state = run([
|
|
142
|
+
{
|
|
143
|
+
type: "STYLES_DATA_SUCCESS",
|
|
144
|
+
snapshot: {
|
|
145
|
+
presets: [],
|
|
146
|
+
imports: [],
|
|
147
|
+
applied: {
|
|
148
|
+
presets: ["direct"],
|
|
149
|
+
imports: ["user:mine"],
|
|
150
|
+
hash: "sha256:x",
|
|
151
|
+
},
|
|
152
|
+
declaration: null,
|
|
153
|
+
status: {
|
|
154
|
+
state: "undeclared",
|
|
155
|
+
detail: "",
|
|
156
|
+
missing: [],
|
|
157
|
+
fetchable: [],
|
|
158
|
+
},
|
|
159
|
+
stylePath: "/x/composed.md",
|
|
160
|
+
settingsPath: "/x/settings.json",
|
|
161
|
+
styleName: "composed",
|
|
162
|
+
presetsRoot: "/styles",
|
|
163
|
+
profile: null,
|
|
164
|
+
currentOutputStyle: "composed",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
expect([...state.styles.selected].sort()).toEqual(["direct", "user:mine"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a snapshot with nothing live seeds an empty selection", () => {
|
|
172
|
+
const state = run([
|
|
173
|
+
{ type: "STYLES_TOGGLE", id: "stale" },
|
|
174
|
+
{
|
|
175
|
+
type: "STYLES_DATA_SUCCESS",
|
|
176
|
+
snapshot: {
|
|
177
|
+
presets: [],
|
|
178
|
+
imports: [],
|
|
179
|
+
applied: null,
|
|
180
|
+
declaration: null,
|
|
181
|
+
status: {
|
|
182
|
+
state: "undeclared",
|
|
183
|
+
detail: "",
|
|
184
|
+
missing: [],
|
|
185
|
+
fetchable: [],
|
|
186
|
+
},
|
|
187
|
+
stylePath: "/x/composed.md",
|
|
188
|
+
settingsPath: "/x/settings.json",
|
|
189
|
+
styleName: "composed",
|
|
190
|
+
presetsRoot: null,
|
|
191
|
+
profile: null,
|
|
192
|
+
currentOutputStyle: null,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
expect([...state.styles.selected]).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("loadStyles reports what is actually in force", () => {
|
|
201
|
+
let dir: string;
|
|
202
|
+
|
|
203
|
+
beforeEach(async () => {
|
|
204
|
+
dir = await mkdtemp(join(tmpdir(), "claudeup-styles-live-"));
|
|
205
|
+
});
|
|
206
|
+
afterEach(async () => {
|
|
207
|
+
await rm(dir, { recursive: true, force: true });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("reports the applied selection while the style is active", async () => {
|
|
211
|
+
await fs.outputFile(
|
|
212
|
+
join(dir, ".claude", "output-styles", "composed.md"),
|
|
213
|
+
[
|
|
214
|
+
"---",
|
|
215
|
+
"name: composed",
|
|
216
|
+
"keep-coding-instructions: true",
|
|
217
|
+
"style-presets: direct",
|
|
218
|
+
"style-imports: none",
|
|
219
|
+
"---",
|
|
220
|
+
"",
|
|
221
|
+
"body",
|
|
222
|
+
].join("\n"),
|
|
223
|
+
);
|
|
224
|
+
await fs.outputJson(join(dir, ".claude", "settings.json"), {
|
|
225
|
+
outputStyle: "composed",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const snapshot = await loadStyles(dir);
|
|
229
|
+
expect(snapshot.applied).toEqual({
|
|
230
|
+
presets: ["direct"],
|
|
231
|
+
imports: [],
|
|
232
|
+
hash: null,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("reports nothing applied once the style is cleared", async () => {
|
|
237
|
+
// clearStyle removes the pointer and deliberately keeps the file. Reading
|
|
238
|
+
// the file alone would keep reporting the rules as live.
|
|
239
|
+
await applyStyles({
|
|
240
|
+
projectPath: dir,
|
|
241
|
+
presets: [
|
|
242
|
+
{
|
|
243
|
+
kind: "preset",
|
|
244
|
+
id: "direct",
|
|
245
|
+
name: "direct",
|
|
246
|
+
axis: "verbosity",
|
|
247
|
+
summary: "",
|
|
248
|
+
conflicts: [],
|
|
249
|
+
template: false,
|
|
250
|
+
body: "rules",
|
|
251
|
+
path: "/styles/direct.md",
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
imports: [],
|
|
255
|
+
});
|
|
256
|
+
expect((await loadStyles(dir)).applied).not.toBeNull();
|
|
257
|
+
|
|
258
|
+
await clearStyle(dir);
|
|
259
|
+
|
|
260
|
+
const snapshot = await loadStyles(dir);
|
|
261
|
+
expect(snapshot.currentOutputStyle).toBeNull();
|
|
262
|
+
expect(snapshot.applied).toBeNull();
|
|
263
|
+
// The file is still there — clearing is reversible.
|
|
264
|
+
expect(await fs.pathExists(snapshot.stylePath)).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("reports nothing applied when another style is active", async () => {
|
|
268
|
+
await fs.outputFile(
|
|
269
|
+
join(dir, ".claude", "output-styles", "composed.md"),
|
|
270
|
+
"---\nname: composed\nstyle-presets: direct\n---\n\nbody",
|
|
271
|
+
);
|
|
272
|
+
await fs.outputJson(join(dir, ".claude", "settings.json"), {
|
|
273
|
+
outputStyle: "Explanatory",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const snapshot = await loadStyles(dir);
|
|
277
|
+
expect(snapshot.currentOutputStyle).toBe("Explanatory");
|
|
278
|
+
expect(snapshot.applied).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("makes no network call, in any phase", async () => {
|
|
282
|
+
// The constraint the whole feature is built around: `loadStyles` runs on
|
|
283
|
+
// every render and every reload. Fetching and update-checking are explicit
|
|
284
|
+
// user actions, never a consequence of drawing a panel.
|
|
285
|
+
const src = await fs.readFile(
|
|
286
|
+
join(import.meta.dir, "..", "services", "styles-manager.ts"),
|
|
287
|
+
"utf8",
|
|
288
|
+
);
|
|
289
|
+
expect(src).not.toMatch(/\bfetch\s*\(/);
|
|
290
|
+
expect(src).not.toMatch(/community-fetcher/);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("looks for the per-profile style when a profile is active", async () => {
|
|
294
|
+
await fs.outputJson(
|
|
295
|
+
join(dir, ".claude", "_profiles", "dev", "settings.json"),
|
|
296
|
+
{
|
|
297
|
+
outputStyle: "composed-dev",
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
await symlink(
|
|
301
|
+
"_profiles/dev/settings.json",
|
|
302
|
+
join(dir, ".claude", "settings.json"),
|
|
303
|
+
);
|
|
304
|
+
await fs.outputFile(
|
|
305
|
+
join(dir, ".claude", "output-styles", "composed-dev.md"),
|
|
306
|
+
"---\nname: composed-dev\nstyle-presets: terse\nstyle-imports: none\n---\n\nbody",
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const snapshot = await loadStyles(dir);
|
|
310
|
+
expect(snapshot.profile).toBe("dev");
|
|
311
|
+
expect(snapshot.styleName).toBe("composed-dev");
|
|
312
|
+
expect(snapshot.applied).toEqual({
|
|
313
|
+
presets: ["terse"],
|
|
314
|
+
imports: [],
|
|
315
|
+
hash: null,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ─── Community styles, end to end through loadStyles ──────────────────────────
|
|
321
|
+
|
|
322
|
+
describe("a project that committed a community style", () => {
|
|
323
|
+
let dir: string;
|
|
324
|
+
let home: string;
|
|
325
|
+
let previousConfigDir: string | undefined;
|
|
326
|
+
|
|
327
|
+
beforeEach(async () => {
|
|
328
|
+
dir = await mkdtemp(join(tmpdir(), "claudeup-styles-community-"));
|
|
329
|
+
home = join(dir, "home");
|
|
330
|
+
previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
331
|
+
// Must be ABSENT, not assigned undefined: `process.env.X = undefined`
|
|
332
|
+
// stores the STRING "undefined", which is truthy.
|
|
333
|
+
// biome-ignore lint/performance/noDelete: absence is the intent
|
|
334
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
335
|
+
});
|
|
336
|
+
afterEach(async () => {
|
|
337
|
+
await rm(dir, { recursive: true, force: true });
|
|
338
|
+
// biome-ignore lint/performance/noDelete: same reason as above
|
|
339
|
+
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
340
|
+
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const declare = () =>
|
|
344
|
+
fs.outputJson(join(dir, ".claude", "style.json"), {
|
|
345
|
+
version: 1,
|
|
346
|
+
presets: [],
|
|
347
|
+
imports: ["community:attention-span--spartan"],
|
|
348
|
+
hash: "sha256:whatever",
|
|
349
|
+
updatedAt: "2026-08-18T00:00:00.000Z",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("reports it as fetchable, with an action that actually converges", async () => {
|
|
353
|
+
// The fresh-clone case, which is the whole point of coordinate ids: this
|
|
354
|
+
// machine has fetched nothing, so the id has to resolve against the
|
|
355
|
+
// registry before any file exists locally.
|
|
356
|
+
await declare();
|
|
357
|
+
const snapshot = await loadStyles(dir, { home });
|
|
358
|
+
expect(snapshot.status.fetchable).toEqual([
|
|
359
|
+
"community:attention-span--spartan",
|
|
360
|
+
]);
|
|
361
|
+
expect(snapshot.status.missing).toEqual([]);
|
|
362
|
+
expect(snapshot.status.detail).toBe(
|
|
363
|
+
"project style needs 1 community style — press f",
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("stops saying so once the style is in the cache", async () => {
|
|
368
|
+
await declare();
|
|
369
|
+
const cacheDir = communityCacheDirOrNull(home);
|
|
370
|
+
if (!cacheDir) throw new Error("community cache dir did not resolve");
|
|
371
|
+
await fs.outputFile(
|
|
372
|
+
join(cacheDir, "attention-span--spartan.md"),
|
|
373
|
+
[
|
|
374
|
+
"---",
|
|
375
|
+
"name: attention-span--spartan",
|
|
376
|
+
'description: "Blunt Spartan mode."',
|
|
377
|
+
"keep-coding-instructions: true",
|
|
378
|
+
"community-source: alexgreensh/attention-span",
|
|
379
|
+
"community-path: output-styles/spartan.md",
|
|
380
|
+
"community-ref: HEAD",
|
|
381
|
+
"community-commit: b860c9f8f3c7",
|
|
382
|
+
"community-sha256: sha256:abc",
|
|
383
|
+
"community-fetched: 2026-08-18",
|
|
384
|
+
"community-licence: AGPL-3.0",
|
|
385
|
+
"community-author: alexgreensh",
|
|
386
|
+
"---",
|
|
387
|
+
"",
|
|
388
|
+
"- Answer first.",
|
|
389
|
+
].join("\n"),
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const snapshot = await loadStyles(dir, { home });
|
|
393
|
+
expect(snapshot.status.fetchable).toEqual([]);
|
|
394
|
+
const style = snapshot.imports.find(
|
|
395
|
+
(s) => s.id === "community:attention-span--spartan",
|
|
396
|
+
);
|
|
397
|
+
// The provenance the detail panel renders, parsed from the file itself —
|
|
398
|
+
// no network call, on a code path that runs on every render.
|
|
399
|
+
expect(style?.community?.source).toBe("alexgreensh/attention-span");
|
|
400
|
+
expect(style?.community?.commit).toBe("b860c9f8f3c7");
|
|
401
|
+
expect(style?.community?.licence).toBe("AGPL-3.0");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("an unresolved pin and an unlicensed repo read as ABSENT, not as values", async () => {
|
|
405
|
+
// The fetcher stamps `unknown` and `unlicensed` because a blank value in
|
|
406
|
+
// the file would read as a truncated write. Neither may survive into the
|
|
407
|
+
// panel, where "unlicensed" in the licence slot looks like an SPDX id.
|
|
408
|
+
const cacheDir = communityCacheDirOrNull(home);
|
|
409
|
+
if (!cacheDir) throw new Error("community cache dir did not resolve");
|
|
410
|
+
await fs.outputFile(
|
|
411
|
+
join(cacheDir, "attention-span--rundown.md"),
|
|
412
|
+
[
|
|
413
|
+
"---",
|
|
414
|
+
"name: attention-span--rundown",
|
|
415
|
+
"community-source: alexgreensh/attention-span",
|
|
416
|
+
"community-commit: unknown",
|
|
417
|
+
"community-licence: unlicensed",
|
|
418
|
+
"---",
|
|
419
|
+
"",
|
|
420
|
+
"- Brief.",
|
|
421
|
+
].join("\n"),
|
|
422
|
+
);
|
|
423
|
+
const snapshot = await loadStyles(dir, { home });
|
|
424
|
+
const style = snapshot.imports.find(
|
|
425
|
+
(s) => s.id === "community:attention-span--rundown",
|
|
426
|
+
);
|
|
427
|
+
expect(style?.community?.commit).toBeNull();
|
|
428
|
+
expect(style?.community?.licence).toBeNull();
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ─── Keybindings ──────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
describe("StylesScreen keybindings", () => {
|
|
435
|
+
test("Shift+U is tested BEFORE plain u, or the plain branch swallows it", async () => {
|
|
436
|
+
// OpenTUI reports Shift+U as `{name: "u", shift: true}`, so a lowercase
|
|
437
|
+
// branch placed first matches it too. That exact shape shipped once in
|
|
438
|
+
// PluginsScreen and made "update" install at user scope instead.
|
|
439
|
+
const src = await fs.readFile(
|
|
440
|
+
join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
|
|
441
|
+
"utf8",
|
|
442
|
+
);
|
|
443
|
+
const shifted = src.indexOf('event.name === "u" && event.shift');
|
|
444
|
+
const plain = src.indexOf('event.name === "u") {');
|
|
445
|
+
expect(shifted).toBeGreaterThan(-1);
|
|
446
|
+
expect(plain).toBeGreaterThan(-1);
|
|
447
|
+
expect(shifted).toBeLessThan(plain);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("f is bound, and it is what the sync status tells the user to press", async () => {
|
|
451
|
+
const src = await fs.readFile(
|
|
452
|
+
join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
|
|
453
|
+
"utf8",
|
|
454
|
+
);
|
|
455
|
+
expect(src).toContain('event.name === "f"');
|
|
456
|
+
// The falls-through-to-the-declaration branch. Without it, "press f" is a
|
|
457
|
+
// lie whenever the cursor is not parked on a Community row.
|
|
458
|
+
expect(src).toContain("status.fetchable");
|
|
459
|
+
});
|
|
460
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
3
|
+
import type { AppAction, AppState } from "../ui/state/types.js";
|
|
4
|
+
|
|
5
|
+
function run(actions: AppAction[], from: AppState = initialState): AppState {
|
|
6
|
+
return actions.reduce(appReducer, from);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The status line lives in app state, not in the screen.
|
|
11
|
+
*
|
|
12
|
+
* Two bugs drove this, and both were invisible to every test we had:
|
|
13
|
+
*
|
|
14
|
+
* 1. It was cleared on a 5-second wall clock, so the message describing what
|
|
15
|
+
* you just did was usually gone before you looked at it — and unobservable
|
|
16
|
+
* to anything sampling the screen a moment later.
|
|
17
|
+
* 2. It was component-local, and `Router` swaps the component type on a tab
|
|
18
|
+
* change, so `9 → 1 → 9` destroyed it.
|
|
19
|
+
*/
|
|
20
|
+
describe("status line state", () => {
|
|
21
|
+
test("survives leaving the tab and coming back", () => {
|
|
22
|
+
const shown = run([
|
|
23
|
+
{
|
|
24
|
+
type: "STYLES_STATUS_SET",
|
|
25
|
+
status: { text: "Selection reset to the live style", tone: "success" },
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
expect(shown.styles.status?.text).toBe("Selection reset to the live style");
|
|
29
|
+
|
|
30
|
+
// Navigating away and back is what unmounts the screen.
|
|
31
|
+
const roundTrip = run(
|
|
32
|
+
[
|
|
33
|
+
{ type: "NAVIGATE", route: { screen: "plugins" } },
|
|
34
|
+
{ type: "NAVIGATE", route: { screen: "styles" } },
|
|
35
|
+
],
|
|
36
|
+
shown,
|
|
37
|
+
);
|
|
38
|
+
expect(roundTrip.styles.status?.text).toBe(
|
|
39
|
+
"Selection reset to the live style",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a new message replaces the previous one", () => {
|
|
44
|
+
const state = run([
|
|
45
|
+
{ type: "STYLES_STATUS_SET", status: { text: "first", tone: "success" } },
|
|
46
|
+
{ type: "STYLES_STATUS_SET", status: { text: "second", tone: "error" } },
|
|
47
|
+
]);
|
|
48
|
+
expect(state.styles.status).toEqual({ text: "second", tone: "error" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("clearing returns the line to its default", () => {
|
|
52
|
+
const state = run([
|
|
53
|
+
{ type: "STYLES_STATUS_SET", status: { text: "done", tone: "success" } },
|
|
54
|
+
{ type: "STYLES_STATUS_CLEAR" },
|
|
55
|
+
]);
|
|
56
|
+
expect(state.styles.status).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("starts empty", () => {
|
|
60
|
+
expect(initialState.styles.status).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("keeps the tone, so an error cannot render as a success", () => {
|
|
64
|
+
const state = run([
|
|
65
|
+
{
|
|
66
|
+
type: "STYLES_STATUS_SET",
|
|
67
|
+
status: { text: "Already talking to GitHub", tone: "error" },
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
expect(state.styles.status?.tone).toBe("error");
|
|
71
|
+
});
|
|
72
|
+
});
|