CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-tests.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 helper. | |
| 3 | * | |
| 4 | * Given a source file from a repo, produces a *failing* test stub that | |
| 5 | * exercises the public surface of the file using whatever test framework | |
| 6 | * the repository appears to be using (bun:test, vitest, jest, pytest, | |
| 7 | * go test, etc.). | |
| 8 | * | |
| 9 | * The HTTP glue lives in `routes/ai-tests.tsx`; this module only exposes | |
| 10 | * pure helpers and an AI wrapper that NEVER throws. When Claude isn't | |
| 11 | * available (no API key, transport error, etc.) `generateTestStub` returns | |
| 12 | * an empty body and `framework: "fallback"` so the route can render a | |
| 13 | * "couldn't generate" message without crashing. | |
| 14 | */ | |
| 15 | ||
| 16 | import { | |
| 17 | MODEL_SONNET, | |
| 18 | extractText, | |
| 19 | getAnthropic, | |
| 20 | isAiAvailable, | |
| 21 | } from "./ai-client"; | |
| 22 | ||
| 23 | // --------------------------------------------------------------------------- | |
| 24 | // Language detection | |
| 25 | // --------------------------------------------------------------------------- | |
| 26 | ||
| 27 | export type TestLanguage = | |
| 28 | | "typescript" | |
| 29 | | "javascript" | |
| 30 | | "python" | |
| 31 | | "go" | |
| 32 | | "rust" | |
| 33 | | "java" | |
| 34 | | "ruby" | |
| 35 | | "other"; | |
| 36 | ||
| 37 | /** | |
| 38 | * Detect a coarse language bucket from a file path's extension. | |
| 39 | * Unknown / non-code paths return "other". | |
| 40 | */ | |
| 41 | export function detectLanguage(path: string): TestLanguage { | |
| 42 | const lower = path.toLowerCase(); | |
| 43 | const dot = lower.lastIndexOf("."); | |
| 44 | if (dot < 0) return "other"; | |
| 45 | const ext = lower.slice(dot + 1); | |
| 46 | switch (ext) { | |
| 47 | case "ts": | |
| 48 | case "tsx": | |
| 49 | case "mts": | |
| 50 | case "cts": | |
| 51 | return "typescript"; | |
| 52 | case "js": | |
| 53 | case "jsx": | |
| 54 | case "mjs": | |
| 55 | case "cjs": | |
| 56 | return "javascript"; | |
| 57 | case "py": | |
| 58 | return "python"; | |
| 59 | case "go": | |
| 60 | return "go"; | |
| 61 | case "rs": | |
| 62 | return "rust"; | |
| 63 | case "java": | |
| 64 | case "kt": | |
| 65 | return "java"; | |
| 66 | case "rb": | |
| 67 | return "ruby"; | |
| 68 | default: | |
| 69 | return "other"; | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | // --------------------------------------------------------------------------- | |
| 74 | // Framework detection | |
| 75 | // --------------------------------------------------------------------------- | |
| 76 | ||
| 77 | /** | |
| 78 | * Given a language bucket and a flat list of repository files (paths, not | |
| 79 | * contents), return a short framework identifier that matches the | |
| 80 | * conventions the repo already appears to be using. | |
| 81 | * | |
| 82 | * The returned string is what gets plumbed into Claude prompts and is used | |
| 83 | * to compute the suggested test file path. | |
| 84 | */ | |
| 85 | export function detectTestFramework( | |
| 86 | language: TestLanguage, | |
| 87 | repoFiles: string[] | |
| 88 | ): string { | |
| 89 | const files = repoFiles.map((f) => f.toLowerCase()); | |
| 90 | const has = (needle: string | RegExp): boolean => { | |
| 91 | if (typeof needle === "string") return files.some((f) => f === needle); | |
| 92 | return files.some((f) => needle.test(f)); | |
| 93 | }; | |
| 94 | ||
| 95 | // Python first — it has the clearest signals. | |
| 96 | if (language === "python") { | |
| 97 | if (has("pytest.ini") || has("pyproject.toml") || has(/(^|\/)tests?\/.+test.*\.py$/)) | |
| 98 | return "pytest"; | |
| 99 | if (has(/(^|\/)test_.+\.py$/) || has(/_test\.py$/)) return "pytest"; | |
| 100 | return "pytest"; | |
| 101 | } | |
| 102 | ||
| 103 | if (language === "go") return "go test"; | |
| 104 | if (language === "rust") return "cargo test"; | |
| 105 | if (language === "java") return "junit"; | |
| 106 | if (language === "ruby") return has("gemfile") ? "rspec" : "minitest"; | |
| 107 | ||
| 108 | // JavaScript / TypeScript — multiple competing frameworks. | |
| 109 | if (language === "typescript" || language === "javascript" || language === "other") { | |
| 110 | if (has(/vitest\.config\.(ts|js|mjs|cjs)$/) || has("vitest.config.ts")) | |
| 111 | return "vitest"; | |
| 112 | if (has(/jest\.config(\..+)?$/)) return "jest"; | |
| 113 | if (has(/\.mocharc(\..+)?$/) || has("mocha.opts")) return "mocha"; | |
| 114 | if (has("playwright.config.ts") || has("playwright.config.js")) | |
| 115 | return "playwright"; | |
| 116 | ||
| 117 | const usesBun = | |
| 118 | has("bun.lockb") || | |
| 119 | has("bunfig.toml") || | |
| 120 | files.some((f) => f.endsWith("package.json")); | |
| 121 | if (usesBun) return "bun:test"; | |
| 122 | } | |
| 123 | ||
| 124 | return "bun:test"; | |
| 125 | } | |
| 126 | ||
| 127 | // --------------------------------------------------------------------------- | |
| 128 | // Suggested test path | |
| 129 | // --------------------------------------------------------------------------- | |
| 130 | ||
| 131 | /** | |
| 132 | * Compute a deterministic path for the generated test file. The rules are | |
| 133 | * conventional rather than exhaustive — reviewers can always move the file | |
| 134 | * after generation. | |
| 135 | */ | |
| 136 | export function suggestedTestPath( | |
| 137 | sourcePath: string, | |
| 138 | language: TestLanguage, | |
| 139 | framework: string | |
| 140 | ): string { | |
| 141 | const parts = sourcePath.split("/"); | |
| 142 | const file = parts[parts.length - 1] || sourcePath; | |
| 143 | const dir = parts.slice(0, -1).join("/"); | |
| 144 | const dotIdx = file.lastIndexOf("."); | |
| 145 | const base = dotIdx > 0 ? file.slice(0, dotIdx) : file; | |
| 146 | const ext = dotIdx > 0 ? file.slice(dotIdx) : ""; | |
| 147 | ||
| 148 | // Python: siblings `test_foo.py` is near-universal. | |
| 149 | if (language === "python" || framework === "pytest") { | |
| 150 | return dir ? `${dir}/test_${base}.py` : `test_${base}.py`; | |
| 151 | } | |
| 152 | ||
| 153 | // Go: `foo_test.go` adjacent to source. | |
| 154 | if (language === "go" || framework === "go test") { | |
| 155 | return dir ? `${dir}/${base}_test.go` : `${base}_test.go`; | |
| 156 | } | |
| 157 | ||
| 158 | // Rust: convention is `#[cfg(test)] mod tests` inline, but for a standalone | |
| 159 | // stub we drop a file into `tests/`. | |
| 160 | if (language === "rust" || framework === "cargo test") { | |
| 161 | return `tests/${base}_test.rs`; | |
| 162 | } | |
| 163 | ||
| 164 | // Java / Kotlin | |
| 165 | if (language === "java" || framework === "junit") { | |
| 166 | const klass = base.charAt(0).toUpperCase() + base.slice(1); | |
| 167 | return dir | |
| 168 | ? `${dir.replace(/\/main\//, "/test/")}/${klass}Test${ext || ".java"}` | |
| 169 | : `${klass}Test${ext || ".java"}`; | |
| 170 | } | |
| 171 | ||
| 172 | // Ruby | |
| 173 | if (language === "ruby") { | |
| 174 | if (framework === "rspec") { | |
| 175 | return dir ? `spec/${dir}/${base}_spec.rb` : `spec/${base}_spec.rb`; | |
| 176 | } | |
| 177 | return dir ? `test/${dir}/${base}_test.rb` : `test/${base}_test.rb`; | |
| 178 | } | |
| 179 | ||
| 180 | // JS/TS with bun / jest / vitest — prefer `__tests__/<name>.test.<ext>`. | |
| 181 | const testExt = ext === ".tsx" ? ".test.ts" : ext.replace(/^\./, ".test."); | |
| 182 | const safeExt = testExt || ".test.ts"; | |
| 183 | ||
| 184 | if (framework === "bun:test") { | |
| 185 | // If the source is already under src/, put tests under src/__tests__/. | |
| 186 | if (dir.startsWith("src/")) { | |
| 187 | return `src/__tests__/${base}${safeExt}`; | |
| 188 | } | |
| 189 | if (dir === "src") { | |
| 190 | return `src/__tests__/${base}${safeExt}`; | |
| 191 | } | |
| 192 | if (dir) return `${dir}/__tests__/${base}${safeExt}`; | |
| 193 | return `__tests__/${base}${safeExt}`; | |
| 194 | } | |
| 195 | ||
| 196 | // vitest / jest default to sibling `.test.` file. | |
| 197 | if (dir) return `${dir}/${base}${safeExt}`; | |
| 198 | return `${base}${safeExt}`; | |
| 199 | } | |
| 200 | ||
| 201 | // --------------------------------------------------------------------------- | |
| 202 | // Prompt | |
| 203 | // --------------------------------------------------------------------------- | |
| 204 | ||
| 205 | export interface TestGenOpts { | |
| 206 | path: string; | |
| 207 | language: string; | |
| 208 | framework: string; | |
| 209 | sourceCode: string; | |
| 210 | apiHints?: string; | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Build the user prompt that instructs Claude to emit a failing test stub. | |
| 215 | * Returning the prompt as a pure function keeps it easy to test. | |
| 216 | */ | |
| 217 | export function buildTestsPrompt(opts: TestGenOpts): string { | |
| 218 | const { path, language, framework, sourceCode, apiHints } = opts; | |
| 219 | const trimmed = sourceCode.length > 40_000 | |
| 220 | ? sourceCode.slice(0, 40_000) + "\n// ... (truncated)" | |
| 221 | : sourceCode; | |
| 222 | ||
| 223 | return `You are writing an initial failing test suite for an open-source project. | |
| 224 | ||
| 225 | Source file path: \`${path}\` | |
| 226 | Detected language: ${language} | |
| 227 | Detected test framework: ${framework} | |
| 228 | ||
| 229 | Write a *failing* test stub — the tests should compile / import cleanly where possible, but assertions MUST fail (or use explicit \`fail()\` / \`todo\` / \`skip\` markers where the framework supports them) so a developer is forced to review, fill in expected values, and confirm the intended behaviour. Prefer realistic \`expect(...)\` calls with placeholder expected values that are obviously wrong (like \`TODO\`) rather than empty bodies. | |
| 230 | ||
| 231 | Rules: | |
| 232 | - Exercise every exported / public symbol you can see in the source. | |
| 233 | - Use only idioms native to "${framework}". | |
| 234 | - No explanations, no Markdown, no surrounding prose — return ONLY the test file body. | |
| 235 | - If imports are needed, compute paths relative to the source file at \`${path}\`. | |
| 236 | - Leave a top-of-file comment that this stub was generated by gluecron's AI test helper and MUST be reviewed before being committed. | |
| 237 | ${apiHints ? `\nAdditional hints about the public API:\n${apiHints}\n` : ""} | |
| 238 | Source file contents: | |
| 239 | \`\`\` | |
| 240 | ${trimmed} | |
| 241 | \`\`\` | |
| 242 | `; | |
| 243 | } | |
| 244 | ||
| 245 | // --------------------------------------------------------------------------- | |
| 246 | // Claude wrapper | |
| 247 | // --------------------------------------------------------------------------- | |
| 248 | ||
| 249 | export interface TestStubResult { | |
| 250 | code: string; | |
| 251 | suggestedPath: string; | |
| 252 | framework: string; | |
| 253 | language: string; | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Strip a leading/trailing Markdown fence (```lang ... ```) that Claude will | |
| 258 | * sometimes add around the returned body, even when told not to. | |
| 259 | */ | |
| 260 | function stripCodeFences(raw: string): string { | |
| 261 | let text = raw.trim(); | |
| 262 | const fence = text.match(/^```[a-zA-Z0-9_+-]*\n?([\s\S]*?)\n?```\s*$/); | |
| 263 | if (fence) return fence[1].trim(); | |
| 264 | // Partial fences (just the opener) — drop them. | |
| 265 | if (text.startsWith("```")) { | |
| 266 | const nl = text.indexOf("\n"); | |
| 267 | if (nl > -1) text = text.slice(nl + 1); | |
| 268 | } | |
| 269 | if (text.endsWith("```")) text = text.slice(0, -3); | |
| 270 | return text.trim(); | |
| 271 | } | |
| 272 | ||
| 273 | /** | |
| 274 | * Ask Claude Sonnet to produce a failing test stub for the given source. | |
| 275 | * Never throws. On any error (AI unavailable, network failure, empty | |
| 276 | * response) returns `{ code: "", framework: "fallback", ... }`. | |
| 277 | */ | |
| 278 | export async function generateTestStub( | |
| 279 | opts: TestGenOpts | |
| 280 | ): Promise<TestStubResult> { | |
| 281 | const lang = (opts.language as TestLanguage) || "other"; | |
| 282 | const suggestedPath = suggestedTestPath(opts.path, lang, opts.framework); | |
| 283 | ||
| 284 | if (!isAiAvailable()) { | |
| 285 | return { | |
| 286 | code: "", | |
| 287 | suggestedPath, | |
| 288 | framework: "fallback", | |
| 289 | language: opts.language, | |
| 290 | }; | |
| 291 | } | |
| 292 | ||
| 293 | try { | |
| 294 | const client = getAnthropic(); | |
| 295 | const prompt = buildTestsPrompt(opts); | |
| 296 | const message = await client.messages.create({ | |
| 297 | model: MODEL_SONNET, | |
| 298 | max_tokens: 2048, | |
| 299 | messages: [{ role: "user", content: prompt }], | |
| 300 | }); | |
| 301 | const raw = extractText(message); | |
| 302 | const code = stripCodeFences(raw); | |
| 303 | if (!code) { | |
| 304 | return { | |
| 305 | code: "", | |
| 306 | suggestedPath, | |
| 307 | framework: "fallback", | |
| 308 | language: opts.language, | |
| 309 | }; | |
| 310 | } | |
| 311 | return { | |
| 312 | code, | |
| 313 | suggestedPath, | |
| 314 | framework: opts.framework, | |
| 315 | language: opts.language, | |
| 316 | }; | |
| 317 | } catch { | |
| 318 | return { | |
| 319 | code: "", | |
| 320 | suggestedPath, | |
| 321 | framework: "fallback", | |
| 322 | language: opts.language, | |
| 323 | }; | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 327 | // --------------------------------------------------------------------------- | |
| 328 | // Content types for ?format=raw | |
| 329 | // --------------------------------------------------------------------------- | |
| 330 | ||
| 331 | /** | |
| 332 | * Return a suitable `Content-Type` header for a generated test file, based | |
| 333 | * on the language bucket. Defaults to `text/plain; charset=utf-8`. | |
| 334 | */ | |
| 335 | export function contentTypeFor(language: string): string { | |
| 336 | switch (language) { | |
| 337 | case "typescript": | |
| 338 | return "application/typescript; charset=utf-8"; | |
| 339 | case "javascript": | |
| 340 | return "application/javascript; charset=utf-8"; | |
| 341 | case "python": | |
| 342 | return "text/x-python; charset=utf-8"; | |
| 343 | case "go": | |
| 344 | return "text/x-go; charset=utf-8"; | |
| 345 | case "rust": | |
| 346 | return "text/x-rust; charset=utf-8"; | |
| 347 | case "java": | |
| 348 | return "text/x-java-source; charset=utf-8"; | |
| 349 | case "ruby": | |
| 350 | return "text/x-ruby; charset=utf-8"; | |
| 351 | default: | |
| 352 | return "text/plain; charset=utf-8"; | |
| 353 | } | |
| 354 | } |