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.
Files changed (33) hide show
  1. package/package.json +4 -4
  2. package/scripts/verify-community-registry.ts +272 -0
  3. package/src/__tests__/community-fetch.test.ts +545 -0
  4. package/src/__tests__/community-registry.test.ts +269 -0
  5. package/src/__tests__/community-staleness.test.ts +722 -0
  6. package/src/__tests__/open-file.test.ts +59 -0
  7. package/src/__tests__/style-wrap.test.ts +220 -0
  8. package/src/__tests__/styles-manager.test.ts +1124 -0
  9. package/src/__tests__/styles-origins.test.ts +416 -0
  10. package/src/__tests__/styles-screen-state.test.ts +460 -0
  11. package/src/__tests__/styles-status-line.test.ts +72 -0
  12. package/src/__tests__/styles-sync.test.ts +452 -0
  13. package/src/__tests__/tabbar-layout.test.ts +62 -0
  14. package/src/__tests__/terminology-filler.test.ts +214 -0
  15. package/src/data/community-styles.ts +521 -0
  16. package/src/main.tsx +15 -0
  17. package/src/services/catalog-cache-store.ts +101 -7
  18. package/src/services/community-fetcher.ts +90 -0
  19. package/src/services/community-styles.ts +1194 -0
  20. package/src/services/styles-manager.ts +1400 -0
  21. package/src/services/terminology-filler.ts +266 -0
  22. package/src/ui/App.tsx +15 -3
  23. package/src/ui/adapters/stylesAdapter.ts +403 -0
  24. package/src/ui/components/TabBar.tsx +43 -9
  25. package/src/ui/components/primitives/ActionHints.tsx +4 -1
  26. package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
  27. package/src/ui/registry.ts +6 -0
  28. package/src/ui/renderers/styleRenderers.tsx +809 -0
  29. package/src/ui/screens/StylesScreen.tsx +1089 -0
  30. package/src/ui/screens/index.ts +1 -0
  31. package/src/ui/state/reducer.ts +113 -1
  32. package/src/ui/state/types.ts +60 -2
  33. package/src/utils/open-file.ts +84 -0
