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

ai-review-trio.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.

ai-review-trio.test.tsBlame565 lines · 1 contributor
422a2d4Claude1/**
2 * Tests for src/lib/ai-review-trio.ts — the parallel security /
3 * correctness / style reviewer pipeline.
4 *
5 * Layered:
6 *
7 * 1. Pure helpers — disagreement detection, finding normalisation,
8 * comment-body rendering. No DB, no network. Always run.
9 *
10 * 2. Persona-runner test seam — verify all three personas are invoked
11 * in parallel with their own system prompts. Always run (uses the
12 * `__setPersonaRunnerForTests` override).
13 *
14 * 3. DB-backed pipeline — gated on HAS_DB. Spins up a real user,
15 * repository, and pull request row, then asserts that all four
16 * prComments (3 personas + 1 summary) land with the correct
17 * markers.
18 */
19
20import {
21 afterEach,
22 beforeEach,
23 describe,
24 expect,
25 it,
26} from "bun:test";
27import { randomBytes } from "crypto";
28
29import {
30 __setPersonaRunnerForTests,
31 __test,
32 TRIO_COMMENT_MARKER,
33 TRIO_SUMMARY_MARKER,
34 alreadyTrioReviewed,
35 computeDisagreements,
36 isTrioReviewEnabled,
37 runTrioReview,
38 type PersonaRunner,
39 type TrioPersona,
40 type TrioVerdict,
41} from "../lib/ai-review-trio";
42
43const HAS_DB = Boolean(process.env.DATABASE_URL);
44
45// ---------------------------------------------------------------------------
46// Reset the persona runner between every test so leaks across files
47// (or across `it` blocks) can't silently mask real bugs.
48// ---------------------------------------------------------------------------
49
50beforeEach(() => {
51 __setPersonaRunnerForTests(null);
52});
53
54afterEach(() => {
55 __setPersonaRunnerForTests(null);
56});
57
58// ---------------------------------------------------------------------------
59// 1. isTrioReviewEnabled — flag plumbing.
60// ---------------------------------------------------------------------------
61
62describe("isTrioReviewEnabled", () => {
63 it("returns false when AI_TRIO_REVIEW_ENABLED is unset", () => {
64 const prev = process.env.AI_TRIO_REVIEW_ENABLED;
65 delete process.env.AI_TRIO_REVIEW_ENABLED;
66 try {
67 expect(isTrioReviewEnabled()).toBe(false);
68 } finally {
69 if (prev !== undefined) process.env.AI_TRIO_REVIEW_ENABLED = prev;
70 }
71 });
72
73 it("returns true when AI_TRIO_REVIEW_ENABLED=1", () => {
74 const prev = process.env.AI_TRIO_REVIEW_ENABLED;
75 process.env.AI_TRIO_REVIEW_ENABLED = "1";
76 try {
77 expect(isTrioReviewEnabled()).toBe(true);
78 } finally {
79 if (prev === undefined) delete process.env.AI_TRIO_REVIEW_ENABLED;
80 else process.env.AI_TRIO_REVIEW_ENABLED = prev;
81 }
82 });
83
84 it("returns false for AI_TRIO_REVIEW_ENABLED=0 or other values", () => {
85 const prev = process.env.AI_TRIO_REVIEW_ENABLED;
86 process.env.AI_TRIO_REVIEW_ENABLED = "0";
87 try {
88 expect(isTrioReviewEnabled()).toBe(false);
89 } finally {
90 if (prev === undefined) delete process.env.AI_TRIO_REVIEW_ENABLED;
91 else process.env.AI_TRIO_REVIEW_ENABLED = prev;
92 }
93 });
94});
95
96// ---------------------------------------------------------------------------
97// 2. computeDisagreements — pure helper.
98// ---------------------------------------------------------------------------
99
100function mkVerdict(
101 persona: TrioPersona,
102 findings: Array<{ file: string | null; line: number | null; issue: string }>,
103 verdict: "pass" | "fail" = "fail"
104): TrioVerdict {
105 return {
106 persona,
107 verdict,
108 findings: findings.map((f) => ({
109 severity: "medium",
110 file: f.file,
111 line: f.line,
112 issue: f.issue,
113 fix: "",
114 })),
115 rawText: "",
116 latencyMs: 0,
117 failed: false,
118 };
119}
120
121describe("computeDisagreements", () => {
122 it("returns [] when no findings exist", () => {
123 const out = computeDisagreements({
124 securityVerdict: mkVerdict("security", [], "pass"),
125 correctnessVerdict: mkVerdict("correctness", [], "pass"),
126 styleVerdict: mkVerdict("style", [], "pass"),
127 });
128 expect(out).toEqual([]);
129 });
130
131 it("returns [] when all three flag the same file:line (unanimous)", () => {
132 const findings = [{ file: "src/a.ts", line: 10, issue: "x" }];
133 const out = computeDisagreements({
134 securityVerdict: mkVerdict("security", findings),
135 correctnessVerdict: mkVerdict("correctness", findings),
136 styleVerdict: mkVerdict("style", findings),
137 });
138 expect(out).toEqual([]);
139 });
140
141 it("detects disagreement when only one persona flags a location", () => {
142 const out = computeDisagreements({
143 securityVerdict: mkVerdict("security", [
144 { file: "src/a.ts", line: 10, issue: "sql injection" },
145 ]),
146 correctnessVerdict: mkVerdict("correctness", [], "pass"),
147 styleVerdict: mkVerdict("style", [], "pass"),
148 });
149 expect(out.length).toBe(1);
150 expect(out[0].file).toBe("src/a.ts");
151 expect(out[0].line).toBe(10);
152 expect(out[0].failingPersonas).toEqual(["security"]);
153 expect(out[0].passingPersonas.sort()).toEqual(["correctness", "style"]);
154 });
155
156 it("detects disagreement when two personas flag and one is silent", () => {
157 const out = computeDisagreements({
158 securityVerdict: mkVerdict("security", [
159 { file: "src/a.ts", line: 10, issue: "x" },
160 ]),
161 correctnessVerdict: mkVerdict("correctness", [
162 { file: "src/a.ts", line: 10, issue: "y" },
163 ]),
164 styleVerdict: mkVerdict("style", [], "pass"),
165 });
166 expect(out.length).toBe(1);
167 expect(out[0].failingPersonas.sort()).toEqual([
168 "correctness",
169 "security",
170 ]);
171 expect(out[0].passingPersonas).toEqual(["style"]);
172 });
173
174 it("ignores findings without a file (can't attribute)", () => {
175 const out = computeDisagreements({
176 securityVerdict: mkVerdict("security", [
177 { file: null, line: null, issue: "ambient" },
178 ]),
179 correctnessVerdict: mkVerdict("correctness", [], "pass"),
180 styleVerdict: mkVerdict("style", [], "pass"),
181 });
182 expect(out).toEqual([]);
183 });
184
185 it("sorts disagreements by file then line for stable rendering", () => {
186 const out = computeDisagreements({
187 securityVerdict: mkVerdict("security", [
188 { file: "src/z.ts", line: 1, issue: "z1" },
189 { file: "src/a.ts", line: 20, issue: "a20" },
190 { file: "src/a.ts", line: 5, issue: "a5" },
191 ]),
192 correctnessVerdict: mkVerdict("correctness", [], "pass"),
193 styleVerdict: mkVerdict("style", [], "pass"),
194 });
195 expect(out.map((d) => `${d.file}:${d.line}`)).toEqual([
196 "src/a.ts:5",
197 "src/a.ts:20",
198 "src/z.ts:1",
199 ]);
200 });
201});
202
203// ---------------------------------------------------------------------------
204// 3. normaliseFinding — pure helper.
205// ---------------------------------------------------------------------------
206
207describe("__test.normaliseFinding", () => {
208 it("accepts a fully-formed finding", () => {
209 const out = __test.normaliseFinding({
210 severity: "high",
211 file: "src/a.ts",
212 line: 42,
213 issue: "boom",
214 fix: "do x",
215 });
216 expect(out).not.toBeNull();
217 expect(out?.severity).toBe("high");
218 expect(out?.file).toBe("src/a.ts");
219 expect(out?.line).toBe(42);
220 });
221
222 it("defaults severity to medium when missing", () => {
223 const out = __test.normaliseFinding({ issue: "x" });
224 expect(out?.severity).toBe("medium");
225 });
226
227 it("rejects findings with no issue text", () => {
228 expect(__test.normaliseFinding({ severity: "high" })).toBeNull();
229 expect(__test.normaliseFinding(null)).toBeNull();
230 expect(__test.normaliseFinding("not an object")).toBeNull();
231 });
232
233 it("falls back to `description` when `issue` is absent", () => {
234 const out = __test.normaliseFinding({ description: "from desc" });
235 expect(out?.issue).toBe("from desc");
236 });
237
238 it("nulls out non-integer or negative line numbers", () => {
239 expect(__test.normaliseFinding({ issue: "x", line: 1.5 })?.line).toBeNull();
240 expect(__test.normaliseFinding({ issue: "x", line: -3 })?.line).toBeNull();
241 expect(__test.normaliseFinding({ issue: "x", line: 0 })?.line).toBeNull();
242 });
243});
244
245// ---------------------------------------------------------------------------
246// 4. Persona-runner — three calls, distinct prompts, parallel.
247// ---------------------------------------------------------------------------
248
249describe("runTrioReview — persona runner", () => {
250 it("invokes all three personas with their own system prompts", async () => {
251 const seen: TrioPersona[] = [];
252 const runner: PersonaRunner = async ({ persona }) => {
253 seen.push(persona);
254 return {
255 text: JSON.stringify({ verdict: "pass", findings: [] }),
256 inputTokens: 0,
257 outputTokens: 0,
258 };
259 };
260 __setPersonaRunnerForTests(runner);
261
262 // No DB → persistence will silently fail when the PR lookup
263 // returns nothing; that's the documented behaviour. We only
264 // assert the runner side here.
265 const result = await runTrioReview({
266 pullRequestId: "00000000-0000-0000-0000-000000000000",
267 headSha: "abc123",
268 diff: "diff --git a b\n+pass",
269 });
270
271 expect(seen.sort()).toEqual(["correctness", "security", "style"]);
272 expect(result.securityVerdict.verdict).toBe("pass");
273 expect(result.correctnessVerdict.verdict).toBe("pass");
274 expect(result.styleVerdict.verdict).toBe("pass");
275 });
276
277 it("returns canned per-persona verdicts and computes disagreements", async () => {
278 const runner: PersonaRunner = async ({ persona }) => {
279 if (persona === "security") {
280 return {
281 text: JSON.stringify({
282 verdict: "fail",
283 findings: [
284 {
285 severity: "high",
286 file: "src/auth.ts",
287 line: 42,
288 issue: "SQL injection",
289 fix: "Use parameterised queries",
290 },
291 ],
292 }),
293 inputTokens: 100,
294 outputTokens: 50,
295 };
296 }
297 if (persona === "correctness") {
298 return {
299 text: JSON.stringify({
300 verdict: "fail",
301 findings: [
302 {
303 severity: "medium",
304 file: "src/auth.ts",
305 line: 42,
306 issue: "Missing await",
307 fix: "Add await",
308 },
309 ],
310 }),
311 inputTokens: 100,
312 outputTokens: 50,
313 };
314 }
315 // style — passes
316 return {
317 text: JSON.stringify({ verdict: "pass", findings: [] }),
318 inputTokens: 80,
319 outputTokens: 20,
320 };
321 };
322 __setPersonaRunnerForTests(runner);
323
324 const result = await runTrioReview({
325 pullRequestId: "00000000-0000-0000-0000-000000000000",
326 headSha: "abc123",
327 diff: "diff --git a b\n+oops",
328 });
329
330 expect(result.securityVerdict.verdict).toBe("fail");
331 expect(result.correctnessVerdict.verdict).toBe("fail");
332 expect(result.styleVerdict.verdict).toBe("pass");
333 expect(result.disagreements.length).toBe(1);
334 expect(result.disagreements[0].file).toBe("src/auth.ts");
335 expect(result.disagreements[0].failingPersonas.sort()).toEqual([
336 "correctness",
337 "security",
338 ]);
339 expect(result.disagreements[0].passingPersonas).toEqual(["style"]);
340 });
341
342 it("fail-closes when the runner throws", async () => {
343 const runner: PersonaRunner = async () => {
344 throw new Error("boom");
345 };
346 __setPersonaRunnerForTests(runner);
347
348 const result = await runTrioReview({
349 pullRequestId: "00000000-0000-0000-0000-000000000000",
350 headSha: "abc123",
351 diff: "",
352 });
353
354 expect(result.securityVerdict.failed).toBe(true);
355 expect(result.securityVerdict.verdict).toBe("fail");
356 expect(result.correctnessVerdict.failed).toBe(true);
357 expect(result.styleVerdict.failed).toBe(true);
358 });
359
360 it("fail-closes when the runner returns unparseable JSON", async () => {
361 const runner: PersonaRunner = async () => ({
362 text: "totally not json {{{",
363 inputTokens: 0,
364 outputTokens: 0,
365 });
366 __setPersonaRunnerForTests(runner);
367
368 const result = await runTrioReview({
369 pullRequestId: "00000000-0000-0000-0000-000000000000",
370 headSha: "abc123",
371 diff: "",
372 });
373
374 expect(result.securityVerdict.failed).toBe(true);
375 expect(result.securityVerdict.verdict).toBe("fail");
376 });
377});
378
379// ---------------------------------------------------------------------------
380// 5. Comment-body rendering — marker presence + verdict word.
381// ---------------------------------------------------------------------------
382
383describe("__test.renderPersonaCommentBody", () => {
384 it("embeds the security marker + Pass word for a pass verdict", () => {
385 const body = __test.renderPersonaCommentBody(
386 mkVerdict("security", [], "pass")
387 );
388 expect(body).toContain(TRIO_COMMENT_MARKER.security);
389 expect(body).toContain("Pass");
390 });
391
392 it("renders findings as a bulleted list", () => {
393 const body = __test.renderPersonaCommentBody(
394 mkVerdict("security", [
395 { file: "src/a.ts", line: 1, issue: "boom" },
396 ])
397 );
398 expect(body).toContain("src/a.ts:1");
399 expect(body).toContain("boom");
400 });
401
402 it("notes when the call failed", () => {
403 const body = __test.renderPersonaCommentBody({
404 ...mkVerdict("style", [], "fail"),
405 failed: true,
406 });
407 expect(body.toLowerCase()).toContain("fail-closed");
408 });
409});
410
411describe("__test.renderSummaryCommentBody", () => {
412 it("embeds the summary marker and lists all three verdicts", () => {
413 const body = __test.renderSummaryCommentBody({
414 securityVerdict: mkVerdict("security", [], "pass"),
415 correctnessVerdict: mkVerdict("correctness", [], "pass"),
416 styleVerdict: mkVerdict("style", [], "pass"),
417 disagreements: [],
418 });
419 expect(body).toContain(TRIO_SUMMARY_MARKER);
420 expect(body).toContain("security");
421 expect(body).toContain("correctness");
422 expect(body).toContain("style");
423 expect(body).toContain("All three reviewers agree");
424 });
425
426 it("formats disagreements clearly", () => {
427 const body = __test.renderSummaryCommentBody({
428 securityVerdict: mkVerdict("security", [
429 { file: "src/a.ts", line: 10, issue: "x" },
430 ]),
431 correctnessVerdict: mkVerdict("correctness", [], "pass"),
432 styleVerdict: mkVerdict("style", [], "pass"),
433 disagreements: [
434 {
435 file: "src/a.ts",
436 line: 10,
437 failingPersonas: ["security"],
438 passingPersonas: ["correctness", "style"],
439 },
440 ],
441 });
442 expect(body).toContain("src/a.ts:10");
443 expect(body).toContain("security");
444 expect(body).toContain("say ✗");
445 expect(body).toContain("say ✓");
446 });
447});
448
449// ---------------------------------------------------------------------------
450// 6. DB-backed — full pipeline inserts 4 prComments with correct markers.
451// ---------------------------------------------------------------------------
452
453describe.skipIf(!HAS_DB)("runTrioReview — DB persistence", () => {
454 it.skipIf(!HAS_DB)(
455 "inserts 3 persona comments + 1 summary, all isAiReview=true, with correct markers",
456 async () => {
457 const { db } = await import("../db");
458 const { users, repositories, pullRequests, prComments } = await import(
459 "../db/schema"
460 );
461 const { eq } = await import("drizzle-orm");
462
463 const stamp = randomBytes(4).toString("hex");
464 const username = `trio-${stamp}`;
465 const reponame = `trio-${stamp}`;
466
467 const [u] = await db
468 .insert(users)
469 .values({
470 username,
471 email: `${username}@test.local`,
472 passwordHash: "x",
473 })
474 .returning();
475 if (!u) return;
476
477 const [r] = await db
478 .insert(repositories)
479 .values({
480 name: reponame,
481 ownerId: u.id,
482 diskPath: `/tmp/${reponame}.git`,
483 defaultBranch: "main",
484 })
485 .returning();
486 if (!r) return;
487
488 const [pr] = await db
489 .insert(pullRequests)
490 .values({
491 repositoryId: r.id,
492 authorId: u.id,
493 title: "Test trio PR",
494 body: "Testing the trio review pipeline.",
495 baseBranch: "main",
496 headBranch: "feature",
497 })
498 .returning();
499 if (!pr) return;
500
501 // Canned runner: security fails on src/a.ts:10, others pass.
502 const runner: PersonaRunner = async ({ persona }) => {
503 if (persona === "security") {
504 return {
505 text: JSON.stringify({
506 verdict: "fail",
507 findings: [
508 {
509 severity: "high",
510 file: "src/a.ts",
511 line: 10,
512 issue: "Hard-coded secret",
513 fix: "Move to env var",
514 },
515 ],
516 }),
517 inputTokens: 0,
518 outputTokens: 0,
519 };
520 }
521 return {
522 text: JSON.stringify({ verdict: "pass", findings: [] }),
523 inputTokens: 0,
524 outputTokens: 0,
525 };
526 };
527 __setPersonaRunnerForTests(runner);
528
529 const result = await runTrioReview({
530 pullRequestId: pr.id,
531 headSha: "abc123",
532 diff: "diff --git a b",
533 repositoryId: r.id,
534 });
535
536 expect(result.securityVerdict.verdict).toBe("fail");
537 expect(result.correctnessVerdict.verdict).toBe("pass");
538 expect(result.styleVerdict.verdict).toBe("pass");
539 expect(result.disagreements.length).toBe(1);
540
541 // Fetch persisted comments — expect exactly 4 (3 personas + summary).
542 const comments = await db
543 .select()
544 .from(prComments)
545 .where(eq(prComments.pullRequestId, pr.id));
546
547 expect(comments.length).toBe(4);
548 // Every comment must be flagged as AI.
549 expect(comments.every((c) => c.isAiReview === true)).toBe(true);
550
551 // Each persona marker should appear exactly once across the 4.
552 const bodies = comments.map((c) => c.body);
553 const hasMarker = (m: string) =>
554 bodies.filter((b) => b.includes(m)).length;
555 expect(hasMarker(TRIO_COMMENT_MARKER.security)).toBe(1);
556 expect(hasMarker(TRIO_COMMENT_MARKER.correctness)).toBe(1);
557 expect(hasMarker(TRIO_COMMENT_MARKER.style)).toBe(1);
558 expect(hasMarker(TRIO_SUMMARY_MARKER)).toBe(1);
559
560 // alreadyTrioReviewed should now return true.
561 const seen = await alreadyTrioReviewed(pr.id);
562 expect(seen).toBe(true);
563 }
564 );
565});