返回 CodeWhale
changelog.test.ts
根目录 / web / lib / changelog.test.ts
1 import { readFileSync } from "node:fs";
2 import { describe, expect, it } from "vitest";
3 import { CHANGELOG } from "./changelog.generated";
4 import { FACTS } from "./facts.generated";
5 import {
6 changelogAnchor,
7 clip,
8 parseChangelog,
9 plainText,
10 renderChangelogModule,
11 } from "../scripts/changelog-lib.mjs";
12
13 const root = new URL("../../", import.meta.url);
14
15 describe("changelog derivation", () => {
16 it("keeps lib/changelog.generated.ts in exact parity with CHANGELOG.md", () => {
17 // The drift gate: a clean re-derivation must reproduce the tracked file
18 // byte for byte, so the /changelog route can never show a stale record.
19 const source = readFileSync(new URL("CHANGELOG.md", root), "utf8");
20 const committed = readFileSync(new URL("../lib/changelog.generated.ts", import.meta.url), "utf8");
21 expect(renderChangelogModule(parseChangelog(source))).toBe(committed);
22 });
23
24 it("puts the unreleased lane first and the published version right behind it", () => {
25 expect(CHANGELOG[0].unreleased).toBe(true);
26 expect(CHANGELOG[0].version).toBe("Unreleased");
27 expect(CHANGELOG[0].date).toBeNull();
28 // The source candidate the site advertises must be the next heading —
29 // either as a dated release (published) or an "Unreleased candidate".
30 expect(CHANGELOG[1].version).toBe(FACTS.version);
31 expect(CHANGELOG[1].unreleased).toBe(false);
32 for (const release of CHANGELOG.slice(1)) {
33 expect(release.version).toMatch(/^\d+\.\d+\.\d+$/);
34 expect(release.compareUrl, release.version).toMatch(
35 /^https:\/\/github\.com\/Hmbown\/CodeWhale\/compare\//,
36 );
37 expect(release.sections.length, release.version).toBeGreaterThan(0);
38 for (const section of release.sections) {
39 expect(section.items.length).toBeGreaterThan(0);
40 expect(section.items.length).toBeLessThanOrEqual(12);
41 expect(section.itemCount).toBeGreaterThanOrEqual(section.items.length);
42 }
43 }
44 });
45
46 it("parses Keep-a-Changelog structure with continued bullets and compare links", () => {
47 const md = `# Changelog
48
49 ## [Unreleased]
50
51 ### Added
52
53 - First entry that
54 continues on the next line.
55 - Second **bold** entry with [a link](https://example.com) and \`code\`.
56
57 ## [1.2.3] - 2026-01-02
58
59 ### Fixed
60
61 - Only fix.
62
63 Trailing prose that is not a bullet.
64
65 [Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v1.2.3...HEAD
66 [1.2.3]: https://github.com/Hmbown/CodeWhale/compare/v1.2.2...v1.2.3
67 `;
68 const { releases } = parseChangelog(md);
69 expect(releases).toHaveLength(2);
70 expect(releases[0]).toMatchObject({
71 version: "Unreleased",
72 unreleased: true,
73 date: null,
74 compareUrl: "https://github.com/Hmbown/CodeWhale/compare/v1.2.3...HEAD",
75 });
76 expect(releases[0].sections[0].items).toEqual([
77 "First entry that continues on the next line.",
78 "Second bold entry with a link and code.",
79 ]);
80 expect(releases[1]).toMatchObject({ version: "1.2.3", date: "2026-01-02", unreleased: false });
81 expect(releases[1].sections[0].items).toEqual(["Only fix."]);
82 });
83
84 it("keeps most of the release record readable in place", () => {
85 // The web clip is a courtesy, not the record: the page must show more
86 // than it hides, and the per-release link carries whatever it does hide.
87 const items = CHANGELOG.flatMap((r) => r.sections.flatMap((s) => s.items));
88 const clipped = items.filter((item) => item.endsWith("…")).length;
89 expect(clipped / items.length).toBeLessThan(0.25);
90 });
91
92 it("names the GitHub heading anchor for every release heading", () => {
93 expect(changelogAnchor({ version: "Unreleased", date: null, unreleased: true })).toBe("unreleased");
94 expect(changelogAnchor({ version: "0.9.11", date: "2026-08-22", unreleased: false })).toBe(
95 "0911---2026-08-22",
96 );
97 expect(changelogAnchor({ version: "1.0.0", date: null, unreleased: false })).toBe("100");
98 });
99
100 it("clips long entries at a word boundary and never mid-word", () => {
101 const long = Array.from({ length: 60 }, (_, i) => `word${i}`).join(" ");
102 const clipped = clip(long, 100);
103 expect(clipped.length).toBeLessThanOrEqual(101);
104 expect(clipped.endsWith("…")).toBe(true);
105 expect(clipped.slice(0, -1).trimEnd()).toMatch(/word\d+$/);
106 expect(clip("short", 100)).toBe("short");
107 expect(plainText(" **a** [b](c) `d` ")).toBe("a b d");
108 });
109 });
110
110 lines TYPESCRIPT