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-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.

ai-tests.tsBlame354 lines · 1 contributor
1e162a8Claude1/**
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
16import {
17 MODEL_SONNET,
18 extractText,
19 getAnthropic,
20 isAiAvailable,
21} from "./ai-client";
22
23// ---------------------------------------------------------------------------
24// Language detection
25// ---------------------------------------------------------------------------
26
27export 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 */
41export 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 */
85export 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 */
136export 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
205export 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 */
217export 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
225Source file path: \`${path}\`
226Detected language: ${language}
227Detected test framework: ${framework}
228
229Write 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
231Rules:
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` : ""}
238Source file contents:
239\`\`\`
240${trimmed}
241\`\`\`
242`;
243}
244
245// ---------------------------------------------------------------------------
246// Claude wrapper
247// ---------------------------------------------------------------------------
248
249export 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 */
260function 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 */
278export 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 */
335export 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}