|
| 1 | +import { test } from "node:test"; |
| 2 | +import assert from "node:assert/strict"; |
| 3 | +import { |
| 4 | + categorize, |
| 5 | + isNoiseEntry, |
| 6 | + parseReleaseEntries, |
| 7 | + formatEntry, |
| 8 | + formatRelease, |
| 9 | + buildChangelog, |
| 10 | + toReleaseModel, |
| 11 | + selectStableReleases, |
| 12 | + parseJsonl, |
| 13 | +} from "../generate-changelog.js"; |
| 14 | + |
| 15 | +// ── categorize ─────────────────────────────────────────────────────────────── |
| 16 | + |
| 17 | +test("categorize extracts type, scope, and description", () => { |
| 18 | + const result = categorize("feat(core): add new feature"); |
| 19 | + assert.deepEqual(result, { |
| 20 | + type: "feat", |
| 21 | + scope: "core", |
| 22 | + description: "add new feature", |
| 23 | + breaking: false, |
| 24 | + }); |
| 25 | +}); |
| 26 | + |
| 27 | +test("categorize handles no scope", () => { |
| 28 | + const result = categorize("fix: resolve crash on startup"); |
| 29 | + assert.deepEqual(result, { |
| 30 | + type: "fix", |
| 31 | + scope: null, |
| 32 | + description: "resolve crash on startup", |
| 33 | + breaking: false, |
| 34 | + }); |
| 35 | +}); |
| 36 | + |
| 37 | +test("categorize detects breaking change marker", () => { |
| 38 | + const result = categorize("feat(api)!: change response format"); |
| 39 | + assert.deepEqual(result, { |
| 40 | + type: "feat", |
| 41 | + scope: "api", |
| 42 | + description: "change response format", |
| 43 | + breaking: true, |
| 44 | + }); |
| 45 | +}); |
| 46 | + |
| 47 | +test("categorize returns raw description for non-conventional titles", () => { |
| 48 | + const result = categorize("update readme"); |
| 49 | + assert.deepEqual(result, { |
| 50 | + type: null, |
| 51 | + scope: null, |
| 52 | + description: "update readme", |
| 53 | + breaking: false, |
| 54 | + }); |
| 55 | +}); |
| 56 | + |
| 57 | +// ── isNoiseEntry ───────────────────────────────────────────────────────────── |
| 58 | + |
| 59 | +test("isNoiseEntry identifies release commits", () => { |
| 60 | + assert.equal(isNoiseEntry({ type: "chore", scope: "release" }), true); |
| 61 | +}); |
| 62 | + |
| 63 | +test("isNoiseEntry ignores non-release chores", () => { |
| 64 | + assert.equal(isNoiseEntry({ type: "chore", scope: "deps" }), false); |
| 65 | +}); |
| 66 | + |
| 67 | +test("isNoiseEntry ignores other types", () => { |
| 68 | + assert.equal(isNoiseEntry({ type: "feat", scope: null }), false); |
| 69 | +}); |
| 70 | + |
| 71 | +// ── parseReleaseEntries ────────────────────────────────────────────────────── |
| 72 | + |
| 73 | +test("parseReleaseEntries extracts entries from GitHub body", () => { |
| 74 | + const body = [ |
| 75 | + "* feat(ui): add dark mode by @alice in https://github.com/o/r/pull/42", |
| 76 | + "* fix(core): resolve crash by @bob in https://github.com/o/r/pull/43", |
| 77 | + "", |
| 78 | + "**Full Changelog**: https://github.com/o/r/compare/v1.0.0...v1.1.0", |
| 79 | + ].join("\n"); |
| 80 | + |
| 81 | + const entries = parseReleaseEntries(body); |
| 82 | + assert.equal(entries.length, 2); |
| 83 | + assert.equal(entries[0].title, "feat(ui): add dark mode"); |
| 84 | + assert.equal(entries[0].author, "alice"); |
| 85 | + assert.equal(entries[0].prNumber, "42"); |
| 86 | + assert.equal(entries[1].title, "fix(core): resolve crash"); |
| 87 | + assert.equal(entries[1].author, "bob"); |
| 88 | +}); |
| 89 | + |
| 90 | +test("parseReleaseEntries handles empty body", () => { |
| 91 | + assert.deepEqual(parseReleaseEntries(""), []); |
| 92 | + assert.deepEqual(parseReleaseEntries(null), []); |
| 93 | +}); |
| 94 | + |
| 95 | +test("parseReleaseEntries handles bot authors", () => { |
| 96 | + const body = "* chore(deps): bump x by @dependabot[bot] in https://github.com/o/r/pull/1"; |
| 97 | + const entries = parseReleaseEntries(body); |
| 98 | + assert.equal(entries.length, 1); |
| 99 | + assert.equal(entries[0].author, "dependabot[bot]"); |
| 100 | +}); |
| 101 | + |
| 102 | +// ── formatEntry ────────────────────────────────────────────────────────────── |
| 103 | + |
| 104 | +test("formatEntry renders known type with scope", () => { |
| 105 | + const entry = { title: "feat(ui): add dark mode", prNumber: "42", prUrl: "https://github.com/o/r/pull/42" }; |
| 106 | + assert.equal(formatEntry(entry), "- ui: add dark mode ([#42](https://github.com/o/r/pull/42))"); |
| 107 | +}); |
| 108 | + |
| 109 | +test("formatEntry renders known type without scope", () => { |
| 110 | + const entry = { title: "fix: resolve crash", prNumber: "10", prUrl: "https://github.com/o/r/pull/10" }; |
| 111 | + assert.equal(formatEntry(entry), "- resolve crash ([#10](https://github.com/o/r/pull/10))"); |
| 112 | +}); |
| 113 | + |
| 114 | +test("formatEntry renders unknown type verbatim", () => { |
| 115 | + const entry = { title: "update readme", prNumber: "5", prUrl: "https://github.com/o/r/pull/5" }; |
| 116 | + assert.equal(formatEntry(entry), "- update readme ([#5](https://github.com/o/r/pull/5))"); |
| 117 | +}); |
| 118 | + |
| 119 | +test("formatEntry marks breaking changes", () => { |
| 120 | + const entry = { title: "feat(api)!: change format", prNumber: "99", prUrl: "https://github.com/o/r/pull/99" }; |
| 121 | + assert.match(formatEntry(entry), /\*\*BREAKING\*\*/); |
| 122 | +}); |
| 123 | + |
| 124 | +// ── formatRelease ──────────────────────────────────────────────────────────── |
| 125 | + |
| 126 | +test("formatRelease groups entries by section", () => { |
| 127 | + const release = { |
| 128 | + version: "1.1.0", |
| 129 | + date: "2026-01-15", |
| 130 | + htmlUrl: "https://github.com/o/r/releases/tag/v1.1.0", |
| 131 | + entries: [ |
| 132 | + { title: "feat(ui): add dark mode", author: "alice", prUrl: "https://github.com/o/r/pull/1", prNumber: "1" }, |
| 133 | + { title: "fix: crash on start", author: "bob", prUrl: "https://github.com/o/r/pull/2", prNumber: "2" }, |
| 134 | + { title: "chore(release): v1.1.0", author: "bot", prUrl: "https://github.com/o/r/pull/3", prNumber: "3" }, |
| 135 | + ], |
| 136 | + }; |
| 137 | + |
| 138 | + const md = formatRelease(release); |
| 139 | + assert.match(md, /## \[1\.1\.0\]/); |
| 140 | + assert.match(md, /### Added/); |
| 141 | + assert.match(md, /### Fixed/); |
| 142 | + assert.doesNotMatch(md, /chore\(release\)/); |
| 143 | +}); |
| 144 | + |
| 145 | +// ── buildChangelog ─────────────────────────────────────────────────────────── |
| 146 | + |
| 147 | +test("buildChangelog produces valid markdown with header", () => { |
| 148 | + const releases = [ |
| 149 | + { |
| 150 | + version: "1.0.0", |
| 151 | + date: "2026-01-01", |
| 152 | + htmlUrl: "https://github.com/o/r/releases/tag/v1.0.0", |
| 153 | + entries: [ |
| 154 | + { title: "feat: initial release", author: "dev", prUrl: "https://github.com/o/r/pull/1", prNumber: "1" }, |
| 155 | + ], |
| 156 | + }, |
| 157 | + ]; |
| 158 | + |
| 159 | + const changelog = buildChangelog(releases); |
| 160 | + assert.match(changelog, /# Changelog/); |
| 161 | + assert.match(changelog, /## \[1\.0\.0\]/); |
| 162 | + assert.match(changelog, /### Added/); |
| 163 | + assert.ok(changelog.endsWith("\n")); |
| 164 | +}); |
| 165 | + |
| 166 | +// ── toReleaseModel ─────────────────────────────────────────────────────────── |
| 167 | + |
| 168 | +test("toReleaseModel parses stable tag", () => { |
| 169 | + const raw = { |
| 170 | + tag: "v1.2.3", |
| 171 | + date: "2026-03-15T10:00:00Z", |
| 172 | + url: "https://github.com/o/r/releases/tag/v1.2.3", |
| 173 | + body: "* feat: new thing by @user in https://github.com/o/r/pull/1", |
| 174 | + }; |
| 175 | + const model = toReleaseModel(raw); |
| 176 | + assert.equal(model.version, "1.2.3"); |
| 177 | + assert.equal(model.date, "2026-03-15"); |
| 178 | + assert.equal(model.entries.length, 1); |
| 179 | +}); |
| 180 | + |
| 181 | +test("toReleaseModel returns null version for unstable tag", () => { |
| 182 | + const raw = { tag: "v1.0.0-beta.1", date: "2026-01-01", url: "", body: "" }; |
| 183 | + const model = toReleaseModel(raw); |
| 184 | + assert.equal(model.version, null); |
| 185 | +}); |
| 186 | + |
| 187 | +// ── selectStableReleases ───────────────────────────────────────────────────── |
| 188 | + |
| 189 | +test("selectStableReleases filters out pre-releases and drafts", () => { |
| 190 | + const raw = [ |
| 191 | + { tag: "v1.2.0", date: "2026-03-01", prerelease: false, draft: false, url: "", body: "" }, |
| 192 | + { tag: "v1.1.0-beta.1", date: "2026-02-01", prerelease: true, draft: false, url: "", body: "" }, |
| 193 | + { tag: "v1.0.0", date: "2026-01-01", prerelease: false, draft: false, url: "", body: "" }, |
| 194 | + { tag: "v2.0.0-draft", date: "2026-04-01", prerelease: false, draft: true, url: "", body: "" }, |
| 195 | + ]; |
| 196 | + const stable = selectStableReleases(raw); |
| 197 | + assert.equal(stable.length, 2); |
| 198 | + assert.equal(stable[0].version, "1.2.0"); |
| 199 | + assert.equal(stable[1].version, "1.0.0"); |
| 200 | +}); |
| 201 | + |
| 202 | +// ── parseJsonl ─────────────────────────────────────────────────────────────── |
| 203 | + |
| 204 | +test("parseJsonl parses newline-delimited JSON", () => { |
| 205 | + const jsonl = '{"a":1}\n{"b":2}\n'; |
| 206 | + const result = parseJsonl(jsonl); |
| 207 | + assert.deepEqual(result, [{ a: 1 }, { b: 2 }]); |
| 208 | +}); |
| 209 | + |
| 210 | +test("parseJsonl skips empty lines", () => { |
| 211 | + const jsonl = '{"a":1}\n\n \n{"b":2}\n'; |
| 212 | + const result = parseJsonl(jsonl); |
| 213 | + assert.equal(result.length, 2); |
| 214 | +}); |
0 commit comments