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.
| 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 | ||
| 29 | import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; | |
| 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 | ||
| 216 | // Mock the git plumbing — return success and stash the call args. | |
| 217 | mock.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. | |
| 226 | mock.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. | |
| 294 | let shipAsSpec: typeof import("../lib/voice-to-pr").shipAsSpec; | |
| 295 | let createIssueFromVoice: typeof import("../lib/voice-to-pr").createIssueFromVoice; | |
| 296 | ||
| 297 | beforeEach(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 | ||
| 308 | afterEach(() => { | |
| 309 | // No global cleanup needed; mocks persist across tests in the suite | |
| 310 | // which is the desired behaviour for shipAsSpec/createIssueFromVoice. | |
| 311 | }); | |
| 312 | ||
| 313 | describe("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 | ||
| 363 | describe("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 | }); |