From db11116cb54abce65fdd9eb78fff7fa9c733c384 Mon Sep 17 00:00:00 2001 From: funnyboy-roks Date: Thu, 9 Oct 2025 08:16:53 -0500 Subject: [PATCH 1/5] initial changes --- client/app/competitor/page.tsx | 13 +- client/app/page.tsx | 4 +- client/components/TestResults.tsx | 229 ++++++++++++++---------------- client/lib/services/auth.ts | 12 +- client/lib/services/testing.ts | 180 +++++++++++++---------- client/lib/services/ws.ts | 143 +++++-------------- client/lib/types.ts | 49 ++++--- 7 files changed, 294 insertions(+), 336 deletions(-) diff --git a/client/app/competitor/page.tsx b/client/app/competitor/page.tsx index d7fd8a9..19471fe 100644 --- a/client/app/competitor/page.tsx +++ b/client/app/competitor/page.tsx @@ -31,7 +31,7 @@ import { isTauri } from '@tauri-apps/api/core'; import Link from 'next/link'; import { ipAtom } from '@/lib/services/api'; import { download } from '@/lib/tauri'; -import { TestResults } from '@/components/TestResults'; +import { TestResultsComp } from '@/components/TestResults'; import { useTesting } from '@/lib/services/testing'; import { Status } from '@/components/Status'; import { useAnnouncements } from '@/lib/services/announcement'; @@ -122,7 +122,7 @@ const EditorButtons = () => {
-
- {(loading || testResults) && ( + {testResults && ( }) = }; const TestResultsPanel = () => { - const { loading } = useTesting(); return (
- {loading ? ( - - ) : ( - - )} +
); }; diff --git a/client/app/page.tsx b/client/app/page.tsx index 6d1c4b4..c348b96 100644 --- a/client/app/page.tsx +++ b/client/app/page.tsx @@ -61,7 +61,7 @@ const Login = () => { try { const role = await login(username, password); if (role) { - router.replace(`/${role}`); + router.replace(`/${role.toLowerCase()}`); return; // don't reset loading state } else { setMessage('Invalid username or password'); @@ -244,7 +244,7 @@ export default function Home() { useEffect(() => { if (role) { - router.replace(`/${role}`); + router.replace(`/${role.toLowerCase()}`); } }, [router, role]); diff --git a/client/components/TestResults.tsx b/client/components/TestResults.tsx index d4e1d3d..3e7c229 100644 --- a/client/components/TestResults.tsx +++ b/client/components/TestResults.tsx @@ -7,9 +7,10 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ import { inlineDiffAtom } from '@/lib/competitor-state'; import AnsiConvert from 'ansi-to-html'; import { useAtom } from 'jotai'; -import { Check, CircleX, Clock, TriangleAlert } from 'lucide-react'; -import { SimpleOutput, Test, TestOutput } from '@/lib/types'; +import { Check, CircleX, Clock, Loader2, TriangleAlert, X } from 'lucide-react'; +import { Test, TestResults, TestResultState } from '@/lib/types'; import { useTesting } from '@/lib/services/testing'; +import { currQuestionAtom } from '@/lib/services/questions'; const c = new AnsiConvert(); const convertAnsi = (x: string): string => { @@ -20,24 +21,24 @@ const IncorrectOutput = ({ input, expected, actual, + useDiff = true, }: { input: string; expected: string; actual: string; + useDiff: boolean; }) => { const [inline, setInline] = useAtom(inlineDiffAtom); return ( <> -
-

- - Incorrect Output -

- - - - -
+ {useDiff && ( +
+ + + + +
+ )}
{input && (
@@ -45,145 +46,135 @@ const IncorrectOutput = ({
)} - -
- - ); -}; - -const GeneralError = ({ error, output }: { error?: string; output: SimpleOutput }) => { - return ( - <> - {error && ( -
-

{error}

-
- )} -
- {output.stdout && ( -
- Standard Output - -
- )} - {output.stderr && ( -
- Standard Error - -
+ {useDiff ? ( + + ) : ( + <> +
+ Expected Output + +
+
+ Actual Output + +
+ )} - {!output.stdout && !output.stderr &&

No output

}
); }; -const TestDetails = ({ output, test }: { output: TestOutput; test: Test }) => { - if (output.kind === 'pass') { - return ( -

- - Test Passed! -

- ); - } - - if (output.reason === 'timeout') { - return ( -

- - Solution timed out -

- ); - } - - if (output.reason === 'incorrect-output') { - return ; +const stylisedState = (state: TestResultState) => { + switch (state) { + case 'pass': return 'Pass'; + case 'runtime-fail': return 'Runtime Fail'; + case 'timed-out': return 'Timed Out'; + case 'incorrect-output': return 'Incorrect Output'; + default: throw new Error(`Unhandled stylisedState: '${state}'`); } - - return ; }; -const SingleResult = ({ - output, - test, - index, -}: { - output: TestOutput; - test: Test; - index: number; -}) => { +const OutputItem = ({ res, index }: { res: TestResults | null; index: number; }) => { + const [currentQuestion] = useAtom(currQuestionAtom); + return ( - <> - -

- Test Case {index + 1} -

-

- {output.kind.toUpperCase()} -

+ + +
+

+ Test Case {index + 1} +

+ { + res === null + ? + : res.state === 'pass' + ? + :

+ {stylisedState(res.state)} +

+ } +
- + {res !== null && ( + <> + {res.exitStatus !== 0 && ( +

+ Exit Status: {res.exitStatus} +

+ )} + + {res.stderr && ( +
+

Standard Error

+ +
+ )} + + )}
- +
); }; -export const TestResults = () => { +export const TestResultsComp = () => { const { testResults } = useTesting(); - if (!testResults?.kind) return null; - switch (testResults.kind) { - case 'individual': { + + if (testResults === null) throw new Error('Unreachable'); + + switch (testResults.resultState) { + case 'partial-results': { return ( <> c === null ? p : p + 1, 0) + / testResults.results.length + * 100 } color={ - testResults.submitKind === 'test' ? 'bg-in-progress/50' : 'bg-pass/50' + testResults.kind === 'test' ? 'bg-in-progress/50' : 'bg-pass/50' } /> - - {testResults.kind === 'individual' - ? testResults.tests?.map(([output, test], i) => ( - - - - )) - : []} + + {testResults.results?.map((res, i) => ( + + ))} ); } - case 'internal-error': { - return ( -

- - There was an error running your{' '} - {testResults.submitKind === 'test' ? 'test' : 'submission'}, please contact a - competition host. -

- ); - } case 'compile-fail': { return ( -
-

Solution failed to compile

- -
- ); - } - case 'other-error': { - return ( -

- Submission Attempt Failed:{' '} - {testResults.message} -

+ <> + +
+

Solution failed to compile

+

+ Exit Status: {testResults.compileExitStatus} +

+ {testResults.compileStdout && ( +
+

Standard Output

+ +
+ )} + {testResults.compileStderr && ( +
+

Standard Error

+ +
+ )} +
+ ); } - default: - throw 'unreachable'; } + }; diff --git a/client/lib/services/auth.ts b/client/lib/services/auth.ts index 22c6d90..ed5a3de 100644 --- a/client/lib/services/auth.ts +++ b/client/lib/services/auth.ts @@ -93,11 +93,11 @@ export const useLogin = () => { }; // if expectedErrors is provided, an error will be thrown with the status if it arrives -export const tryFetch = async ( +export const tryFetch = async ( url: string | URL, token: string, ip?: string, - init?: Partial & { item?: string }, + init?: Partial & { item?: string; bodyJson?: B }, expectedErrors?: number[] ): Promise => { const innitBruv = { ...init }; @@ -108,6 +108,14 @@ export const tryFetch = async ( }; } + if (innitBruv.bodyJson) { + innitBruv.body = JSON.stringify(innitBruv.bodyJson); + innitBruv.headers = { + ...innitBruv.headers, + 'Content-Type': 'application/json', + }; + } + const urlStr = url.toString(); const res = await fetch(urlStr.startsWith('http') ? url : `${ip}${url}`, innitBruv); if (res.ok) { diff --git a/client/lib/services/testing.ts b/client/lib/services/testing.ts index a2e0daf..4c0c8d4 100644 --- a/client/lib/services/testing.ts +++ b/client/lib/services/testing.ts @@ -1,100 +1,134 @@ import { toast } from '@/hooks'; import { atom, useAtom } from 'jotai'; -import { allQuestionsAtom, currQuestionIdxAtom, useSubmissionStates } from './questions'; +import { allQuestionsAtom, currQuestionAtom, currQuestionIdxAtom, useSubmissionStates } from './questions'; import { useWebSocket } from './ws'; -import { TestResults } from '../types'; +import { SubmissionHistory, TestResults } from '../types'; import { editorContentAtom, selectedLanguageAtom } from '../competitor-state'; import { ToastActionElement } from '@/components/ui/toast'; +import { tokenAtom, tryFetch } from './auth'; +import { ipAtom } from './api'; -const testsLoadingAtom = atom<'test' | 'submit' | null>(null); const testResultsAtom = atom< - (TestResults & { failed: number; passed: number; submitKind: 'test' | 'submit' }) | null + | null + | ( + | ({ resultState: 'compile-fail' } & SubmissionHistory) + | { resultState: 'partial-results'; results: (TestResults | null)[] } + | ({ resultState: 'test-complete'; results: TestResults[]; } & SubmissionHistory) + ) & { kind: 'test' | 'submission' } + >(null); export const useTesting = () => { - const [loading, setLoading] = useAtom(testsLoadingAtom); const [testResults, setTestResults] = useAtom(testResultsAtom); const { ws } = useWebSocket(); const [editorContent] = useAtom(editorContentAtom); const [currentQuestionIdx] = useAtom(currQuestionIdxAtom); - const [allQuestions] = useAtom(allQuestionsAtom); + const [currentQuestion] = useAtom(currQuestionAtom); const [selectedLanguage] = useAtom(selectedLanguageAtom); const { setCurrentState } = useSubmissionStates(); + const [token] = useAtom(tokenAtom); + const [ip] = useAtom(ipAtom); + + + const runTests = async (kind: 'test' | 'submission') => { + if (token === null) return; + if (ip === null) return; + if (selectedLanguage === undefined) return; + + setTestResults({ + resultState: 'partial-results', + kind, + results: currentQuestion.tests.map(() => null), + }); - const runTests = async () => { - setLoading('test'); - try { - const { results, failed, passed } = await ws.sendAndWait({ - kind: 'run-test', - language: selectedLanguage?.toLowerCase() || 'java', - problem: currentQuestionIdx, + const out = await tryFetch(`/questions/${currentQuestionIdx}/${kind}s`, token, ip, { + method: 'POST', + bodyJson: { + language: selectedLanguage.toLowerCase(), solution: editorContent, - }); - setTestResults({ ...results, failed, passed, submitKind: 'test' }); - } catch (ex) { - console.error('Error running tests:', ex); + }, + }); + if (out === null) { setTestResults(null); + return; } - setLoading(null); - }; - const submit = async (nextQuestion?: ToastActionElement) => { - setLoading('submit'); - try { - const res = await ws.sendAndWait({ - kind: 'submit', - language: selectedLanguage?.toLowerCase() || 'java', - problem: currentQuestionIdx, - solution: editorContent, - }); + const testId: string = out; + const wsPrefix = `tests-${currentQuestionIdx}`; + const removeWsListeners = () => { + ws.removeEvent('tests-error', `${wsPrefix}-error`); + ws.removeEvent('test-results', `${wsPrefix}-results`); + ws.removeEvent('tests-complete', `${wsPrefix}-complete`); + ws.removeEvent('tests-cancelled', `${wsPrefix}-cancelled`); + ws.removeEvent('tests-compile-fail', `${wsPrefix}-compile-fail`); + }; - if (res.kind === 'submit') { - const { passed, failed } = res; - setTestResults({ ...res.results, failed, passed, submitKind: 'submit' }); - if (res.results.kind === 'individual') { - if (failed === 0) { - toast({ - title: 'Submission Passed!', - variant: 'success', - action: - currentQuestionIdx < allQuestions.length - 1 - ? nextQuestion - : undefined, - }); - } else { - toast({ - title: `Your solution passed ${passed} out of ${failed + passed} tests.`, - description: - res.remainingAttempts !== null && - `You have ${res.remainingAttempts} ${res.remainingAttempts === 1 ? 'attempt' : 'attempts'} remaining`, - variant: 'destructive', - }); - } - } - setCurrentState((s) => ({ - ...s, - remainingAttempts: res.remainingAttempts, - })); - } else { + ws.registerEvent('tests-error', ({ id }) => { + if (id === testId) { + setTestResults(null); + toast({ + title: 'An unexpected error occurred while running your tests', + description: 'Please contact a competition host.', + variant: 'destructive', + }); + removeWsListeners(); + } + }, `${wsPrefix}-error`, false); + + ws.registerEvent('tests-cancelled', ({ id }) => { + if (id === testId) { + setTestResults(null); + removeWsListeners(); + toast({ + title: 'Your running tests have been cancelled', + }); + } + }, `${wsPrefix}-cancelled`, false); + + ws.registerEvent('tests-complete', (data) => { + if (data.id === testId) { setTestResults({ - kind: 'other-error', - message: res.message, - failed: 0, - passed: 0, - submitKind: 'submit', + resultState: 'test-complete', + kind, + ...data, }); + removeWsListeners(); } - } catch (ex) { - console.error('Error running submissions:', ex); - setTestResults(null); - } - setLoading(null); - }; + }, `${wsPrefix}-complete`, false); + + ws.registerEvent('tests-compile-fail', (data) => { + if (data.id === testId) { + setTestResults({ + resultState: 'compile-fail', + kind, + ...data, + }); + removeWsListeners(); + } + }, `${wsPrefix}-compile-fail`, false); - return { - loading, - runTests, - submit, - testResults, - clearTestResults: () => setTestResults(null), + ws.registerEvent('test-results', ({ id, results }) => { + if (id === testId) { + setTestResults(old => { + if (!old || old.resultState !== 'partial-results') { + console.error(`Recieved 'test-results' for '${id}' while results were in state '${old?.resultState}'`); + return old; + } + console.log('before', old.results); + const newResults = old === null ? [] : [...old.results]; + for (const result of results) { + newResults[result.index] = result; + } + console.log('after', newResults); + + return { + resultState: 'partial-results', + kind, + results: newResults, + }; + }); + } + }, `${wsPrefix}-results`, false); }; + + return { testResults, runTests }; }; diff --git a/client/lib/services/ws.ts b/client/lib/services/ws.ts index 2ddb455..e77a0c0 100644 --- a/client/lib/services/ws.ts +++ b/client/lib/services/ws.ts @@ -1,11 +1,12 @@ import { atom, useAtom } from 'jotai'; -import { TestResults, TestState } from '../types'; +import { CompileOutput, SubmissionHistory, TestResults, TestState } from '../types'; import { Announcement } from '../types'; import { toast, ToasterToast } from '@/hooks'; import { relativeTime } from '../utils'; import { TeamInfo } from './teams'; type EVENT_MAPPING = { + // Broadcast events 'game-paused': object; 'game-unpaused': { timeLeftInSeconds: number; @@ -27,50 +28,23 @@ type EVENT_MAPPING = { 'new-announcement': Announcement; 'team-connected': TeamInfo; 'team-disconnected': TeamInfo; -}; - -type WebsocketSend = - | { kind: 'run-test'; id: number; language: string; solution: string; problem: number } - | { kind: 'submit'; id: number; language: string; solution: string; problem: number }; -interface WebsocketError { - kind: 'error'; - id: number | null; - message: string; -} - -export interface WebsocketRes { - 'run-test': { - kind: 'test-results'; - id: number; - results: TestResults; - failed: number; - passed: number; - }; - submit: - | { - kind: 'submit'; - id: number; - results: TestResults; - failed: number; - passed: number; - remainingAttempts: number | null; - } - | WebsocketError; -} + // Private events + 'test-results': { id: string; results: TestResults[] }; + 'tests-error': { id: string; }; + 'tests-cancelled': { id: string; }; + 'tests-complete': { results: TestResults[] } & SubmissionHistory; + 'tests-compile-fail': SubmissionHistory; +}; type BroadcastEventKind = keyof EVENT_MAPPING; type BroadcastEventFn = (data: EVENT_MAPPING[K]) => void; -type BasaltBroadcastEvent = { kind: K } & EVENT_MAPPING[K]; - -type BasaltEvent = - | { kind: 'broadcast'; broadcast: { kind: BroadcastEventKind } & unknown } - | WebsocketRes[keyof WebsocketRes]; +type BasaltEvent = { kind: keyof EVENT_MAPPING } & EVENT_MAPPING[keyof EVENT_MAPPING]; class BasaltWSClient { - private broadcastHandlers: { + private eventHandlers: { [K in keyof EVENT_MAPPING]: { id: string | null; fn: (d: unknown) => void; @@ -84,14 +58,14 @@ class BasaltWSClient { 'new-announcement': [], 'team-connected': [], 'team-disconnected': [], + + 'tests-error': [], + 'tests-cancelled': [], + 'tests-complete': [], + 'tests-compile-fail': [], + 'test-results': [], }; private onCloseTasks: (() => void)[] = []; - private pendingTasks: { - id: number; - resolve: (t: WebsocketRes[keyof WebsocketRes]) => void; - reject: (reason: string) => void; - }[] = []; - private nextId = 0; private ws!: WebSocket; public ip: string | null = null; @@ -160,50 +134,13 @@ class BasaltWSClient { try { const msg = JSON.parse(m.data) as BasaltEvent; console.log('msg', msg); - switch (msg.kind) { - case 'broadcast': - { - const { kind, ...data } = msg.broadcast as BasaltBroadcastEvent< - typeof msg.broadcast.kind - >; - if (!(kind in this.broadcastHandlers)) return; + const { kind, ...data } = msg; + if (!(kind in this.eventHandlers)) return; - for (const { fn } of this.broadcastHandlers[kind]) { - fn(data); - } - } - break; - case 'error': - { - const { message, id } = msg; - toast({ - title: 'WebSocket Error', - description: message, - variant: 'destructive', - }); - if (id !== null) { - for (let i = this.pendingTasks.length; i--; ) { - const { id, reject } = this.pendingTasks[i]; - if (id === msg.id) { - reject(message); - } - } - } - } - break; - default: - { - if (Object.hasOwn(msg, 'id')) { - for (let i = this.pendingTasks.length; i--; ) { - const { id, resolve } = this.pendingTasks[i]; - if (id === msg.id) { - this.pendingTasks.splice(i, 1); - resolve(msg); - } - } - } - } - break; + for (let i = this.eventHandlers[kind].length; --i >= 0; ) { + const { fn, oneTime } = this.eventHandlers[kind][i]; + fn(data); + if (oneTime) this.eventHandlers[kind].splice(i, 1); } } catch (e) { console.error('Error processing message:', e); @@ -214,39 +151,30 @@ class BasaltWSClient { public registerEvent( eventName: K, fn: BroadcastEventFn, - id: string | null = null + id: string | null = null, + oneTime: boolean = false, ) { - const idx = this.broadcastHandlers[eventName].findIndex((h) => h.id === id); + const idx = this.eventHandlers[eventName].findIndex((h) => h.id === id); if (idx !== -1) { - this.broadcastHandlers[eventName][idx] = { + this.eventHandlers[eventName][idx] = { id, fn: fn as (data: unknown) => void, - oneTime: false, + oneTime, }; } else { - this.broadcastHandlers[eventName].push({ + this.eventHandlers[eventName].push({ id, fn: fn as (data: unknown) => void, - oneTime: false, + oneTime, }); } } - public sendAndWait, U extends WebsocketRes[T['kind']]>( - data: T - ): Promise { - const id = this.nextId++; - const send: WebsocketSend = { ...data, id }; - let resolve: ((u: WebsocketRes[keyof WebsocketRes]) => void) | undefined = undefined; - let reject: (() => void) | undefined = undefined; - const promise = new Promise((res, rej) => { - resolve = res as typeof resolve; - reject = rej; - }); - this.pendingTasks.push({ id, resolve: resolve!, reject: reject! }); - console.log('send', send); - this.ws.send(JSON.stringify(send)); - return promise; + public removeEvent(eventName: K, id: string): boolean { + const idx = this.eventHandlers[eventName].findIndex((h) => h.id === id); + if (idx === -1) return false; + this.eventHandlers[eventName].splice(idx, 1); + return true; } public closeConnection() { @@ -259,7 +187,6 @@ class BasaltWSClient { } private cleanup() { - this.pendingTasks.forEach(({ reject }) => reject('socket closed')); if (this.isOpen) { this.onCloseTasks.forEach((t) => t()); } diff --git a/client/lib/types.ts b/client/lib/types.ts index 5d3d541..05079db 100644 --- a/client/lib/types.ts +++ b/client/lib/types.ts @@ -16,24 +16,19 @@ export interface QuestionResponse { tests: Test[]; } -export interface SimpleOutput { +export type TestResultState = 'pass' | 'runtime-fail' | 'timed-out' | 'incorrect-output'; +export type SubmissionState = 'started' | 'finished' | 'cancelled' | 'failed'; +export type CompileResultState = 'no-compile' | 'success' | 'runtime-fail' | 'timed-out'; + +export interface TestResults { + index: number; + state: TestResultState; stdout: string; stderr: string; - status: number; + exitStatus: number; + // milliseconds + timeTaken: number; } -export type TestOutput = - | { kind: 'pass' } - | ({ kind: 'fail' } & ( - | { reason: 'timeout' } - | ({ reason: 'incorrect-output' } & SimpleOutput) - | ({ reason: 'crash' } & SimpleOutput) - )); - -export type TestResults = - | { kind: 'other-error'; message: string } - | { kind: 'internal-error' } - | ({ kind: 'compile-fail' } & SimpleOutput) - | { kind: 'individual'; tests: [TestOutput, Test][] }; export interface QuestionSubmissionState { state: TestState; @@ -41,14 +36,22 @@ export interface QuestionSubmissionState { } export interface SubmissionHistory { - id: string; - submitter: string; - time: string; - compile_fail: boolean; - code: string; - question_index: number; - score: number; - success: boolean; + id: string, + submitter: string, + time: string, + code: string, + questionIndex: number, + language: string, + compileResult: CompileResultState, + compileStdout: string, + compileStderr: string, + compileExitStatus: number, + test_only: boolean, + state: SubmissionState, + score: number, + success: boolean, + // milliseconds + timeTaken: number, } export interface LeaderboardEntry { From 21d2bb2549c55c9a638a9eea20b9cb2ffe782b28 Mon Sep 17 00:00:00 2001 From: funnyboy-roks Date: Fri, 10 Oct 2025 03:28:00 -0500 Subject: [PATCH 2/5] more progress --- client/app/competitor/page.tsx | 37 +++---- client/components/TestResults.tsx | 166 ++++++++++++++++++++--------- client/components/ui/accordion.tsx | 6 +- client/components/util.tsx | 22 ++++ client/lib/services/testing.ts | 41 ++++--- client/lib/services/ws.ts | 12 ++- client/lib/types.ts | 2 + client/lib/utils.ts | 21 ++++ client/tailwind.config.ts | 3 + 9 files changed, 220 insertions(+), 90 deletions(-) diff --git a/client/app/competitor/page.tsx b/client/app/competitor/page.tsx index 19471fe..4d5d675 100644 --- a/client/app/competitor/page.tsx +++ b/client/app/competitor/page.tsx @@ -31,7 +31,7 @@ import { isTauri } from '@tauri-apps/api/core'; import Link from 'next/link'; import { ipAtom } from '@/lib/services/api'; import { download } from '@/lib/tauri'; -import { TestResultsComp } from '@/components/TestResults'; +import TestResultsPanel from '@/components/TestResults'; import { useTesting } from '@/lib/services/testing'; import { Status } from '@/components/Status'; import { useAnnouncements } from '@/lib/services/announcement'; @@ -52,7 +52,7 @@ const EditorButtons = () => { const fileUploadRef = useRef(null); const [currQuestion] = useAtom(currQuestionAtom); const [ip] = useAtom(ipAtom); - const { loading, runTests, submit } = useTesting(); + const { pending, runTests } = useTesting(); const { currentState } = useSubmissionStates(); const [selectedLanguage, setSelectedLanguage] = useAtom(selectedLanguageAtom); const setCurrQuestionIdx = useSetAtom(currQuestionIdxAtom); @@ -84,11 +84,12 @@ const EditorButtons = () => { }; const submitSolution = () => { - submit( - setCurrQuestionIdx((n) => n + 1)}> - Next Question - - ); + runTests('submission'); + // submit( + // setCurrQuestionIdx((n) => n + 1)}> + // Next Question + // + // ); }; return ( @@ -122,8 +123,8 @@ const EditorButtons = () => {
-
- + {testResults && ( }) = collapsedSize={0} className="h-full" > - - - + )} @@ -240,14 +239,6 @@ const TabContent = ({ tab }: { tab: ExtractAtomValue }) = } }; -const TestResultsPanel = () => { - return ( -
- -
- ); -}; - const Summary = () => { const [questions] = useAtom(allQuestionsAtom); const { allStates } = useSubmissionStates(); diff --git a/client/components/TestResults.tsx b/client/components/TestResults.tsx index 3e7c229..6614143 100644 --- a/client/components/TestResults.tsx +++ b/client/components/TestResults.tsx @@ -1,23 +1,28 @@ import { Progress } from '@/components/ui/progress'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { Diff } from './Diff'; import { CodeBlock } from './util'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './ui/accordion'; +import * as Accordion from './ui/accordion'; +import * as Tabs from './ui/tabs'; import { inlineDiffAtom } from '@/lib/competitor-state'; import AnsiConvert from 'ansi-to-html'; import { useAtom } from 'jotai'; -import { Check, CircleX, Clock, Loader2, TriangleAlert, X } from 'lucide-react'; -import { Test, TestResults, TestResultState } from '@/lib/types'; +import { Check, Loader2, X } from 'lucide-react'; +import { TestResults, TestResultState } from '@/lib/types'; import { useTesting } from '@/lib/services/testing'; import { currQuestionAtom } from '@/lib/services/questions'; +import { useEffect, useState } from 'react'; +import { formatDuration } from '@/lib/utils'; const c = new AnsiConvert(); const convertAnsi = (x: string): string => { return c.toHtml(x); }; -const IncorrectOutput = ({ +const InputOutput = ({ input, expected, actual, @@ -39,7 +44,7 @@ const IncorrectOutput = ({ )} -
+
{input && (
Input @@ -68,7 +73,7 @@ const IncorrectOutput = ({ const stylisedState = (state: TestResultState) => { switch (state) { case 'pass': return 'Pass'; - case 'runtime-fail': return 'Runtime Fail'; + case 'runtime-fail': return 'Failed at Runtime'; case 'timed-out': return 'Timed Out'; case 'incorrect-output': return 'Incorrect Output'; default: throw new Error(`Unhandled stylisedState: '${state}'`); @@ -76,12 +81,13 @@ const stylisedState = (state: TestResultState) => { }; const OutputItem = ({ res, index }: { res: TestResults | null; index: number; }) => { + const { testResults } = useTesting(); const [currentQuestion] = useAtom(currQuestionAtom); return ( - - -
+ + +

Test Case {index + 1}

@@ -95,58 +101,120 @@ const OutputItem = ({ res, index }: { res: TestResults | null; index: number; })

}
- - - {res !== null && ( +
+ + {res !== null && testResults!.kind === 'test' && ( <> {res.exitStatus !== 0 && (

Exit Status: {res.exitStatus}

)} - {res.stderr && ( -
+

Standard Error

)} )} - - + + ); }; -export const TestResultsComp = () => { +export default function TestResultsComponent() { const { testResults } = useTesting(); + const [openAccordions, setOpenAccordions] = useState([]); + // This comonent is only rendered when testResults !== null if (testResults === null) throw new Error('Unreachable'); + useEffect(() => { + // Collapse open accordions if they are loading + setOpenAccordions(a => { + if (testResults.resultState === 'compile-fail') return []; + return a.filter(o => testResults.results[+o.split('-')[1]] !== null); + }) + }, [testResults]); + switch (testResults.resultState) { + case 'test-complete': case 'partial-results': { + const complete = testResults.resultState === 'test-complete'; + const compilerTab = complete && (testResults.compileStderr || testResults.compileStdout); + const completed = testResults.results.reduce((p, c) => c === null ? p : p + 1, 0); return ( <> c === null ? p : p + 1, 0) - / testResults.results.length - * 100 - } - color={ - testResults.kind === 'test' ? 'bg-in-progress/50' : 'bg-pass/50' - } + value={completed / testResults.cases * 100} + color={testResults.kind === 'test' ? 'bg-in-progress/50' : 'bg-pass/50'} /> - - {testResults.results?.map((res, i) => ( - - ))} - + + +
+ {testResults.kind === 'submission' && ( +

+ Score: {complete ? testResults.score : } +

+ )} + +

+ Passed: + { + complete ? testResults.passed : testResults.results.reduce((p, c) => c?.state === 'pass' ? p + 1 : p, 0) + } / { + complete ? testResults.passed + testResults.failed : testResults.cases + } +

+ +

+ Time Taken: {complete + ? formatDuration(testResults.timeTaken) + : + } +

+ + {compilerTab + && ( + + Tests + Compiler Output + + )} +
+ + + + {testResults.results?.map((res, i) => ( + + ))} + + + {complete && compilerTab && ( + + {testResults.compileStdout && ( +
+

Compiler Standard Output

+ +
+ )} + {testResults.compileStderr && ( +
+

Compiler Standard Error

+ +
+ )} +
+ )} +
+
); } @@ -154,24 +222,26 @@ export const TestResultsComp = () => { return ( <> -
-

Solution failed to compile

-

- Exit Status: {testResults.compileExitStatus} -

- {testResults.compileStdout && ( -
-

Standard Output

- -
- )} - {testResults.compileStderr && ( -
-

Standard Error

- -
- )} -
+ +
+

Solution failed to compile

+

+ Exit Status: {testResults.compileExitStatus} +

+ {testResults.compileStdout && ( +
+

Standard Output

+ +
+ )} + {testResults.compileStderr && ( +
+

Standard Error

+ +
+ )} +
+
); } diff --git a/client/components/ui/accordion.tsx b/client/components/ui/accordion.tsx index 04693fc..7ae40cc 100644 --- a/client/components/ui/accordion.tsx +++ b/client/components/ui/accordion.tsx @@ -18,8 +18,8 @@ AccordionItem.displayName = 'AccordionItem'; const AccordionTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { hideChevron?: boolean } +>(({ className, children, hideChevron = false, ...props }, ref) => ( {children} - + {!hideChevron && } )); diff --git a/client/components/util.tsx b/client/components/util.tsx index cd87f16..3f6c0f4 100644 --- a/client/components/util.tsx +++ b/client/components/util.tsx @@ -35,3 +35,25 @@ export const CodeBlock = ({ text, rawHtml = false }: { text: string; rawHtml?: b ) : (
{text}
); + +const DTF = Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'medium' }); +export const humanTime = (date: Date | string) => { + const date2 = typeof date === 'string' ? new Date(date) : date; + return DTF.format(date2); +}; + +const RTF = new Intl.RelativeTimeFormat(undefined, { style: 'long' }); +export const relativeTime = (date: Date | string) => { + const date2 = typeof date === 'string' ? new Date(date) : date; + const elapsedSecs = (date2.valueOf() - Date.now()) / 1000; + console.log(elapsedSecs); + if (Math.abs(elapsedSecs) < 60) { + return RTF.format(elapsedSecs, 'second'); + } + const elapsedMins = Math.trunc(elapsedSecs / 60); + if (Math.abs(elapsedMins) < 60) { + return RTF.format(elapsedMins, 'minute'); + } + const elapsedHours = Math.trunc(elapsedMins / 60); + return RTF.format(elapsedHours, 'hour'); +}; diff --git a/client/lib/services/testing.ts b/client/lib/services/testing.ts index 4c0c8d4..0779599 100644 --- a/client/lib/services/testing.ts +++ b/client/lib/services/testing.ts @@ -8,15 +8,22 @@ import { ToastActionElement } from '@/components/ui/toast'; import { tokenAtom, tryFetch } from './auth'; import { ipAtom } from './api'; +type ActiveKind = 'test' | 'submission'; const testResultsAtom = atom< | null | ( | ({ resultState: 'compile-fail' } & SubmissionHistory) - | { resultState: 'partial-results'; results: (TestResults | null)[] } - | ({ resultState: 'test-complete'; results: TestResults[]; } & SubmissionHistory) - ) & { kind: 'test' | 'submission' } + | { resultState: 'partial-results'; results: (TestResults | null)[]; cases: number; } + | ({ resultState: 'test-complete'; results: TestResults[]; cases: number; } & SubmissionHistory) + ) & { kind: ActiveKind } >(null); +const pendingAtom = atom(get => { + const x = get(testResultsAtom); + return x !== null && x.resultState === 'partial-results' + ? x.kind + : null; +}); export const useTesting = () => { const [testResults, setTestResults] = useAtom(testResultsAtom); const { ws } = useWebSocket(); @@ -27,32 +34,34 @@ export const useTesting = () => { const { setCurrentState } = useSubmissionStates(); const [token] = useAtom(tokenAtom); const [ip] = useAtom(ipAtom); + const [pending] = useAtom(pendingAtom); - - const runTests = async (kind: 'test' | 'submission') => { + const runTests = async (kind: ActiveKind) => { if (token === null) return; if (ip === null) return; if (selectedLanguage === undefined) return; - setTestResults({ - resultState: 'partial-results', - kind, - results: currentQuestion.tests.map(() => null), - }); - - const out = await tryFetch(`/questions/${currentQuestionIdx}/${kind}s`, token, ip, { + const out = await tryFetch<{ id: string; cases: number; }>(`/questions/${currentQuestionIdx}/${kind}s`, token, ip, { method: 'POST', bodyJson: { language: selectedLanguage.toLowerCase(), solution: editorContent, }, }); + if (out === null) { setTestResults(null); return; } - const testId: string = out; + setTestResults({ + resultState: 'partial-results', + kind, + results: Array.from({ length: out.cases }, () => null), + cases: out.cases, + }); + + const testId: string = out.id; const wsPrefix = `tests-${currentQuestionIdx}`; const removeWsListeners = () => { ws.removeEvent('tests-error', `${wsPrefix}-error`); @@ -89,9 +98,11 @@ export const useTesting = () => { setTestResults({ resultState: 'test-complete', kind, + cases: out.cases, ...data, }); removeWsListeners(); + setCurrentState(s => ({ ...s, remainingAttempts: data.remainingAttempts })); } }, `${wsPrefix}-complete`, false); @@ -113,6 +124,7 @@ export const useTesting = () => { console.error(`Recieved 'test-results' for '${id}' while results were in state '${old?.resultState}'`); return old; } + console.log('before', old.results); const newResults = old === null ? [] : [...old.results]; for (const result of results) { @@ -123,6 +135,7 @@ export const useTesting = () => { return { resultState: 'partial-results', kind, + cases: old.cases, results: newResults, }; }); @@ -130,5 +143,5 @@ export const useTesting = () => { }, `${wsPrefix}-results`, false); }; - return { testResults, runTests }; + return { testResults, runTests, pending }; }; diff --git a/client/lib/services/ws.ts b/client/lib/services/ws.ts index e77a0c0..2d9a1de 100644 --- a/client/lib/services/ws.ts +++ b/client/lib/services/ws.ts @@ -30,10 +30,18 @@ type EVENT_MAPPING = { 'team-disconnected': TeamInfo; // Private events - 'test-results': { id: string; results: TestResults[] }; + 'test-results': { + id: string; + // The number of completed test cases + completed: number; + results: TestResults[]; + }; 'tests-error': { id: string; }; 'tests-cancelled': { id: string; }; - 'tests-complete': { results: TestResults[] } & SubmissionHistory; + 'tests-complete': { + results: TestResults[]; + remainingAttempts: number | null; + } & SubmissionHistory; 'tests-compile-fail': SubmissionHistory; }; diff --git a/client/lib/types.ts b/client/lib/types.ts index 05079db..90592d0 100644 --- a/client/lib/types.ts +++ b/client/lib/types.ts @@ -49,6 +49,8 @@ export interface SubmissionHistory { test_only: boolean, state: SubmissionState, score: number, + passed: number, + failed: number, success: boolean, // milliseconds timeTaken: number, diff --git a/client/lib/utils.ts b/client/lib/utils.ts index 12c5eac..3cb5f3c 100644 --- a/client/lib/utils.ts +++ b/client/lib/utils.ts @@ -31,6 +31,27 @@ export const relativeTime = (date: Date | string | number) => { return RTF.format(elapsedHours, 'hour'); }; +export const formatDuration = (milliseconds: number): string => { + const result = []; + if (milliseconds >= 60 * 1000) { + const mins = Math.floor(milliseconds / 60 * 1000); + result.push(mins + 'm'); + milliseconds %= 60 * 1000; + } + + if (milliseconds >= 1000) { + const secs = Math.floor(milliseconds / 1000); + result.push(secs + 's'); + milliseconds %= 1000; + } + + if (milliseconds > 0) { + result.push(milliseconds + 'ms'); + } + + return result.join(' '); +}; + export const titleCase = (s: string): string => s[0].toUpperCase() + s.slice(1).toLowerCase(); export const randomName = () => { diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 2d0733a..0865d57 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -9,6 +9,9 @@ export default { './lib/*.{js,ts,jsx,tsx,mdx}', ], theme: { + fontFamily: { + mono: ['monospace'] + }, extend: { colors: { pass: '#03dd70', From a484cbfb2a34c045d7f0afb83e29c77bfe2d90f4 Mon Sep 17 00:00:00 2001 From: funnyboy-roks Date: Fri, 10 Oct 2025 04:16:29 -0500 Subject: [PATCH 3/5] show compile live --- client/app/competitor/page.tsx | 7 +- client/components/TestResults.tsx | 187 ++++++++++++++-------- client/components/ui/accordion.tsx | 4 +- client/lib/services/testing.ts | 240 ++++++++++++++++++----------- client/lib/services/ws.ts | 14 +- client/lib/types.ts | 34 ++-- client/lib/utils.ts | 2 +- client/tailwind.config.ts | 2 +- 8 files changed, 313 insertions(+), 177 deletions(-) diff --git a/client/app/competitor/page.tsx b/client/app/competitor/page.tsx index 4d5d675..da65b5e 100644 --- a/client/app/competitor/page.tsx +++ b/client/app/competitor/page.tsx @@ -123,7 +123,12 @@ const EditorButtons = () => {
-