CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-commit-message.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 9018b1f | 1 | /** |
| 2 | * Tests for src/lib/ai-commit-message.ts. | |
| 3 | * | |
| 4 | * We cover the pure / deterministic surface here (parsing, heuristics, | |
| 5 | * truncation, prompt shape). The Anthropic-calling path is exercised | |
| 6 | * indirectly via the heuristic fallback when ANTHROPIC_API_KEY is unset | |
| 7 | * (the default in CI). | |
| 8 | */ | |
| 9 | ||
| 10 | import { describe, it, expect } from "bun:test"; | |
| 11 | import { | |
| 12 | generateCommitMessage, | |
| 13 | truncateDiff, | |
| 14 | DIFF_BYTE_CAP, | |
| 15 | __test, | |
| 16 | } from "../lib/ai-commit-message"; | |
| 17 | ||
| 18 | const { heuristicMessage, parseModelOutput, buildPrompt, CONVENTIONAL_TYPES } = | |
| 19 | __test; | |
| 20 | ||
| 21 | const SAMPLE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts | |
| 22 | index abc..def 100644 | |
| 23 | --- a/src/foo.ts | |
| 24 | +++ b/src/foo.ts | |
| 25 | @@ -1,3 +1,3 @@ | |
| 26 | -old line | |
| 27 | +new line | |
| 28 | unchanged | |
| 29 | `; | |
| 30 | ||
| 31 | const NEW_FILE_DIFF = `diff --git a/src/feature.ts b/src/feature.ts | |
| 32 | new file mode 100644 | |
| 33 | index 0000000..abc | |
| 34 | --- /dev/null | |
| 35 | +++ b/src/feature.ts | |
| 36 | @@ -0,0 +1,3 @@ | |
| 37 | +export function hello() { | |
| 38 | + return "world"; | |
| 39 | +} | |
| 40 | `; | |
| 41 | ||
| 42 | const DOCS_DIFF = `diff --git a/README.md b/README.md | |
| 43 | index 1..2 100644 | |
| 44 | --- a/README.md | |
| 45 | +++ b/README.md | |
| 46 | @@ -1,1 +1,1 @@ | |
| 47 | -Old | |
| 48 | +New | |
| 49 | `; | |
| 50 | ||
| 51 | const TEST_DIFF = `diff --git a/src/__tests__/foo.test.ts b/src/__tests__/foo.test.ts | |
| 52 | new file mode 100644 | |
| 53 | --- /dev/null | |
| 54 | +++ b/src/__tests__/foo.test.ts | |
| 55 | @@ -0,0 +1,1 @@ | |
| 56 | +test("hi", () => {}); | |
| 57 | `; | |
| 58 | ||
| 59 | // ──────────────────────────── truncation ──────────────────────────── | |
| 60 | ||
| 61 | describe("truncateDiff", () => { | |
| 62 | it("returns the diff unchanged when under cap", () => { | |
| 63 | expect(truncateDiff("hello")).toBe("hello"); | |
| 64 | }); | |
| 65 | ||
| 66 | it("truncates to cap with a marker when over", () => { | |
| 67 | const big = "x".repeat(DIFF_BYTE_CAP + 1000); | |
| 68 | const out = truncateDiff(big); | |
| 69 | expect(out.length).toBeLessThanOrEqual(DIFF_BYTE_CAP); | |
| 70 | expect(out.endsWith("... (more)")).toBe(true); | |
| 71 | }); | |
| 72 | ||
| 73 | it("respects a custom cap", () => { | |
| 74 | const big = "x".repeat(2000); | |
| 75 | const out = truncateDiff(big, 200); | |
| 76 | expect(out.length).toBeLessThanOrEqual(200); | |
| 77 | expect(out.endsWith("... (more)")).toBe(true); | |
| 78 | }); | |
| 79 | ||
| 80 | it("does not append marker when input exactly fits", () => { | |
| 81 | const exact = "x".repeat(50); | |
| 82 | expect(truncateDiff(exact, 50)).toBe(exact); | |
| 83 | }); | |
| 84 | }); | |
| 85 | ||
| 86 | // ──────────────────────────── heuristic ──────────────────────────── | |
| 87 | ||
| 88 | describe("heuristicMessage", () => { | |
| 89 | it("returns a chore message for empty input", () => { | |
| 90 | const m = heuristicMessage(""); | |
| 91 | expect(m.subject).toBe("chore: update"); | |
| 92 | expect(m.body).toBe(""); | |
| 93 | }); | |
| 94 | ||
| 95 | it("classifies docs-only diffs as docs:", () => { | |
| 96 | const m = heuristicMessage(DOCS_DIFF); | |
| 97 | expect(m.subject.startsWith("docs")).toBe(true); | |
| 98 | }); | |
| 99 | ||
| 100 | it("classifies test-only diffs as test:", () => { | |
| 101 | const m = heuristicMessage(TEST_DIFF); | |
| 102 | expect(m.subject.startsWith("test")).toBe(true); | |
| 103 | }); | |
| 104 | ||
| 105 | it("classifies new-file diffs as feat:", () => { | |
| 106 | const m = heuristicMessage(NEW_FILE_DIFF); | |
| 107 | expect(m.subject.startsWith("feat")).toBe(true); | |
| 108 | }); | |
| 109 | ||
| 110 | it("extracts a scope when all paths share a top-level dir", () => { | |
| 111 | const m = heuristicMessage(NEW_FILE_DIFF); | |
| 112 | expect(m.subject).toMatch(/^feat\(src\):/); | |
| 113 | }); | |
| 114 | ||
| 115 | it("includes touched files in the body", () => { | |
| 116 | const m = heuristicMessage(SAMPLE_DIFF); | |
| 117 | expect(m.body).toContain("src/foo.ts"); | |
| 118 | }); | |
| 119 | ||
| 120 | it("caps subject at 72 chars", () => { | |
| 121 | const longPaths = Array.from({ length: 50 }, (_, i) => `src/${i}/file${i}.ts`).join("\n"); | |
| 122 | const big = longPaths | |
| 123 | .split("\n") | |
| 124 | .map((p) => `diff --git a/${p} b/${p}\n+++ b/${p}\n`) | |
| 125 | .join(""); | |
| 126 | const m = heuristicMessage(big); | |
| 127 | expect(m.subject.length).toBeLessThanOrEqual(72); | |
| 128 | }); | |
| 129 | ||
| 130 | it("supports plain style (no type prefix)", () => { | |
| 131 | const m = heuristicMessage(SAMPLE_DIFF, "plain"); | |
| 132 | expect(m.subject).not.toMatch(/^(feat|fix|chore|docs|test|refactor):/); | |
| 133 | expect(m.subject[0]).toBe(m.subject[0].toUpperCase()); | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | // ──────────────────────────── parseModelOutput ──────────────────────────── | |
| 138 | ||
| 139 | describe("parseModelOutput", () => { | |
| 140 | it("parses well-formed JSON with subject + body", () => { | |
| 141 | const m = parseModelOutput( | |
| 142 | '{"subject": "feat(auth): add login", "body": "Because users need it."}' | |
| 143 | ); | |
| 144 | expect(m.subject).toBe("feat(auth): add login"); | |
| 145 | expect(m.body).toBe("Because users need it."); | |
| 146 | }); | |
| 147 | ||
| 148 | it("strips a ```json code fence", () => { | |
| 149 | const text = '```json\n{"subject":"fix: bug","body":""}\n```'; | |
| 150 | const m = parseModelOutput(text); | |
| 151 | expect(m.subject).toBe("fix: bug"); | |
| 152 | }); | |
| 153 | ||
| 154 | it("falls back to first-line/body parsing for plain text", () => { | |
| 155 | const m = parseModelOutput("feat(api): expose new endpoint\n\nDetails here."); | |
| 156 | expect(m.subject).toBe("feat(api): expose new endpoint"); | |
| 157 | expect(m.body).toContain("Details here."); | |
| 158 | }); | |
| 159 | ||
| 160 | it("strips quotes around the subject", () => { | |
| 161 | const m = parseModelOutput('"fix: oops"'); | |
| 162 | expect(m.subject).toBe("fix: oops"); | |
| 163 | }); | |
| 164 | ||
| 165 | it("normalises non-conventional subjects when conventional style is requested", () => { | |
| 166 | const m = parseModelOutput("Add new feature", "conventional"); | |
| 167 | expect(m.subject.startsWith("chore:")).toBe(true); | |
| 168 | }); | |
| 169 | ||
| 170 | it("preserves conventional subjects unchanged", () => { | |
| 171 | const m = parseModelOutput("feat(x): do thing", "conventional"); | |
| 172 | expect(m.subject).toBe("feat(x): do thing"); | |
| 173 | }); | |
| 174 | ||
| 175 | it("caps overly long subjects at 72 chars", () => { | |
| 176 | const long = "feat: " + "x".repeat(200); | |
| 177 | const m = parseModelOutput(long); | |
| 178 | expect(m.subject.length).toBeLessThanOrEqual(72); | |
| 179 | expect(m.subject.endsWith("...")).toBe(true); | |
| 180 | }); | |
| 181 | ||
| 182 | it("returns a safe default for empty / garbage input", () => { | |
| 183 | const m = parseModelOutput(""); | |
| 184 | expect(m.subject).toBe("chore: update"); | |
| 185 | }); | |
| 186 | }); | |
| 187 | ||
| 188 | // ──────────────────────────── buildPrompt ──────────────────────────── | |
| 189 | ||
| 190 | describe("buildPrompt", () => { | |
| 191 | it("mentions every conventional-commit type", () => { | |
| 192 | const p = buildPrompt("(diff)", "conventional"); | |
| 193 | for (const t of CONVENTIONAL_TYPES) { | |
| 194 | expect(p).toContain(t); | |
| 195 | } | |
| 196 | }); | |
| 197 | ||
| 198 | it("requires JSON output", () => { | |
| 199 | const p = buildPrompt("(diff)", "conventional"); | |
| 200 | expect(p).toContain("JSON"); | |
| 201 | expect(p).toContain('"subject"'); | |
| 202 | expect(p).toContain('"body"'); | |
| 203 | }); | |
| 204 | ||
| 205 | it("omits the type-list when plain style is requested", () => { | |
| 206 | const p = buildPrompt("(diff)", "plain"); | |
| 207 | expect(p).toContain("plain-English"); | |
| 208 | // The plain prompt should NOT list conventional types as required types. | |
| 209 | expect(p).not.toContain("MUST start with one of: feat"); | |
| 210 | }); | |
| 211 | ||
| 212 | it("includes the diff verbatim", () => { | |
| 213 | const p = buildPrompt("DIFF_MARKER", "conventional"); | |
| 214 | expect(p).toContain("DIFF_MARKER"); | |
| 215 | }); | |
| 216 | }); | |
| 217 | ||
| 218 | // ──────────────────────────── generateCommitMessage ──────────────────────────── | |
| 219 | ||
| 220 | describe("generateCommitMessage — fallback path", () => { | |
| 221 | it("returns a heuristic message when ANTHROPIC_API_KEY is missing", async () => { | |
| 222 | const before = process.env.ANTHROPIC_API_KEY; | |
| 223 | delete process.env.ANTHROPIC_API_KEY; | |
| 224 | try { | |
| 225 | const m = await generateCommitMessage(SAMPLE_DIFF); | |
| 226 | expect(m.subject.length).toBeGreaterThan(0); | |
| 227 | // Heuristic always emits Conventional style by default. | |
| 228 | expect(m.subject).toMatch(/^[a-z]+(\([^)]+\))?:/); | |
| 229 | } finally { | |
| 230 | if (before) process.env.ANTHROPIC_API_KEY = before; | |
| 231 | } | |
| 232 | }); | |
| 233 | ||
| 234 | it("returns an empty-commit placeholder for an empty diff", async () => { | |
| 235 | const m = await generateCommitMessage(""); | |
| 236 | expect(m.subject).toContain("empty"); | |
| 237 | expect(m.body).toBe(""); | |
| 238 | }); | |
| 239 | ||
| 240 | it("respects the plain style option in fallback mode", async () => { | |
| 241 | const before = process.env.ANTHROPIC_API_KEY; | |
| 242 | delete process.env.ANTHROPIC_API_KEY; | |
| 243 | try { | |
| 244 | const m = await generateCommitMessage(SAMPLE_DIFF, { style: "plain" }); | |
| 245 | expect(m.subject).not.toMatch(/^[a-z]+:/); | |
| 246 | } finally { | |
| 247 | if (before) process.env.ANTHROPIC_API_KEY = before; | |
| 248 | } | |
| 249 | }); | |
| 250 | ||
| 251 | it("never throws on malformed input", async () => { | |
| 252 | let threw = false; | |
| 253 | try { | |
| 254 | await generateCommitMessage("not a real diff at all 🤷"); | |
| 255 | } catch { | |
| 256 | threw = true; | |
| 257 | } | |
| 258 | expect(threw).toBe(false); | |
| 259 | }); | |
| 260 | ||
| 261 | it("handles a diff far over the truncation cap without crashing", async () => { | |
| 262 | const before = process.env.ANTHROPIC_API_KEY; | |
| 263 | delete process.env.ANTHROPIC_API_KEY; | |
| 264 | try { | |
| 265 | const big = "diff --git a/x.ts b/x.ts\n+++ b/x.ts\n" + "+x\n".repeat(200_000); | |
| 266 | const m = await generateCommitMessage(big); | |
| 267 | expect(m.subject.length).toBeGreaterThan(0); | |
| 268 | expect(m.subject.length).toBeLessThanOrEqual(72); | |
| 269 | } finally { | |
| 270 | if (before) process.env.ANTHROPIC_API_KEY = before; | |
| 271 | } | |
| 272 | }); | |
| 273 | }); | |
| 274 | ||
| 275 | // ──────────────────────────── conventional format ──────────────────────────── | |
| 276 | ||
| 277 | describe("Conventional Commits format", () => { | |
| 278 | // Scope allows any non-paren chars — Conventional Commits' BNF | |
| 279 | // is permissive and our heuristic might surface a filename like | |
| 280 | // "README.md" as the scope. | |
| 281 | const CONVENTIONAL_RE = /^[a-z]+(\([^)]+\))?(!?): .+/; | |
| 282 | ||
| 283 | it("heuristic output for a code change matches conventional regex", () => { | |
| 284 | const m = heuristicMessage(SAMPLE_DIFF); | |
| 285 | expect(m.subject).toMatch(CONVENTIONAL_RE); | |
| 286 | }); | |
| 287 | ||
| 288 | it("heuristic output for a new-file diff matches conventional regex", () => { | |
| 289 | const m = heuristicMessage(NEW_FILE_DIFF); | |
| 290 | expect(m.subject).toMatch(CONVENTIONAL_RE); | |
| 291 | }); | |
| 292 | ||
| 293 | it("heuristic output for docs matches conventional regex", () => { | |
| 294 | const m = heuristicMessage(DOCS_DIFF); | |
| 295 | expect(m.subject).toMatch(CONVENTIONAL_RE); | |
| 296 | }); | |
| 297 | ||
| 298 | it("parseModelOutput normalisation produces a conventional subject", () => { | |
| 299 | const m = parseModelOutput("Just a sentence with no type.", "conventional"); | |
| 300 | expect(m.subject).toMatch(CONVENTIONAL_RE); | |
| 301 | }); | |
| 302 | }); |