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

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.tsxBlame990 lines · 1 contributor
1e162a8Claude1/**
2 * Block D8 — AI-generated test suite route.
3 *
4 * GET /:owner/:repo/ai/tests?path=...&ref=...
5 * Renders a form to pick a source file and generate a failing test
6 * stub for it. When `path` is provided the form is pre-filled with
7 * the currently-selected file so the user can "Generate" with one
8 * click.
9 *
10 * GET /:owner/:repo/ai/tests?path=...&format=raw
11 * Returns `c.text(result.code, 200, {"Content-Type": ...})` for CLI
12 * consumption (e.g. `curl | bat`). No HTML shell.
13 *
14 * POST /:owner/:repo/ai/tests/generate
15 * Auth required. Actually runs the model and renders the result
16 * page with highlighted source, highlighted test, a copy-to-clipboard
17 * button, a review warning, and a regenerate button.
e73e288Claude18 *
19 * 2026 polish — visual only. Every form action, POST target, raw-format
20 * branch, `generateTestStub(...)` AI call, and the highlightCode pipeline
21 * are preserved verbatim. CSS scoped under `.ai-tests-*` so it can't
22 * collide with `.ai-explain-*` or `.ai-changelog-*` on a shared Layout.
1e162a8Claude23 */
24
25import { Hono } from "hono";
e73e288Claude26import { raw } from "hono/html";
1e162a8Claude27import { and, eq } from "drizzle-orm";
28import { db } from "../db";
29import { repositories, users } from "../db/schema";
30import { Layout } from "../views/layout";
31import { RepoHeader } from "../views/components";
32import { IssueNav } from "./issues";
33import { softAuth, requireAuth } from "../middleware/auth";
34import type { AuthEnv } from "../middleware/auth";
35import {
36 getBlob,
37 getDefaultBranch,
38 getTree,
39 resolveRef,
40} from "../git/repository";
41import type { GitTreeEntry } from "../git/repository";
42import { highlightCode } from "../lib/highlight";
43import {
44 contentTypeFor,
45 detectLanguage,
46 detectTestFramework,
47 generateTestStub,
48} from "../lib/ai-tests";
49
50const aiTestsRoutes = new Hono<AuthEnv>();
51
e73e288Claude52/* ─────────────────────────────────────────────────────────────────────────
53 * Scoped CSS — every class prefixed `.ai-tests-` so this surface stays
54 * isolated from `.ai-explain-*` and `.ai-changelog-*`. Mirrors the
55 * gradient-hairline hero + white spec-block patterns from
56 * admin-integrations.tsx / build-agent-spec.tsx, with focus-rings on
57 * inputs via the :root --border-focus token.
58 * ───────────────────────────────────────────────────────────────────── */
59const styles = `
eed4684Claude60 .ai-tests-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e73e288Claude61
62 .ai-tests-hero {
63 position: relative;
64 margin-bottom: var(--space-5);
65 padding: var(--space-5) var(--space-6);
66 background: var(--bg-elevated);
67 border: 1px solid var(--border);
68 border-radius: 16px;
69 overflow: hidden;
70 }
71 .ai-tests-hero::before {
72 content: '';
73 position: absolute;
74 top: 0; left: 0; right: 0;
75 height: 2px;
6fd5915Claude76 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
e73e288Claude77 opacity: 0.7;
78 pointer-events: none;
79 }
80 .ai-tests-hero-orb {
81 position: absolute;
82 inset: -20% -10% auto auto;
83 width: 420px; height: 420px;
6fd5915Claude84 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
e73e288Claude85 filter: blur(80px);
86 opacity: 0.75;
87 pointer-events: none;
88 z-index: 0;
89 }
90 .ai-tests-hero-inner { position: relative; z-index: 1; max-width: 720px; }
91 .ai-tests-eyebrow {
92 font-size: 12px;
93 color: var(--text-muted);
94 margin-bottom: var(--space-2);
95 letter-spacing: 0.02em;
96 display: inline-flex;
97 align-items: center;
98 gap: 8px;
99 }
100 .ai-tests-eyebrow .pill {
101 display: inline-flex;
102 align-items: center;
103 justify-content: center;
104 width: 18px; height: 18px;
105 border-radius: 6px;
6fd5915Claude106 background: rgba(91,110,232,0.14);
107 color: #5b6ee8;
108 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
e73e288Claude109 }
110 .ai-tests-title {
111 font-size: clamp(28px, 4vw, 42px);
112 font-family: var(--font-display);
113 font-weight: 800;
114 letter-spacing: -0.028em;
115 line-height: 1.05;
116 margin: 0 0 var(--space-2);
117 color: var(--text-strong);
118 }
119 .ai-tests-title-grad {
6fd5915Claude120 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
e73e288Claude121 -webkit-background-clip: text;
122 background-clip: text;
123 -webkit-text-fill-color: transparent;
124 color: transparent;
125 }
126 .ai-tests-sub {
127 font-size: 15px;
128 color: var(--text-muted);
129 margin: 0;
130 line-height: 1.55;
131 max-width: 640px;
132 }
133 .ai-tests-sub strong { color: var(--text-strong); font-weight: 700; }
134 .ai-tests-sub code {
135 font-family: var(--font-mono);
136 background: var(--bg-tertiary);
137 color: var(--text);
138 padding: 1px 5px;
139 border-radius: 4px;
140 font-size: 12.5px;
141 }
142
143 /* Form card */
144 .ai-tests-form-card {
145 margin-bottom: var(--space-5);
146 background: var(--bg-elevated);
147 border: 1px solid var(--border);
148 border-radius: 14px;
149 padding: var(--space-4) var(--space-5);
150 }
151 .ai-tests-field {
152 display: flex;
153 flex-direction: column;
154 gap: 6px;
155 margin-bottom: var(--space-3);
156 }
157 .ai-tests-field-label {
158 font-size: 11px;
159 font-weight: 700;
160 text-transform: uppercase;
161 letter-spacing: 0.12em;
162 color: var(--text-muted);
163 }
164 .ai-tests-input,
165 .ai-tests-select {
166 width: 100%;
167 padding: 9px 12px;
168 font-size: 13.5px;
169 color: var(--text);
170 background: var(--bg);
171 border: 1px solid var(--border-strong);
172 border-radius: 8px;
173 outline: none;
174 font-family: var(--font-mono);
175 transition: border-color 120ms ease, box-shadow 120ms ease;
176 box-sizing: border-box;
177 }
178 .ai-tests-input:focus,
179 .ai-tests-select:focus {
180 border-color: var(--border-focus);
6fd5915Claude181 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
e73e288Claude182 }
183 .ai-tests-submit {
184 display: inline-flex;
185 align-items: center;
186 gap: 7px;
187 padding: 9px 16px;
6fd5915Claude188 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
e73e288Claude189 color: #ffffff;
190 border: 1px solid transparent;
191 border-radius: 10px;
192 font-size: 13.5px;
193 font-weight: 600;
194 cursor: pointer;
6fd5915Claude195 box-shadow: 0 6px 16px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
e73e288Claude196 font-family: inherit;
197 transition: transform 120ms ease, box-shadow 120ms ease;
198 line-height: 1;
199 }
200 .ai-tests-submit:hover {
201 transform: translateY(-1px);
6fd5915Claude202 box-shadow: 0 10px 22px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
e73e288Claude203 }
204 .ai-tests-submit svg { display: block; }
205
206 .ai-tests-detected {
207 display: inline-flex;
208 gap: 12px;
209 margin-top: 6px;
210 font-size: 12px;
211 color: var(--text-muted);
212 flex-wrap: wrap;
213 }
214 .ai-tests-detected code {
215 font-family: var(--font-mono);
216 background: var(--bg-tertiary);
217 color: var(--text);
218 padding: 1px 6px;
219 border-radius: 4px;
220 font-size: 11.5px;
221 }
222
223 /* Header bar between hero + results */
224 .ai-tests-resulthead {
225 display: flex;
226 justify-content: space-between;
227 align-items: center;
228 gap: var(--space-2);
229 margin: var(--space-4) 0 var(--space-3);
230 flex-wrap: wrap;
231 }
232 .ai-tests-resulthead h2 {
233 margin: 0 0 4px;
234 font-family: var(--font-display);
235 font-size: 18px;
236 font-weight: 700;
237 letter-spacing: -0.018em;
238 color: var(--text-strong);
239 }
240 .ai-tests-resulthead h2 code {
241 font-family: var(--font-mono);
242 background: var(--bg-tertiary);
243 color: var(--text);
244 padding: 1px 6px;
245 border-radius: 4px;
246 font-size: 14px;
247 font-weight: 600;
248 }
249 .ai-tests-regen {
250 display: inline-flex;
251 align-items: center;
252 gap: 6px;
253 padding: 8px 14px;
6fd5915Claude254 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
e73e288Claude255 color: #ffffff;
256 border: 1px solid transparent;
257 border-radius: 10px;
258 font-size: 13px;
259 font-weight: 600;
260 cursor: pointer;
6fd5915Claude261 box-shadow: 0 6px 16px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
e73e288Claude262 font-family: inherit;
263 transition: transform 120ms ease, box-shadow 120ms ease;
264 }
265 .ai-tests-regen:hover {
266 transform: translateY(-1px);
6fd5915Claude267 box-shadow: 0 10px 22px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
e73e288Claude268 }
269 .ai-tests-regen svg { display: block; }
270
271 /* Banner — review warning */
272 .ai-tests-warning {
273 margin-bottom: var(--space-4);
274 padding: 12px 14px;
275 border-radius: 12px;
276 background: rgba(210, 153, 34, 0.10);
277 border: 1px solid rgba(210, 153, 34, 0.40);
278 color: #fde68a;
279 font-size: 13.5px;
280 line-height: 1.55;
281 }
282 .ai-tests-warning strong { color: #fef3c7; }
283 .ai-tests-warning em { color: #fde68a; font-style: italic; }
284
285 /* Source + test panels — white container, dark code inside (matches the
286 existing .hljs styling on the dark theme). */
287 .ai-tests-section { margin-bottom: var(--space-5); }
288 .ai-tests-panel {
289 position: relative;
290 background: #ffffff;
291 color: #0a0a0a;
292 border: 1px solid #e5e7eb;
293 border-radius: 14px;
294 overflow: hidden;
295 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.18);
296 }
297 .ai-tests-panel-head {
298 display: flex;
299 align-items: center;
300 justify-content: space-between;
301 gap: 12px;
302 padding: 12px 16px;
303 background: #f9fafb;
304 border-bottom: 1px solid #e5e7eb;
305 flex-wrap: wrap;
306 }
307 .ai-tests-panel-title {
308 display: flex;
309 align-items: center;
310 gap: 10px;
311 font-family: var(--font-display, system-ui, sans-serif);
312 font-size: 13.5px;
313 font-weight: 700;
314 color: #111827;
315 letter-spacing: -0.005em;
316 margin: 0;
317 }
318 .ai-tests-panel-title code {
319 font-family: var(--font-mono);
320 background: #eef2ff;
321 color: #4338ca;
322 padding: 1px 6px;
323 border-radius: 4px;
324 font-size: 12px;
325 }
326 .ai-tests-panel-dot {
327 width: 8px; height: 8px;
328 border-radius: 9999px;
6fd5915Claude329 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
330 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
e73e288Claude331 }
332 .ai-tests-copy {
333 display: inline-flex;
334 align-items: center;
335 gap: 6px;
336 padding: 6px 12px;
337 font-size: 12.5px;
338 font-weight: 600;
339 color: #111827;
340 background: #ffffff;
341 border: 1px solid #d1d5db;
342 border-radius: 8px;
343 cursor: pointer;
344 font-family: inherit;
345 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
346 }
347 .ai-tests-copy:hover { background: #f3f4f6; border-color: #9ca3af; }
348 .ai-tests-copy.is-copied {
349 background: #ecfdf5;
350 border-color: #6ee7b7;
351 color: #047857;
352 }
353 .ai-tests-copy svg { display: block; }
354
355 .ai-tests-pre {
356 margin: 0;
357 padding: 14px 16px;
358 background: #0f111a;
359 color: #e6edf3;
360 font-family: var(--font-mono);
361 font-size: 12.5px;
362 line-height: 1.6;
363 overflow: auto;
364 white-space: pre;
365 max-height: 70vh;
366 }
367 .ai-tests-pre code { background: transparent; color: inherit; padding: 0; }
368
369 /* Empty-state — dashed card w/ orb + suggestion prompts. */
370 .ai-tests-empty {
371 position: relative;
372 margin: var(--space-4) 0;
373 padding: var(--space-5);
374 border: 1px dashed var(--border-strong, var(--border));
375 border-radius: 14px;
376 background: var(--bg-elevated);
377 overflow: hidden;
378 text-align: center;
379 }
380 .ai-tests-empty-orb {
381 position: absolute;
382 inset: -50% 30% auto 30%;
383 width: 320px; height: 320px;
6fd5915Claude384 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.06) 45%, transparent 70%);
e73e288Claude385 filter: blur(70px);
386 pointer-events: none;
387 z-index: 0;
388 }
389 .ai-tests-empty > * { position: relative; z-index: 1; }
390 .ai-tests-empty h3 {
391 margin: 0 0 6px;
392 font-family: var(--font-display);
393 font-size: 16px;
394 color: var(--text-strong);
395 }
396 .ai-tests-empty p {
397 margin: 0 0 6px;
398 color: var(--text-muted);
399 font-size: 13px;
400 }
401 .ai-tests-suggests {
402 display: inline-flex;
403 flex-direction: column;
404 gap: 6px;
405 margin-top: 10px;
406 font-size: 12px;
407 color: var(--text-muted);
408 text-align: left;
409 }
410 .ai-tests-suggests code {
411 font-family: var(--font-mono);
412 background: var(--bg-tertiary);
413 color: var(--text);
414 padding: 2px 7px;
415 border-radius: 4px;
416 font-size: 11.5px;
417 }
418
419 /* Loading shimmer skeleton — kept for future async modes. */
420 @keyframes ai-tests-shimmer {
421 0% { background-position: -300px 0; }
422 100% { background-position: 300px 0; }
423 }
424 .ai-tests-skeleton {
425 height: 12px;
426 border-radius: 4px;
6fd5915Claude427 background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(91,110,232,0.08) 50%, rgba(255,255,255,0.04) 100%);
e73e288Claude428 background-size: 600px 100%;
429 animation: ai-tests-shimmer 1.4s linear infinite;
430 margin-bottom: 8px;
431 }
432
433 /* Powered by Claude pill */
434 .ai-tests-poweredby {
435 margin-top: var(--space-5);
436 text-align: center;
437 }
438 .ai-tests-poweredby-pill {
439 display: inline-flex;
440 align-items: center;
441 gap: 6px;
442 padding: 4px 10px;
443 border-radius: 9999px;
6fd5915Claude444 background: rgba(91,110,232,0.08);
445 border: 1px solid rgba(91,110,232,0.22);
e73e288Claude446 color: var(--text-muted);
447 font-size: 11px;
448 letter-spacing: 0.04em;
449 text-transform: uppercase;
450 font-weight: 600;
451 }
452 .ai-tests-poweredby-pill .dot {
453 width: 6px; height: 6px;
454 border-radius: 9999px;
6fd5915Claude455 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
e73e288Claude456 }
457
458 :root[data-theme='light'] .ai-tests-panel {
459 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 8px 28px rgba(15,16,28,0.08);
460 }
461`;
462
463const COPY_SCRIPT = `
464 (function(){
465 var btn = document.querySelector('[data-ai-tests-copy]');
466 var src = document.querySelector('[data-ai-tests-code]');
467 var label = document.querySelector('[data-ai-tests-copy-label]');
468 if (!btn || !src || !label) return;
469 btn.addEventListener('click', function(){
470 var text = src.innerText || src.textContent || '';
471 var done = function(){
472 btn.classList.add('is-copied');
473 label.textContent = 'Copied';
474 setTimeout(function(){
475 btn.classList.remove('is-copied');
476 label.textContent = 'Copy';
477 }, 1800);
478 };
479 if (navigator.clipboard && navigator.clipboard.writeText) {
480 navigator.clipboard.writeText(text).then(done).catch(function(){
481 var ta = document.createElement('textarea');
482 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
483 document.body.appendChild(ta); ta.select();
484 try { document.execCommand('copy'); done(); } catch(e){}
485 document.body.removeChild(ta);
486 });
487 } else {
488 var ta = document.createElement('textarea');
489 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
490 document.body.appendChild(ta); ta.select();
491 try { document.execCommand('copy'); done(); } catch(e){}
492 document.body.removeChild(ta);
493 }
494 });
495 })();
496`;
497
1e162a8Claude498interface ResolvedRepo {
499 ownerId: string;
500 ownerUsername: string;
501 repoId: string;
502 repoName: string;
503}
504
505async function resolveRepo(
506 ownerName: string,
507 repoName: string
508): Promise<ResolvedRepo | null> {
509 try {
510 const [ownerRow] = await db
511 .select()
512 .from(users)
513 .where(eq(users.username, ownerName))
514 .limit(1);
515 if (!ownerRow) return null;
516 const [repoRow] = await db
517 .select()
518 .from(repositories)
519 .where(
520 and(
521 eq(repositories.ownerId, ownerRow.id),
522 eq(repositories.name, repoName)
523 )
524 )
525 .limit(1);
526 if (!repoRow) return null;
527 return {
528 ownerId: ownerRow.id,
529 ownerUsername: ownerRow.username,
530 repoId: repoRow.id,
531 repoName: repoRow.name,
532 };
533 } catch {
534 return null;
535 }
536}
537
538/**
539 * Shallow listing of source blobs reachable from the tree root and a couple
540 * of common top-level source directories. Kept intentionally small — the
541 * picker in the form is just a convenience, users can also type a path
542 * directly.
543 */
544async function listRepoFiles(
545 owner: string,
546 repo: string,
547 ref: string
548): Promise<string[]> {
549 const out: string[] = [];
550 let root: GitTreeEntry[] = [];
551 try {
552 root = await getTree(owner, repo, ref, "");
553 } catch {
554 root = [];
555 }
556 for (const entry of root) {
557 if (entry.type === "blob") {
558 out.push(entry.name);
559 }
560 }
561 const candidates = ["src", "lib", "app", "server", "pkg", "tests"];
562 for (const dir of candidates) {
563 const hit = root.find((e) => e.type === "tree" && e.name === dir);
564 if (!hit) continue;
565 let children: GitTreeEntry[] = [];
566 try {
567 children = await getTree(owner, repo, ref, dir);
568 } catch {
569 children = [];
570 }
571 for (const child of children) {
572 if (child.type === "blob") {
573 out.push(`${dir}/${child.name}`);
574 } else if (child.type === "tree") {
575 let grand: GitTreeEntry[] = [];
576 try {
577 grand = await getTree(owner, repo, ref, `${dir}/${child.name}`);
578 } catch {
579 grand = [];
580 }
581 for (const g of grand) {
582 if (g.type === "blob") {
583 out.push(`${dir}/${child.name}/${g.name}`);
584 }
585 }
586 }
587 }
588 if (out.length > 500) break;
589 }
590 return out;
591}
592
e73e288Claude593function TestsHero(props: { eyebrowExtra?: string }) {
594 return (
595 <section class="ai-tests-hero">
596 <div class="ai-tests-hero-orb" aria-hidden="true" />
597 <div class="ai-tests-hero-inner">
598 <div class="ai-tests-eyebrow">
599 <span class="pill" aria-hidden="true">
600 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
601 <polyline points="9 11 12 14 22 4" />
602 <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
603 </svg>
604 </span>
605 AI · gluecron · tests{props.eyebrowExtra ? ` · ${props.eyebrowExtra}` : ""}
606 </div>
607 <h1 class="ai-tests-title">
608 <span class="ai-tests-title-grad">Generate.</span>{" "}
609 <span style="color:var(--text-strong)">AI tests</span>
610 </h1>
611 <p class="ai-tests-sub">
612 Pick a source file and gluecron will ask Claude to draft a{" "}
613 <strong>failing</strong> test stub that exercises its public surface.
614 Treat the output as a starting-point — always review before
615 committing.
616 </p>
617 </div>
618 </section>
619 );
620}
621
1e162a8Claude622function renderPicker(
623 ownerName: string,
624 repoName: string,
625 allFiles: string[],
626 currentPath: string,
627 ref: string
628) {
629 const trimmed = allFiles.slice(0, 200);
630 return (
e73e288Claude631 <div class="ai-tests-form-card">
632 <form
633 method="post"
634 action={`/${ownerName}/${repoName}/ai/tests/generate`}
635 >
636 <input type="hidden" name="ref" value={ref} />
637 <div class="ai-tests-field">
638 <label class="ai-tests-field-label" for="ai-tests-path">
639 Source file
640 </label>
641 <input
642 id="ai-tests-path"
643 type="text"
644 name="path"
645 value={currentPath}
646 placeholder="src/lib/foo.ts"
647 required
648 aria-label="Source file"
649 class="ai-tests-input"
650 />
651 </div>
652 {trimmed.length > 0 && (
653 <div class="ai-tests-field">
654 <label class="ai-tests-field-label" for="ai-tests-pick">
655 …or pick from the repo
656 </label>
657 <select
658 id="ai-tests-pick"
659 name="pickPath"
660 onchange="this.form.path.value = this.value"
661 class="ai-tests-select"
662 >
663 <option value="">— select a file —</option>
664 {trimmed.map((f) => (
665 <option value={f} selected={f === currentPath}>
666 {f}
667 </option>
668 ))}
669 </select>
670 </div>
671 )}
672 <div>
673 <button type="submit" class="ai-tests-submit">
674 Generate tests
675 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
676 <line x1="5" y1="12" x2="19" y2="12" />
677 <polyline points="12 5 19 12 12 19" />
678 </svg>
679 </button>
680 </div>
681 </form>
682 </div>
1e162a8Claude683 );
684}
685
686aiTestsRoutes.get("/:owner/:repo/ai/tests", softAuth, async (c) => {
687 const { owner, repo } = c.req.param();
688 const user = c.get("user");
689 const path = (c.req.query("path") || "").trim();
690 const reqRef = (c.req.query("ref") || "").trim();
691 const format = (c.req.query("format") || "").trim().toLowerCase();
692
693 const resolved = await resolveRepo(owner, repo);
694 if (!resolved) {
695 return c.html(
696 <Layout title="Not Found" user={user}>
697 <div class="empty-state">
698 <h2>Repository not found</h2>
699 </div>
700 </Layout>,
701 404
702 );
703 }
704
705 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
706 const ref = reqRef || defaultBranch;
707 const sha = (await resolveRef(owner, repo, ref)) || ref;
708
709 // Raw format: just emit the generated code (for CLI use).
710 if (format === "raw") {
711 if (!path) {
712 return c.text("missing ?path=", 400, {
713 "Content-Type": "text/plain; charset=utf-8",
714 });
715 }
716 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
717 if (!blob || blob.isBinary) {
718 return c.text("file not found", 404, {
719 "Content-Type": "text/plain; charset=utf-8",
720 });
721 }
722 const language = detectLanguage(path);
723 const repoFiles = await listRepoFiles(owner, repo, sha);
724 const framework = detectTestFramework(language, repoFiles);
725 const result = await generateTestStub({
726 path,
727 language,
728 framework,
729 sourceCode: blob.content,
730 });
731 return c.text(result.code, 200, {
732 "Content-Type": contentTypeFor(result.language),
733 });
734 }
735
736 // HTML form mode.
737 const repoFiles = await listRepoFiles(owner, repo, sha);
738 const detectedLang = path ? detectLanguage(path) : "other";
739 const detectedFramework = detectTestFramework(detectedLang, repoFiles);
740
741 return c.html(
742 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
743 <RepoHeader owner={owner} repo={repo} />
744 <IssueNav owner={owner} repo={repo} active="code" />
e73e288Claude745 <div class="ai-tests-wrap">
746 <TestsHero />
747
1e162a8Claude748 {path && (
e73e288Claude749 <div class="ai-tests-detected">
750 <span>
751 Detected language: <code>{detectedLang}</code>
752 </span>
753 <span>
754 Framework: <code>{detectedFramework}</code>
755 </span>
756 </div>
757 )}
758
759 {renderPicker(owner, repo, repoFiles, path, ref)}
760
761 {!path && (
762 <div class="ai-tests-empty">
763 <div class="ai-tests-empty-orb" aria-hidden="true" />
764 <h3>No file picked yet</h3>
765 <p>
766 Choose any source file in this repo — Claude will draft a failing
767 test stub matching your detected framework.
768 </p>
769 <div class="ai-tests-suggests">
770 <div>
771 Try: <code>src/lib/auth.ts</code>
772 </div>
773 <div>
774 Or:&nbsp; <code>src/git/repository.ts</code>
775 </div>
776 </div>
777 </div>
1e162a8Claude778 )}
e73e288Claude779
780 <div class="ai-tests-poweredby">
781 <span class="ai-tests-poweredby-pill">
782 <span class="dot" aria-hidden="true" />
783 Powered by Claude
784 </span>
785 </div>
1e162a8Claude786 </div>
e73e288Claude787 <style dangerouslySetInnerHTML={{ __html: styles }} />
1e162a8Claude788 </Layout>
789 );
790});
791
792aiTestsRoutes.post(
793 "/:owner/:repo/ai/tests/generate",
794 requireAuth,
795 async (c) => {
796 const { owner, repo } = c.req.param();
797 const user = c.get("user")!;
798 const body = await c.req.parseBody().catch(() => ({} as Record<string, unknown>));
799 const path = String(body.path || "").trim();
800 const reqRef = String(body.ref || "").trim();
801
802 const resolved = await resolveRepo(owner, repo);
803 if (!resolved) {
804 return c.html(
805 <Layout title="Not Found" user={user}>
806 <div class="empty-state">
807 <h2>Repository not found</h2>
808 </div>
809 </Layout>,
810 404
811 );
812 }
813
814 if (!path) {
815 return c.redirect(`/${owner}/${repo}/ai/tests`);
816 }
817
818 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
819 const ref = reqRef || defaultBranch;
820 const sha = (await resolveRef(owner, repo, ref)) || ref;
821
822 const blob = await getBlob(owner, repo, sha, path).catch(() => null);
823 if (!blob || blob.isBinary) {
824 return c.html(
825 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
826 <RepoHeader owner={owner} repo={repo} />
827 <IssueNav owner={owner} repo={repo} active="code" />
e73e288Claude828 <div class="ai-tests-wrap">
829 <TestsHero eyebrowExtra="error" />
830 <div class="ai-tests-empty">
831 <div class="ai-tests-empty-orb" aria-hidden="true" />
832 <h3>Couldn't read that file</h3>
833 <p>
834 No such path at <code>{ref}</code>, or the file is binary.
835 </p>
836 <p>
837 <a href={`/${owner}/${repo}/ai/tests`}>Back to the picker</a>
838 </p>
839 </div>
840 <div class="ai-tests-poweredby">
841 <span class="ai-tests-poweredby-pill">
842 <span class="dot" aria-hidden="true" />
843 Powered by Claude
844 </span>
845 </div>
1e162a8Claude846 </div>
e73e288Claude847 <style dangerouslySetInnerHTML={{ __html: styles }} />
1e162a8Claude848 </Layout>,
849 404
850 );
851 }
852
853 const language = detectLanguage(path);
854 const repoFiles = await listRepoFiles(owner, repo, sha);
855 const framework = detectTestFramework(language, repoFiles);
856
857 const result = await generateTestStub({
858 path,
859 language,
860 framework,
861 sourceCode: blob.content,
862 });
863
864 const sourceHl = highlightCode(blob.content, path);
865 const testHl = highlightCode(result.code || "", result.suggestedPath);
866
867 const aiFailed = result.framework === "fallback" || !result.code;
868
869 return c.html(
870 <Layout title={`AI tests — ${owner}/${repo}`} user={user}>
871 <RepoHeader owner={owner} repo={repo} />
872 <IssueNav owner={owner} repo={repo} active="code" />
e73e288Claude873 <div class="ai-tests-wrap">
874 <TestsHero eyebrowExtra={path} />
1e162a8Claude875
e73e288Claude876 <div class="ai-tests-resulthead">
877 <div>
878 <h2>
879 Tests for <code>{path}</code>
880 </h2>
881 <div class="ai-tests-detected">
882 <span>
883 Language: <code>{language}</code>
884 </span>
885 <span>
886 Framework: <code>{aiFailed ? "fallback" : framework}</code>
887 </span>
888 <span>
889 Ref: <code>{ref}</code>
890 </span>
891 </div>
892 </div>
893 <form
894 method="post"
895 action={`/${owner}/${repo}/ai/tests/generate`}
896 style="display: inline;"
897 >
898 <input type="hidden" name="path" value={path} />
899 <input type="hidden" name="ref" value={ref} />
900 <button type="submit" class="ai-tests-regen">
901 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
902 <polyline points="23 4 23 10 17 10" />
903 <polyline points="1 20 1 14 7 14" />
904 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
905 </svg>
906 Regenerate
907 </button>
908 </form>
909 </div>
1e162a8Claude910
e73e288Claude911 <div class="ai-tests-warning flash-warning">
912 <strong>Review before committing.</strong> These tests are a
913 starting-point only — they are intentionally written to{" "}
914 <em>fail</em> so you are forced to supply real expected values.
915 Gluecron does not verify the behaviour is correct.
1e162a8Claude916 </div>
917
e73e288Claude918 {aiFailed && (
919 <div class="ai-tests-empty">
920 <div class="ai-tests-empty-orb" aria-hidden="true" />
921 <h3>AI backend unavailable</h3>
922 <p>
923 Couldn't generate a test stub. The AI backend may not be
924 configured, or the model returned an empty response. A suggested
925 path was still computed: <code>{result.suggestedPath}</code>.
926 </p>
927 </div>
928 )}
929
930 <section class="ai-tests-section">
931 <div class="ai-tests-panel">
932 <header class="ai-tests-panel-head">
933 <p class="ai-tests-panel-title">
934 <span class="ai-tests-panel-dot" aria-hidden="true" />
935 Source · <code>{path}</code>
936 </p>
937 </header>
938 <pre class="ai-tests-pre hljs">
939 <code>{raw(sourceHl.html)}</code>
940 </pre>
941 </div>
942 </section>
943
944 <section class="ai-tests-section">
945 <div class="ai-tests-panel">
946 <header class="ai-tests-panel-head">
947 <p class="ai-tests-panel-title">
948 <span class="ai-tests-panel-dot" aria-hidden="true" />
949 Suggested test · <code>{result.suggestedPath}</code>
950 </p>
951 <button
952 type="button"
953 class="ai-tests-copy"
954 id="copy-test-btn"
955 data-ai-tests-copy
956 data-test-code-id="ai-test-code"
957 aria-label="Copy test to clipboard"
958 >
959 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
960 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
961 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
962 </svg>
963 <span data-ai-tests-copy-label>Copy</span>
964 </button>
965 </header>
966 <pre
967 class="ai-tests-pre hljs"
968 id="ai-test-code"
969 data-ai-tests-code
970 >
971 <code>{result.code ? raw(testHl.html) : "// (no output)"}</code>
972 </pre>
973 </div>
974 </section>
975
976 <div class="ai-tests-poweredby">
977 <span class="ai-tests-poweredby-pill">
978 <span class="dot" aria-hidden="true" />
979 Powered by Claude
980 </span>
1e162a8Claude981 </div>
e73e288Claude982 </div>
983 <style dangerouslySetInnerHTML={{ __html: styles }} />
984 <script dangerouslySetInnerHTML={{ __html: COPY_SCRIPT }} />
1e162a8Claude985 </Layout>
986 );
987 }
988);
989
990export default aiTestsRoutes;