diff --git a/extension/src/components/panel/editor/EditorPanel.tsx b/extension/src/components/panel/editor/EditorPanel.tsx index b866e6c5..90220bd3 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 { useCopyCode } from "@cb/hooks/editor"; @@ -27,7 +31,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 copyCode = useCopyCode(); @@ -40,6 +45,16 @@ const EditorPanel = () => { const upperTabConfigs = React.useMemo(() => { const extension = getLanguageExtension(selectedPeer?.questions[url]?.code?.language) ?? ""; + + const activeTestResult = selectedPeer?.questions[url]?.testResults.find( + (testResult) => testResult.selected + ); + + const allTestResult = selectedPeer?.questions[url]?.testResults ?? []; + const generalResult = allTestResult.every( + (r) => r.testResult[0].output === r.testResult[0].expected + ); + return [ { value: "code", @@ -59,8 +74,28 @@ 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..b625d7d5 --- /dev/null +++ b/extension/src/components/panel/editor/tab/TestResultTab.tsx @@ -0,0 +1,118 @@ +import { SkeletonWrapper } from "@cb/components/ui/SkeletonWrapper"; +import { useRoomData } from "@cb/hooks/store"; +import { Identifiable, PeerState, SelectableTestResult } from "@cb/types"; +import React from "react"; + +interface TestResultTabProps { + activePeer: Identifiable | undefined; + activeTestResult: SelectableTestResult | undefined; + selectTestResult: (index: number) => void; + generalResult: boolean; +} + +export const TestResultTab: React.FC = ({ + activePeer, + activeTestResult, + selectTestResult, + generalResult, +}) => { + const { self } = useRoomData(); + return ( + +
+
+
+ {activeTestResult ? ( + <> + {generalResult ? ( + Accepted + ) : ( + Wrong Answer + )} + + ) : null} +
+
+ {(activePeer?.questions[self?.url ?? ""]?.testResults ?? []).map( + (test, idx) => { + const passed = (test.testResult ?? []).every( + (r: any) => r.output === r.expected + ); + const selected = !!test.selected; + return ( +
selectTestResult(idx)}> + {selected ? ( + passed ? ( + + ) : ( + + ) + ) : passed ? ( + + ) : ( + + )} +
+ ); + } + )} +
+
+
+
+
+ {activeTestResult?.testResult.map((assignment, idx) => ( + + {/* Input / Value */} +
+ Input +
+ {Object.keys(assignment.input ?? {}).map( + (variable: string) => ( +
+
+ {variable} = {assignment.input[variable]} +
+
+ ) + )} + + {/* Output */} +
+ Output +
+
+
+ {assignment.output ?? "-"} +
+
+ + {/* Expected */} +
+ Expected +
+
+
+ {assignment.expected ?? "-"} +
+
+
+ )) ?? null} +
+
+
+
+
+ ); +}; 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/constants/index.ts b/extension/src/constants/index.ts index 2132b78d..166a578c 100644 --- a/extension/src/constants/index.ts +++ b/extension/src/constants/index.ts @@ -13,6 +13,7 @@ export const DOM = { IFRAME_CSS_ID: "codebuddy-css", LEETCODE_ROOT_ID: "#qd-content", LEETCODE_TEST_ID: ".cm-content", + LEETCODE_RUN_TEST_RESULT: ".cm-content", LEETCODE_SUBMIT_BUTTON: '[data-e2e-locator="console-submit-button"]', LEETCODE_SUBMISSION_RESULT: '[data-e2e-locator="submission-result"]', PROBLEM_ID: ".elfjS", diff --git a/extension/src/services/controllers/MessageDispatcher.ts b/extension/src/services/controllers/MessageDispatcher.ts index a13165ae..527afa12 100644 --- a/extension/src/services/controllers/MessageDispatcher.ts +++ b/extension/src/services/controllers/MessageDispatcher.ts @@ -69,6 +69,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()); @@ -152,6 +153,84 @@ export class MessageDispatcher { return () => observer.disconnect(); } + private subscribeToRunTest(): () => void { + let cleanup = () => {}; + waitForElement('[data-e2e-locator="console-run-button"]') + .then(() => { + const clickHandler = async () => { + try { + const resultEl = await waitForElement(DOM.LEETCODE_RUN_TEST_RESULT); + await new Promise((resolve, reject) => { + const observer = new MutationObserver(() => { + const hasContent = + (resultEl.textContent ?? "").trim().length > 0 || + resultEl.children.length > 0; + if (hasContent) { + observer.disconnect(); + clearTimeout(timeout); + resolve(); + } + }); + + observer.observe(resultEl, { + childList: true, + subtree: true, + characterData: true, + }); + + const timeout = setTimeout(() => { + observer.disconnect(); + reject(new Error("Timed out waiting for test result mutation")); + }, DOM.TIMEOUT); + }).catch((err) => { + console.warn("Timed out waiting for test result mutation", err); + }); + + const testResult = await this.getTestResultPayload(); + + this.roomStore.getState().actions.self.update({ + questions: { + [testResult.url]: { + testResults: testResult.testResults, + }, + }, + }); + + console.log( + "Emitting test result -- message dispatcher", + testResult + ); + this.emitter.emit("rtc.send.message", { + message: testResult, + }); + } catch (e) { + console.error("Error handling run button click", e); + } + }; + + const delegatedHandler = (e: MouseEvent) => { + const el = e.target as Element | null; + if (el?.closest('[data-e2e-locator="console-run-button"]')) { + clickHandler(); + } + }; + + document.addEventListener("click", delegatedHandler); + + cleanup = () => { + document.removeEventListener("click", delegatedHandler); + }; + }) + .catch(() => + console.error( + "Unable to find Run button", + '[data-e2e-locator="console-run-button"]' + ) + ); + + return () => cleanup(); + } + private subscribeToSubmission() { // todo(nickbar01234): On teardown, we need to revert the changes const sendSuccessSubmission = () => { @@ -246,6 +325,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) { @@ -378,4 +470,12 @@ export class MessageDispatcher { .actions.room.getVariables(getNormalizedUrl(window.location.href)) ); } + + private getTestResultPayload() { + return getTestResultsPayload( + this.roomStore + .getState() + .actions.room.getVariables(getNormalizedUrl(window.location.href)) + ); + } } diff --git a/extension/src/store/roomStore.ts b/extension/src/store/roomStore.ts index adb0e6ef..a740ee69 100644 --- a/extension/src/store/roomStore.ts +++ b/extension/src/store/roomStore.ts @@ -17,6 +17,7 @@ import { SelfState, Slug, TestCases, + TestResults, User, } from "@cb/types"; import { Identifiable } from "@cb/types/utils"; @@ -47,6 +48,7 @@ export enum RoomStatus { interface UpdatePeerQuestionProgress { code?: CodeWithChanges; tests?: TestCases; + testResults?: TestResults; status?: QuestionProgressStatus; } @@ -101,6 +103,7 @@ interface RoomAction { remove: (ids: Id[]) => void; selectPeer: (id: string) => void; selectTest: (idx: number) => void; + selectTestResult: (idx: number) => void; toggleCodeVisibility: () => void; }; self: { @@ -157,10 +160,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, }; @@ -186,6 +195,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; } @@ -210,11 +238,13 @@ const createRoomStore = (background: BackgroundProxy, appStore: AppStore) => { const setSelfProgressForCurrentUrl = async (question: Question) => { const code = await background.getCode({}); const { tests } = getTestsPayload(question.variables); + const { testResults } = getTestResultsPayload(question.variables); useRoom.getState().actions.self.update({ questions: { [question.url]: { code, tests, + testResults, }, }, }); @@ -473,6 +503,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/index.ts b/extension/src/types/index.ts index 7f92ef4e..e4efee17 100644 --- a/extension/src/types/index.ts +++ b/extension/src/types/index.ts @@ -15,14 +15,28 @@ interface Assignment { value: string; } +interface ResultAssignment { + input: Record; + output: string; + expected: string; +} + export interface TestCase { test: Assignment[]; } +export interface TestResult { + 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 94f6c24d..14e3c78f 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 b7caa0bc..88253315 100644 --- a/extension/src/utils/messages.ts +++ b/extension/src/utils/messages.ts @@ -17,6 +17,38 @@ export const getTestsPayload = ( }; }; +export const getTestResultsPayload = ( + variables: Question["variables"] | undefined +): ExtractMessage => { + const testResults = Array.from( + document.querySelectorAll(DOM.LEETCODE_TEST_ID) + ); + + const countScrapedResult = testResults.length; + + //testResults[0] is the test cases (not test results) most of the time but not always + const testInputs = [ + ...(testResults[countScrapedResult - 3]?.children ?? []), + ].map((line) => (line as HTMLElement).innerText); + const testOutputs = [ + ...(testResults[countScrapedResult - 2]?.children ?? []), + ].map((line) => (line as HTMLElement).innerText); + const testExpectedOutputs = [ + ...(testResults[countScrapedResult - 1]?.children ?? []), + ].map((line) => (line as HTMLElement).innerText); + + return { + action: "testResults", + testResults: groupTestResults( + variables, + testInputs, + testOutputs, + testExpectedOutputs + ), + 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 6da34db2..a1c9f213 100644 --- a/extension/src/utils/string.ts +++ b/extension/src/utils/string.ts @@ -1,4 +1,4 @@ -import { Question, TestCase } from "@cb/types"; +import { Question, TestCase, TestResult } from "@cb/types"; export const capitalize = (str: string | undefined) => str ? str.charAt(0).toUpperCase() + str.slice(1) : ""; @@ -28,6 +28,54 @@ export const groupTestCases = ( })); }; +export const groupTestResults = ( + variables: Question["variables"] | undefined, + testInputs: string[], + testOutputs: string[], + testExpectedOutputs: string[] +): TestResult[] => { + const numCases = testOutputs.length; + const varCount = testInputs.length / numCases; + + if ( + variables == undefined || + testInputs.length % varCount !== 0 || + testOutputs.length !== numCases || + testExpectedOutputs.length !== numCases + ) { + console.error( + "Variables are undefined or tests do not match up", + variables, + testInputs, + testOutputs, + testExpectedOutputs + ); + return []; + } + + const results: TestResult[] = []; + for (let i = 0; i < numCases; i++) { + const inputObj: Record = {}; + const start = i * varCount; + for (let v = 0; v < varCount; v++) { + const name = variables.names[v]; + inputObj[name] = testInputs[start + v]; + } + + results.push({ + testResult: [ + { + input: inputObj, + output: testOutputs[i] ?? "", + expected: testExpectedOutputs[i] ?? "", + }, + ], + }); + } + + return results; +}; + export const safeJsonParse = (content: string): object => { try { return JSON.parse(content);