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 | ||
| 57cdb50 | 216 | // Injected fakes — no mock.module anywhere in this file. |
| bdf47f0 | 217 | // |
| 57cdb50 | 218 | // This file used to mock.module both `../git/repository` and `../db` at |
| 219 | // module scope, and never restored either. Those calls are process-global: | |
| 220 | // bun swaps the module registry entry and does NOT rebind namespaces an | |
| 221 | // importer already holds. Because bun loads every test module before running | |
| 222 | // any test, the db stub bound itself into routes/wikis.tsx and | |
| 223 | // routes/auth.tsx before any afterAll could fire — which is why | |
| 224 | // `wikis — route smoke` and `auth landing pages` failed in full runs while | |
| 225 | // passing in isolation. An afterAll restore cannot fix that; not mocking is | |
| 226 | // the fix. The git mock was worse still: it replaced the WHOLE module with a | |
| 227 | // single export, so any later importer of getRepoPath/repoExists got | |
| 228 | // undefined. | |
| bdf47f0 | 229 | // |
| 57cdb50 | 230 | // src/lib/voice-to-pr.ts now exposes __setVoiceDepsForTests(), matching |
| 231 | // push-workflow-sync.ts / first-repo-scaffold.ts / index-existing-repo.ts. | |
| 232 | async function fakeWriteFile(input: any) { | |
| 233 | gitWrites.push(input); | |
| 234 | return { commitSha: "deadbeef", blobSha: "cafebabe", parentSha: null }; | |
| 235 | } | |
| bdf47f0 | 236 | |
| 45f3b73 | 237 | // Mock the DB layer with a tiny fluent stub. Only the call shapes the |
| 238 | // real code uses are implemented — anything else throws so we catch drift. | |
| 57cdb50 | 239 | function makeFakeDb() { |
| 45f3b73 | 240 | // We need to vary the returned row shape per select call so both |
| 241 | // `resolveRepoAndAuthor` (which uses `{repoName, defaultBranch, ownerName}`) | |
| 242 | // and `createIssueFromVoice` (which uses `{id, name, issueCount, ownerName}`) | |
| 243 | // get back rows that match the column aliases they asked for. The stub | |
| 244 | // remembers the aliases passed to `select()` and returns a row whose keys | |
| 245 | // include all of them. | |
| 246 | function selectStub(shape: Record<string, unknown>): any { | |
| 247 | const keys = Object.keys(shape || {}); | |
| 248 | const row: Record<string, unknown> = {}; | |
| 249 | // Fill in a sensible value per known alias. | |
| 250 | const aliasDefaults: Record<string, unknown> = { | |
| 251 | id: "repo-1", | |
| 252 | name: "demo", | |
| 253 | repoName: "demo", | |
| 254 | defaultBranch: "main", | |
| 255 | ownerName: "alice", | |
| 256 | issueCount: 0, | |
| 257 | username: "alice", | |
| 258 | email: "alice@example.com", | |
| 259 | }; | |
| 260 | for (const k of keys) { | |
| 261 | row[k] = aliasDefaults[k] ?? null; | |
| 262 | } | |
| 263 | // For the user lookup (`users.username`, `users.email`), no leftJoin. | |
| 264 | return { | |
| 265 | from: (_table: any) => ({ | |
| 266 | leftJoin: (_t: any, _on: any) => ({ | |
| 267 | where: (_cond: any) => ({ | |
| 268 | limit: async (_n: number) => [row], | |
| 269 | }), | |
| 270 | }), | |
| 271 | innerJoin: (_t: any, _on: any) => ({ | |
| 272 | where: () => ({ limit: async () => [row] }), | |
| 273 | }), | |
| 274 | where: (_cond: any) => ({ | |
| 275 | limit: async (_n: number) => [row], | |
| 276 | }), | |
| 277 | }), | |
| 278 | }; | |
| 279 | } | |
| 280 | function insertStub(): any { | |
| 281 | return { | |
| 282 | values: (row: any) => ({ | |
| 283 | returning: async () => { | |
| 284 | issueInserts.push(row); | |
| 285 | return [{ id: "issue-id", number: 42 }]; | |
| 286 | }, | |
| 287 | }), | |
| 288 | }; | |
| 289 | } | |
| 290 | function updateStub(): any { | |
| 291 | return { | |
| 292 | set: (_row: any) => ({ | |
| 293 | where: async (_cond: any) => undefined, | |
| 294 | }), | |
| 295 | }; | |
| 296 | } | |
| 297 | return { | |
| 298 | db: { | |
| 299 | select: (shape?: any) => selectStub(shape || {}), | |
| 300 | insert: () => insertStub(), | |
| 301 | update: () => updateStub(), | |
| 302 | }, | |
| 303 | }; | |
| 57cdb50 | 304 | } |
| 45f3b73 | 305 | |
| 57cdb50 | 306 | // A plain static import now — no mock ordering to dance around, because the |
| 307 | // fakes go in through the module's own seam rather than the module registry. | |
| 308 | import { | |
| 309 | shipAsSpec, | |
| 310 | createIssueFromVoice, | |
| 311 | __setVoiceDepsForTests, | |
| 312 | } from "../lib/voice-to-pr"; | |
| 45f3b73 | 313 | |
| 57cdb50 | 314 | beforeEach(() => { |
| 45f3b73 | 315 | gitWrites.length = 0; |
| 316 | issueInserts.length = 0; | |
| 57cdb50 | 317 | __setVoiceDepsForTests({ |
| 318 | db: makeFakeDb().db as any, | |
| 319 | writeFile: fakeWriteFile as any, | |
| 320 | }); | |
| 45f3b73 | 321 | }); |
| 322 | ||
| 323 | afterEach(() => { | |
| 57cdb50 | 324 | // Scoped to this file by construction: the seam is a module-local variable, |
| 325 | // so restoring it cannot affect anything another test file imported. | |
| 326 | __setVoiceDepsForTests(null); | |
| 45f3b73 | 327 | }); |
| 328 | ||
| 329 | describe("shipAsSpec", () => { | |
| 330 | it("rejects an empty transcript", async () => { | |
| 331 | const r = await shipAsSpec({ | |
| 332 | repositoryId: "repo-1", | |
| 333 | transcript: " ", | |
| 334 | userId: "user-1", | |
| 335 | }); | |
| 336 | expect(r.ok).toBe(false); | |
| 337 | }); | |
| 338 | ||
| 339 | it("writes to `.gluecron/specs/voice-<slug>-<ts>.md` on the default branch", async () => { | |
| 340 | const r = await shipAsSpec({ | |
| 341 | repositoryId: "repo-1", | |
| 342 | transcript: "Add dark mode toggle to settings", | |
| 343 | userId: "user-1", | |
| 344 | interpretation: { | |
| 345 | kind: "spec", | |
| 346 | title: "Add dark mode toggle", | |
| 347 | body_markdown: "Users want a moon icon.", | |
| 348 | }, | |
| 349 | }); | |
| 350 | expect(r.ok).toBe(true); | |
| 351 | if (!r.ok) throw new Error(); | |
| 352 | expect(r.specPath.startsWith(".gluecron/specs/voice-add-dark-mode-toggle-")).toBe(true); | |
| 353 | expect(r.specPath.endsWith(".md")).toBe(true); | |
| 354 | expect(r.branch).toBe("main"); | |
| 355 | expect(gitWrites.length).toBe(1); | |
| 356 | const w = gitWrites[0]; | |
| 357 | expect(w.owner).toBe("alice"); | |
| 358 | expect(w.name).toBe("demo"); | |
| 359 | expect(w.branch).toBe("main"); | |
| 360 | // Decode the spec body and assert key invariants. | |
| 361 | const body = new TextDecoder().decode(w.bytes); | |
| 362 | expect(body.includes("status: ready")).toBe(true); | |
| 363 | expect(body.includes("source: voice-to-pr")).toBe(true); | |
| 364 | expect(body.includes("Users want a moon icon.")).toBe(true); | |
| 365 | }); | |
| 366 | ||
| 367 | it("uses a heuristic title when interpretation is omitted", async () => { | |
| 368 | const r = await shipAsSpec({ | |
| 369 | repositoryId: "repo-1", | |
| 370 | transcript: "Implement CSV export", | |
| 371 | userId: "user-1", | |
| 372 | }); | |
| 373 | expect(r.ok).toBe(true); | |
| 374 | if (!r.ok) throw new Error(); | |
| 375 | expect(r.specPath.includes("voice-implement-csv-export-")).toBe(true); | |
| 376 | }); | |
| 377 | }); | |
| 378 | ||
| 379 | describe("createIssueFromVoice", () => { | |
| 380 | it("rejects an empty transcript", async () => { | |
| 381 | const r = await createIssueFromVoice({ | |
| 382 | repositoryId: "repo-1", | |
| 383 | transcript: "", | |
| 384 | userId: "user-1", | |
| 385 | }); | |
| 386 | expect(r.ok).toBe(false); | |
| 387 | }); | |
| 388 | ||
| 389 | it("inserts an issue row and returns the issue number", async () => { | |
| 390 | const r = await createIssueFromVoice({ | |
| 391 | repositoryId: "repo-1", | |
| 392 | transcript: "Header flickers on Safari", | |
| 393 | userId: "user-1", | |
| 394 | interpretation: { | |
| 395 | kind: "issue", | |
| 396 | title: "Header flickers on Safari", | |
| 397 | body_markdown: "Repro: open dashboard on Safari 17.", | |
| 398 | }, | |
| 399 | }); | |
| 400 | expect(r.ok).toBe(true); | |
| 401 | if (!r.ok) throw new Error(); | |
| 402 | expect(r.issueNumber).toBe(42); | |
| 403 | expect(r.ownerName).toBe("alice"); | |
| 404 | expect(r.repoName).toBe("demo"); | |
| 405 | expect(issueInserts.length).toBe(1); | |
| 406 | expect(issueInserts[0].repositoryId).toBe("repo-1"); | |
| 407 | expect(issueInserts[0].authorId).toBe("user-1"); | |
| 408 | expect(issueInserts[0].title).toContain("Header flickers"); | |
| 409 | expect(issueInserts[0].body).toContain("Repro"); | |
| 410 | expect(issueInserts[0].body.toLowerCase()).toContain("voice-to-pr"); | |
| 411 | }); | |
| 412 | }); |