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

voice-to-pr.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.

voice-to-pr.test.tsBlame396 lines · 1 contributor
45f3b73Claude1/**
2 * Tests for src/lib/voice-to-pr.ts.
3 *
4 * Three concerns we can cover without standing up a DB or a real Anthropic
5 * client:
6 *
7 * 1. `interpretVoiceTranscript` — the pure pipeline. We exercise it with
8 * a stub `client.call(prompt)` that returns canned JSON for various
9 * transcripts (spec, issue, unclear, malformed). Also covers the
10 * graceful-degrade path when ANTHROPIC_API_KEY is missing AND no stub
11 * is supplied — the function must still return a valid suggestion.
12 *
13 * 2. `shipAsSpec` — confirms it computes the correct
14 * `.gluecron/specs/voice-<slug>-*.md` path and calls the underlying
15 * git writer with the right shape. We mock the DB + git plumbing via
16 * module override on the global `mockGitWrite` symbol injected by the
17 * test setup. Verifies the spec body carries `status: ready`.
18 *
19 * 3. `createIssueFromVoice` — confirms it goes through the existing
20 * `issues` table insert and returns the new issue number. Mocked DB
21 * symbol matches the shape used by the real code so the import path
22 * stays stable.
23 *
24 * The DB-touching tests use module-level mock injection — we wrap the
25 * imports with `mock.module` (bun:test) so the real Drizzle/Neon stack is
26 * never reached.
27 */
28
29import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
30
31import {
32 interpretVoiceTranscript,
33 voiceSlug,
34 normaliseInterpretation,
35 buildInterpretPrompt,
36 __voiceTest,
37} from "../lib/voice-to-pr";
38
39// ---------------------------------------------------------------------------
40// Pure helpers
41// ---------------------------------------------------------------------------
42
43describe("voiceSlug", () => {
44 it("kebab-cases the input", () => {
45 expect(voiceSlug("Add dark mode toggle")).toBe("add-dark-mode-toggle");
46 });
47 it("trims leading/trailing hyphens", () => {
48 expect(voiceSlug("--hello--")).toBe("hello");
49 });
50 it("caps at 40 chars", () => {
51 const long = "a".repeat(80);
52 expect(voiceSlug(long).length).toBeLessThanOrEqual(40);
53 });
54 it("falls back to 'voice-note' when empty", () => {
55 expect(voiceSlug("")).toBe("voice-note");
56 expect(voiceSlug(" !!! ")).toBe("voice-note");
57 });
58});
59
60describe("normaliseInterpretation", () => {
61 it("returns the heuristic when raw is null", () => {
62 const out = normaliseInterpretation(null, "Add dark mode");
63 expect(out.kind).toBe("spec");
64 expect(out.title.length).toBeGreaterThan(0);
65 expect(out.body_markdown).toBe("Add dark mode");
66 });
67
68 it("rejects unknown kinds and falls back to 'unclear'", () => {
69 const out = normaliseInterpretation(
70 { kind: "thingy", title: "t", body_markdown: "b" },
71 "noop"
72 );
73 expect(out.kind).toBe("unclear");
74 });
75
76 it("trims and preserves valid fields", () => {
77 const out = normaliseInterpretation(
78 {
79 kind: "issue",
80 title: " Bug: header flickers ",
81 body_markdown: " On Safari only. ",
82 target_repo_id_hint: "repo-123",
83 },
84 "fallback"
85 );
86 expect(out.kind).toBe("issue");
87 expect(out.title).toBe("Bug: header flickers");
88 expect(out.body_markdown).toBe("On Safari only.");
89 expect(out.target_repo_id_hint).toBe("repo-123");
90 });
91});
92
93describe("buildInterpretPrompt", () => {
94 it("embeds the transcript verbatim", () => {
95 const p = buildInterpretPrompt("Add dark mode", []);
96 expect(p.includes("Add dark mode")).toBe(true);
97 expect(p.includes('"kind"')).toBe(true);
98 });
99 it("includes a repo list block when repos are supplied", () => {
100 const p = buildInterpretPrompt("x", [
101 { id: "id-1", fullName: "alice/dashboard" },
102 { id: "id-2", fullName: "alice/landing" },
103 ]);
104 expect(p.includes("alice/dashboard")).toBe(true);
105 expect(p.includes("id-1")).toBe(true);
106 });
107});
108
109describe("__voiceTest.classifyHeuristically", () => {
110 const { classifyHeuristically } = __voiceTest;
111 it("classifies feature requests as spec", () => {
112 expect(classifyHeuristically("Add a dark mode toggle").kind).toBe("spec");
113 expect(classifyHeuristically("Implement export to CSV").kind).toBe("spec");
114 });
115 it("classifies bug reports as issue", () => {
116 expect(classifyHeuristically("Login is broken on Safari").kind).toBe("issue");
117 expect(classifyHeuristically("The dashboard crashes when I click").kind).toBe("issue");
118 });
119 it("falls back to 'unclear' for ambiguous text", () => {
120 expect(classifyHeuristically("hmm something").kind).toBe("unclear");
121 });
122});
123
124// ---------------------------------------------------------------------------
125// interpretVoiceTranscript — with mocked Claude
126// ---------------------------------------------------------------------------
127
128describe("interpretVoiceTranscript", () => {
129 it("returns ok:false on empty transcript", async () => {
130 const r = await interpretVoiceTranscript({ transcript: "" });
131 expect(r.ok).toBe(false);
132 });
133
134 it("uses the injected client.call and parses spec JSON", async () => {
135 const r = await interpretVoiceTranscript({
136 transcript: "Add a dark mode toggle to settings",
137 client: {
138 call: async () =>
139 JSON.stringify({
140 kind: "spec",
141 title: "Add dark mode toggle",
142 body_markdown: "Users want a moon icon in settings.",
143 }),
144 },
145 });
146 expect(r.ok).toBe(true);
147 if (!r.ok) throw new Error();
148 expect(r.suggestion.kind).toBe("spec");
149 expect(r.suggestion.title).toBe("Add dark mode toggle");
150 expect(r.suggestion.body_markdown).toContain("moon");
151 });
152
153 it("parses issue JSON wrapped in a ```json fence", async () => {
154 const r = await interpretVoiceTranscript({
155 transcript: "The deploy pill keeps flashing",
156 client: {
157 call: async () =>
158 '```json\n{"kind":"issue","title":"Deploy pill flashes","body_markdown":"Race in SSE reconnect."}\n```',
159 },
160 });
161 expect(r.ok).toBe(true);
162 if (!r.ok) throw new Error();
163 expect(r.suggestion.kind).toBe("issue");
164 });
165
166 it("falls back to 'unclear' on malformed model output", async () => {
167 const r = await interpretVoiceTranscript({
168 transcript: "Some random utterance",
169 client: { call: async () => "not json at all" },
170 });
171 expect(r.ok).toBe(true);
172 if (!r.ok) throw new Error();
173 // We don't enforce kind here — heuristic picks it. We *do* enforce
174 // the title isn't empty so the UI can render something.
175 expect(r.suggestion.title.length).toBeGreaterThan(0);
176 });
177
178 it("gracefully degrades to the heuristic when ANTHROPIC_API_KEY is missing", async () => {
179 const before = process.env.ANTHROPIC_API_KEY;
180 delete process.env.ANTHROPIC_API_KEY;
181 try {
182 const r = await interpretVoiceTranscript({
183 transcript: "Add a settings page",
184 });
185 expect(r.ok).toBe(true);
186 if (!r.ok) throw new Error();
187 expect(r.suggestion.kind).toBe("spec");
188 } finally {
189 if (before) process.env.ANTHROPIC_API_KEY = before;
190 }
191 });
192
193 it("returns ok:false (not throw) when the injected client throws", async () => {
194 const r = await interpretVoiceTranscript({
195 transcript: "stub fails",
196 client: {
197 call: async () => {
198 throw new Error("boom");
199 },
200 },
201 });
202 expect(r.ok).toBe(false);
203 if (r.ok) throw new Error();
204 expect(r.error).toContain("boom");
205 });
206});
207
208// ---------------------------------------------------------------------------
209// shipAsSpec + createIssueFromVoice — DB + git plumbing mocks
210// ---------------------------------------------------------------------------
211
212// Track captured git writes across the suite.
213const gitWrites: Array<any> = [];
214const issueInserts: Array<any> = [];
215
216// Mock the git plumbing — return success and stash the call args.
217mock.module("../git/repository", () => ({
218 createOrUpdateFileOnBranch: async (input: any) => {
219 gitWrites.push(input);
220 return { commitSha: "deadbeef", blobSha: "cafebabe", parentSha: null };
221 },
222}));
223
224// Mock the DB layer with a tiny fluent stub. Only the call shapes the
225// real code uses are implemented — anything else throws so we catch drift.
226mock.module("../db", () => {
227 // We need to vary the returned row shape per select call so both
228 // `resolveRepoAndAuthor` (which uses `{repoName, defaultBranch, ownerName}`)
229 // and `createIssueFromVoice` (which uses `{id, name, issueCount, ownerName}`)
230 // get back rows that match the column aliases they asked for. The stub
231 // remembers the aliases passed to `select()` and returns a row whose keys
232 // include all of them.
233 function selectStub(shape: Record<string, unknown>): any {
234 const keys = Object.keys(shape || {});
235 const row: Record<string, unknown> = {};
236 // Fill in a sensible value per known alias.
237 const aliasDefaults: Record<string, unknown> = {
238 id: "repo-1",
239 name: "demo",
240 repoName: "demo",
241 defaultBranch: "main",
242 ownerName: "alice",
243 issueCount: 0,
244 username: "alice",
245 email: "alice@example.com",
246 };
247 for (const k of keys) {
248 row[k] = aliasDefaults[k] ?? null;
249 }
250 // For the user lookup (`users.username`, `users.email`), no leftJoin.
251 return {
252 from: (_table: any) => ({
253 leftJoin: (_t: any, _on: any) => ({
254 where: (_cond: any) => ({
255 limit: async (_n: number) => [row],
256 }),
257 }),
258 innerJoin: (_t: any, _on: any) => ({
259 where: () => ({ limit: async () => [row] }),
260 }),
261 where: (_cond: any) => ({
262 limit: async (_n: number) => [row],
263 }),
264 }),
265 };
266 }
267 function insertStub(): any {
268 return {
269 values: (row: any) => ({
270 returning: async () => {
271 issueInserts.push(row);
272 return [{ id: "issue-id", number: 42 }];
273 },
274 }),
275 };
276 }
277 function updateStub(): any {
278 return {
279 set: (_row: any) => ({
280 where: async (_cond: any) => undefined,
281 }),
282 };
283 }
284 return {
285 db: {
286 select: (shape?: any) => selectStub(shape || {}),
287 insert: () => insertStub(),
288 update: () => updateStub(),
289 },
290 };
291});
292
293// Re-import AFTER the mocks so the lib picks them up.
294let shipAsSpec: typeof import("../lib/voice-to-pr").shipAsSpec;
295let createIssueFromVoice: typeof import("../lib/voice-to-pr").createIssueFromVoice;
296
297beforeEach(async () => {
298 gitWrites.length = 0;
299 issueInserts.length = 0;
300 // Bun's module cache + mock.module work together: require/import returns
301 // the patched module after the call above. We re-import here so each
302 // suite sees the freshest mocks.
303 const mod = await import("../lib/voice-to-pr");
304 shipAsSpec = mod.shipAsSpec;
305 createIssueFromVoice = mod.createIssueFromVoice;
306});
307
308afterEach(() => {
309 // No global cleanup needed; mocks persist across tests in the suite
310 // which is the desired behaviour for shipAsSpec/createIssueFromVoice.
311});
312
313describe("shipAsSpec", () => {
314 it("rejects an empty transcript", async () => {
315 const r = await shipAsSpec({
316 repositoryId: "repo-1",
317 transcript: " ",
318 userId: "user-1",
319 });
320 expect(r.ok).toBe(false);
321 });
322
323 it("writes to `.gluecron/specs/voice-<slug>-<ts>.md` on the default branch", async () => {
324 const r = await shipAsSpec({
325 repositoryId: "repo-1",
326 transcript: "Add dark mode toggle to settings",
327 userId: "user-1",
328 interpretation: {
329 kind: "spec",
330 title: "Add dark mode toggle",
331 body_markdown: "Users want a moon icon.",
332 },
333 });
334 expect(r.ok).toBe(true);
335 if (!r.ok) throw new Error();
336 expect(r.specPath.startsWith(".gluecron/specs/voice-add-dark-mode-toggle-")).toBe(true);
337 expect(r.specPath.endsWith(".md")).toBe(true);
338 expect(r.branch).toBe("main");
339 expect(gitWrites.length).toBe(1);
340 const w = gitWrites[0];
341 expect(w.owner).toBe("alice");
342 expect(w.name).toBe("demo");
343 expect(w.branch).toBe("main");
344 // Decode the spec body and assert key invariants.
345 const body = new TextDecoder().decode(w.bytes);
346 expect(body.includes("status: ready")).toBe(true);
347 expect(body.includes("source: voice-to-pr")).toBe(true);
348 expect(body.includes("Users want a moon icon.")).toBe(true);
349 });
350
351 it("uses a heuristic title when interpretation is omitted", async () => {
352 const r = await shipAsSpec({
353 repositoryId: "repo-1",
354 transcript: "Implement CSV export",
355 userId: "user-1",
356 });
357 expect(r.ok).toBe(true);
358 if (!r.ok) throw new Error();
359 expect(r.specPath.includes("voice-implement-csv-export-")).toBe(true);
360 });
361});
362
363describe("createIssueFromVoice", () => {
364 it("rejects an empty transcript", async () => {
365 const r = await createIssueFromVoice({
366 repositoryId: "repo-1",
367 transcript: "",
368 userId: "user-1",
369 });
370 expect(r.ok).toBe(false);
371 });
372
373 it("inserts an issue row and returns the issue number", async () => {
374 const r = await createIssueFromVoice({
375 repositoryId: "repo-1",
376 transcript: "Header flickers on Safari",
377 userId: "user-1",
378 interpretation: {
379 kind: "issue",
380 title: "Header flickers on Safari",
381 body_markdown: "Repro: open dashboard on Safari 17.",
382 },
383 });
384 expect(r.ok).toBe(true);
385 if (!r.ok) throw new Error();
386 expect(r.issueNumber).toBe(42);
387 expect(r.ownerName).toBe("alice");
388 expect(r.repoName).toBe("demo");
389 expect(issueInserts.length).toBe(1);
390 expect(issueInserts[0].repositoryId).toBe("repo-1");
391 expect(issueInserts[0].authorId).toBe("user-1");
392 expect(issueInserts[0].title).toContain("Header flickers");
393 expect(issueInserts[0].body).toContain("Repro");
394 expect(issueInserts[0].body.toLowerCase()).toContain("voice-to-pr");
395 });
396});