@@ -0,0 +1,722 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } 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
+ COMMUNITY_SOURCES,
8
+ type CommunityStyleSource,
9
+ resolveCommunityStyle,
10
+ } from "../data/community-styles.js";
11
+ import {
12
+ readCommunityChecks,
13
+ resetCatalogCacheMemo,
14
+ writeCommunityCheck,
15
+ } from "../services/catalog-cache-store.js";
16
+ import type {
17
+ StyleFetchResponse,
18
+ StyleFetcher,
19
+ } from "../services/community-fetcher.js";
20
+ import {
21
+ COMMUNITY_CHECK_TTL_MS,
22
+ acceptPendingUpdate,
23
+ cachePathFor,
24
+ checkAllSources,
25
+ checkSourceForUpdates,
26
+ countChangedLines,
27
+ describeCommunityFailure,
28
+ fetchCommunityStyle,
29
+ fetchCommunityStyles,
30
+ pendingPathFor,
31
+ readCommunityStatuses,
32
+ resetCommunityApiHeadroom,
33
+ } from "../services/community-styles.js";
34
+ import { resetGitHubBudget } from "../services/github-budget.js";
35
+ import { splitFrontmatter } from "../services/styles-manager.js";
36
+
37
+ // NOTHING here touches the network. Every entry point takes the `StyleFetcher`
38
+ // port as a REQUIRED argument, so omitting it is a type error rather than a
39
+ // silent live call — which is the whole reason the port exists.
40
+
41
+ let dir: string;
42
+ let configDir: string;
43
+ let previousConfigDir: string | undefined;
44
+
45
+ const NOW = Date.parse("2026-08-18T12:00:00Z");
46
+
47
+ beforeEach(async () => {
48
+ dir = await mkdtemp(join(tmpdir(), "claudeup-staleness-"));
49
+ configDir = join(dir, "config");
50
+ previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
51
+ process.env.CLAUDE_CONFIG_DIR = configDir;
52
+ resetCatalogCacheMemo();
53
+ resetGitHubBudget();
54
+ resetCommunityApiHeadroom();
55
+ });
56
+
57
+ afterEach(async () => {
58
+ // `github-budget` persists cooldowns WRITE-BEHIND — `recordSuccess` fires a
59
+ // lock-and-write it deliberately does not await, so its many synchronous call
60
+ // sites stay synchronous. Deleting the config directory out from under that
61
+ // in-flight write produces an ENOENT warning from the lock release. Draining
62
+ // first keeps the suite's output honest; it is not papering over a race, the
63
+ // write-behind is the documented design.
64
+ await new Promise((resolve) => setTimeout(resolve, 20));
65
+ await rm(dir, { recursive: true, force: true });
66
+ // biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
67
+ if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
68
+ else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
69
+ resetCatalogCacheMemo();
70
+ resetGitHubBudget();
71
+ resetCommunityApiHeadroom();
72
+ });
73
+
74
+ const source: CommunityStyleSource = COMMUNITY_SOURCES[0];
75
+ const STYLE_ID = "attention-span--spartan";
76
+ // biome-ignore lint/style/noNonNullAssertion: a registry entry the tests pin
77
+ const style = resolveCommunityStyle(STYLE_ID)!.style;
78
+
79
+ function upstreamText(body: string): string {
80
+ return [
81
+ "---",
82
+ "name: Spartan",
83
+ "description: Blunt.",
84
+ "---",
85
+ "",
86
+ body,
87
+ "",
88
+ ].join("\n");
89
+ }
90
+
91
+ const ORIGINAL = upstreamText("- Answer first.\n- No filler.");
92
+ const CHANGED = upstreamText("- Answer first.\n- No filler.\n- No warmth.");
93
+
94
+ interface Recorder {
95
+ fetcher: StyleFetcher;
96
+ urls: string[];
97
+ apiCalls: () => number;
98
+ rawCalls: () => number;
99
+ }
100
+
101
+ /**
102
+ * A fetcher that answers by HOST, because the two hosts are the whole point:
103
+ * `api.github.com` resolves the pin and is the scarce budget, and
104
+ * `raw.githubusercontent.com` serves the bytes and costs nothing.
105
+ */
106
+ function recorder(opts: {
107
+ commit?: string;
108
+ raw?: string | (() => Partial<StyleFetchResponse>);
109
+ apiStatus?: number;
110
+ apiHeaders?: Headers;
111
+ }): Recorder {
112
+ const urls: string[] = [];
113
+ const fetcher: StyleFetcher = async (url) => {
114
+ urls.push(url);
115
+ const base = {
116
+ etag: null,
117
+ contentType: "text/plain; charset=utf-8",
118
+ headers: new Headers(),
119
+ };
120
+ if (new URL(url).hostname === "api.github.com") {
121
+ return {
122
+ ...base,
123
+ status: opts.apiStatus ?? 200,
124
+ body: JSON.stringify([{ sha: opts.commit ?? "aaaaaaaaaaaa" }]),
125
+ headers: opts.apiHeaders ?? new Headers(),
126
+ };
127
+ }
128
+ const raw = typeof opts.raw === "function" ? opts.raw() : undefined;
129
+ return { ...base, status: 200, body: opts.raw ?? ORIGINAL, ...raw };
130
+ };
131
+ const host = (name: string) => () =>
132
+ urls.filter((u) => new URL(u).hostname === name).length;
133
+ return {
134
+ fetcher,
135
+ urls,
136
+ apiCalls: host("api.github.com"),
137
+ rawCalls: host("raw.githubusercontent.com"),
138
+ };
139
+ }
140
+
141
+ /** Put a fetched style on disk the same way the real fetch path would. */
142
+ async function install(body = ORIGINAL): Promise<string> {
143
+ const { fetcher } = recorder({ raw: body });
144
+ const result = await fetchCommunityStyle({
145
+ style,
146
+ source,
147
+ fetcher,
148
+ cacheDir: dir,
149
+ commit: "aaaaaaaaaaaa",
150
+ now: NOW,
151
+ });
152
+ if (result.outcome !== "written")
153
+ throw new Error("fixture failed to install");
154
+ return result.file.sha256;
155
+ }
156
+
157
+ describe("phase A — one API call per repo answers the common case", () => {
158
+ test("an unchanged directory head reports up-to-date with NO raw fetches", async () => {
159
+ const sha256 = await install();
160
+ await writeCommunityCheck(source.id, {
161
+ checkedAt: NOW - 2 * COMMUNITY_CHECK_TTL_MS,
162
+ dirHeadSha: "aaaaaaaaaaaa",
163
+ styles: { [STYLE_ID]: { sha256 } },
164
+ });
165
+
166
+ const probe = recorder({ commit: "aaaaaaaaaaaa" });
167
+ const result = await checkSourceForUpdates({
168
+ source,
169
+ cached: [{ id: STYLE_ID, sha256 }],
170
+ fetcher: probe.fetcher,
171
+ cacheDir: dir,
172
+ now: NOW,
173
+ force: true,
174
+ });
175
+
176
+ expect(result.statuses[STYLE_ID].state).toBe("up-to-date");
177
+ expect(result.apiCalls).toBe(1);
178
+ expect(probe.apiCalls()).toBe(1);
179
+ // The inversion that makes the design affordable: nothing in the directory
180
+ // moved, so nothing in it can have changed, so no file is downloaded.
181
+ expect(probe.rawCalls()).toBe(0);
182
+ });
183
+
184
+ test("a moved directory drills into each style with FREE raw fetches", async () => {
185
+ const sha256 = await install();
186
+ await writeCommunityCheck(source.id, {
187
+ checkedAt: NOW - 2 * COMMUNITY_CHECK_TTL_MS,
188
+ dirHeadSha: "aaaaaaaaaaaa",
189
+ styles: { [STYLE_ID]: { sha256 } },
190
+ });
191
+
192
+ const probe = recorder({ commit: "bbbbbbbbbbbb", raw: CHANGED });
193
+ const result = await checkSourceForUpdates({
194
+ source,
195
+ cached: [{ id: STYLE_ID, sha256 }],
196
+ fetcher: probe.fetcher,
197
+ cacheDir: dir,
198
+ now: NOW,
199
+ force: true,
200
+ });
201
+
202
+ expect(result.statuses[STYLE_ID].state).toBe("update-available");
203
+ // Still ONE API call. Phase B spends raw budget, which is free.
204
+ expect(probe.apiCalls()).toBe(1);
205
+ expect(probe.rawCalls()).toBe(1);
206
+ });
207
+
208
+ test("a directory that moved for another file leaves this one up to date", async () => {
209
+ const sha256 = await install();
210
+ await writeCommunityCheck(source.id, {
211
+ checkedAt: NOW,
212
+ dirHeadSha: "aaaaaaaaaaaa",
213
+ styles: { [STYLE_ID]: { sha256 } },
214
+ });
215
+
216
+ const probe = recorder({ commit: "cccccccccccc", raw: ORIGINAL });
217
+ const result = await checkSourceForUpdates({
218
+ source,
219
+ cached: [{ id: STYLE_ID, sha256 }],
220
+ fetcher: probe.fetcher,
221
+ cacheDir: dir,
222
+ now: NOW,
223
+ force: true,
224
+ });
225
+
226
+ expect(result.statuses[STYLE_ID].state).toBe("up-to-date");
227
+ // The download is removed, so no pending update is left that is
228
+ // byte-identical to what is already installed.
229
+ expect(await fs.pathExists(pendingPathFor(dir, STYLE_ID))).toBe(false);
230
+ });
231
+ });
232
+
233
+ describe("phase B — an update is downloaded, never installed", () => {
234
+ test("the new bytes land in .pending and the live file is untouched", async () => {
235
+ const sha256 = await install();
236
+ const live = cachePathFor(dir, STYLE_ID);
237
+ const before = await fs.readFile(live, "utf8");
238
+
239
+ const probe = recorder({ commit: "bbbbbbbbbbbb", raw: CHANGED });
240
+ await checkSourceForUpdates({
241
+ source,
242
+ cached: [{ id: STYLE_ID, sha256 }],
243
+ fetcher: probe.fetcher,
244
+ cacheDir: dir,
245
+ now: NOW,
246
+ force: true,
247
+ });
248
+
249
+ expect(await fs.readFile(live, "utf8")).toBe(before);
250
+ const pending = pendingPathFor(dir, STYLE_ID);
251
+ expect(await fs.pathExists(pending)).toBe(true);
252
+ expect(await fs.readFile(pending, "utf8")).toContain("No warmth");
253
+ });
254
+
255
+ test(".pending is a SUBDIRECTORY, so style discovery cannot see it", async () => {
256
+ // `markdownFiles` does not recurse, which is why the cache is flat and the
257
+ // pending area is nested — invisible by construction rather than by a
258
+ // filter someone could later drop.
259
+ const sha256 = await install();
260
+ const probe = recorder({ commit: "bbbbbbbbbbbb", raw: CHANGED });
261
+ await checkSourceForUpdates({
262
+ source,
263
+ cached: [{ id: STYLE_ID, sha256 }],
264
+ fetcher: probe.fetcher,
265
+ cacheDir: dir,
266
+ now: NOW,
267
+ force: true,
268
+ });
269
+ const top = (await fs.readdir(dir)).filter((n) => n.endsWith(".md"));
270
+ expect(top).toEqual([`${STYLE_ID}.md`]);
271
+ });
272
+
273
+ test("reports how many lines changed, over bodies rather than frontmatter", async () => {
274
+ const sha256 = await install();
275
+ const probe = recorder({ commit: "bbbbbbbbbbbb", raw: CHANGED });
276
+ const result = await checkSourceForUpdates({
277
+ source,
278
+ cached: [{ id: STYLE_ID, sha256 }],
279
+ fetcher: probe.fetcher,
280
+ cacheDir: dir,
281
+ now: NOW,
282
+ force: true,
283
+ });
284
+ // One line added. `community-fetched` and `community-commit` differ on
285
+ // every fetch, so counting frontmatter would report a change for a style
286
+ // whose words are identical.
287
+ expect(result.statuses[STYLE_ID].changedLines).toBe(1);
288
+ });
289
+
290
+ test("accepting an update installs the bytes the diff was shown for", async () => {
291
+ const sha256 = await install();
292
+ const probe = recorder({ commit: "bbbbbbbbbbbb", raw: CHANGED });
293
+ await checkSourceForUpdates({
294
+ source,
295
+ cached: [{ id: STYLE_ID, sha256 }],
296
+ fetcher: probe.fetcher,
297
+ cacheDir: dir,
298
+ now: NOW,
299
+ force: true,
300
+ });
301
+
302
+ const accepted = await acceptPendingUpdate({ cacheDir: dir, id: STYLE_ID });
303
+ expect(accepted.outcome).toBe("accepted");
304
+ expect(await fs.readFile(cachePathFor(dir, STYLE_ID), "utf8")).toContain(
305
+ "No warmth",
306
+ );
307
+ // A rename, not a re-fetch: nothing goes back to the network, so the user
308
+ // gets the text they were shown, not whatever upstream serves a second later.
309
+ expect(await fs.pathExists(pendingPathFor(dir, STYLE_ID))).toBe(false);
310
+
311
+ // The store now records the accepted bytes, so the next check compares
312
+ // against what is actually installed.
313
+ const checks = await readCommunityChecks();
314
+ expect(checks[source.id].styles[STYLE_ID].pendingSha256).toBeUndefined();
315
+ const { frontmatter } = splitFrontmatter(
316
+ await fs.readFile(cachePathFor(dir, STYLE_ID), "utf8"),
317
+ );
318
+ expect(checks[source.id].styles[STYLE_ID].sha256).toBe(
319
+ frontmatter["community-sha256"],
320
+ );
321
+ });
322
+
323
+ test("accepting with nothing pending is a no-op, not an error", async () => {
324
+ await install();
325
+ const result = await acceptPendingUpdate({ cacheDir: dir, id: STYLE_ID });
326
+ expect(result.outcome).toBe("none");
327
+ });
328
+
329
+ test("a re-fetch supersedes a waiting update instead of leaving it behind", async () => {
330
+ // Otherwise the row keeps advertising "(new)" for an update the user has
331
+ // already taken by another route, and the panel is lying about disk.
332
+ const sha256 = await install();
333
+ const check = recorder({ commit: "bbbbbbbbbbbb", raw: CHANGED });
334
+ await checkSourceForUpdates({
335
+ source,
336
+ cached: [{ id: STYLE_ID, sha256 }],
337
+ fetcher: check.fetcher,
338
+ cacheDir: dir,
339
+ now: NOW,
340
+ force: true,
341
+ });
342
+ expect((await readCommunityStatuses(NOW))[STYLE_ID].state).toBe(
343
+ "update-available",
344
+ );
345
+
346
+ const refetch = recorder({ commit: "bbbbbbbbbbbb", raw: CHANGED });
347
+ await fetchCommunityStyles({
348
+ ids: [STYLE_ID],
349
+ fetcher: refetch.fetcher,
350
+ cacheDir: dir,
351
+ now: NOW,
352
+ });
353
+
354
+ expect(await fs.pathExists(pendingPathFor(dir, STYLE_ID))).toBe(false);
355
+ expect((await readCommunityStatuses(NOW))[STYLE_ID].state).not.toBe(
356
+ "update-available",
357
+ );
358
+ });
359
+ });
360
+
361
+ describe("an incomplete check is not recorded as a complete one", () => {
362
+ test("a failed drilldown does not advance the directory sha", async () => {
363
+ // The fail-open this closes: recording the new head after a failed phase B
364
+ // would make the NEXT phase A short-circuit on "nothing moved", and a style
365
+ // whose bytes were never compared would start reading as up to date on the
366
+ // strength of a check that failed.
367
+ const sha256 = await install();
368
+ await writeCommunityCheck(source.id, {
369
+ checkedAt: NOW - 2 * COMMUNITY_CHECK_TTL_MS,
370
+ dirHeadSha: "aaaaaaaaaaaa",
371
+ styles: { [STYLE_ID]: { sha256 } },
372
+ });
373
+
374
+ const failing: StyleFetcher = async (url) => {
375
+ if (new URL(url).hostname === "api.github.com") {
376
+ return {
377
+ status: 200,
378
+ body: JSON.stringify([{ sha: "bbbbbbbbbbbb" }]),
379
+ etag: null,
380
+ contentType: "application/json",
381
+ headers: new Headers(),
382
+ };
383
+ }
384
+ throw Object.assign(new Error("fetch failed"), { code: "ENETUNREACH" });
385
+ };
386
+
387
+ const result = await checkSourceForUpdates({
388
+ source,
389
+ cached: [{ id: STYLE_ID, sha256 }],
390
+ fetcher: failing,
391
+ cacheDir: dir,
392
+ now: NOW,
393
+ force: true,
394
+ });
395
+ expect(result.statuses[STYLE_ID].state).toBe("unknown");
396
+
397
+ const stored = (await readCommunityChecks())[source.id];
398
+ expect(stored.dirHeadSha).toBe("aaaaaaaaaaaa");
399
+ // And the style itself is not recorded, so nothing can infer a pass for it.
400
+ expect(stored.styles[STYLE_ID]).toBeUndefined();
401
+ expect((await readCommunityStatuses(NOW))[STYLE_ID]).toBeUndefined();
402
+ });
403
+ });
404
+
405
+ describe("fail closed — a check that could not look never says up to date", () => {
406
+ test("a rate-limited phase A yields unknown with the reason", async () => {
407
+ const sha256 = await install();
408
+ await writeCommunityCheck(source.id, {
409
+ checkedAt: NOW,
410
+ dirHeadSha: "aaaaaaaaaaaa",
411
+ styles: { [STYLE_ID]: { sha256 } },
412
+ });
413
+
414
+ const probe = recorder({
415
+ apiStatus: 429,
416
+ apiHeaders: new Headers({ "retry-after": "240" }),
417
+ });
418
+ const result = await checkSourceForUpdates({
419
+ source,
420
+ cached: [{ id: STYLE_ID, sha256 }],
421
+ fetcher: probe.fetcher,
422
+ cacheDir: dir,
423
+ now: NOW,
424
+ force: true,
425
+ });
426
+
427
+ const status = result.statuses[STYLE_ID];
428
+ expect(status.state).toBe("unknown");
429
+ expect(status.state).not.toBe("up-to-date");
430
+ expect(status.detail).toMatch(/rate limit/i);
431
+ // No raw drilldown either — a check that could not establish the premise
432
+ // does not spend the other host's budget guessing.
433
+ expect(probe.rawCalls()).toBe(0);
434
+ });
435
+
436
+ test("an offline phase A yields unknown, not a quiet pass", async () => {
437
+ const sha256 = await install();
438
+ const fetcher: StyleFetcher = async () => {
439
+ throw Object.assign(new Error("fetch failed"), { code: "ENETUNREACH" });
440
+ };
441
+ const result = await checkSourceForUpdates({
442
+ source,
443
+ cached: [{ id: STYLE_ID, sha256 }],
444
+ fetcher,
445
+ cacheDir: dir,
446
+ now: NOW,
447
+ force: true,
448
+ });
449
+ expect(result.statuses[STYLE_ID].state).toBe("unknown");
450
+ expect(result.statuses[STYLE_ID].detail).toMatch(/no network/);
451
+ });
452
+
453
+ test("a copy with no content hash reports unknown rather than guessing", async () => {
454
+ const probe = recorder({ commit: "bbbbbbbbbbbb" });
455
+ const result = await checkSourceForUpdates({
456
+ source,
457
+ cached: [{ id: STYLE_ID, sha256: null }],
458
+ fetcher: probe.fetcher,
459
+ cacheDir: dir,
460
+ now: NOW,
461
+ force: true,
462
+ });
463
+ expect(result.statuses[STYLE_ID].state).toBe("unknown");
464
+ expect(result.statuses[STYLE_ID].detail).toMatch(/no content hash/);
465
+ });
466
+
467
+ test("a recorded pass DECAYS to unknown once it ages past the TTL", async () => {
468
+ // The fail-closed clause applied to time. Evidence about three days ago is
469
+ // not evidence about now, and continuing to render "up to date" from it
470
+ // would be the quiet lie this design exists to avoid.
471
+ await writeCommunityCheck(source.id, {
472
+ checkedAt: NOW - COMMUNITY_CHECK_TTL_MS - 1,
473
+ dirHeadSha: "aaaaaaaaaaaa",
474
+ styles: { [STYLE_ID]: { sha256: "sha256:x" } },
475
+ });
476
+ const stale = await readCommunityStatuses(NOW);
477
+ expect(stale[STYLE_ID].state).toBe("unknown");
478
+
479
+ resetCatalogCacheMemo();
480
+ await writeCommunityCheck(source.id, {
481
+ checkedAt: NOW - 1000,
482
+ dirHeadSha: "aaaaaaaaaaaa",
483
+ styles: { [STYLE_ID]: { sha256: "sha256:x" } },
484
+ });
485
+ const fresh = await readCommunityStatuses(NOW);
486
+ expect(fresh[STYLE_ID].state).toBe("up-to-date");
487
+ });
488
+
489
+ test("a pending update survives the TTL — a downloaded file is a fact", async () => {
490
+ await writeCommunityCheck(source.id, {
491
+ checkedAt: NOW - 10 * COMMUNITY_CHECK_TTL_MS,
492
+ dirHeadSha: "aaaaaaaaaaaa",
493
+ styles: {
494
+ [STYLE_ID]: {
495
+ sha256: "sha256:x",
496
+ pendingSha256: "sha256:y",
497
+ pendingLines: 12,
498
+ },
499
+ },
500
+ });
501
+ const statuses = await readCommunityStatuses(NOW);
502
+ expect(statuses[STYLE_ID].state).toBe("update-available");
503
+ expect(statuses[STYLE_ID].changedLines).toBe(12);
504
+ });
505
+
506
+ test("a style never checked reports not checked", async () => {
507
+ expect((await readCommunityStatuses(NOW))[STYLE_ID]).toBeUndefined();
508
+ });
509
+ });
510
+
511
+ describe("the TTL decides whether a check goes to the network at all", () => {
512
+ test("a fresh check is served from the store with zero calls", async () => {
513
+ const sha256 = await install();
514
+ await writeCommunityCheck(source.id, {
515
+ checkedAt: NOW - 1000,
516
+ dirHeadSha: "aaaaaaaaaaaa",
517
+ styles: { [STYLE_ID]: { sha256 } },
518
+ });
519
+
520
+ const probe = recorder({ commit: "bbbbbbbbbbbb" });
521
+ const result = await checkSourceForUpdates({
522
+ source,
523
+ cached: [{ id: STYLE_ID, sha256 }],
524
+ fetcher: probe.fetcher,
525
+ cacheDir: dir,
526
+ now: NOW,
527
+ });
528
+ expect(result.apiCalls).toBe(0);
529
+ expect(probe.urls).toEqual([]);
530
+ expect(result.statuses[STYLE_ID].state).toBe("up-to-date");
531
+ });
532
+
533
+ test("an expired check re-checks", async () => {
534
+ const sha256 = await install();
535
+ await writeCommunityCheck(source.id, {
536
+ checkedAt: NOW - COMMUNITY_CHECK_TTL_MS - 1,
537
+ dirHeadSha: "aaaaaaaaaaaa",
538
+ styles: { [STYLE_ID]: { sha256 } },
539
+ });
540
+
541
+ const probe = recorder({ commit: "aaaaaaaaaaaa" });
542
+ const result = await checkSourceForUpdates({
543
+ source,
544
+ cached: [{ id: STYLE_ID, sha256 }],
545
+ fetcher: probe.fetcher,
546
+ cacheDir: dir,
547
+ now: NOW,
548
+ });
549
+ expect(result.apiCalls).toBe(1);
550
+ });
551
+
552
+ test("an explicit u forces a check inside the TTL", async () => {
553
+ const sha256 = await install();
554
+ await writeCommunityCheck(source.id, {
555
+ checkedAt: NOW - 1000,
556
+ dirHeadSha: "aaaaaaaaaaaa",
557
+ styles: { [STYLE_ID]: { sha256 } },
558
+ });
559
+ const probe = recorder({ commit: "aaaaaaaaaaaa" });
560
+ await checkSourceForUpdates({
561
+ source,
562
+ cached: [{ id: STYLE_ID, sha256 }],
563
+ fetcher: probe.fetcher,
564
+ cacheDir: dir,
565
+ now: NOW,
566
+ force: true,
567
+ });
568
+ expect(probe.apiCalls()).toBe(1);
569
+ });
570
+
571
+ test("a repo with nothing cached costs nothing at all", async () => {
572
+ const probe = recorder({});
573
+ const result = await checkSourceForUpdates({
574
+ source,
575
+ cached: [],
576
+ fetcher: probe.fetcher,
577
+ cacheDir: dir,
578
+ now: NOW,
579
+ force: true,
580
+ });
581
+ expect(result.apiCalls).toBe(0);
582
+ expect(probe.urls).toEqual([]);
583
+ });
584
+ });
585
+
586
+ describe("the budget, which is the binding constraint", () => {
587
+ test("a five-repo sweep makes exactly five API calls", async () => {
588
+ // The regression this pins: 57 per-style API calls would exhaust the
589
+ // machine's 60/hr unauthenticated budget on first use and break `gh` for
590
+ // everything else on the box. The cost must be a function of REPOS.
591
+ const oneEach = [
592
+ "attention-span--spartan",
593
+ "claude-mods--executive",
594
+ "lej-output-fixer--scannable",
595
+ "hesreallyhim--zen-master",
596
+ "nattergabriel--socratic",
597
+ ];
598
+ for (const id of oneEach) {
599
+ // biome-ignore lint/style/noNonNullAssertion: ids pinned from the registry
600
+ const sourceId = resolveCommunityStyle(id)!.source.id;
601
+ await writeCommunityCheck(sourceId, {
602
+ checkedAt: NOW - 2 * COMMUNITY_CHECK_TTL_MS,
603
+ dirHeadSha: "aaaaaaaaaaaa",
604
+ styles: { [id]: { sha256: "sha256:x" } },
605
+ });
606
+ }
607
+
608
+ const probe = recorder({ commit: "aaaaaaaaaaaa" });
609
+ const result = await checkAllSources({
610
+ cached: oneEach.map((id) => ({ id, sha256: "sha256:x" })),
611
+ fetcher: probe.fetcher,
612
+ cacheDir: dir,
613
+ now: NOW,
614
+ force: true,
615
+ });
616
+
617
+ expect(result.apiCalls).toBe(5);
618
+ expect(probe.apiCalls()).toBe(5);
619
+ expect(probe.rawCalls()).toBe(0);
620
+ });
621
+
622
+ test("many styles from ONE repo still cost one API call", async () => {
623
+ const ids = [
624
+ "attention-span--spartan",
625
+ "attention-span--rundown",
626
+ "attention-span--attention-kind",
627
+ ];
628
+ await writeCommunityCheck(source.id, {
629
+ checkedAt: NOW - 2 * COMMUNITY_CHECK_TTL_MS,
630
+ dirHeadSha: "aaaaaaaaaaaa",
631
+ styles: Object.fromEntries(ids.map((id) => [id, { sha256: "sha256:x" }])),
632
+ });
633
+
634
+ const probe = recorder({ commit: "aaaaaaaaaaaa" });
635
+ const result = await checkAllSources({
636
+ cached: ids.map((id) => ({ id, sha256: "sha256:x" })),
637
+ fetcher: probe.fetcher,
638
+ cacheDir: dir,
639
+ now: NOW,
640
+ force: true,
641
+ });
642
+ expect(result.apiCalls).toBe(1);
643
+ expect(Object.keys(result.statuses).sort()).toEqual([...ids].sort());
644
+ });
645
+ });
646
+
647
+ describe("countChangedLines", () => {
648
+ test("is zero for identical text", () => {
649
+ expect(countChangedLines("a\nb\nc", "a\nb\nc")).toBe(0);
650
+ });
651
+
652
+ test("counts one insertion as one", () => {
653
+ expect(countChangedLines("a\nb", "a\nb\nc")).toBe(1);
654
+ });
655
+
656
+ test("counts one deletion as one", () => {
657
+ expect(countChangedLines("a\nb\nc", "a\nc")).toBe(1);
658
+ });
659
+
660
+ test("counts a modified line as a deletion plus an insertion", () => {
661
+ expect(countChangedLines("a\nb\nc", "a\nB\nc")).toBe(2);
662
+ });
663
+
664
+ test("a reordering does not report every line as changed", () => {
665
+ // The reason this is an LCS rather than a length difference: the number is
666
+ // shown to the user as the reason to accept an update, and inflating it
667
+ // teaches them to stop reading it.
668
+ expect(countChangedLines("a\nb\nc\nd", "b\na\nc\nd")).toBe(2);
669
+ });
670
+ });
671
+
672
+ describe("describeCommunityFailure", () => {
673
+ test("names an escape hatch for a rate limit", () => {
674
+ expect(
675
+ describeCommunityFailure(
676
+ { kind: "rate-limited", detail: "GitHub rate limit resets in 4m" },
677
+ "Spartan",
678
+ ),
679
+ ).toMatch(/GITHUB_TOKEN/);
680
+ });
681
+
682
+ test("a 404 blames claudeup, because it IS our bug", () => {
683
+ // The set of fetchable coordinates is a constant in a reviewed commit, so a
684
+ // missing file means our registry entry went stale. Telling the user "not
685
+ // found" would send them looking for something they cannot fix.
686
+ expect(
687
+ describeCommunityFailure(
688
+ {
689
+ kind: "not-found",
690
+ detail: "claudeup's registry entry is out of date",
691
+ },
692
+ "Spartan",
693
+ ),
694
+ ).toMatch(/claudeup/);
695
+ });
696
+
697
+ test("a DNS failure stands alone so the diagnosis is not truncated", () => {
698
+ const detail = "Could not resolve raw.githubusercontent.com. MagicDNS…";
699
+ expect(describeCommunityFailure({ kind: "dns", detail }, "Spartan")).toBe(
700
+ detail,
701
+ );
702
+ });
703
+
704
+ test("every kind produces its own sentence, and all mention the style", () => {
705
+ const kinds = [
706
+ "offline",
707
+ "timeout",
708
+ "too-large",
709
+ "invalid",
710
+ "http",
711
+ "network",
712
+ ] as const;
713
+ const messages = kinds.map((kind) =>
714
+ describeCommunityFailure(
715
+ { kind, detail: `detail for ${kind}` },
716
+ "Spartan",
717
+ ),
718
+ );
719
+ expect(new Set(messages).size).toBe(kinds.length);
720
+ for (const message of messages) expect(message).toContain("Spartan");
721
+ });
722
+ });