Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame422 lines · 2 contributors
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
bdf47f0ccantynz-alt29import { afterEach, beforeEach, describe, expect, it, mock , afterAll } from "bun:test";
45f3b73Claude30
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
bdf47f0ccantynz-alt216// Capture the REAL modules before mocking so afterAll can put them back.
217//
218// This file previously mocked `../git/repository` and `../db` and never
219// restored either. Both are process-global: bun's `mock.module` swaps the
220// registry entry for every subsequent importer. The db mock leaked into
221// wikis.test.ts, whose "GET /:owner/:repo/wiki on missing repo → 404" case
222// then saw a stubbed repo row instead of no row and failed — in a full run
223// only, which is why it looked flaky rather than broken. Bisected to here.
224//
225// The git mock was worse than a leak: it replaced the WHOLE module with a
226// single export, so any module importing getRepoPath/repoExists/getTree after
227// this file ran would have received `undefined`. Spreading from the real
228// module keeps the other exports intact even during the mock window.
229const _real_git = await import("../git/repository");
230const _real_db_mod = await import("../db");
231
45f3b73Claude232// Mock the git plumbing — return success and stash the call args.
233mock.module("../git/repository", () => ({
bdf47f0ccantynz-alt234 ..._real_git,
45f3b73Claude235 createOrUpdateFileOnBranch: async (input: any) => {
236 gitWrites.push(input);
237 return { commitSha: "deadbeef", blobSha: "cafebabe", parentSha: null };
238 },
239}));
240
bdf47f0ccantynz-alt241// Put the real modules back so nothing after this file inherits the stubs.
242// This only helps modules that load AFTER the restore — mock.module cannot
243// rebind namespaces an importer already captured — but wikis.tsx and friends
244// do load later, which is exactly the leak this closes.
245afterAll(() => {
246 mock.module("../git/repository", () => _real_git);
247 mock.module("../db", () => _real_db_mod);
248});
249
45f3b73Claude250// Mock the DB layer with a tiny fluent stub. Only the call shapes the
251// real code uses are implemented — anything else throws so we catch drift.
252mock.module("../db", () => {
253 // We need to vary the returned row shape per select call so both
254 // `resolveRepoAndAuthor` (which uses `{repoName, defaultBranch, ownerName}`)
255 // and `createIssueFromVoice` (which uses `{id, name, issueCount, ownerName}`)
256 // get back rows that match the column aliases they asked for. The stub
257 // remembers the aliases passed to `select()` and returns a row whose keys
258 // include all of them.
259 function selectStub(shape: Record<string, unknown>): any {
260 const keys = Object.keys(shape || {});
261 const row: Record<string, unknown> = {};
262 // Fill in a sensible value per known alias.
263 const aliasDefaults: Record<string, unknown> = {
264 id: "repo-1",
265 name: "demo",
266 repoName: "demo",
267 defaultBranch: "main",
268 ownerName: "alice",
269 issueCount: 0,
270 username: "alice",
271 email: "alice@example.com",
272 };
273 for (const k of keys) {
274 row[k] = aliasDefaults[k] ?? null;
275 }
276 // For the user lookup (`users.username`, `users.email`), no leftJoin.
277 return {
278 from: (_table: any) => ({
279 leftJoin: (_t: any, _on: any) => ({
280 where: (_cond: any) => ({
281 limit: async (_n: number) => [row],
282 }),
283 }),
284 innerJoin: (_t: any, _on: any) => ({
285 where: () => ({ limit: async () => [row] }),
286 }),
287 where: (_cond: any) => ({
288 limit: async (_n: number) => [row],
289 }),
290 }),
291 };
292 }
293 function insertStub(): any {
294 return {
295 values: (row: any) => ({
296 returning: async () => {
297 issueInserts.push(row);
298 return [{ id: "issue-id", number: 42 }];
299 },
300 }),
301 };
302 }
303 function updateStub(): any {
304 return {
305 set: (_row: any) => ({
306 where: async (_cond: any) => undefined,
307 }),
308 };
309 }
310 return {
311 db: {
312 select: (shape?: any) => selectStub(shape || {}),
313 insert: () => insertStub(),
314 update: () => updateStub(),
315 },
316 };
317});
318
319// Re-import AFTER the mocks so the lib picks them up.
320let shipAsSpec: typeof import("../lib/voice-to-pr").shipAsSpec;
321let createIssueFromVoice: typeof import("../lib/voice-to-pr").createIssueFromVoice;
322
323beforeEach(async () => {
324 gitWrites.length = 0;
325 issueInserts.length = 0;
326 // Bun's module cache + mock.module work together: require/import returns
327 // the patched module after the call above. We re-import here so each
328 // suite sees the freshest mocks.
329 const mod = await import("../lib/voice-to-pr");
330 shipAsSpec = mod.shipAsSpec;
331 createIssueFromVoice = mod.createIssueFromVoice;
332});
333
334afterEach(() => {
335 // No global cleanup needed; mocks persist across tests in the suite
336 // which is the desired behaviour for shipAsSpec/createIssueFromVoice.
337});
338
339describe("shipAsSpec", () => {
340 it("rejects an empty transcript", async () => {
341 const r = await shipAsSpec({
342 repositoryId: "repo-1",
343 transcript: " ",
344 userId: "user-1",
345 });
346 expect(r.ok).toBe(false);
347 });
348
349 it("writes to `.gluecron/specs/voice-<slug>-<ts>.md` on the default branch", async () => {
350 const r = await shipAsSpec({
351 repositoryId: "repo-1",
352 transcript: "Add dark mode toggle to settings",
353 userId: "user-1",
354 interpretation: {
355 kind: "spec",
356 title: "Add dark mode toggle",
357 body_markdown: "Users want a moon icon.",
358 },
359 });
360 expect(r.ok).toBe(true);
361 if (!r.ok) throw new Error();
362 expect(r.specPath.startsWith(".gluecron/specs/voice-add-dark-mode-toggle-")).toBe(true);
363 expect(r.specPath.endsWith(".md")).toBe(true);
364 expect(r.branch).toBe("main");
365 expect(gitWrites.length).toBe(1);
366 const w = gitWrites[0];
367 expect(w.owner).toBe("alice");
368 expect(w.name).toBe("demo");
369 expect(w.branch).toBe("main");
370 // Decode the spec body and assert key invariants.
371 const body = new TextDecoder().decode(w.bytes);
372 expect(body.includes("status: ready")).toBe(true);
373 expect(body.includes("source: voice-to-pr")).toBe(true);
374 expect(body.includes("Users want a moon icon.")).toBe(true);
375 });
376
377 it("uses a heuristic title when interpretation is omitted", async () => {
378 const r = await shipAsSpec({
379 repositoryId: "repo-1",
380 transcript: "Implement CSV export",
381 userId: "user-1",
382 });
383 expect(r.ok).toBe(true);
384 if (!r.ok) throw new Error();
385 expect(r.specPath.includes("voice-implement-csv-export-")).toBe(true);
386 });
387});
388
389describe("createIssueFromVoice", () => {
390 it("rejects an empty transcript", async () => {
391 const r = await createIssueFromVoice({
392 repositoryId: "repo-1",
393 transcript: "",
394 userId: "user-1",
395 });
396 expect(r.ok).toBe(false);
397 });
398
399 it("inserts an issue row and returns the issue number", async () => {
400 const r = await createIssueFromVoice({
401 repositoryId: "repo-1",
402 transcript: "Header flickers on Safari",
403 userId: "user-1",
404 interpretation: {
405 kind: "issue",
406 title: "Header flickers on Safari",
407 body_markdown: "Repro: open dashboard on Safari 17.",
408 },
409 });
410 expect(r.ok).toBe(true);
411 if (!r.ok) throw new Error();
412 expect(r.issueNumber).toBe(42);
413 expect(r.ownerName).toBe("alice");
414 expect(r.repoName).toBe("demo");
415 expect(issueInserts.length).toBe(1);
416 expect(issueInserts[0].repositoryId).toBe("repo-1");
417 expect(issueInserts[0].authorId).toBe("user-1");
418 expect(issueInserts[0].title).toContain("Header flickers");
419 expect(issueInserts[0].body).toContain("Repro");
420 expect(issueInserts[0].body.toLowerCase()).toContain("voice-to-pr");
421 });
422});