diff --git a/src/app/(web)/crm/task-templates/[id]/edit/page.tsx b/src/app/(web)/crm/task-templates/[id]/edit/page.tsx index 92e56ac6..aa7fa88e 100644 --- a/src/app/(web)/crm/task-templates/[id]/edit/page.tsx +++ b/src/app/(web)/crm/task-templates/[id]/edit/page.tsx @@ -9,6 +9,7 @@ import TestCaseEditor from '@/lib/components/core/TestCaseEditor'; import { Button } from '@/lib/components/ui/Button'; import TaskEditorSidebar from '@/lib/components/core/TaskEditorSidebar'; import Breadcrumbs from '@/lib/components/core/Breadcrumbs'; +import useTestRunner from '@/lib/hooks/useTestRunner'; export default function TaskTemplateEditPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); @@ -43,7 +44,12 @@ export default function TaskTemplateEditPage({ params }: { params: Promise<{ id: clearAllLanguages, handleLanguageSelectionChange, generateStubsForLanguages, + getEditorContent, } = useTaskTemplateEditPage(id); + const { runEditPageTests } = useTestRunner( + getEditorContent(), + languages ? languages[selectedLanguage].language : 'python' // UHHHH + ); if (isLoading) { return ( @@ -133,6 +139,7 @@ export default function TaskTemplateEditPage({ params }: { params: Promise<{ id: setPublicTestCases={setPublicTestCases} privateTestCases={privateTestCases} setPrivateTestCases={setPrivateTestCases} + runTests={runEditPageTests} isSaving={isSaving} /> diff --git a/src/app/api/runner/[taskTemplateId]/route.ts b/src/app/api/runner/[taskTemplateId]/route.ts new file mode 100644 index 00000000..e7ae19e8 --- /dev/null +++ b/src/app/api/runner/[taskTemplateId]/route.ts @@ -0,0 +1,40 @@ +import judge0Connector, { + type JudgeSubmissionRequestBody, +} from '@/lib/connectors/judge0.connector'; +import { SubmissionSchema } from '@/lib/schemas/submission.schema'; +import TaskTemplateService from '@/lib/services/task-template.service'; +// import { getSession } from '@/lib/utils/auth.utils'; +import { handleError } from '@/lib/utils/errors.utils'; +import { mapLanguageToJudge } from '@/lib/utils/language.utils'; +import { type NextRequest } from 'next/server'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ taskTemplateId: string }> } +) { + try { + const body = await request.json(); + const { taskTemplateId } = await params; + const parsed = SubmissionSchema.parse(body); + const languageId = mapLanguageToJudge(parsed.language); + const taskTemplate = await TaskTemplateService.getTaskTemplate( + taskTemplateId, + 'TODO: REPLACE THIS LATER LAITH WITH COOKIE' + ); + const tests = [...taskTemplate.privateTestCases, ...taskTemplate.publicTestCases]; + const formatted: JudgeSubmissionRequestBody[] = tests.map((test) => ({ + source_code: parsed.code, + language_id: languageId, + stdin: test.input, + expected_output: test.output, + })); + + const result = await judge0Connector.executeSubmissions(formatted); + return Response.json({ + data: result, + status: 200, + }); + } catch (err) { + return handleError(err); + } +} diff --git a/src/app/api/runner/route.ts b/src/app/api/runner/route.ts new file mode 100644 index 00000000..fd462659 --- /dev/null +++ b/src/app/api/runner/route.ts @@ -0,0 +1,32 @@ +import { handleError } from '@/lib/utils/errors.utils'; +import { assertRecruiterOrAbove } from '@/lib/utils/permissions.utils'; +import { type NextRequest } from 'next/server'; +import judge0Connector, { + type JudgeSubmissionRequestBody, +} from '@/lib/connectors/judge0.connector'; +import { TestSubmissionSchema } from '@/lib/schemas/submission.schema'; +import { mapLanguageToJudge } from '@/lib/utils/language.utils'; + +export async function POST(request: NextRequest) { + try { + await assertRecruiterOrAbove(request.headers); + const body = await request.json(); + const parsed = TestSubmissionSchema.parse(body); + const languageId = mapLanguageToJudge(parsed.language); + const formatted: JudgeSubmissionRequestBody[] = parsed.tests.map((test) => ({ + source_code: parsed.code, + language_id: languageId, + stdin: test.input, + expected_output: test.output, + })); + + const result = await judge0Connector.executeSubmissions(formatted); + + return Response.json({ + data: result, + status: 200, + }); + } catch (err) { + return handleError(err); + } +} diff --git a/src/lib/api/runner.ts b/src/lib/api/runner.ts new file mode 100644 index 00000000..bf448046 --- /dev/null +++ b/src/lib/api/runner.ts @@ -0,0 +1,46 @@ +import { + type SubmissionSchemaDTO, + type TestSubmissionSchemaDTO, +} from '@/lib/schemas/submission.schema'; +import { type JudgeResultRequestBody } from '@/lib/connectors/judge0.connector'; + +export async function runEditorSubmission( + submission: TestSubmissionSchemaDTO +): Promise { + const result = await fetch(`/api/runner`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submission), + }); + + const json = await result.json(); + + if (!result.ok) { + throw new Error(json.message); + } + + return json.data; +} + +export async function runAssessmentSubmission( + taskTemplateId: string, + submission: SubmissionSchemaDTO +): Promise { + const result = await fetch(`/api/runner/${taskTemplateId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submission), + }); + + const json = await result.json(); + + if (!result.ok) { + throw new Error(json.message); + } + + return json.data; +} diff --git a/src/lib/components/core/TestCaseEditor.tsx b/src/lib/components/core/TestCaseEditor.tsx index 62463995..e12bdf05 100644 --- a/src/lib/components/core/TestCaseEditor.tsx +++ b/src/lib/components/core/TestCaseEditor.tsx @@ -8,11 +8,18 @@ interface TestCaseEditorProps { privateTestCases: TestCaseDTO[]; setPrivateTestCases: React.Dispatch>; isSaving: boolean; + runTests: (tests: TestCaseDTO[]) => void; } export default function TestCaseEditor(props: TestCaseEditorProps) { - const { publicTestCases, setPublicTestCases, privateTestCases, setPrivateTestCases, isSaving } = - props; + const { + publicTestCases, + setPublicTestCases, + privateTestCases, + setPrivateTestCases, + isSaving, + runTests, + } = props; const { addTestCase, removeTestCase, @@ -36,6 +43,7 @@ export default function TestCaseEditor(props: TestCaseEditorProps) { onRemoveTestCase={removeTestCase} onTestCaseUpdate={updateTestCase} onToggleTestCaseVisibility={toggleTestCaseVisibility} + runTests={runTests} /> ); } diff --git a/src/lib/components/core/TestCasePanel.tsx b/src/lib/components/core/TestCasePanel.tsx index 4b85d159..e6ea50e4 100644 --- a/src/lib/components/core/TestCasePanel.tsx +++ b/src/lib/components/core/TestCasePanel.tsx @@ -26,6 +26,7 @@ type EditablePanelProps = TestCasePanelBaseProps & { value: string ) => void; onToggleTestCaseVisibility: (index: number, tab: TestTab) => void; + runTests: (tests: TestCaseDTO[]) => void; }; type ReadOnlyPanelProps = TestCasePanelBaseProps & { @@ -151,15 +152,28 @@ export default function TestCasePanel(props: TestCasePanelProps) { {activeLabel} ({activeTestCases.length}) {!props.readOnly && ( - +
+ + +
)} diff --git a/src/lib/connectors/judge0.connector.ts b/src/lib/connectors/judge0.connector.ts index 32a43255..31de9f2f 100644 --- a/src/lib/connectors/judge0.connector.ts +++ b/src/lib/connectors/judge0.connector.ts @@ -10,10 +10,12 @@ export interface JudgeSubmissionRequestBody { export interface JudgeResultRequestBody { stdout: string; - status_id: number; language_id: number; stderr: string; - status: object; + status: { + id: number; + description: string; + }; token: string; } @@ -35,7 +37,6 @@ class Judge0Connector { if (!body || body.length === 0) { throw new BadRequestException('At least one submission is required'); } - const url = `${process.env.JUDGE_URL}/submissions/batch?fields=${this.fields.join(',')}`; const requestBody = { submissions: [...body] }; const response = await fetch(url, { @@ -71,7 +72,10 @@ class Judge0Connector { throw new InternalServerException(`Judge0 API error: ${jsonResponse.error}`); } - return jsonResponse; + if (!('submissions' in jsonResponse)) { + throw new InternalServerException(`Submissions Not Returned`); + } + return jsonResponse['submissions']; } // create the provided submissions w/judge0 for running test cases @@ -114,7 +118,7 @@ class Judge0Connector { results.forEach((submissionResult, index) => { const isComplete = - submissionResult.status_id !== 1 && submissionResult.status_id !== 2; + submissionResult.status.id !== 1 && submissionResult.status.id !== 2; if (isComplete) { allResults.push(submissionResult); } else { @@ -146,7 +150,7 @@ class Judge0Connector { }; allResults.forEach((submissionResult) => { - switch (submissionResult.status_id) { + switch (submissionResult.status.id) { case 3: // Accepted categorizedResults.accepted.push(submissionResult); break; diff --git a/src/lib/hooks/useTestRunner.ts b/src/lib/hooks/useTestRunner.ts new file mode 100644 index 00000000..c46bf258 --- /dev/null +++ b/src/lib/hooks/useTestRunner.ts @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { runEditorSubmission, runAssessmentSubmission } from '@/lib/api/runner'; +import { type JudgeResultRequestBody } from '@/lib/connectors/judge0.connector'; +import { type ProgrammingLanguage } from '@/generated/prisma'; +import { type TestCaseDTO } from '@/lib/schemas/task-template.schema'; + +export default function useTestRunner(code: string, language: ProgrammingLanguage) { + const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + const [output, setOutput] = useState(); + + async function runEditPageTests(tests: TestCaseDTO[]) { + try { + setLoading(true); + const result = await runEditorSubmission({ + code, + language, + tests, + }); + setOutput(result); + console.warn(result); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + async function runAssessmentTests(taskTemplateId: string) { + try { + setLoading(true); + const result = await runAssessmentSubmission(taskTemplateId, { + code, + language, + }); + setOutput(result); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + return { + runAssessmentTests, + runEditPageTests, + error, + loading, + output, + }; +} diff --git a/src/lib/schemas/submission.schema.ts b/src/lib/schemas/submission.schema.ts new file mode 100644 index 00000000..b89b1008 --- /dev/null +++ b/src/lib/schemas/submission.schema.ts @@ -0,0 +1,18 @@ +import { ProgrammingLanguage } from '@/generated/prisma'; +import { testCaseSchema } from '@/lib/schemas/task-template.schema'; +import { z } from 'zod'; + +export const SubmissionSchema = z.object({ + code: z.string(), + language: z.enum(ProgrammingLanguage), + additionalTests: z.array(testCaseSchema).optional(), // May be useful for letting users create their own test cases in the future +}); + +export const TestSubmissionSchema = SubmissionSchema.omit({ + additionalTests: true, +}).extend({ + tests: z.array(testCaseSchema), +}); + +export type SubmissionSchemaDTO = z.infer; +export type TestSubmissionSchemaDTO = z.infer; diff --git a/src/lib/schemas/task-template.schema.ts b/src/lib/schemas/task-template.schema.ts index f0d7bdb2..f1185be9 100644 --- a/src/lib/schemas/task-template.schema.ts +++ b/src/lib/schemas/task-template.schema.ts @@ -4,8 +4,8 @@ import { TagSchema } from './tag.schema'; import { TaskTemplateLanguageSchema } from './task-template-language.schema'; export const testCaseSchema = z.object({ - input: z.string().min(1, 'Expected input required'), - output: z.string().min(1, 'Expected output required'), + input: z.string(), + output: z.string(), }); export const getTaskTemplateSchema = z.object({ diff --git a/src/lib/utils/language.utils.ts b/src/lib/utils/language.utils.ts index ec6435ab..48dfe855 100644 --- a/src/lib/utils/language.utils.ts +++ b/src/lib/utils/language.utils.ts @@ -205,3 +205,27 @@ export function generateCodeStub( return `// Code stub for ${language} is not available.`; } } + +export function mapLanguageToJudge(language: string): number { + try { + switch (language) { + case 'python': + return 100; + case 'javascript': + return 102; + case 'ruby': + return 72; + case 'typescript': + return 101; + case 'c': + return 103; + case 'cpp': + return 105; + default: + return -1; + } + } catch { + // figure this out later + throw new Error('language not foudn'); + } +}