Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 93 additions & 2 deletions app/api/run-code/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { sql } from '@/lib/db';
import { readFileSync } from 'fs';
import path from 'path';

Expand Down Expand Up @@ -34,14 +35,22 @@ export async function POST(req: NextRequest) {
}, { status: 503 });
}

let body: { code: string; language: string; stdin?: string; exercise?: string };
let body: {
code: string;
language: string;
stdin?: string;
exercise?: string;
session_id?: string;
question_index?: number;
test_cases?: Array<{ input: string; expected_output: string }>;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const { code, language, stdin = '', exercise = '' } = body;
const { code, language, stdin = '', exercise = '', session_id, question_index, test_cases } = body;

if (!code || typeof code !== 'string') {
return NextResponse.json({ error: 'code is required' }, { status: 400 });
Expand Down Expand Up @@ -86,6 +95,22 @@ export async function POST(req: NextRequest) {

// Categorize errors for better user feedback
const categorized = categorizeRunResult(result);

// If test cases provided, run each one and evaluate correctness
if (test_cases && test_cases.length > 0) {
const testResults = await runTestCases(code, language, exercise, test_cases, RUNNER_URL);
const allPassed = testResults.every((r) => r.passed);

// Persist tests_passed on the submission if session context provided
if (session_id != null && question_index != null) {
await persistTestResult(session_id, question_index, allPassed, session.user.id).catch((err) => {
console.error('[run-code] Failed to persist test result:', err);
});
}

return NextResponse.json({ ...categorized, test_results: testResults, tests_passed: allPassed });
}

return NextResponse.json(categorized);
} catch (err) {
console.error('[run-code] fetch error:', (err as Error).message);
Expand Down Expand Up @@ -149,3 +174,69 @@ function categorizeRunResult(result: RunResult): RunResult {

return result;
}

interface TestCaseResult {
input: string;
expected: string;
actual: string;
passed: boolean;
error?: string;
}

async function runTestCases(
code: string,
language: string,
exercise: string,
testCases: Array<{ input: string; expected_output: string }>,
runnerUrl: string
): Promise<TestCaseResult[]> {
const results: TestCaseResult[] = [];

for (const tc of testCases) {
try {
const res = await fetchWithRetry(`${runnerUrl}/run`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(process.env.RUNNER_API_KEY ? { 'Authorization': `Bearer ${process.env.RUNNER_API_KEY}` } : {}),
},
body: JSON.stringify({ code, language, stdin: tc.input }),
});

if (!res.ok) {
results.push({ input: tc.input, expected: tc.expected_output, actual: '', passed: false, error: 'Runner error' });
continue;
}

const data = await res.json() as { stdout: string; stderr: string; exit_code: number; compile_output: string };
const actual = (data.stdout ?? '').trim();
const expected = tc.expected_output.trim();
results.push({ input: tc.input, expected, actual, passed: actual === expected });
} catch (err) {
results.push({ input: tc.input, expected: tc.expected_output, actual: '', passed: false, error: (err as Error).message });
}
}

return results;
}

async function persistTestResult(
sessionId: string,
questionIndex: number,
testsPassed: boolean,
userId: string
): Promise<void> {
// Verify session belongs to user
const sessionRows = await sql`
SELECT id FROM sessions WHERE id = ${sessionId} AND user_id = ${userId} LIMIT 1
`;
if (sessionRows.length === 0) return;

// Upsert submission with tests_passed result
await sql`
INSERT INTO submissions (session_id, question_index, response_text, submitted_at, tests_passed)
VALUES (${sessionId}, ${questionIndex}, '', now(), ${testsPassed})
ON CONFLICT (session_id, question_index)
DO UPDATE SET tests_passed = ${testsPassed}
`;
}
2 changes: 1 addition & 1 deletion app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function LoginForm() { const searchParams = useSearchParams();
setLoading(false);

if (!result || result.error) {
toast.error(result?.error, {
toast.error(result?.error ?? 'Sign in failed', {
style: {
border: 'red 1px solid',
background: '#ef44440f',
Expand Down
16 changes: 12 additions & 4 deletions app/participant/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default async function ExerciseCataloguePage() {
const userId = session.user.id;
let exercises: Exercise[] = [];
let fetchError = false;
let fetchErrorMessage = '';
let sessionStatusMap = new Map<string, 'active' | 'completed'>();
let sessionScoreMap = new Map<string, number>();
let sessionPassedMap = new Map<string, boolean | null>();
Expand All @@ -42,8 +43,14 @@ export default async function ExerciseCataloguePage() {
ORDER BY e.title
`;
exercises = rows as unknown as Exercise[];
} catch (err) {
fetchError = true;
fetchErrorMessage = (err as Error).message ?? String(err);
console.error('[participant/page] Failed to load exercises:', err);
}

if (exercises.length > 0) {
if (!fetchError && exercises.length > 0) {
try {
const ids = exercises.map((e) => e.id);
const sessions = await sql`
SELECT exercise_id, closed_at, score, passed,
Expand Down Expand Up @@ -84,9 +91,10 @@ export default async function ExerciseCataloguePage() {
sessionFailReasonsMap.set(eid, reasons);
}
}
} catch (err) {
// Session data failed — exercises still show, just without status
console.error('[participant/page] Failed to load session data:', err);
}
} catch {
fetchError = true;
}

return (
Expand All @@ -101,7 +109,7 @@ export default async function ExerciseCataloguePage() {

{fetchError && (
<div className="alert alert-error" style={{ marginBottom: '1rem' }}>
Failed to load exercises. Please refresh the page.
Failed to load exercises: {fetchErrorMessage || 'Unknown error'}. Please refresh the page.
</div>
)}

Expand Down
57 changes: 39 additions & 18 deletions app/participant/session/[id]/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ interface RunResult {
stderr: string;
compile_output: string;
exit_code: number | null;
error_category?: string;
error_message?: string;
tests_passed?: boolean;
test_results?: Array<{ input: string; expected: string; actual: string; passed: boolean }>;
}

interface EditEvent {
Expand Down Expand Up @@ -330,17 +334,30 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter
const res = await fetch('/api/run-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: codeRef.current, language, stdin, exercise: exerciseSlug }),
body: JSON.stringify({
code: codeRef.current,
language,
stdin,
exercise: exerciseSlug,
// Pass context so server can run test cases and persist result
session_id: sessionId,
question_index: questionIndex,
test_cases: testCases,
}),
});
const data = await res.json();
const runResult = res.ok ? data as RunResult : { stdout: '', stderr: data.error ?? 'Unknown error', compile_output: '', exit_code: 1 };
const runResult = res.ok ? data as RunResult : { stdout: '', stderr: data.error ?? 'Unknown error', compile_output: '', exit_code: 1, test_results: undefined as RunResult['test_results'] };
setResult(runResult);
// Surface compile errors as syntax warning
if (runResult.compile_output && runResult.compile_output.trim().length > 0) {
setSyntaxWarning('Compile error detected — fix before submitting');
} else {
setSyntaxWarning(null);
}
// If server ran test cases, show results inline
if (runResult.test_results) {
setTestResults(runResult.test_results);
}
} catch (err) {
setResult({ stdout: '', stderr: (err as Error).message, compile_output: '', exit_code: 1 });
} finally {
Expand All @@ -355,24 +372,28 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter
if (!testCases || testCases.length === 0) return;
setRunningTests(true);
setTestResults(null);
const results: Array<{ input: string; expected: string; actual: string; passed: boolean }> = [];
for (const tc of testCases) {
try {
const res = await fetch('/api/run-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: codeRef.current, language, stdin: tc.input, exercise: exerciseSlug }),
});
const data = await res.json();
const actual = (data.stdout ?? '').trim();
const expected = tc.expected_output.trim();
results.push({ input: tc.input, expected, actual, passed: actual === expected });
} catch {
results.push({ input: tc.input, expected: tc.expected_output, actual: 'Error', passed: false });
try {
const res = await fetch('/api/run-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: codeRef.current,
language,
exercise: exerciseSlug,
session_id: sessionId,
question_index: questionIndex,
test_cases: testCases,
}),
});
const data = await res.json();
if (data.test_results) {
setTestResults(data.test_results);
}
} catch (err) {
setTestResults(testCases.map((tc) => ({ input: tc.input, expected: tc.expected_output, actual: 'Error', passed: false })));
} finally {
setRunningTests(false);
}
setTestResults(results);
setRunningTests(false);
}

return (
Expand Down
85 changes: 53 additions & 32 deletions app/participant/session/[id]/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,47 +119,35 @@ export default function SessionView({ exerciseId }: { exerciseId: string }) {
// Local countdown — ticks every second, synced from server every 10s
const [localRemaining, setLocalRemaining] = useState<number | null>(null);

// Centralized focus tracking to prevent duplicates
// Centralized focus tracking — use visibilitychange only to avoid double-firing
useEffect(() => {
if (!sessionState || sessionClosed) return;

let focusLostAt: number | null = null;

const recordFocusLoss = () => {
if (focusLostAt !== null) return; // Already lost
focusLostAt = Date.now();
};

const recordFocusRegain = async () => {
if (focusLostAt === null) return; // Wasn't lost
const duration = Date.now() - focusLostAt;
focusLostAt = null;

try {
await fetch('/api/events/focus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionState.session_id, duration_ms: duration }),
});
} catch (err) {
console.error('Failed to record focus event:', err);
const handleVisibility = async () => {
if (document.visibilityState === 'hidden') {
// Tab hidden — record loss time
if (focusLostAt === null) focusLostAt = Date.now();
} else {
// Tab visible again — record the event
if (focusLostAt === null) return;
const duration = Date.now() - focusLostAt;
focusLostAt = null;
try {
await fetch('/api/events/focus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionState.session_id, duration_ms: duration }),
});
} catch (err) {
console.error('Failed to record focus event:', err);
}
}
};

const handleVisibility = () => {
if (document.visibilityState === 'hidden') recordFocusLoss();
else recordFocusRegain();
};

document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('blur', recordFocusLoss);
window.addEventListener('focus', recordFocusRegain);

return () => {
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('blur', recordFocusLoss);
window.removeEventListener('focus', recordFocusRegain);
};
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [sessionState, sessionClosed]);

const fetchSession = useCallback(async () => {
Expand Down Expand Up @@ -370,6 +358,39 @@ export default function SessionView({ exerciseId }: { exerciseId: string }) {
</button>
) : <div />}

{/* When viewing a non-current (skipped/previous) question — allow submitting it as final */}
{!isViewingCurrent && !sessionClosed && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
{advanceError && <span style={{ fontSize: 12, color: 'var(--red)' }}>{advanceError}</span>}
<button
onClick={async () => {
setAdvancing(true); setAdvanceError(null);
try {
const res = await fetch(`/api/submissions/${sessionState.session_id}/final`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question_index: activeIndex }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
if ((d as { error?: string }).error !== 'Submission is already final') {
setAdvanceError((d as { error?: string }).error ?? 'Failed to submit.');
return;
}
}
setViewingIndex(null);
await fetchSession();
} catch { setAdvanceError('Network error.'); }
finally { setAdvancing(false); }
}}
disabled={advancing}
className="btn btn-success"
>
{advancing ? 'Submitting…' : 'Submit This Answer ✓'}
</button>
</div>
)}

{isViewingCurrent && sessionState.current_question_index + 1 < sessionState.question_count && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '0.25rem' }}>
{advanceError && <span style={{ fontSize: 12, color: 'var(--red)' }}>{advanceError}</span>}
Expand Down
4 changes: 2 additions & 2 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ export const authOptions: NextAuthOptions = {
`;

const user = rows[0];
if (!user) return null;
if (!user) throw new Error('Invalid username or password');

const passwordMatch = await bcrypt.compare(
credentials.password,
user.password_hash as string
);
if (!passwordMatch) return null;
if (!passwordMatch) throw new Error('Incorrect password');

return {
id: user.id as string,
Expand Down
Loading
Loading