From 8f722b9182fbbc50d396cfa2274c7e5271b32316 Mon Sep 17 00:00:00 2001 From: jvcByte Date: Thu, 11 Jun 2026 21:43:37 +0100 Subject: [PATCH 1/8] feat: batch test runner with test case editor and run tests button --- .../exercises/[id]/batch-test-run/route.ts | 153 ++++++++++++++ .../exercises/[id]/QuestionManager.tsx | 10 + .../exercises/[id]/TestCaseEditor.tsx | 189 ++++++++++++++++++ .../[id]/submissions/BatchRunButton.tsx | 84 ++++++++ .../exercises/[id]/submissions/page.tsx | 2 + 5 files changed, 438 insertions(+) create mode 100644 app/api/instructor/exercises/[id]/batch-test-run/route.ts create mode 100644 app/instructor/exercises/[id]/TestCaseEditor.tsx create mode 100644 app/instructor/exercises/[id]/submissions/BatchRunButton.tsx diff --git a/app/api/instructor/exercises/[id]/batch-test-run/route.ts b/app/api/instructor/exercises/[id]/batch-test-run/route.ts new file mode 100644 index 0000000..66b4063 --- /dev/null +++ b/app/api/instructor/exercises/[id]/batch-test-run/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; + +interface TestCase { + input: string; + expected_output: string; +} + +async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 2): Promise { + let lastError: Error | null = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const res = await fetch(url, options); + if (res.status >= 500 && attempt < maxRetries) { + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + continue; + } + return res; + } catch (err) { + lastError = err as Error; + if (attempt < maxRetries) await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } + } + throw lastError ?? new Error('Max retries exceeded'); +} + +async function runAgainstTestCases( + code: string, + language: string, + testCases: TestCase[], + runnerUrl: string, + apiKey?: string, +): Promise { + for (const tc of testCases) { + try { + const res = await fetchWithRetry(`${runnerUrl}/run`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ code, language, stdin: tc.input }), + }); + if (!res.ok) return false; + const data = await res.json() as { stdout: string; exit_code: number }; + if ((data.stdout ?? '').trim() !== tc.expected_output.trim()) return false; + } catch { + return false; + } + } + return true; +} + +export async function POST( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id || session.user.role !== 'instructor') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const RUNNER_URL = process.env.RUNNER_URL; + if (!RUNNER_URL) { + return NextResponse.json( + { error: 'Code execution is not configured. Set RUNNER_URL in your environment.' }, + { status: 503 } + ); + } + + const exerciseId = params.id; + + const exRows = await sql`SELECT id FROM exercises WHERE id = ${exerciseId} LIMIT 1`; + if (exRows.length === 0) { + return NextResponse.json({ error: 'Exercise not found' }, { status: 404 }); + } + + // Fetch all final submissions for questions that have test cases + const rows = await sql` + SELECT + sub.id AS submission_id, + sub.response_text, + sub.question_index, + q.test_cases, + q.language + FROM submissions sub + JOIN sessions s ON s.id = sub.session_id + JOIN questions q ON q.exercise_id = s.exercise_id + AND q.question_index = sub.question_index + WHERE s.exercise_id = ${exerciseId} + AND sub.is_final = true + AND sub.status != 'skipped' + AND q.test_cases IS NOT NULL + AND jsonb_array_length(q.test_cases) > 0 + `; + + let processed = 0; + let passed = 0; + let failed = 0; + + const results: { id: string; tests_passed: boolean }[] = []; + + for (const row of rows) { + processed++; + const code = (row.response_text as string) ?? ''; + const testCases = row.test_cases as TestCase[]; + const language = (row.language as string) ?? 'go'; + + let testsPassed = false; + if (code.trim().length === 0) { + testsPassed = false; + } else { + testsPassed = await runAgainstTestCases(code, language, testCases, RUNNER_URL, process.env.RUNNER_API_KEY); + } + + results.push({ id: row.submission_id as string, tests_passed: testsPassed }); + if (testsPassed) passed++; else failed++; + } + + // Bulk update tests_passed + for (const { id, tests_passed } of results) { + await sql`UPDATE submissions SET tests_passed = ${tests_passed} WHERE id = ${id}`; + } + + // Trigger recalculate-scores + let recalculated = 0; + let warning: string | undefined; + try { + const recalcRes = await fetch( + `${process.env.NEXTAUTH_URL ?? 'http://localhost:3000'}/api/instructor/exercises/${exerciseId}/recalculate-scores`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: _req.headers.get('cookie') ?? '', + }, + } + ); + if (recalcRes.ok) { + const data = await recalcRes.json() as { recalculated?: number }; + recalculated = data.recalculated ?? 0; + } else { + warning = 'Scores could not be recalculated automatically. Please use the Recalculate button.'; + } + } catch (err) { + console.error('[batch-test-run] recalculate-scores failed:', err); + warning = 'Scores could not be recalculated automatically. Please use the Recalculate button.'; + } + + return NextResponse.json({ processed, passed, failed, recalculated, ...(warning ? { warning } : {}) }); +} diff --git a/app/instructor/exercises/[id]/QuestionManager.tsx b/app/instructor/exercises/[id]/QuestionManager.tsx index 5d05f72..cbf0a23 100644 --- a/app/instructor/exercises/[id]/QuestionManager.tsx +++ b/app/instructor/exercises/[id]/QuestionManager.tsx @@ -4,6 +4,7 @@ import { useState, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import { toast } from 'sonner'; import { Plus, Trash2, Edit3, ChevronDown, ChevronUp, Save, X, Upload, FileText, RefreshCw, AlertTriangle } from 'lucide-react'; +import TestCaseEditor, { type TestCase } from './TestCaseEditor'; interface Question { id: string; @@ -12,6 +13,7 @@ interface Question { type: 'written' | 'code'; language: string; starter: string; + test_cases?: TestCase[] | null; } const CODE_EXERCISE_SLUGS = new Set(['ascii-art', 'ascii-art-web', 'go-reloaded']); @@ -263,6 +265,14 @@ export default function QuestionManager({ exerciseId, exerciseSlug, initialQuest
{q.starter}
)} + {q.type === 'code' && ( + setQuestions((qs) => qs.map((x) => x.id === q.id ? { ...x, test_cases: updated } : x))} + /> + )} )} diff --git a/app/instructor/exercises/[id]/TestCaseEditor.tsx b/app/instructor/exercises/[id]/TestCaseEditor.tsx new file mode 100644 index 0000000..373508d --- /dev/null +++ b/app/instructor/exercises/[id]/TestCaseEditor.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, Trash2, Save, X } from 'lucide-react'; +import { toast } from 'sonner'; + +export interface TestCase { + input: string; + expected_output: string; +} + +interface Props { + exerciseId: string; + questionId: string; + initialTestCases: TestCase[]; + onSaved?: (testCases: TestCase[]) => void; +} + +async function saveTestCases(exerciseId: string, questionId: string, testCases: TestCase[]): Promise { + const res = await fetch(`/api/instructor/exercises/${exerciseId}/questions/${questionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test_cases: testCases }), + }); + if (!res.ok) throw new Error(await res.text()); +} + +export default function TestCaseEditor({ exerciseId, questionId, initialTestCases, onSaved }: Props) { + const [testCases, setTestCases] = useState(initialTestCases ?? []); + const [newInput, setNewInput] = useState(''); + const [newExpected, setNewExpected] = useState(''); + const [inputError, setInputError] = useState(''); + const [saving, setSaving] = useState<'add' | number | null>(null); + const [editingIndex, setEditingIndex] = useState(null); + const [editDraft, setEditDraft] = useState({ input: '', expected_output: '' }); + + async function handleAdd() { + if (!newInput.trim()) { + setInputError('Input is required'); + return; + } + setInputError(''); + const updated = [...testCases, { input: newInput, expected_output: newExpected }]; + setSaving('add'); + try { + await saveTestCases(exerciseId, questionId, updated); + setTestCases(updated); + setNewInput(''); + setNewExpected(''); + onSaved?.(updated); + toast.success('Test case added'); + } catch { + toast.error('Failed to save test case'); + } finally { + setSaving(null); + } + } + + async function handleDelete(index: number) { + const updated = testCases.filter((_, i) => i !== index); + setSaving(index); + try { + await saveTestCases(exerciseId, questionId, updated); + setTestCases(updated); + onSaved?.(updated); + toast.success('Test case removed'); + } catch { + toast.error('Failed to remove test case'); + } finally { + setSaving(null); + } + } + + async function handleSaveEdit(index: number) { + if (!editDraft.input.trim()) { + toast.error('Input is required'); + return; + } + const updated = testCases.map((tc, i) => (i === index ? editDraft : tc)); + setSaving(index); + try { + await saveTestCases(exerciseId, questionId, updated); + setTestCases(updated); + setEditingIndex(null); + onSaved?.(updated); + toast.success('Test case updated'); + } catch { + toast.error('Failed to update test case'); + } finally { + setSaving(null); + } + } + + return ( +
+
+ Test Cases ({testCases.length}) +
+ + {/* Existing test cases */} + {testCases.length > 0 && ( +
+ {testCases.map((tc, i) => ( +
+ {editingIndex === i ? ( +
+
+
+ +