diff --git a/client/app/competitor/page.tsx b/client/app/competitor/page.tsx index d7fd8a9..af9dcfa 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 TestResultsPanel from '@/components/TestResults'; import { useTesting } from '@/lib/services/testing'; import { Status } from '@/components/Status'; import { useAnnouncements } from '@/lib/services/announcement'; @@ -46,16 +46,18 @@ import { } from '@/components/ui/table'; import { ToastAction } from '@radix-ui/react-toast'; import { QuestionDetails } from '@/components/QuestionDetails'; +import { toast } from '@/hooks'; const EditorButtons = () => { const setEditorContent = useSetAtom(editorContentAtom); 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); + const [currQuestionIdx, setCurrQuestionIdx] = useAtom(currQuestionIdxAtom); + const [allQuestions] = useAtom(allQuestionsAtom); // Defaults to first language if no language selected useEffect(() => { @@ -83,12 +85,64 @@ const EditorButtons = () => { event.target.value = ''; }; - const submitSolution = () => { - submit( - setCurrQuestionIdx((n) => n + 1)}> - Next Question - - ); + const submitSolution = async () => { + const ret = await runTests('submission'); + if (ret === null) return; + const history = await ret.activeTest; + if (history.compileResult === 'runtime-fail' || history.compileResult === 'timed-out') { + toast({ + title: 'Compilation Error!', + variant: 'destructive', + description: currentState?.remainingAttempts + ? `Your solution could not be compiled! You have ${currentState?.remainingAttempts} ${currentState?.remainingAttempts === 1 ? 'attempt' : 'attempts'} left.` + : `Your solution could not be compiled!`, + }); + return; + } + + switch (history.state) { + case 'failed': + case 'started': + { + console.error( + `Got history in state '${history.state}' after testing "finished"` + ); + } + break; + case 'finished': + { + if (history.success) { + toast({ + title: 'Solution Passed!', + variant: 'success', + description: 'Great Work!', + action: + currQuestionIdx < allQuestions.length - 1 ? ( + setCurrQuestionIdx((n) => n + 1)} + > + Next Question + + ) : undefined, + }); + } else { + toast({ + title: `Your solution passed ${history.passed} out of ${history.failed + history.passed} tests.`, + description: + currentState!.remainingAttempts !== null && + `You have ${currentState!.remainingAttempts} ${currentState!.remainingAttempts === 1 ? 'attempt' : 'attempts'} remaining`, + variant: 'destructive', + }); + } + } + break; + case 'cancelled': + { + // we can ignore this + } + break; + } }; return ( @@ -122,8 +176,13 @@ const EditorButtons = () => {
-
- - {(loading || testResults) && ( + + {testResults && ( }) = collapsedSize={0} className="h-full" > - - - + )} @@ -240,19 +297,6 @@ const TabContent = ({ tab }: { tab: ExtractAtomValue }) = } }; -const TestResultsPanel = () => { - const { loading } = useTesting(); - return ( -
- {loading ? ( - - ) : ( - - )} -
- ); -}; - const Summary = () => { const [questions] = useAtom(allQuestionsAtom); const { allStates } = useSubmissionStates(); 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..a9acfdf 100644 --- a/client/components/TestResults.tsx +++ b/client/components/TestResults.tsx @@ -1,189 +1,311 @@ 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, TriangleAlert } from 'lucide-react'; -import { SimpleOutput, Test, TestOutput } 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, + useDiff = true, }: { input: string; expected: string; actual: string; + useDiff: boolean; }) => { const [inline, setInline] = useAtom(inlineDiffAtom); return ( <> -
-

- - Incorrect Output -

- - - - -
-
+ {useDiff && ( +
+ + + + +
+ )} +
{input && (
Input
)} - -
- - ); -}; - -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 -

