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,545 @@
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 CommunityStyle,
9
+ type CommunityStyleSource,
10
+ } from "../data/community-styles.js";
11
+ import type {
12
+ StyleFetchResponse,
13
+ StyleFetcher,
14
+ } from "../services/community-fetcher.js";
15
+ import {
16
+ MAX_STYLE_BYTES,
17
+ fetchCommunityStyle,
18
+ fetchSource,
19
+ resetCommunityApiHeadroom,
20
+ resolveCommitPin,
21
+ } from "../services/community-styles.js";
22
+ import { resetGitHubBudget } from "../services/github-budget.js";
23
+ import { splitFrontmatter } from "../services/styles-manager.js";
24
+
25
+ // NOTHING here touches the network. `StyleFetcher` is a REQUIRED argument on
26
+ // every exported function in the service, so a test cannot reach GitHub by
27
+ // forgetting to stub — omitting the fetcher is a type error, not a silent live
28
+ // call. That is the entire reason the port exists.
29
+
30
+ let dir: string;
31
+
32
+ beforeEach(async () => {
33
+ dir = await mkdtemp(join(tmpdir(), "claudeup-community-"));
34
+ resetGitHubBudget();
35
+ resetCommunityApiHeadroom();
36
+ });
37
+ afterEach(async () => {
38
+ await rm(dir, { recursive: true, force: true });
39
+ resetGitHubBudget();
40
+ resetCommunityApiHeadroom();
41
+ });
42
+
43
+ const source: CommunityStyleSource = COMMUNITY_SOURCES[0];
44
+
45
+ const style: CommunityStyle = {
46
+ id: "attention-span--spartan",
47
+ sourceId: "attention-span",
48
+ path: "output-styles/spartan.md",
49
+ displayName: "Spartan",
50
+ summary: "Blunt and answer-first.",
51
+ };
52
+
53
+ /** The exact shape research.md captured from the real repo. */
54
+ const UPSTREAM = [
55
+ "---",
56
+ "name: Spartan",
57
+ "description: Blunt Spartan mode for ADHD. Answer-first, arrow points, zero warmth or filler.",
58
+ "keep-coding-instructions: true",
59
+ "---",
60
+ "",
61
+ "# Spartan",
62
+ "",
63
+ "- Answer first.",
64
+ "",
65
+ ].join("\n");
66
+
67
+ interface FakeCall {
68
+ url: string;
69
+ etag?: string | null;
70
+ }
71
+
72
+ /** A fetcher that records what it was asked and replies from a script. */
73
+ function fake(replies: Array<Partial<StyleFetchResponse> | (() => never)>): {
74
+ fetcher: StyleFetcher;
75
+ calls: FakeCall[];
76
+ } {
77
+ const calls: FakeCall[] = [];
78
+ let index = 0;
79
+ const fetcher: StyleFetcher = async (url, opts) => {
80
+ calls.push({ url, etag: opts?.etag });
81
+ const reply = replies[Math.min(index++, replies.length - 1)];
82
+ if (typeof reply === "function") reply();
83
+ return {
84
+ status: 200,
85
+ body: UPSTREAM,
86
+ etag: null,
87
+ contentType: "text/plain; charset=utf-8",
88
+ headers: new Headers(),
89
+ ...(reply as Partial<StyleFetchResponse>),
90
+ };
91
+ };
92
+ return { fetcher, calls };
93
+ }
94
+
95
+ async function fetchOne(
96
+ replies: Array<Partial<StyleFetchResponse> | (() => never)>,
97
+ over: Partial<Parameters<typeof fetchCommunityStyle>[0]> = {},
98
+ ) {
99
+ const { fetcher, calls } = fake(replies);
100
+ const result = await fetchCommunityStyle({
101
+ style,
102
+ source,
103
+ fetcher,
104
+ cacheDir: dir,
105
+ commit: "b860c9f8f3c7",
106
+ now: Date.parse("2026-08-18T12:00:00"),
107
+ ...over,
108
+ });
109
+ return { result, calls };
110
+ }
111
+
112
+ describe("a successful fetch", () => {
113
+ test("writes the file and stamps the full provenance set", async () => {
114
+ const { result } = await fetchOne([{}]);
115
+ expect(result.outcome).toBe("written");
116
+ if (result.outcome !== "written") return;
117
+
118
+ const { frontmatter } = splitFrontmatter(
119
+ await fs.readFile(result.file.path, "utf8"),
120
+ );
121
+ expect(frontmatter.name).toBe("attention-span--spartan");
122
+ expect(frontmatter["community-source"]).toBe("alexgreensh/attention-span");
123
+ expect(frontmatter["community-path"]).toBe("output-styles/spartan.md");
124
+ expect(frontmatter["community-ref"]).toBe("HEAD");
125
+ expect(frontmatter["community-commit"]).toBe("b860c9f8f3c7");
126
+ expect(frontmatter["community-sha256"]).toMatch(/^sha256:[0-9a-f]{64}$/);
127
+ expect(frontmatter["community-fetched"]).toBe("2026-08-18");
128
+ expect(frontmatter["community-licence"]).toBe("AGPL-3.0");
129
+ expect(frontmatter["community-author"]).toBe("alexgreensh");
130
+ });
131
+
132
+ test("names the file after the coordinate, flat, in the cache directory", async () => {
133
+ const { result } = await fetchOne([{}]);
134
+ if (result.outcome !== "written") throw new Error("expected a write");
135
+ expect(result.file.path).toBe(join(dir, "attention-span--spartan.md"));
136
+ });
137
+
138
+ test("forces keep-coding-instructions even when upstream turned it off", async () => {
139
+ // A style about how to COMMUNICATE has no business switching off how to
140
+ // write code, and a third-party file is exactly where that would arrive.
141
+ const hostile = UPSTREAM.replace(
142
+ "keep-coding-instructions: true",
143
+ "keep-coding-instructions: false",
144
+ );
145
+ const { result } = await fetchOne([{ body: hostile }]);
146
+ if (result.outcome !== "written") throw new Error("expected a write");
147
+ const { frontmatter } = splitFrontmatter(
148
+ await fs.readFile(result.file.path, "utf8"),
149
+ );
150
+ expect(frontmatter["keep-coding-instructions"]).toBe("true");
151
+ });
152
+
153
+ test("drops every upstream key we did not ask for", async () => {
154
+ // Frontmatter is rewritten, not passed through. This also blocks any
155
+ // future harness-recognised key we have not heard of yet.
156
+ const extra = UPSTREAM.replace(
157
+ "keep-coding-instructions: true",
158
+ "keep-coding-instructions: true\nsome-future-harness-key: dangerous\nmodel: opus",
159
+ );
160
+ const { result } = await fetchOne([{ body: extra }]);
161
+ if (result.outcome !== "written") throw new Error("expected a write");
162
+ const written = await fs.readFile(result.file.path, "utf8");
163
+ expect(written).not.toContain("some-future-harness-key");
164
+ expect(splitFrontmatter(written).frontmatter.model).toBeUndefined();
165
+ });
166
+
167
+ test("keeps upstream's description, quoted so its colon cannot break the YAML", async () => {
168
+ const colon = UPSTREAM.replace(
169
+ "description: Blunt Spartan mode for ADHD. Answer-first, arrow points, zero warmth or filler.",
170
+ "description: Spartan: blunt, answer-first",
171
+ );
172
+ const { result } = await fetchOne([{ body: colon }]);
173
+ if (result.outcome !== "written") throw new Error("expected a write");
174
+ const written = await fs.readFile(result.file.path, "utf8");
175
+ expect(written).toContain('description: "Spartan: blunt, answer-first"');
176
+ expect(splitFrontmatter(written).frontmatter.description).toBe(
177
+ "Spartan: blunt, answer-first",
178
+ );
179
+ });
180
+
181
+ test("carries the body through verbatim", async () => {
182
+ const { result } = await fetchOne([{}]);
183
+ if (result.outcome !== "written") throw new Error("expected a write");
184
+ const { body } = splitFrontmatter(
185
+ await fs.readFile(result.file.path, "utf8"),
186
+ );
187
+ expect(body).toContain("- Answer first.");
188
+ });
189
+
190
+ test("hashes the RAW UPSTREAM bytes, not our rewritten copy", async () => {
191
+ // The staleness check compares this against a fresh raw fetch. Hashing what
192
+ // we wrote would mean it never matched and every style read as changed
193
+ // forever.
194
+ const { createHash } = await import("node:crypto");
195
+ const expected = `sha256:${createHash("sha256").update(UPSTREAM).digest("hex")}`;
196
+ const { result } = await fetchOne([{}]);
197
+ if (result.outcome !== "written") throw new Error("expected a write");
198
+ expect(result.file.sha256).toBe(expected);
199
+ });
200
+
201
+ test("stamps unknown rather than lying when no pin was resolved", async () => {
202
+ const { result } = await fetchOne([{}], { commit: null });
203
+ if (result.outcome !== "written") throw new Error("expected a write");
204
+ const { frontmatter } = splitFrontmatter(
205
+ await fs.readFile(result.file.path, "utf8"),
206
+ );
207
+ expect(frontmatter["community-commit"]).toBe("unknown");
208
+ });
209
+
210
+ test("leaves no temp file behind", async () => {
211
+ await fetchOne([{}]);
212
+ const entries = await fs.readdir(dir);
213
+ expect(entries).toEqual(["attention-span--spartan.md"]);
214
+ });
215
+ });
216
+
217
+ describe("failures write nothing", () => {
218
+ const noFile = async () => {
219
+ expect(await fs.pathExists(join(dir, "attention-span--spartan.md"))).toBe(
220
+ false,
221
+ );
222
+ };
223
+
224
+ test("404 blames claudeup's registry, not the user", async () => {
225
+ const { result } = await fetchOne([
226
+ { status: 404, body: "404: Not Found" },
227
+ ]);
228
+ expect(result.outcome).toBe("failed");
229
+ if (result.outcome !== "failed") return;
230
+ expect(result.failure.kind).toBe("not-found");
231
+ expect(result.failure.detail).toContain("claudeup's registry entry");
232
+ await noFile();
233
+ });
234
+
235
+ test("429 records a cooldown and writes nothing", async () => {
236
+ const { result } = await fetchOne([
237
+ {
238
+ status: 429,
239
+ body: "",
240
+ headers: new Headers({ "retry-after": "120" }),
241
+ },
242
+ ]);
243
+ expect(result.outcome).toBe("failed");
244
+ if (result.outcome !== "failed") return;
245
+ expect(result.failure.kind).toBe("rate-limited");
246
+ // Against the INJECTED clock, not Date.now(). The service computes
247
+ // retryAt from the `now` the harness passes in; comparing that to the
248
+ // real clock made this a time bomb that went red once wall-clock time
249
+ // passed the fixture's noon-plus-120s. retry-after: 120 → exactly +120s.
250
+ expect(result.failure.retryAt).toBe(
251
+ Date.parse("2026-08-18T12:00:00") + 120_000,
252
+ );
253
+ await noFile();
254
+ });
255
+
256
+ test("a recorded cooldown stops the NEXT attempt before it leaves the process", async () => {
257
+ await fetchOne([
258
+ { status: 429, body: "", headers: new Headers({ "retry-after": "120" }) },
259
+ ]);
260
+ const { result, calls } = await fetchOne([{}]);
261
+ expect(result.outcome).toBe("failed");
262
+ // The point of sharing github-budget rather than forking it: a known limit
263
+ // is not rediscovered by spending another request against it.
264
+ expect(calls).toHaveLength(0);
265
+ });
266
+
267
+ test("a body over 64 KiB is rejected, with the real size in the message", async () => {
268
+ const huge = `${UPSTREAM}\n${"x".repeat(MAX_STYLE_BYTES)}`;
269
+ const { result } = await fetchOne([{ body: huge }]);
270
+ expect(result.outcome).toBe("failed");
271
+ if (result.outcome !== "failed") return;
272
+ expect(result.failure.kind).toBe("too-large");
273
+ expect(result.failure.detail).toMatch(/KiB/);
274
+ await noFile();
275
+ });
276
+
277
+ test("a file with no frontmatter is not an output style", async () => {
278
+ const { result } = await fetchOne([
279
+ { body: "# Just a heading\n\nWords.\n" },
280
+ ]);
281
+ expect(result.outcome).toBe("failed");
282
+ if (result.outcome !== "failed") return;
283
+ expect(result.failure.kind).toBe("invalid");
284
+ await noFile();
285
+ });
286
+
287
+ test("a file with frontmatter but no body is not an output style", async () => {
288
+ const { result } = await fetchOne([{ body: "---\nname: x\n---\n\n" }]);
289
+ expect(result.outcome).toBe("failed");
290
+ if (result.outcome !== "failed") return;
291
+ expect(result.failure.kind).toBe("invalid");
292
+ await noFile();
293
+ });
294
+
295
+ test("an HTML response is rejected on content-type", async () => {
296
+ // The shape a captive portal or a redirect to a login page produces.
297
+ const { result } = await fetchOne([
298
+ { body: "<html>sign in</html>", contentType: "text/html" },
299
+ ]);
300
+ expect(result.outcome).toBe("failed");
301
+ if (result.outcome !== "failed") return;
302
+ expect(result.failure.kind).toBe("invalid");
303
+ await noFile();
304
+ });
305
+
306
+ test("any other non-2xx reports the status", async () => {
307
+ const { result } = await fetchOne([{ status: 500, body: "" }]);
308
+ expect(result.outcome).toBe("failed");
309
+ if (result.outcome !== "failed") return;
310
+ expect(result.failure.kind).toBe("http");
311
+ expect(result.failure.httpStatus).toBe(500);
312
+ });
313
+
314
+ test("304 leaves the existing file untouched", async () => {
315
+ await fetchOne([{}]);
316
+ const before = await fs.readFile(
317
+ join(dir, "attention-span--spartan.md"),
318
+ "utf8",
319
+ );
320
+ const { result } = await fetchOne([{ status: 304, body: "" }], {
321
+ etag: 'W/"abc"',
322
+ });
323
+ expect(result.outcome).toBe("unchanged");
324
+ expect(
325
+ await fs.readFile(join(dir, "attention-span--spartan.md"), "utf8"),
326
+ ).toBe(before);
327
+ });
328
+ });
329
+
330
+ describe("thrown errors are classified on the code, never on the message", () => {
331
+ const thrower = (error: unknown) => () => {
332
+ throw error;
333
+ };
334
+
335
+ test("ENOTFOUND names MagicDNS, because that is what it usually is here", async () => {
336
+ // A generic "network error" sends the user to GitHub's status page. A
337
+ // split-DNS resolver hijacking raw.githubusercontent.com looks identical
338
+ // and has cost real time on this machine before.
339
+ const error = new TypeError("fetch failed");
340
+ (error as { cause?: unknown }).cause = { code: "ENOTFOUND" };
341
+ const { result } = await fetchOne([thrower(error)]);
342
+ expect(result.outcome).toBe("failed");
343
+ if (result.outcome !== "failed") return;
344
+ expect(result.failure.kind).toBe("dns");
345
+ expect(result.failure.detail).toContain("MagicDNS");
346
+ expect(result.failure.detail).toContain("dig raw.githubusercontent.com");
347
+ });
348
+
349
+ test("EAI_AGAIN is the same hazard", async () => {
350
+ const error = new TypeError("fetch failed");
351
+ (error as { cause?: unknown }).cause = { code: "EAI_AGAIN" };
352
+ const { result } = await fetchOne([thrower(error)]);
353
+ if (result.outcome !== "failed") throw new Error("expected a failure");
354
+ expect(result.failure.kind).toBe("dns");
355
+ });
356
+
357
+ test("no route reads as offline, and says the cache still works", async () => {
358
+ const error = new TypeError("fetch failed");
359
+ (error as { cause?: unknown }).cause = { code: "ENETUNREACH" };
360
+ const { result } = await fetchOne([thrower(error)]);
361
+ if (result.outcome !== "failed") throw new Error("expected a failure");
362
+ expect(result.failure.kind).toBe("offline");
363
+ expect(result.failure.detail).toContain("already fetched still work");
364
+ });
365
+
366
+ test("a timeout names the host rather than blaming the network", async () => {
367
+ const error = new Error("The operation timed out.");
368
+ error.name = "TimeoutError";
369
+ const { result } = await fetchOne([thrower(error)]);
370
+ if (result.outcome !== "failed") throw new Error("expected a failure");
371
+ expect(result.failure.kind).toBe("timeout");
372
+ expect(result.failure.detail).toContain("raw.githubusercontent.com");
373
+ });
374
+
375
+ test("an unclassified throw is reported as such, not guessed at", async () => {
376
+ const { result } = await fetchOne([thrower(new Error("something odd"))]);
377
+ if (result.outcome !== "failed") throw new Error("expected a failure");
378
+ expect(result.failure.kind).toBe("network");
379
+ expect(result.failure.detail).toContain("something odd");
380
+ });
381
+ });
382
+
383
+ describe("coordinate validation happens before any I/O", () => {
384
+ test("a traversing path throws WITHOUT calling the fetcher", async () => {
385
+ const { fetcher, calls } = fake([{}]);
386
+ await expect(
387
+ fetchCommunityStyle({
388
+ style: { ...style, path: "output-styles/../../../etc/passwd.md" },
389
+ source,
390
+ fetcher,
391
+ cacheDir: dir,
392
+ }),
393
+ ).rejects.toThrow(/invalid/);
394
+ // The assertion that matters: a bad coordinate never becomes a URL.
395
+ expect(calls).toHaveLength(0);
396
+ });
397
+
398
+ test("a malformed repo throws WITHOUT calling the fetcher", async () => {
399
+ const { fetcher, calls } = fake([{}]);
400
+ await expect(
401
+ fetchCommunityStyle({
402
+ style,
403
+ source: { ...source, repo: "not a repo" },
404
+ fetcher,
405
+ cacheDir: dir,
406
+ }),
407
+ ).rejects.toThrow(/invalid/);
408
+ expect(calls).toHaveLength(0);
409
+ });
410
+
411
+ test("the thrown message names the entry, so the bug is findable", async () => {
412
+ const { fetcher } = fake([{}]);
413
+ await expect(
414
+ fetchCommunityStyle({
415
+ style: { ...style, path: "README.md" },
416
+ source,
417
+ fetcher,
418
+ cacheDir: dir,
419
+ }),
420
+ ).rejects.toThrow(/attention-span--spartan/);
421
+ });
422
+ });
423
+
424
+ describe("the commit pin", () => {
425
+ const commitsBody = JSON.stringify([
426
+ { sha: "b860c9f8f3c7a1b2c3d4e5f60718293a4b5c6d7e" },
427
+ ]);
428
+
429
+ test("resolves a short sha from one API call", async () => {
430
+ const { fetcher, calls } = fake([
431
+ { body: commitsBody, contentType: "application/json" },
432
+ ]);
433
+ const pin = await resolveCommitPin({ source, fetcher });
434
+ expect(pin.commit).toBe("b860c9f8f3c7");
435
+ expect(calls).toHaveLength(1);
436
+ expect(calls[0].url).toContain(
437
+ "api.github.com/repos/alexgreensh/attention-span/commits",
438
+ );
439
+ expect(calls[0].url).toContain("per_page=1");
440
+ });
441
+
442
+ test("a failed pin never blocks the fetch — the raw content is free", async () => {
443
+ const { fetcher } = fake([{ status: 500, body: "" }]);
444
+ const pin = await resolveCommitPin({ source, fetcher });
445
+ expect(pin.commit).toBeNull();
446
+ expect(pin.reason).toBeTruthy();
447
+ });
448
+
449
+ test("stops spending API budget once the machine's reserve is reached", async () => {
450
+ // claudeup must not be the tool that exhausts a machine-wide 60/hr limit
451
+ // and breaks `gh` for the next hour to fill in one display field.
452
+ const { fetcher, calls } = fake([
453
+ {
454
+ body: commitsBody,
455
+ headers: new Headers({ "x-ratelimit-remaining": "3" }),
456
+ },
457
+ ]);
458
+ expect((await resolveCommitPin({ source, fetcher })).commit).toBe(
459
+ "b860c9f8f3c7",
460
+ );
461
+ const second = await resolveCommitPin({ source, fetcher });
462
+ expect(second.commit).toBeNull();
463
+ expect(second.reason).toMatch(/budget/);
464
+ // One call made, the second refused before it left the process.
465
+ expect(calls).toHaveLength(1);
466
+ });
467
+
468
+ test("a rate-limited pin is reported as unknown, never as resolved", async () => {
469
+ const { fetcher } = fake([
470
+ {
471
+ status: 403,
472
+ body: "",
473
+ headers: new Headers({ "x-ratelimit-remaining": "0" }),
474
+ },
475
+ ]);
476
+ const pin = await resolveCommitPin({ source, fetcher });
477
+ expect(pin.commit).toBeNull();
478
+ expect(pin.reason).toMatch(/rate limit/i);
479
+ });
480
+
481
+ test("a response with no commits yields no pin rather than a bogus one", async () => {
482
+ const { fetcher } = fake([{ body: "[]" }]);
483
+ expect((await resolveCommitPin({ source, fetcher })).commit).toBeNull();
484
+ });
485
+ });
486
+
487
+ describe("fetchSource", () => {
488
+ test("spends ONE API call for the whole batch, whatever its size", async () => {
489
+ // The budget design in one assertion: 22 styles across 5 repos costs 5 API
490
+ // calls, not 22. Per-style pinning would exhaust the hourly limit on first
491
+ // use.
492
+ const commitsBody = JSON.stringify([{ sha: "0123456789abcdef" }]);
493
+ const { fetcher, calls } = fake([
494
+ { body: commitsBody },
495
+ { body: UPSTREAM },
496
+ { body: UPSTREAM },
497
+ { body: UPSTREAM },
498
+ ]);
499
+ const styles = ["spartan", "rundown", "attention-kind"].map((slug) => ({
500
+ ...style,
501
+ id: `attention-span--${slug}`,
502
+ path: `output-styles/${slug}.md`,
503
+ }));
504
+
505
+ const result = await fetchSource({
506
+ source,
507
+ styles,
508
+ fetcher,
509
+ cacheDir: dir,
510
+ });
511
+
512
+ expect(result.pin.commit).toBe("0123456789ab");
513
+ expect(
514
+ calls.filter((call) => call.url.includes("api.github.com")),
515
+ ).toHaveLength(1);
516
+ expect(result.results.every((r) => r.result.outcome === "written")).toBe(
517
+ true,
518
+ );
519
+ expect((await fs.readdir(dir)).sort()).toEqual([
520
+ "attention-span--attention-kind.md",
521
+ "attention-span--rundown.md",
522
+ "attention-span--spartan.md",
523
+ ]);
524
+ });
525
+
526
+ test("every style in the batch carries the batch's pin", async () => {
527
+ const { fetcher } = fake([
528
+ { body: JSON.stringify([{ sha: "0123456789abcdef" }]) },
529
+ { body: UPSTREAM },
530
+ { body: UPSTREAM },
531
+ ]);
532
+ const styles = ["spartan", "rundown"].map((slug) => ({
533
+ ...style,
534
+ id: `attention-span--${slug}`,
535
+ path: `output-styles/${slug}.md`,
536
+ }));
537
+ await fetchSource({ source, styles, fetcher, cacheDir: dir });
538
+ for (const slug of ["spartan", "rundown"]) {
539
+ const { frontmatter } = splitFrontmatter(
540
+ await fs.readFile(join(dir, `attention-span--${slug}.md`), "utf8"),
541
+ );
542
+ expect(frontmatter["community-commit"]).toBe("0123456789ab");
543
+ }
544
+ });
545
+ });