CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-tests.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.
| 1e162a8 | 1 | /** |
| 2 | * Block D8 — AI-generated test suite tests. | |
| 3 | * | |
| 4 | * Pure-helper tests run directly; route tests tolerate the usual graceful | |
| 5 | * degradation envelope (200 / 404 / 503) because these suites run without | |
| 6 | * a live database. | |
| 7 | */ | |
| 8 | ||
| 9 | import { describe, it, expect } from "bun:test"; | |
| 10 | import { | |
| 11 | buildTestsPrompt, | |
| 12 | contentTypeFor, | |
| 13 | detectLanguage, | |
| 14 | detectTestFramework, | |
| 15 | generateTestStub, | |
| 16 | } from "../lib/ai-tests"; | |
| 17 | ||
| 18 | // --------------------------------------------------------------------------- | |
| 19 | // detectLanguage | |
| 20 | // --------------------------------------------------------------------------- | |
| 21 | ||
| 22 | describe("lib/ai-tests — detectLanguage", () => { | |
| 23 | it("maps .ts and .tsx to typescript", () => { | |
| 24 | expect(detectLanguage("src/foo.ts")).toBe("typescript"); | |
| 25 | expect(detectLanguage("src/Foo.tsx")).toBe("typescript"); | |
| 26 | }); | |
| 27 | ||
| 28 | it("maps .js and .jsx to javascript", () => { | |
| 29 | expect(detectLanguage("lib/foo.js")).toBe("javascript"); | |
| 30 | expect(detectLanguage("lib/bar.jsx")).toBe("javascript"); | |
| 31 | }); | |
| 32 | ||
| 33 | it("maps .py to python", () => { | |
| 34 | expect(detectLanguage("pkg/mod.py")).toBe("python"); | |
| 35 | }); | |
| 36 | ||
| 37 | it("maps .go to go", () => { | |
| 38 | expect(detectLanguage("cmd/main.go")).toBe("go"); | |
| 39 | }); | |
| 40 | ||
| 41 | it("maps .rs to rust", () => { | |
| 42 | expect(detectLanguage("src/lib.rs")).toBe("rust"); | |
| 43 | }); | |
| 44 | ||
| 45 | it("maps unknown / extensionless to other", () => { | |
| 46 | expect(detectLanguage("notes.txt")).toBe("other"); | |
| 47 | expect(detectLanguage("Makefile")).toBe("other"); | |
| 48 | expect(detectLanguage("README")).toBe("other"); | |
| 49 | }); | |
| 50 | }); | |
| 51 | ||
| 52 | // --------------------------------------------------------------------------- | |
| 53 | // detectTestFramework | |
| 54 | // --------------------------------------------------------------------------- | |
| 55 | ||
| 56 | describe("lib/ai-tests — detectTestFramework", () => { | |
| 57 | it("returns bun:test when repo looks bun-ish with jest-style test files", () => { | |
| 58 | const f = detectTestFramework("typescript", [ | |
| 59 | "package.json", | |
| 60 | "bun.lockb", | |
| 61 | "src/__tests__/foo.test.ts", | |
| 62 | "src/__tests__/bar.test.ts", | |
| 63 | ]); | |
| 64 | expect(f).toBe("bun:test"); | |
| 65 | }); | |
| 66 | ||
| 67 | it("returns vitest when vitest.config.ts is present", () => { | |
| 68 | const f = detectTestFramework("typescript", [ | |
| 69 | "package.json", | |
| 70 | "vitest.config.ts", | |
| 71 | "src/foo.ts", | |
| 72 | ]); | |
| 73 | expect(f).toBe("vitest"); | |
| 74 | }); | |
| 75 | ||
| 76 | it("returns jest when a jest.config.* file is present", () => { | |
| 77 | const f = detectTestFramework("javascript", [ | |
| 78 | "package.json", | |
| 79 | "jest.config.js", | |
| 80 | ]); | |
| 81 | expect(f).toBe("jest"); | |
| 82 | }); | |
| 83 | ||
| 84 | it("returns pytest when pytest.ini is present", () => { | |
| 85 | const f = detectTestFramework("python", [ | |
| 86 | "pytest.ini", | |
| 87 | "pkg/__init__.py", | |
| 88 | "pkg/mod.py", | |
| 89 | ]); | |
| 90 | expect(f).toBe("pytest"); | |
| 91 | }); | |
| 92 | ||
| 93 | it("returns pytest for python regardless of signals (sensible default)", () => { | |
| 94 | expect(detectTestFramework("python", [])).toBe("pytest"); | |
| 95 | }); | |
| 96 | ||
| 97 | it("returns go test for go language", () => { | |
| 98 | expect(detectTestFramework("go", ["go.mod"])).toBe("go test"); | |
| 99 | }); | |
| 100 | ||
| 101 | it("falls back to bun:test when repoFiles is empty", () => { | |
| 102 | expect(detectTestFramework("typescript", [])).toBe("bun:test"); | |
| 103 | expect(detectTestFramework("javascript", [])).toBe("bun:test"); | |
| 104 | expect(detectTestFramework("other", [])).toBe("bun:test"); | |
| 105 | }); | |
| 106 | }); | |
| 107 | ||
| 108 | // --------------------------------------------------------------------------- | |
| 109 | // buildTestsPrompt | |
| 110 | // --------------------------------------------------------------------------- | |
| 111 | ||
| 112 | describe("lib/ai-tests — buildTestsPrompt", () => { | |
| 113 | it("embeds the source code and file path", () => { | |
| 114 | const src = "export function add(a: number, b: number): number { return a + b; }"; | |
| 115 | const prompt = buildTestsPrompt({ | |
| 116 | path: "src/math.ts", | |
| 117 | language: "typescript", | |
| 118 | framework: "bun:test", | |
| 119 | sourceCode: src, | |
| 120 | }); | |
| 121 | expect(prompt).toContain("src/math.ts"); | |
| 122 | expect(prompt).toContain("bun:test"); | |
| 123 | expect(prompt).toContain(src); | |
| 124 | }); | |
| 125 | ||
| 126 | it("explicitly instructs Claude to emit FAILING stubs", () => { | |
| 127 | const prompt = buildTestsPrompt({ | |
| 128 | path: "foo.ts", | |
| 129 | language: "typescript", | |
| 130 | framework: "bun:test", | |
| 131 | sourceCode: "export const x = 1;", | |
| 132 | }); | |
| 133 | // Allow either casing — implementation uses *failing* with emphasis. | |
| 134 | expect(prompt.toLowerCase()).toContain("failing"); | |
| 135 | }); | |
| 136 | ||
| 137 | it("includes optional apiHints when provided", () => { | |
| 138 | const prompt = buildTestsPrompt({ | |
| 139 | path: "foo.py", | |
| 140 | language: "python", | |
| 141 | framework: "pytest", | |
| 142 | sourceCode: "def add(a, b): return a + b", | |
| 143 | apiHints: "add() returns the arithmetic sum", | |
| 144 | }); | |
| 145 | expect(prompt).toContain("add() returns the arithmetic sum"); | |
| 146 | }); | |
| 147 | ||
| 148 | it("truncates excessively large source files", () => { | |
| 149 | const huge = "a".repeat(50_000); | |
| 150 | const prompt = buildTestsPrompt({ | |
| 151 | path: "big.ts", | |
| 152 | language: "typescript", | |
| 153 | framework: "bun:test", | |
| 154 | sourceCode: huge, | |
| 155 | }); | |
| 156 | expect(prompt.length).toBeLessThan(huge.length + 4_000); | |
| 157 | expect(prompt).toContain("truncated"); | |
| 158 | }); | |
| 159 | }); | |
| 160 | ||
| 161 | // --------------------------------------------------------------------------- | |
| 162 | // generateTestStub | |
| 163 | // --------------------------------------------------------------------------- | |
| 164 | ||
| 165 | describe("lib/ai-tests — generateTestStub (AI unavailable)", () => { | |
| 166 | const hadKey = !!process.env.ANTHROPIC_API_KEY; | |
| 167 | const originalKey = process.env.ANTHROPIC_API_KEY; | |
| 168 | ||
| 169 | // Ensure we exercise the AI-unavailable path deterministically. | |
| 170 | if (hadKey) delete process.env.ANTHROPIC_API_KEY; | |
| 171 | ||
| 172 | it("returns empty code and framework=fallback when no API key is set", async () => { | |
| 173 | const result = await generateTestStub({ | |
| 174 | path: "src/lib/foo.ts", | |
| 175 | language: "typescript", | |
| 176 | framework: "bun:test", | |
| 177 | sourceCode: "export const x = 1;", | |
| 178 | }); | |
| 179 | expect(result.code).toBe(""); | |
| 180 | expect(result.framework).toBe("fallback"); | |
| 181 | expect(result.language).toBe("typescript"); | |
| 182 | }); | |
| 183 | ||
| 184 | it("still computes a sensible suggestedPath for bun:test", async () => { | |
| 185 | const result = await generateTestStub({ | |
| 186 | path: "src/lib/foo.ts", | |
| 187 | language: "typescript", | |
| 188 | framework: "bun:test", | |
| 189 | sourceCode: "export const x = 1;", | |
| 190 | }); | |
| 191 | expect(result.suggestedPath).toContain("foo"); | |
| 192 | expect(result.suggestedPath).toContain(".test."); | |
| 193 | }); | |
| 194 | ||
| 195 | it("picks `test_*.py` for pytest", async () => { | |
| 196 | const result = await generateTestStub({ | |
| 197 | path: "pkg/widgets.py", | |
| 198 | language: "python", | |
| 199 | framework: "pytest", | |
| 200 | sourceCode: "def f(): pass", | |
| 201 | }); | |
| 202 | expect(result.suggestedPath.endsWith("test_widgets.py")).toBe(true); | |
| 203 | }); | |
| 204 | ||
| 205 | it("picks `*_test.go` for go test", async () => { | |
| 206 | const result = await generateTestStub({ | |
| 207 | path: "cmd/server.go", | |
| 208 | language: "go", | |
| 209 | framework: "go test", | |
| 210 | sourceCode: "package main", | |
| 211 | }); | |
| 212 | expect(result.suggestedPath.endsWith("server_test.go")).toBe(true); | |
| 213 | }); | |
| 214 | ||
| 215 | // Restore the env we found on entry — other test files may depend on it. | |
| 216 | if (hadKey && originalKey !== undefined) { | |
| 217 | process.env.ANTHROPIC_API_KEY = originalKey; | |
| 218 | } | |
| 219 | }); | |
| 220 | ||
| 221 | // --------------------------------------------------------------------------- | |
| 222 | // contentTypeFor | |
| 223 | // --------------------------------------------------------------------------- | |
| 224 | ||
| 225 | describe("lib/ai-tests — contentTypeFor", () => { | |
| 226 | it("returns a typescript MIME for typescript", () => { | |
| 227 | expect(contentTypeFor("typescript")).toContain("typescript"); | |
| 228 | }); | |
| 229 | it("returns a python MIME for python", () => { | |
| 230 | expect(contentTypeFor("python")).toContain("python"); | |
| 231 | }); | |
| 232 | it("returns a safe plain fallback for unknown", () => { | |
| 233 | expect(contentTypeFor("other")).toContain("text/plain"); | |
| 234 | }); | |
| 235 | }); | |
| 236 | ||
| 237 | // --------------------------------------------------------------------------- | |
| 238 | // Route-level guard tests | |
| 239 | // --------------------------------------------------------------------------- | |
| 240 | ||
| 241 | describe("routes/ai-tests — guards", () => { | |
| 242 | it("GET /:owner/:repo/ai/tests renders a form or 404s when repo doesn't exist", async () => { | |
| 243 | const { default: aiTestsRoutes } = await import("../routes/ai-tests"); | |
| 244 | const res = await aiTestsRoutes.request("/alice/does-not-exist/ai/tests"); | |
| 245 | // 200 means the page rendered a form (repo exists, somehow), 404 means | |
| 246 | // our handler matched but the repo row was absent, 503 means the DB | |
| 247 | // proxy was down. Any of those is acceptable in CI. | |
| 248 | expect([200, 404, 503]).toContain(res.status); | |
| 249 | }); | |
| 250 | ||
| 251 | it("POST /:owner/:repo/ai/tests/generate without auth redirects to /login or 404s", async () => { | |
| 252 | const { default: aiTestsRoutes } = await import("../routes/ai-tests"); | |
| 253 | const res = await aiTestsRoutes.request( | |
| 254 | "/alice/does-not-exist/ai/tests/generate", | |
| 255 | { | |
| 256 | method: "POST", | |
| 257 | redirect: "manual", | |
| 258 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 259 | body: "path=src/foo.ts", | |
| 260 | } | |
| 261 | ); | |
| 262 | expect([302, 303, 404, 503]).toContain(res.status); | |
| 263 | if (res.status === 302 || res.status === 303) { | |
| 264 | const loc = res.headers.get("location") || ""; | |
| 265 | expect(loc).toContain("/login"); | |
| 266 | } | |
| 267 | }); | |
| 268 | ||
| 269 | it("GET /:owner/:repo/ai/tests?format=raw without path returns 400 / 404 / 503", async () => { | |
| 270 | const { default: aiTestsRoutes } = await import("../routes/ai-tests"); | |
| 271 | const res = await aiTestsRoutes.request( | |
| 272 | "/alice/does-not-exist/ai/tests?format=raw" | |
| 273 | ); | |
| 274 | expect([400, 404, 503]).toContain(res.status); | |
| 275 | }); | |
| 276 | }); |