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.
| 422a2d4 | 1 | /** |
| 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 | ||
| 20 | import { | |
| 21 | afterEach, | |
| 22 | beforeEach, | |
| 23 | describe, | |
| 24 | expect, | |
| 25 | it, | |
| 26 | } from "bun:test"; | |
| 27 | import { randomBytes } from "crypto"; | |
| 28 | ||
| 29 | import { | |
| 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 | ||
| 43 | const 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 | ||
| 50 | beforeEach(() => { | |
| 51 | __setPersonaRunnerForTests(null); | |
| 52 | }); | |
| 53 | ||
| 54 | afterEach(() => { | |
| 55 | __setPersonaRunnerForTests(null); | |
| 56 | }); | |
| 57 | ||
| 58 | // --------------------------------------------------------------------------- | |
| 59 | // 1. isTrioReviewEnabled — flag plumbing. | |
| 60 | // --------------------------------------------------------------------------- | |
| 61 | ||
| 62 | describe("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 | ||
| 100 | function 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 | ||
| 121 | describe("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 | ||
| 207 | describe("__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 | ||
| 249 | describe("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 | ||
| 383 | describe("__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 | ||
| 411 | describe("__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 | ||
| 453 | describe.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 | }); |