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.
| 45f3b73 | 1 | /** |
| 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 | ||
| bdf47f0 | 29 | import { afterEach, beforeEach, describe, expect, it, mock , afterAll } from "bun:test"; |
| 45f3b73 | 30 | |
| 31 | import { | |
| 32 | interpretVoiceTranscript, | |
| 33 | voiceSlug, | |
| 34 | normaliseInterpretation, | |
| 35 | buildInterpretPrompt, | |
| 36 | __voiceTest, | |
| 37 | } from "../lib/voice-to-pr"; | |
| 38 | ||
| 39 | // --------------------------------------------------------------------------- | |
| 40 | // Pure helpers | |
| 41 | // --------------------------------------------------------------------------- | |
| 42 | ||
| 43 | describe("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 | ||
| 60 | describe("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 | ||
| 93 | describe("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 | ||
| 109 | describe("__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 | ||
| 128 | describe("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. | |
| 213 | const gitWrites: Array<any> = []; | |
| 214 | const issueInserts: Array<any> = []; | |
| 215 | ||
| bdf47f0 | 216 | // 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. | |
| 229 | const _real_git = await import("../git/repository"); | |
| 230 | const _real_db_mod = await import("../db"); | |
| 231 | ||
| 45f3b73 | 232 | // Mock the git plumbing — return success and stash the call args. |
| 233 | mock.module("../git/repository", () => ({ | |
| bdf47f0 | 234 | ..._real_git, |
| 45f3b73 | 235 | createOrUpdateFileOnBranch: async (input: any) => { |
| 236 | gitWrites.push(input); | |
| 237 | return { commitSha: "deadbeef", blobSha: "cafebabe", parentSha: null }; | |
| 238 | }, | |
| 239 | })); | |
| 240 | ||
| bdf47f0 | 241 | // 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. | |
| 245 | afterAll(() => { | |
| 246 | mock.module("../git/repository", () => _real_git); | |
| 247 | mock.module("../db", () => _real_db_mod); | |
| 248 | }); | |
| 249 | ||
| 45f3b73 | 250 | // 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. | |
| 252 | mock.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. | |
| 320 | let shipAsSpec: typeof import("../lib/voice-to-pr").shipAsSpec; | |
| 321 | let createIssueFromVoice: typeof import("../lib/voice-to-pr").createIssueFromVoice; | |
| 322 | ||
| 323 | beforeEach(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 | ||
| 334 | afterEach(() => { | |
| 335 | // No global cleanup needed; mocks persist across tests in the suite | |
| 336 | // which is the desired behaviour for shipAsSpec/createIssueFromVoice. | |
| 337 | }); | |
| 338 | ||
| 339 | describe("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 | ||
| 389 | describe("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 | }); |