diff --git a/extension/src/assets/accepted_icon.png b/extension/src/assets/accepted_icon.png new file mode 100644 index 00000000..f67acc70 Binary files /dev/null and b/extension/src/assets/accepted_icon.png differ diff --git a/extension/src/assets/wrong_answer_icon.png b/extension/src/assets/wrong_answer_icon.png new file mode 100644 index 00000000..795ab1f9 Binary files /dev/null and b/extension/src/assets/wrong_answer_icon.png differ diff --git a/extension/src/components/panel/editor/EditorPanel.tsx b/extension/src/components/panel/editor/EditorPanel.tsx index ab9308cf..8e014542 100644 --- a/extension/src/components/panel/editor/EditorPanel.tsx +++ b/extension/src/components/panel/editor/EditorPanel.tsx @@ -1,6 +1,10 @@ import { UserDropDownMenu } from "@cb/components/navigator/menu/UserDropDownMenu"; import CreateRoomLoadingPanel from "@cb/components/panel/editor/CreateRoomLoadingPanel"; -import { CodeTab, TestTab } from "@cb/components/panel/editor/tab"; +import { + CodeTab, + TestResultTab, + TestTab, +} from "@cb/components/panel/editor/tab"; import { Tooltip } from "@cb/components/tooltip"; import { SkeletonWrapper } from "@cb/components/ui/SkeletonWrapper"; import { @@ -26,7 +30,8 @@ const EditorPanel = () => { const { selectedPeer, peers } = usePeers(); const { self } = useRoomData(); const roomStatus = useRoomStatus(); - const { selectTest, toggleCodeVisibility } = usePeerActions(); + const { selectTest, selectTestResult, toggleCodeVisibility } = + usePeerActions(); const { getLanguageExtension } = useLeetCodeActions(); const url = self?.url ?? ""; @@ -38,6 +43,11 @@ const EditorPanel = () => { const upperTabConfigs = React.useMemo(() => { const extension = getLanguageExtension(selectedPeer?.questions[url]?.code?.language) ?? ""; + + const activeTestResult = selectedPeer?.questions[url]?.testResults.find( + (testResult) => testResult.selected + ); + return [ { value: "code", @@ -57,8 +67,27 @@ const EditorPanel = () => { /> ), }, + { + value: "testResult", + label: "Test Result", + Icon: FlaskConical, + Content: ( + + ), + }, ]; - }, [selectedPeer, activeTest, selectTest, getLanguageExtension, url]); + }, [ + selectedPeer, + activeTest, + selectTest, + selectTestResult, + getLanguageExtension, + url, + ]); const hideCode = !selectedPeer?.questions[self?.url ?? ""]?.viewable; diff --git a/extension/src/components/panel/editor/tab/TestResultTab.tsx b/extension/src/components/panel/editor/tab/TestResultTab.tsx new file mode 100644 index 00000000..51f6f239 --- /dev/null +++ b/extension/src/components/panel/editor/tab/TestResultTab.tsx @@ -0,0 +1,134 @@ +import checkIcon from "@cb/assets/accepted_icon.png"; +import xIcon from "@cb/assets/wrong_answer_icon.png"; +import { SkeletonWrapper } from "@cb/components/ui/SkeletonWrapper"; +import { useRoomData } from "@cb/hooks/store"; +import { + Identifiable, + PeerState, + ResultAssignment, + SelectableTestResult, +} from "@cb/types"; +import React from "react"; +import { + CompileErrorResult, + InvalidTestCaseResult, + MemoryLimitExceededResult, + RuntimeErrorResult, + TestResultDisplay, + TestResultStatus, + TimeLimitExceededResult, +} from "./testResultComponents"; + +const STATUS_CONFIG: Record< + TestResultStatus, + { label: string; className: string } +> = { + Accepted: { label: "Accepted", className: "text-green-500" }, + "Wrong Answer": { label: "Wrong Answer", className: "text-red-500" }, + "Time Limit Exceeded": { + label: "Time Limit Exceeded", + className: "text-red-500", + }, + "Invalid Test Case": { + label: "Invalid Test Case", + className: "text-red-500", + }, + "Runtime Error": { label: "Runtime Error", className: "text-red-500" }, + "Compile Error": { label: "Compile Error", className: "text-red-500" }, + "Memory Limit Exceeded": { + label: "Memory Limit Exceeded", + className: "text-red-500", + }, +}; + +const renderTestResultContent = (activeTestResult: SelectableTestResult) => { + const status = activeTestResult.testResultStatus as TestResultStatus; + + switch (status) { + case "Accepted": + return ; + case "Wrong Answer": + return ; + case "Compile Error": + return ; + case "Runtime Error": + return ; + case "Time Limit Exceeded": + return ; + case "Memory Limit Exceeded": + return ; + case "Invalid Test Case": + return ; + default: + return null; + } +}; + +interface TestResultTabProps { + activePeer: Identifiable | undefined; + activeTestResult: SelectableTestResult | undefined; + selectTestResult: (index: number) => void; +} + +export const TestResultTab: React.FC = ({ + activePeer, + activeTestResult, + selectTestResult, +}) => { + const { self } = useRoomData(); + const testResults = activePeer?.questions[self?.url ?? ""]?.testResults ?? []; + + const getStatusBadge = (status: TestResultStatus) => { + const config = STATUS_CONFIG[status] ?? { + label: status, + className: "text-label-1 dark:text-dark-label-1", + }; + return {config.label}; + }; + + return ( + +
+
+
+ {activeTestResult && + getStatusBadge( + activeTestResult.testResultStatus as TestResultStatus + )} +
+
+ {(activeTestResult?.testResultStatus === "Accepted" || + activeTestResult?.testResultStatus === "Wrong Answer") && + testResults.map((test: SelectableTestResult, idx: number) => { + const passed = (test.testResult ?? []).every( + (r: ResultAssignment) => r.output === r.expected + ); + const selected = !!test.selected; + const baseClasses = + "relative inline-flex items-center whitespace-nowrap rounded-lg px-4 py-1 text-sm font-semibold focus:outline-none"; + const selectedClasses = + "bg-fill-3 dark:bg-dark-fill-3 hover:bg-fill-2 dark:hover:bg-dark-fill-2 hover:text-label-1 dark:hover:text-dark-label-1 text-label-1 dark:text-dark-label-1"; + const unselectedClasses = + "hover:bg-fill-2 dark:hover:bg-dark-fill-2 text-label-2 dark:text-dark-label-2 hover:text-label-1 dark:hover:text-dark-label-1 dark:bg-dark-transparent bg-transparent"; + return ( +
selectTestResult(idx)}> + +
+ ); + })} +
+
+ {activeTestResult && renderTestResultContent(activeTestResult)} +
+
+ ); +}; diff --git a/extension/src/components/panel/editor/tab/index.ts b/extension/src/components/panel/editor/tab/index.ts index 817b43f3..1e577d9a 100644 --- a/extension/src/components/panel/editor/tab/index.ts +++ b/extension/src/components/panel/editor/tab/index.ts @@ -1,2 +1,3 @@ export { CodeTab } from "@cb/components/panel/editor/tab/CodeTab"; +export { TestResultTab } from "@cb/components/panel/editor/tab/TestResultTab"; export { TestTab } from "@cb/components/panel/editor/tab/TestTab"; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/CompileErrorResult.tsx b/extension/src/components/panel/editor/tab/testResultComponents/CompileErrorResult.tsx new file mode 100644 index 00000000..49c471d1 --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/CompileErrorResult.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { ErrorMessage } from "./sharedComponents"; +import { TestResultContentProps } from "./types"; + +export const CompileErrorResult: React.FC = ({ + activeTestResult, +}) => { + return ( +
+ +
+ ); +}; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/InvalidTestCaseResult.tsx b/extension/src/components/panel/editor/tab/testResultComponents/InvalidTestCaseResult.tsx new file mode 100644 index 00000000..cfe36a4e --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/InvalidTestCaseResult.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { ErrorMessage } from "./sharedComponents"; +import { TestResultContentProps } from "./types"; + +export const InvalidTestCaseResult: React.FC = ({ + activeTestResult, +}) => { + return ( +
+
+ Case {activeTestResult.invalidTestCaseIdx} +
+ +
+ ); +}; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/MemoryLimitExceededResult.tsx b/extension/src/components/panel/editor/tab/testResultComponents/MemoryLimitExceededResult.tsx new file mode 100644 index 00000000..7b77c87c --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/MemoryLimitExceededResult.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { LastExecutedInput } from "./sharedComponents"; +import { TestResultContentProps } from "./types"; + +export const MemoryLimitExceededResult: React.FC = ({ + activeTestResult, +}) => { + return ( +
+ +
+ ); +}; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/RuntimeErrorResult.tsx b/extension/src/components/panel/editor/tab/testResultComponents/RuntimeErrorResult.tsx new file mode 100644 index 00000000..1825d316 --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/RuntimeErrorResult.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { ErrorMessage, LastExecutedInput } from "./sharedComponents"; +import { TestResultContentProps } from "./types"; + +export const RuntimeErrorResult: React.FC = ({ + activeTestResult, +}) => { + return ( +
+ + +
+ ); +}; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/TestResultDisplay.tsx b/extension/src/components/panel/editor/tab/testResultComponents/TestResultDisplay.tsx new file mode 100644 index 00000000..8fc134f2 --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/TestResultDisplay.tsx @@ -0,0 +1,35 @@ +import { ResultAssignment, SelectableTestResult } from "@cb/types"; +import React from "react"; +import { + ExpectedDisplay, + InputDisplay, + OutputDisplay, +} from "./sharedComponents"; + +interface TestResultDisplayProps { + activeTestResult: SelectableTestResult; +} + +export const TestResultDisplay: React.FC = ({ + activeTestResult, +}) => { + return ( +
+
+
+ {activeTestResult.testResult?.map( + (testResult: ResultAssignment, testIdx: number) => ( + + {Array.isArray(testResult.input) && ( + + )} + + + + ) + ) ?? null} +
+
+
+ ); +}; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/TimeLimitExceededResult.tsx b/extension/src/components/panel/editor/tab/testResultComponents/TimeLimitExceededResult.tsx new file mode 100644 index 00000000..19982569 --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/TimeLimitExceededResult.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { LastExecutedInput } from "./sharedComponents"; +import { TestResultContentProps } from "./types"; + +export const TimeLimitExceededResult: React.FC = ({ + activeTestResult, +}) => { + return ( +
+ +
+ ); +}; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/WrongAnswerResult.tsx b/extension/src/components/panel/editor/tab/testResultComponents/WrongAnswerResult.tsx new file mode 100644 index 00000000..69cc6e9b --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/WrongAnswerResult.tsx @@ -0,0 +1,57 @@ +import { ResultAssignment } from "@cb/types"; +import React from "react"; +import { TestResultContentProps } from "./types"; + +export const WrongAnswerResult: React.FC = ({ + activeTestResult, +}) => { + return ( +
+
+
+ {activeTestResult.testResult?.map( + (testResult: ResultAssignment, testIdx: number) => ( + +
+ Input +
+ {Array.isArray(testResult.input) && + testResult.input.map((input, idx) => ( + +
+
+ {input.variable + ? `${input.variable}=${input.value}` + : input.value} +
+
+
+ ))} + + {/* Output */} +
+ Output +
+
+
+ {testResult.output ?? "-"} +
+
+ + {/* Expected */} +
+ Expected +
+
+
+ {testResult.expected ?? "-"} +
+
+
+ ) + ) ?? null} +
+
+
+ ); +}; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/index.ts b/extension/src/components/panel/editor/tab/testResultComponents/index.ts new file mode 100644 index 00000000..ed295140 --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/index.ts @@ -0,0 +1,8 @@ +export { CompileErrorResult } from "./CompileErrorResult"; +export { InvalidTestCaseResult } from "./InvalidTestCaseResult"; +export { MemoryLimitExceededResult } from "./MemoryLimitExceededResult"; +export { RuntimeErrorResult } from "./RuntimeErrorResult"; +export * from "./sharedComponents"; +export { TestResultDisplay } from "./TestResultDisplay"; +export { TimeLimitExceededResult } from "./TimeLimitExceededResult"; +export * from "./types"; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/sharedComponents.tsx b/extension/src/components/panel/editor/tab/testResultComponents/sharedComponents.tsx new file mode 100644 index 00000000..f408e761 --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/sharedComponents.tsx @@ -0,0 +1,112 @@ +import { SelectableTestResult } from "@cb/types"; +import React from "react"; + +interface ErrorMessageProps { + message: string | undefined; +} + +export const ErrorMessage: React.FC = ({ message }) => ( +
+ {message} +
+); + +interface InputDisplayProps { + inputs: { variable?: string; value: string }[]; +} + +export const InputDisplay: React.FC = ({ inputs }) => ( + <> +
+ Input +
+ {inputs.map((input, idx) => ( +
+
+ {input.variable ? ( + <> +
{input.variable}=
+ {input.value} + + ) : ( + input.value + )} +
+
+ ))} + +); + +interface OutputDisplayProps { + output: string | undefined; +} + +export const OutputDisplay: React.FC = ({ output }) => ( + <> +
+ Output +
+
+
+ {output ?? "-"} +
+
+ +); + +interface ExpectedDisplayProps { + expected: string | undefined; +} + +export const ExpectedDisplay: React.FC = ({ + expected, +}) => ( + <> +
+ Expected +
+
+
+ {expected ?? "-"} +
+
+ +); + +interface LastExecutedInputProps { + activeTestResult: SelectableTestResult; +} + +export const LastExecutedInput: React.FC = ({ + activeTestResult, +}) => { + const lastIndex = activeTestResult.lastTestCaseRun ?? 0; + const inputs = activeTestResult.testResult[lastIndex]?.input ?? []; + + return ( + <> +
+ Last Executed Input +
+
+
+ {inputs.map((input, idx) => ( +
+ {input.variable ? ( + <> +
{input.variable}=
+ {input.value} + + ) : ( + input.value + )} +
+ ))} +
+
+ + ); +}; diff --git a/extension/src/components/panel/editor/tab/testResultComponents/types.ts b/extension/src/components/panel/editor/tab/testResultComponents/types.ts new file mode 100644 index 00000000..1c3cdabe --- /dev/null +++ b/extension/src/components/panel/editor/tab/testResultComponents/types.ts @@ -0,0 +1,14 @@ +import { SelectableTestResult } from "@cb/types"; + +export type TestResultStatus = + | "Accepted" + | "Wrong Answer" + | "Time Limit Exceeded" + | "Runtime Error" + | "Compile Error" + | "Memory Limit Exceeded" + | "Invalid Test Case"; + +export interface TestResultContentProps { + activeTestResult: SelectableTestResult; +} diff --git a/extension/src/constants/index.ts b/extension/src/constants/index.ts index 19244691..bdd09996 100644 --- a/extension/src/constants/index.ts +++ b/extension/src/constants/index.ts @@ -112,3 +112,9 @@ export const PANEL = { DEFAULT_WIDTH: 350, // px COLLAPSED_WIDTH: 40, // px }; + +export const TEST_RESULT_ERROR = { + "Runtime Error": "full_runtime_error", + "Compile Error": "full_compile_error", + "Invalid Test Case": "full_runtime_error", +}; diff --git a/extension/src/entrypoints/content.tsx b/extension/src/entrypoints/content.tsx index 32088c6a..d72f1258 100644 --- a/extension/src/entrypoints/content.tsx +++ b/extension/src/entrypoints/content.tsx @@ -11,6 +11,12 @@ export default defineContentScript({ matches: [URLS.ALL_PROBLEMS], runAt: "document_end", async main(ctx) { + const s = document.createElement("script"); + s.src = chrome.runtime.getURL("proxy.js"); + s.type = "text/javascript"; + s.onload = () => s.remove(); + document.documentElement.appendChild(s); + // Initialize controllers on startup getOrCreateControllers(); await injectScript("/router.js", { keepInDom: true }); diff --git a/extension/src/entrypoints/router.ts b/extension/src/entrypoints/router.ts index b93b10bb..0f6efbd3 100644 --- a/extension/src/entrypoints/router.ts +++ b/extension/src/entrypoints/router.ts @@ -3,6 +3,52 @@ import { defineUnlistedScript } from "wxt/utils/define-unlisted-script"; export default defineUnlistedScript(() => { console.log("Inject router"); + + (function () { + console.log("Inject proxy"); + if (window.__LC_FETCH_HOOKED__) return; + window.__LC_FETCH_HOOKED__ = true; + + const origFetch = window.fetch; + + window.fetch = async (...args) => { + const res = await origFetch(...args); + + try { + const regexTestResult = + /^https:\/\/leetcode\.com\/submissions\/detail\/runcode_[^/]+\/check\/$/; + + if (typeof res.url === "string" && regexTestResult.test(res.url)) { + const contentType = res.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const clone = res.clone(); + clone + .json() + .then((data) => { + window.postMessage( + { + source: "LC_HOOK", + url: res.url, + data: data, + type: "LC_HOOK", + }, + "*" + ); + }) + + .catch((e) => { + console.error("Failed to parse JSON from LeetCode response", e); + }); + } + } + } catch (e) { + console.error("Error in fetch hook:", e); + } + return res; + }; + })(); + window.addEventListener("message", (message: MessageEvent) => { if (message.data.action == undefined) { return; diff --git a/extension/src/services/controllers/MessageDispatcher.ts b/extension/src/services/controllers/MessageDispatcher.ts index e3496777..96c2b46f 100644 --- a/extension/src/services/controllers/MessageDispatcher.ts +++ b/extension/src/services/controllers/MessageDispatcher.ts @@ -63,6 +63,7 @@ export class MessageDispatcher { this.unsubscribers.push(this.subscribeToCodeEditor()); this.unsubscribers.push(this.subscribeToTestEditor()); + this.unsubscribers.push(this.subscribeToRunTest()); this.unsubscribers.push(this.subscribeToRtcOpen()); this.unsubscribers.push(this.subscribeToRtcMessage()); this.unsubscribers.push(this.subscribeToRoomChanges()); @@ -162,6 +163,21 @@ export class MessageDispatcher { }; } + private subscribeToRunTest(): () => void { + const messageHandler = async (event: MessageEvent) => { + if (event.data.type === "LC_HOOK") { + const testResult = await this.getTestResultPayload(event.data); + this.emitter.emit("rtc.send.message", { + message: testResult, + }); + } + }; + window.addEventListener("message", messageHandler); + return () => { + window.removeEventListener("message", messageHandler); + }; + } + private subscribeToSubmission() { // todo(nickbar01234): On teardown, we need to revert the changes const sendSuccessSubmission = () => { @@ -267,6 +283,19 @@ export class MessageDispatcher { break; } + case "testResults": { + const { testResults, url } = message; + this.roomStore.getState().actions.peers.update(from, { + questions: { + [url]: { + testResults, + status: QuestionProgressStatus.IN_PROGRESS, + }, + }, + }); + break; + } + case "event": { const { url, event } = message; if (event === EventType.SUBMIT_SUCCESS) { @@ -402,4 +431,13 @@ export class MessageDispatcher { .actions.room.getVariables(getNormalizedUrl(window.location.href)) ); } + + private getTestResultPayload(eventData: MessageEvent) { + return getTestResultsPayload( + this.roomStore + .getState() + .actions.room.getVariables(getNormalizedUrl(window.location.href)), + eventData.data + ); + } } diff --git a/extension/src/store/roomStore.ts b/extension/src/store/roomStore.ts index e7302f3c..553bf2fe 100644 --- a/extension/src/store/roomStore.ts +++ b/extension/src/store/roomStore.ts @@ -18,6 +18,7 @@ import { SelfState, Slug, TestCases, + TestResults, User, } from "@cb/types"; import { ChatMessage, DatabaseService } from "@cb/types/db"; @@ -53,6 +54,7 @@ export enum RoomStatus { interface UpdatePeerQuestionProgress { code?: CodeWithChanges; tests?: TestCases; + testResults?: TestResults; status?: QuestionProgressStatus; } @@ -116,6 +118,7 @@ interface RoomAction { remove: (ids: Id[]) => void; selectPeer: (id: string) => void; selectTest: (idx: number) => void; + selectTestResult: (idx: number) => void; toggleCodeVisibility: () => void; }; self: { @@ -181,10 +184,16 @@ const createRoomStore = (background: BackgroundProxy, appStore: AppStore) => { (acc, curr) => { const [url, data] = curr; const normalizedUrl = getNormalizedUrl(url); - const { code, tests: testsPayload, status } = data; + const { + code, + tests: testsPayload, + testResults: testResultsPayload, + status, + } = data; const questionProgressOrDefault = state.questions[normalizedUrl] ?? { code: undefined, tests: [], + testResults: [], status: QuestionProgressStatus.NOT_STARTED, viewable: false, }; @@ -210,6 +219,25 @@ const createRoomStore = (background: BackgroundProxy, appStore: AppStore) => { questionProgressOrDefault.tests = tests; } + if (testResultsPayload != undefined) { + const testResults = testResultsPayload.map((testResult) => ({ + ...testResult, + selected: false, + })); + const previousSelectedTest = + questionProgressOrDefault.testResults.findIndex( + (testResult) => testResult.selected + ); + const selectedTestIndex = + previousSelectedTest >= testResults.length + ? testResults.length - 1 + : Math.max(previousSelectedTest, 0); + if (testResults[selectedTestIndex]) { + testResults[selectedTestIndex].selected = true; + } + questionProgressOrDefault.testResults = testResults; + } + if (status != undefined) { questionProgressOrDefault.status = status; } @@ -234,11 +262,13 @@ const createRoomStore = (background: BackgroundProxy, appStore: AppStore) => { const setSelfProgressForCurrentUrl = async (question: Question) => { const code = await background.getUserCode({}); const { tests } = getTestsPayload(question.variables); + const { testResults } = getTestResultsPayload(question.variables); useRoom.getState().actions.self.update({ questions: { [question.url]: { code, tests, + testResults, }, }, }); @@ -573,6 +603,22 @@ const createRoomStore = (background: BackgroundProxy, appStore: AppStore) => { })); } }), + selectTestResult: (idx) => + set((state) => { + const active = getSelectedPeer(state.peers); + const progress = + state.peers[active?.id ?? ""].questions[ + getNormalizedUrl(window.location.href) + ]; + if (progress != undefined) { + progress.testResults = progress.testResults.map( + (test, i) => ({ + ...test, + selected: i === idx, + }) + ); + } + }), toggleCodeVisibility: () => set((state) => { const active = getSelectedPeer(state.peers); diff --git a/extension/src/types/global.ts b/extension/src/types/global.ts index a982e3d1..03d65893 100644 --- a/extension/src/types/global.ts +++ b/extension/src/types/global.ts @@ -10,5 +10,6 @@ declare global { push: (url: string) => Promise; }; }; + __LC_FETCH_HOOKED__?: boolean; } } diff --git a/extension/src/types/index.ts b/extension/src/types/index.ts index 7f92ef4e..fd72a579 100644 --- a/extension/src/types/index.ts +++ b/extension/src/types/index.ts @@ -15,14 +15,32 @@ interface Assignment { value: string; } +export interface ResultAssignment { + input: Assignment[]; + output: string; + expected: string; +} + export interface TestCase { test: Assignment[]; } +export interface TestResult { + testResultStatus: string; + errorMessage?: string; + lastTestCaseRun?: number; + invalidTestCaseIdx?: number; + testResult: ResultAssignment[]; +} + export type TestCases = TestCase[]; +export type TestResults = TestResult[]; + export interface SelectableTestCase extends TestCase, Selectable {} +export interface SelectableTestResult extends TestResult, Selectable {} + // Refactor post redux export interface LocalStorage { signIn: { diff --git a/extension/src/types/peers.ts b/extension/src/types/peers.ts index 73c78586..cb8ad4d6 100644 --- a/extension/src/types/peers.ts +++ b/extension/src/types/peers.ts @@ -2,8 +2,11 @@ import { Id, QuestionProgressStatus, SelectableTestCase, + SelectableTestResult, TestCase, TestCases, + TestResult, + TestResults, } from "."; import type { ServiceResponse } from "./services"; import { GenericMessage, Selectable } from "./utils"; @@ -24,6 +27,11 @@ interface PeerCodeMessage extends PeerGenericMessage, CodeWithChanges { action: "code"; } +interface PeerTestResultMessage extends PeerGenericMessage { + action: "testResults"; + testResults: TestResults; +} + interface PeerTestMessage extends PeerGenericMessage { action: "tests"; tests: TestCases; @@ -65,6 +73,7 @@ type PeerEventMessage = PeerEventSubmissionMesage | PeerEventAddQuestionMessage; export type PeerMessage = | PeerCodeMessage | PeerTestMessage + | PeerTestResultMessage | PeerEventMessage | RequestProgressMessage | SendProgressMessage; @@ -72,6 +81,7 @@ export type PeerMessage = interface PeerQuestionProgress { code?: CodeWithChanges; tests: SelectableTestCase[]; + testResults: SelectableTestResult[]; status: QuestionProgressStatus; viewable: boolean; } @@ -79,6 +89,7 @@ interface PeerQuestionProgress { interface SelfQuestionProgress { code?: MonacoCode; tests: TestCase[]; + testResults?: TestResult[]; status: QuestionProgressStatus; } diff --git a/extension/src/utils/messages.ts b/extension/src/utils/messages.ts index e4f5ceef..b3d1a671 100644 --- a/extension/src/utils/messages.ts +++ b/extension/src/utils/messages.ts @@ -1,4 +1,4 @@ -import { DOM } from "@cb/constants"; +import { DOM, TEST_RESULT_ERROR } from "@cb/constants"; import background from "@cb/services/background"; import { ExtractMessage, PeerMessage, Question } from "@cb/types"; import monaco from "monaco-editor"; @@ -17,6 +17,44 @@ export const getTestsPayload = ( }; }; +export const getTestResultsPayload = ( + variables: Question["variables"] | undefined, + testResults?: any +): ExtractMessage => { + if (!testResults) { + return { + action: "testResults", + testResults: [], + url: getNormalizedUrl(window.location.href), + }; + } + + // Because statusMessage of invalid test case is the same as runtime error although they are handled differently + const statusMsg: string = testResults.invalid_testcase + ? "Invalid Test Case" + : testResults.status_msg; + + return { + action: "testResults", + testResults: groupTestResults( + variables, + statusMsg, + testResults.code_answer, + testResults.case_idx + 1, + getTestsPayload(variables).tests, + testResults.code_answer?.slice(0, -1), + testResults.expected_code_answer?.slice(0, -1), + // To read the error message for invalid test case, runtime error, and compile error (empty for other test result statuses) + statusMsg === "Invalid Test Case" || + statusMsg === "Runtime Error" || + statusMsg === "Compile Error" + ? testResults[TEST_RESULT_ERROR[statusMsg]] + : "" + ), + url: getNormalizedUrl(window.location.href), + }; +}; + export const getCodePayload = async ( changes: Partial ): Promise> => { diff --git a/extension/src/utils/string.ts b/extension/src/utils/string.ts index a2ffdeea..0c2da751 100644 --- a/extension/src/utils/string.ts +++ b/extension/src/utils/string.ts @@ -1,5 +1,5 @@ -import { Question, TestCase } from "@cb/types"; import { Timestamp } from "firebase/firestore"; +import { Question, TestCase, TestCases, TestResult } from "@cb/types"; export const capitalize = (str: string | undefined) => str ? str.charAt(0).toUpperCase() + str.slice(1) : ""; @@ -29,6 +29,114 @@ export const groupTestCases = ( })); }; +export const groupTestResults = ( + variables: Question["variables"] | undefined, + testResultStatus: string, + codeAnswer: string[] = [], + invalidTestCaseIdx: number | undefined, + testInputs: TestCases, + testOutputs: string[] = [], + testExpectedOutputs: string[] = [], + testResultError: string = "" +): TestResult[] => { + const numCases = testInputs.length; + const varCount = variables?.count ?? 0; + const results: TestResult[] = []; + + const getLastRunIndex = () => + codeAnswer.findIndex((val) => val !== "0") === -1 + ? codeAnswer.length + : codeAnswer.findIndex((val) => val !== "0"); + + const getFirstInput = () => { + if (numCases === 0) return []; + const firstTest = testInputs[0]; + if (firstTest.test.length !== varCount) return []; + return firstTest.test.map((t) => ({ + variable: t.variable ?? "", + value: t.value, + })); + }; + + const createErrorResponse = ( + overrides: Partial = {} + ): TestResult => ({ + testResultStatus, + testResult: [{ input: getFirstInput(), output: "", expected: "" }], + ...overrides, + }); + + if ( + [ + "Time Limit Exceeded", + "Memory Limit Exceeded", + "Output Limit Exceeded", + ].includes(testResultStatus) + ) { + return [createErrorResponse({ lastTestCaseRun: getLastRunIndex() })]; + } + + if (testResultStatus === "Runtime Error") { + return [ + createErrorResponse({ + errorMessage: testResultError, + lastTestCaseRun: getLastRunIndex(), + }), + ]; + } + + if (testResultStatus === "Compile Error") { + return [createErrorResponse({ errorMessage: testResultError })]; + } + + if (testResultStatus === "Invalid Test Case") { + return [ + createErrorResponse({ + errorMessage: testResultError, + invalidTestCaseIdx, + }), + ]; + } + + // Determine overall status before looping + const allMatch = testOutputs.every( + (output, i) => output === testExpectedOutputs[i] + ); + const overallStatus = allMatch ? "Accepted" : "Wrong Answer"; + + // Only loop for Accepted cases + for (let i = 0; i < numCases; i++) { + const currentTest = testInputs[i]; + + if (currentTest.test.length !== varCount) { + console.error( + `Case ${i} does not match expected variable count`, + variables, + testInputs + ); + return [{ testResultStatus, testResult: [] }]; + } + + const input = currentTest.test.map((t) => ({ + variable: t.variable ?? "", + value: t.value, + })); + + results.push({ + testResultStatus: overallStatus, + testResult: [ + { + input, + output: testOutputs[i] ?? "", + expected: testExpectedOutputs[i] ?? "", + }, + ], + }); + } + + return results.length > 0 ? results : [{ testResultStatus, testResult: [] }]; +}; + export const safeJsonParse = (content: string): object => { try { return JSON.parse(content);