- ); +const stylisedState = (state: TestResultState) => { + switch (state) { + case 'pass': + return 'Pass'; + 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}'`); } +}; - if (output.reason === 'incorrect-output') { - return ; - } +const OutputItem = ({ res, index }: { res: TestResults | null; index: number }) => { + const { testResults } = useTesting(); + const [currentQuestion] = useAtom(currQuestionAtom); - return ; + return ( + + +
+

+ Test Case {index + 1} +

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

+ {stylisedState(res.state)} +

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

+ Exit Status: {res.exitStatus} +

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

Standard Error

+ +
+ )} + + )} +
+
+ ); }; -const SingleResult = ({ - output, - test, - index, -}: { - output: TestOutput; - test: Test; - index: number; -}) => { +const CompilerOutput = ({ stdout, stderr }: { stdout: string; stderr: string }) => { return ( <> - -

- Test Case {index + 1} -

-

- {output.kind.toUpperCase()} -

-
- - - + {stdout && ( +
+

Standard Output

+ +
+ )} + {stderr && ( +
+

Standard Error

+ +
+ )} ); }; -export const TestResults = () => { +export default function TestResultsComponent() { const { testResults } = useTesting(); - if (!testResults?.kind) return null; - switch (testResults.kind) { - case 'individual': { + 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 + : testResults.compileOutput !== null && + (testResults.compileOutput.stderr || testResults.compileOutput.stdout); + const numCompleted = testResults.results.reduce((p, c) => (c === null ? p : p + 1), 0); return ( <> - - {testResults.kind === 'individual' - ? testResults.tests?.map(([output, test], 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) => ( + + ))} + + + {compilerTab && ( + + {testResults.resultState === 'partial-results' ? ( + + ) : ( + + )} + + )} +
+
); } - 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/components/ui/accordion.tsx b/client/components/ui/accordion.tsx index 04693fc..a29c6a3 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/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..f06b6e3 100644 --- a/client/lib/services/testing.ts +++ b/client/lib/services/testing.ts @@ -1,100 +1,257 @@ import { toast } from '@/hooks'; import { atom, useAtom } from 'jotai'; -import { allQuestionsAtom, currQuestionIdxAtom, useSubmissionStates } from './questions'; +import { 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'; + +type ActiveKind = 'test' | 'submission'; +type TestComplete = { results: TestResults[]; cases: number } & SubmissionHistory; +type PartialResults = { + results: (TestResults | null)[]; + cases: number; + compileOutput: { stdout: string; stderr: string } | null; +}; -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' } & PartialResults) + | ({ resultState: 'test-complete' } & TestComplete) + ) & { 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 [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 [selectedLanguage] = useAtom(selectedLanguageAtom); const { setCurrentState } = useSubmissionStates(); + const [token] = useAtom(tokenAtom); + const [ip] = useAtom(ipAtom); + const [pending] = useAtom(pendingAtom); - const runTests = async () => { - setLoading('test'); - try { - const { results, failed, passed } = await ws.sendAndWait({ - kind: 'run-test', - language: selectedLanguage?.toLowerCase() || 'java', - problem: currentQuestionIdx, - solution: editorContent, - }); - setTestResults({ ...results, failed, passed, submitKind: 'test' }); - } catch (ex) { - console.error('Error running tests:', ex); - setTestResults(null); - } - setLoading(null); - }; + const runTests = async ( + kind: ActiveKind + ): Promise<{ id: string; activeTest: Promise } | null> => { + if (token === null) return null; + if (ip === null) return null; + if (selectedLanguage === undefined) return null; - const submit = async (nextQuestion?: ToastActionElement) => { - setLoading('submit'); - try { - const res = await ws.sendAndWait({ - kind: 'submit', - language: selectedLanguage?.toLowerCase() || 'java', - problem: currentQuestionIdx, - solution: editorContent, - }); - - 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 { - setTestResults({ - kind: 'other-error', - message: res.message, - failed: 0, - passed: 0, - submitKind: 'submit', - }); + const out = await tryFetch<{ id: string; cases: number }>( + `/questions/${currentQuestionIdx}/${kind}s`, + token, + ip, + { + method: 'POST', + bodyJson: { + language: selectedLanguage.toLowerCase(), + solution: editorContent, + }, } - } catch (ex) { - console.error('Error running submissions:', ex); + ); + + if (out === null) { setTestResults(null); + return null; } - setLoading(null); + + let resolve: (result: SubmissionHistory) => void; + let reject: (reason: 'cancelled' | 'error') => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + setTestResults({ + resultState: 'partial-results', + kind, + results: Array.from({ length: out.cases }, () => null), + cases: out.cases, + compileOutput: null, + }); + + const testId: string = out.id; + 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`); + ws.removeEvent('tests-compiled', `${wsPrefix}-compiled`); + }; + + setCurrentState((old) => ({ + ...old, + state: old.state === 'not-attempted' ? 'in-progress' : old.state, + })); + + 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(); + reject('error'); + } + }, + `${wsPrefix}-error`, + false + ); + + ws.registerEvent( + 'tests-cancelled', + ({ id }) => { + if (id === testId) { + setTestResults(null); + removeWsListeners(); + toast({ + title: 'Your running tests have been cancelled', + }); + reject('cancelled'); + } + }, + `${wsPrefix}-cancelled`, + false + ); + + ws.registerEvent( + 'tests-complete', + (data) => { + if (data.id === testId) { + setTestResults({ + resultState: 'test-complete', + kind, + cases: out.cases, + ...data, + }); + + setCurrentState((old) => ({ + ...old, + state: + kind === 'test' + ? old.state === 'not-attempted' + ? 'in-progress' + : old.state + : data.results.every((t) => t.state === 'pass') + ? 'pass' + : 'fail', + })); + resolve(data); + removeWsListeners(); + setCurrentState((s) => ({ ...s, remainingAttempts: data.remainingAttempts })); + } + }, + `${wsPrefix}-complete`, + false + ); + + ws.registerEvent( + 'tests-compile-fail', + (data) => { + if (data.id === testId) { + setTestResults({ + resultState: 'compile-fail', + kind, + ...data, + }); + setCurrentState((old) => ({ + ...old, + state: + kind === 'test' + ? old.state === 'not-attempted' + ? 'in-progress' + : old.state + : 'fail', + })); + resolve(data); + removeWsListeners(); + } + }, + `${wsPrefix}-compile-fail`, + false + ); + + 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; + } + + const newResults = old === null ? [] : [...old.results]; + for (const result of results) { + newResults[result.index] = result; + } + + return { + resultState: 'partial-results', + kind, + cases: old.cases, + results: newResults, + compileOutput: old.compileOutput, + }; + }); + } + }, + `${wsPrefix}-results`, + false + ); + + ws.registerEvent( + 'tests-compiled', + ({ id, stdout, stderr }) => { + if (id === testId) { + setTestResults((old) => { + if (!old || old.resultState !== 'partial-results') { + console.error( + `Recieved 'tests-compiled' for '${id}' while results were in state '${old?.resultState}'` + ); + return old; + } + + return { + resultState: 'partial-results', + kind, + cases: old.cases, + results: old.results, + compileOutput: { stdout, stderr }, + }; + }); + } + }, + `${wsPrefix}-compiled`, + false + ); + + return { + id: testId, + activeTest: promise, + }; }; return { - loading, - runTests, - submit, testResults, - clearTestResults: () => setTestResults(null), + runTests, + pending, + resetTestResults: () => setTestResults(null), }; }; diff --git a/client/lib/services/ws.ts b/client/lib/services/ws.ts index 2ddb455..2ebab8b 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 { 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,34 @@ 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; + // Private events + 'test-results': { + id: string; + results: TestResults[]; }; - submit: - | { - kind: 'submit'; - id: number; - results: TestResults; - failed: number; - passed: number; - remainingAttempts: number | null; - } - | WebsocketError; -} + 'tests-compiled': { + id: string; + stdout: string; + stderr: string; + }; + 'tests-error': { id: string }; + 'tests-cancelled': { id: string }; + 'tests-complete': { + results: TestResults[]; + remainingAttempts: number | null; + } & 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 +69,15 @@ class BasaltWSClient { 'new-announcement': [], 'team-connected': [], 'team-disconnected': [], + + 'tests-error': [], + 'tests-cancelled': [], + 'tests-complete': [], + 'tests-compile-fail': [], + 'test-results': [], + 'tests-compiled': [], }; 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 +146,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 +163,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 +199,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..d63dd2a 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; @@ -44,11 +39,21 @@ export interface SubmissionHistory { id: string; submitter: string; time: string; - compile_fail: boolean; code: string; - question_index: number; + questionIndex: number; + language: string; + compileResult: CompileResultState; + compileStdout: string; + compileStderr: string; + compileExitStatus: number; + test_only: boolean; + state: SubmissionState; score: number; + passed: number; + failed: number; success: boolean; + // milliseconds + timeTaken: number; } export interface LeaderboardEntry { diff --git a/client/lib/utils.ts b/client/lib/utils.ts index 12c5eac..1c932c6 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..3f1bcf1 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',