Skip to content

Commit ac51dbf

Browse files
cherman23LOTaher
andauthored
[OA/CRM][Fullstack]: Test Runner (#269)
* feat: test runner * fixes * fix lint * pr review feedback * changes * lint --------- Co-authored-by: Laith Taher <lotaher04@gmail.com>
1 parent 7612f16 commit ac51dbf

11 files changed

Lines changed: 263 additions & 19 deletions

File tree

src/app/(web)/crm/task-templates/[id]/edit/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import TestCaseEditor from '@/lib/components/core/TestCaseEditor';
99
import { Button } from '@/lib/components/ui/Button';
1010
import TaskEditorSidebar from '@/lib/components/core/TaskEditorSidebar';
1111
import Breadcrumbs from '@/lib/components/core/Breadcrumbs';
12+
import useTestRunner from '@/lib/hooks/useTestRunner';
1213

1314
export default function TaskTemplateEditPage({ params }: { params: Promise<{ id: string }> }) {
1415
const { id } = use(params);
@@ -43,7 +44,12 @@ export default function TaskTemplateEditPage({ params }: { params: Promise<{ id:
4344
clearAllLanguages,
4445
handleLanguageSelectionChange,
4546
generateStubsForLanguages,
47+
getEditorContent,
4648
} = useTaskTemplateEditPage(id);
49+
const { runEditPageTests } = useTestRunner(
50+
getEditorContent(),
51+
languages ? languages[selectedLanguage].language : 'python' // UHHHH
52+
);
4753

4854
if (isLoading) {
4955
return (
@@ -133,6 +139,7 @@ export default function TaskTemplateEditPage({ params }: { params: Promise<{ id:
133139
setPublicTestCases={setPublicTestCases}
134140
privateTestCases={privateTestCases}
135141
setPrivateTestCases={setPrivateTestCases}
142+
runTests={runEditPageTests}
136143
isSaving={isSaving}
137144
/>
138145
</div>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import judge0Connector, {
2+
type JudgeSubmissionRequestBody,
3+
} from '@/lib/connectors/judge0.connector';
4+
import { SubmissionSchema } from '@/lib/schemas/submission.schema';
5+
import TaskTemplateService from '@/lib/services/task-template.service';
6+
// import { getSession } from '@/lib/utils/auth.utils';
7+
import { handleError } from '@/lib/utils/errors.utils';
8+
import { mapLanguageToJudge } from '@/lib/utils/language.utils';
9+
import { type NextRequest } from 'next/server';
10+
11+
export async function POST(
12+
request: NextRequest,
13+
{ params }: { params: Promise<{ taskTemplateId: string }> }
14+
) {
15+
try {
16+
const body = await request.json();
17+
const { taskTemplateId } = await params;
18+
const parsed = SubmissionSchema.parse(body);
19+
const languageId = mapLanguageToJudge(parsed.language);
20+
const taskTemplate = await TaskTemplateService.getTaskTemplate(
21+
taskTemplateId,
22+
'TODO: REPLACE THIS LATER LAITH WITH COOKIE'
23+
);
24+
const tests = [...taskTemplate.privateTestCases, ...taskTemplate.publicTestCases];
25+
const formatted: JudgeSubmissionRequestBody[] = tests.map((test) => ({
26+
source_code: parsed.code,
27+
language_id: languageId,
28+
stdin: test.input,
29+
expected_output: test.output,
30+
}));
31+
32+
const result = await judge0Connector.executeSubmissions(formatted);
33+
return Response.json({
34+
data: result,
35+
status: 200,
36+
});
37+
} catch (err) {
38+
return handleError(err);
39+
}
40+
}

src/app/api/runner/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { handleError } from '@/lib/utils/errors.utils';
2+
import { assertRecruiterOrAbove } from '@/lib/utils/permissions.utils';
3+
import { type NextRequest } from 'next/server';
4+
import judge0Connector, {
5+
type JudgeSubmissionRequestBody,
6+
} from '@/lib/connectors/judge0.connector';
7+
import { TestSubmissionSchema } from '@/lib/schemas/submission.schema';
8+
import { mapLanguageToJudge } from '@/lib/utils/language.utils';
9+
10+
export async function POST(request: NextRequest) {
11+
try {
12+
await assertRecruiterOrAbove(request.headers);
13+
const body = await request.json();
14+
const parsed = TestSubmissionSchema.parse(body);
15+
const languageId = mapLanguageToJudge(parsed.language);
16+
const formatted: JudgeSubmissionRequestBody[] = parsed.tests.map((test) => ({
17+
source_code: parsed.code,
18+
language_id: languageId,
19+
stdin: test.input,
20+
expected_output: test.output,
21+
}));
22+
23+
const result = await judge0Connector.executeSubmissions(formatted);
24+
25+
return Response.json({
26+
data: result,
27+
status: 200,
28+
});
29+
} catch (err) {
30+
return handleError(err);
31+
}
32+
}

src/lib/api/runner.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
type SubmissionSchemaDTO,
3+
type TestSubmissionSchemaDTO,
4+
} from '@/lib/schemas/submission.schema';
5+
import { type JudgeResultRequestBody } from '@/lib/connectors/judge0.connector';
6+
7+
export async function runEditorSubmission(
8+
submission: TestSubmissionSchemaDTO
9+
): Promise<JudgeResultRequestBody> {
10+
const result = await fetch(`/api/runner`, {
11+
method: 'POST',
12+
headers: {
13+
'Content-Type': 'application/json',
14+
},
15+
body: JSON.stringify(submission),
16+
});
17+
18+
const json = await result.json();
19+
20+
if (!result.ok) {
21+
throw new Error(json.message);
22+
}
23+
24+
return json.data;
25+
}
26+
27+
export async function runAssessmentSubmission(
28+
taskTemplateId: string,
29+
submission: SubmissionSchemaDTO
30+
): Promise<JudgeResultRequestBody> {
31+
const result = await fetch(`/api/runner/${taskTemplateId}`, {
32+
method: 'POST',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
},
36+
body: JSON.stringify(submission),
37+
});
38+
39+
const json = await result.json();
40+
41+
if (!result.ok) {
42+
throw new Error(json.message);
43+
}
44+
45+
return json.data;
46+
}

src/lib/components/core/TestCaseEditor.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@ interface TestCaseEditorProps {
88
privateTestCases: TestCaseDTO[];
99
setPrivateTestCases: React.Dispatch<React.SetStateAction<TestCaseDTO[]>>;
1010
isSaving: boolean;
11+
runTests: (tests: TestCaseDTO[]) => void;
1112
}
1213

1314
export default function TestCaseEditor(props: TestCaseEditorProps) {
14-
const { publicTestCases, setPublicTestCases, privateTestCases, setPrivateTestCases, isSaving } =
15-
props;
15+
const {
16+
publicTestCases,
17+
setPublicTestCases,
18+
privateTestCases,
19+
setPrivateTestCases,
20+
isSaving,
21+
runTests,
22+
} = props;
1623
const {
1724
addTestCase,
1825
removeTestCase,
@@ -36,6 +43,7 @@ export default function TestCaseEditor(props: TestCaseEditorProps) {
3643
onRemoveTestCase={removeTestCase}
3744
onTestCaseUpdate={updateTestCase}
3845
onToggleTestCaseVisibility={toggleTestCaseVisibility}
46+
runTests={runTests}
3947
/>
4048
);
4149
}

src/lib/components/core/TestCasePanel.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type EditablePanelProps = TestCasePanelBaseProps & {
2626
value: string
2727
) => void;
2828
onToggleTestCaseVisibility: (index: number, tab: TestTab) => void;
29+
runTests: (tests: TestCaseDTO[]) => void;
2930
};
3031

3132
type ReadOnlyPanelProps = TestCasePanelBaseProps & {
@@ -151,15 +152,28 @@ export default function TestCasePanel(props: TestCasePanelProps) {
151152
{activeLabel} ({activeTestCases.length})
152153
</span>
153154
{!props.readOnly && (
154-
<Button
155-
className="items-center gap-1 rounded-md px-3 py-1 text-sm"
156-
variant="secondary"
157-
onClick={() => props.onAddTestCase(activeTab)}
158-
disabled={props.isSaving}
159-
>
160-
<PlusIcon className="stroke-sarge-primary-500" height={18} width={18} />
161-
Add test
162-
</Button>
155+
<div className="flex items-center justify-between gap-2">
156+
<Button
157+
className="items-center gap-1 rounded-md px-3 py-1 text-sm"
158+
variant="secondary"
159+
onClick={() => props.onAddTestCase(activeTab)}
160+
disabled={props.isSaving}
161+
>
162+
<PlusIcon
163+
className="stroke-sarge-primary-500"
164+
height={18}
165+
width={18}
166+
/>
167+
Add test
168+
</Button>
169+
<Button
170+
className="items-center rounded-md px-3 py-1 text-sm"
171+
variant="primary"
172+
onClick={() => props.runTests(activeTestCases)}
173+
>
174+
Run
175+
</Button>
176+
</div>
163177
)}
164178
</div>
165179

src/lib/connectors/judge0.connector.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ export interface JudgeSubmissionRequestBody {
1010

1111
export interface JudgeResultRequestBody {
1212
stdout: string;
13-
status_id: number;
1413
language_id: number;
1514
stderr: string;
16-
status: object;
15+
status: {
16+
id: number;
17+
description: string;
18+
};
1719
token: string;
1820
}
1921

@@ -35,7 +37,6 @@ class Judge0Connector {
3537
if (!body || body.length === 0) {
3638
throw new BadRequestException('At least one submission is required');
3739
}
38-
3940
const url = `${process.env.JUDGE_URL}/submissions/batch?fields=${this.fields.join(',')}`;
4041
const requestBody = { submissions: [...body] };
4142
const response = await fetch(url, {
@@ -71,7 +72,10 @@ class Judge0Connector {
7172
throw new InternalServerException(`Judge0 API error: ${jsonResponse.error}`);
7273
}
7374

74-
return jsonResponse;
75+
if (!('submissions' in jsonResponse)) {
76+
throw new InternalServerException(`Submissions Not Returned`);
77+
}
78+
return jsonResponse['submissions'];
7579
}
7680

7781
// create the provided submissions w/judge0 for running test cases
@@ -114,7 +118,7 @@ class Judge0Connector {
114118

115119
results.forEach((submissionResult, index) => {
116120
const isComplete =
117-
submissionResult.status_id !== 1 && submissionResult.status_id !== 2;
121+
submissionResult.status.id !== 1 && submissionResult.status.id !== 2;
118122
if (isComplete) {
119123
allResults.push(submissionResult);
120124
} else {
@@ -146,7 +150,7 @@ class Judge0Connector {
146150
};
147151

148152
allResults.forEach((submissionResult) => {
149-
switch (submissionResult.status_id) {
153+
switch (submissionResult.status.id) {
150154
case 3: // Accepted
151155
categorizedResults.accepted.push(submissionResult);
152156
break;

src/lib/hooks/useTestRunner.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useState } from 'react';
2+
import { runEditorSubmission, runAssessmentSubmission } from '@/lib/api/runner';
3+
import { type JudgeResultRequestBody } from '@/lib/connectors/judge0.connector';
4+
import { type ProgrammingLanguage } from '@/generated/prisma';
5+
import { type TestCaseDTO } from '@/lib/schemas/task-template.schema';
6+
7+
export default function useTestRunner(code: string, language: ProgrammingLanguage) {
8+
const [error, setError] = useState<Error>();
9+
const [loading, setLoading] = useState<boolean>(false);
10+
const [output, setOutput] = useState<JudgeResultRequestBody>();
11+
12+
async function runEditPageTests(tests: TestCaseDTO[]) {
13+
try {
14+
setLoading(true);
15+
const result = await runEditorSubmission({
16+
code,
17+
language,
18+
tests,
19+
});
20+
setOutput(result);
21+
console.warn(result);
22+
} catch (err) {
23+
setError(err as Error);
24+
} finally {
25+
setLoading(false);
26+
}
27+
}
28+
29+
async function runAssessmentTests(taskTemplateId: string) {
30+
try {
31+
setLoading(true);
32+
const result = await runAssessmentSubmission(taskTemplateId, {
33+
code,
34+
language,
35+
});
36+
setOutput(result);
37+
} catch (err) {
38+
setError(err as Error);
39+
} finally {
40+
setLoading(false);
41+
}
42+
}
43+
44+
return {
45+
runAssessmentTests,
46+
runEditPageTests,
47+
error,
48+
loading,
49+
output,
50+
};
51+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ProgrammingLanguage } from '@/generated/prisma';
2+
import { testCaseSchema } from '@/lib/schemas/task-template.schema';
3+
import { z } from 'zod';
4+
5+
export const SubmissionSchema = z.object({
6+
code: z.string(),
7+
language: z.enum(ProgrammingLanguage),
8+
additionalTests: z.array(testCaseSchema).optional(), // May be useful for letting users create their own test cases in the future
9+
});
10+
11+
export const TestSubmissionSchema = SubmissionSchema.omit({
12+
additionalTests: true,
13+
}).extend({
14+
tests: z.array(testCaseSchema),
15+
});
16+
17+
export type SubmissionSchemaDTO = z.infer<typeof SubmissionSchema>;
18+
export type TestSubmissionSchemaDTO = z.infer<typeof TestSubmissionSchema>;

src/lib/schemas/task-template.schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { TagSchema } from './tag.schema';
44
import { TaskTemplateLanguageSchema } from './task-template-language.schema';
55

66
export const testCaseSchema = z.object({
7-
input: z.string().min(1, 'Expected input required'),
8-
output: z.string().min(1, 'Expected output required'),
7+
input: z.string(),
8+
output: z.string(),
99
});
1010

1111
export const getTaskTemplateSchema = z.object({

0 commit comments

Comments
 (0)