Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

ai-commit-message.test.tsBlame302 lines · 1 contributor
9018b1fClaude1/**
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
10import { describe, it, expect } from "bun:test";
11import {
12 generateCommitMessage,
13 truncateDiff,
14 DIFF_BYTE_CAP,
15 __test,
16} from "../lib/ai-commit-message";
17
18const { heuristicMessage, parseModelOutput, buildPrompt, CONVENTIONAL_TYPES } =
19 __test;
20
21const SAMPLE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts
22index 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
31const NEW_FILE_DIFF = `diff --git a/src/feature.ts b/src/feature.ts
32new file mode 100644
33index 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
42const DOCS_DIFF = `diff --git a/README.md b/README.md
43index 1..2 100644
44--- a/README.md
45+++ b/README.md
46@@ -1,1 +1,1 @@
47-Old
48+New
49`;
50
51const TEST_DIFF = `diff --git a/src/__tests__/foo.test.ts b/src/__tests__/foo.test.ts
52new 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
61describe("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
88describe("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
139describe("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
190describe("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
220describe("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
277describe("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});