diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 706ccbed1b..f37cd6c7a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v2 - + - name: Set up .env env: QUESTION_FIREBASE_CREDENTIAL_PATH: ${{ vars.QUESTION_SERVICE_FIREBASE_CREDENTIAL_PATH }} @@ -30,7 +30,7 @@ jobs: echo "FIREBASE_CREDENTIAL_PATH=$QUESTION_FIREBASE_CREDENTIAL_PATH" >> .env echo "JWT_SECRET=$JWT_SECRET" >> .env echo "EXECUTION_SERVICE_URL=$EXECUTION_SERVICE_URL" >> .env - + - name: Set up credentials env: QUESTION_FIREBASE_JSON: ${{ secrets.QUESTION_SERVICE_FIREBASE_CREDENTIAL }} @@ -41,8 +41,8 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 - with: - go-version: "1.23.x" + with: + go-version: '1.23.x' - name: Install Go dependencies run: | @@ -51,7 +51,7 @@ jobs: - name: Install firebase tools run: curl -sL firebase.tools | bash - + - name: Run Go tests with Firebase emulator run: firebase emulators:exec --only firestore 'cd ./apps/question-service; go test -v ./tests' @@ -66,11 +66,11 @@ jobs: run: | cd ./apps/frontend cp .env.example .env - + - name: Set up Node.js uses: actions/setup-node@v2 with: - node-version: "22" + node-version: '22' - name: Install pnpm run: npm i -g pnpm @@ -83,7 +83,7 @@ jobs: - name: Run tests run: | cd ./apps/frontend - pnpm test + pnpm unit-test test-docker-compose: runs-on: ubuntu-latest @@ -148,13 +148,13 @@ jobs: cd ../history-service echo "FIREBASE_CREDENTIAL_PATH=$HISTORY_FIREBASE_CREDENTIAL_PATH" >> .env echo "PORT=$HISTORY_SERVICE_PORT" >> .env - echo "RABBITMQ_URL=$RABBITMQ_URL" >> .env - + echo "RABBMITMQ_URL=$RABBITMQ_URL" >> .env + cd ../execution-service echo "FIREBASE_CREDENTIAL_PATH=$EXECUTION_FIREBASE_CREDENTIAL_PATH" >> .env echo "PORT=$EXECUTION_SERVICE_PORT" >> .env echo "HISTORY_SERVICE_URL=$HISTORY_SERVICE_URL" >> .env - echo "RABBITMQ_URL=$RABBITMQ_URL" >> .env + echo "RABBMITMQ_URL=$RABBITMQ_URL" >> .env cd ../signalling-service echo "PORT=$SIGNALLING_SERVICE_PORT" >> .env @@ -173,7 +173,7 @@ jobs: cd ../history-service echo "$HISTORY_FIREBASE_JSON" > "./$HISTORY_FIREBASE_CREDENTIAL_PATH" - + cd ../execution-service echo "$EXECUTION_FIREBASE_JSON" > "./$EXECUTION_FIREBASE_CREDENTIAL_PATH" @@ -201,6 +201,7 @@ jobs: SIGNALLING_SERVICE_URL: ${{ vars.SIGNALLING_SERVICE_URL }} EXECUTION_SERVICE_URL: ${{ vars.EXECUTION_SERVICE_URL }} run: | + docker ps -a echo "Testing Question Service..." curl -sSL -o /dev/null $QUESTION_SERVICE_URL && echo "Question Service is up" echo "Testing User Service..." @@ -225,3 +226,30 @@ jobs: echo "WebSocket for Signalling Service is live" fi # We can add more tests here + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.1.4 + + - name: Install dependencies + run: | + cd ./apps/frontend + pnpm i + + - name: Install Chrome WebDriver + uses: nanasess/setup-chromedriver@v2 + with: + chromedriver-version: '130.0.6723.116' + - name: Install Edge + uses: browser-actions/setup-edge@v1 + with: + edge-version: stable + + - name: Install Geckodriver + uses: browser-actions/setup-geckodriver@latest + + - name: Run Browser Test + run: | + cd ./apps/frontend + pnpm browser-test diff --git a/README.md b/README.md index bdb87090be..68984bc014 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,38 @@ - You can choose to develop individual microservices within separate folders within this repository **OR** use individual repositories (all public) for each microservice. - In the latter scenario, you should enable sub-modules on this GitHub classroom repository to manage the development/deployment **AND** add your mentor to the individual repositories as a collaborator. - The teaching team should be given access to the repositories as we may require viewing the history of the repository in case of any disputes or disagreements. + +--- + +## Architecture Diagram + +![Overall Architecture Diagram](./docs/architecture_diagram.png) + +The overall architecture of PeerPrep follows a microservices architecture. The client acts as an orchestrator for the interaction between the different services. + +## Screenshots + +![Home Page](./docs/home_page.png) + +![Collaboration Page](./docs/collab_page_1.png) + +![Collaboration Page](./docs/collab_page_2.png) + +![Question Page](./docs/question_page.png) + +![Question Page](./docs/indiv_question_page.png) + +![History Page](./docs/submission_history_page.png) + +## More details + +- [Frontend](./apps/frontend/README.md) +- [User Service](./apps/user-service/README.md) +- [Question Service](./apps/question-service/README.md) +- [Matching Service](./apps/matching-service/README.md) +- [Signalling Service](./apps/signalling-service/README.md) +- [History Service](./apps/history-service/README.md) +- [Execution Service](./apps/execution-service/README.md) +- [CI/CD Guide](./docs/cicid.md) +- [Docker Compose Guide](./apps/README.md) +- [Set Up Guide](./docs/setup.md) diff --git a/apps/README.md b/apps/README.md index ae12a36fc1..696619fb27 100644 --- a/apps/README.md +++ b/apps/README.md @@ -2,6 +2,8 @@ This project uses Docker Compose to manage multiple services such as a frontend, backend, and a database. The configuration is defined in the `docker-compose.yml` file, and environment variables can be stored in environment files for different environments (e.g., development, production). +More details on how to set up Docker Compose can be found [here](../docs/setup.md) + ## Prerequisites Before you begin, ensure you have the following installed on your machine: @@ -30,11 +32,25 @@ In the `./apps` directory: ├── user-service │ ├── Dockerfile # Dockerfile for user-service │ └── ... (other user-service files) - +├── execution-service +│ ├── Dockerfile # Dockerfile for execution-service +│ └── ... (other execution-service files) +├── signalling-service +│ ├── Dockerfile # Dockerfile for signalling-service +│ └── ... (other signalling-service files) +├── history-service +│ ├── Dockerfile # Dockerfile for history-service +│ └── ... (other history-service files) ``` ## Docker Compose Setup +Ensure that you are currently using **Docker Compose v2** in your local Docker Desktop. +- Launch your local Docker Desktop application +- Click on settings button at the top right hand corner (beside the name) +- Under the General tab, scroll down until you see a checkbox that says Use Docker Compose V2, ensure that the box is checked then apply and restart (refer to the image below) +![Docker Compose V2](https://github.com/user-attachments/assets/3b8d47c2-c488-4fc1-804d-418ffebbdd9c) + By using multiple Dockerfiles in Docker Compose, we can manage complex multi-container applications where each service has its own environment and build process. 1. Build and Start the Application @@ -54,11 +70,15 @@ This will: Once running, you can access: -- The **frontend** at http://localhost:3000 -- The **user service** at http://localhost:3001 -- The **question service** at http://localhost:8080 (REST) and http://localhost:50051 (gRPC) -- The **matching service** at http://localhost:8081 -- The **redis service** at http://localhost:6379 +- The [**frontend**](./frontend/README.md) at http://localhost:3000 +- The [**user-service**](./user-service/README.md) at http://localhost:3001 +- The [**question-service**](./question-service/README.md) at http://localhost:8080 (REST) and http://localhost:50051 (gRPC) +- The [**matching-service**](./matching-service/README.md) at http://localhost:8081 +- The [**history-service**](./history-service/README.md) at http://localhost:8082 +- The [**execution-service**](./execution-service/README.md) at http://localhost:8083 +- The [**signalling-service**](./signalling-service/README.md) at http://localhost:4444 +- The **redis** at http://localhost:6379 +- The **rabbitmq** at http://localhost:5672 3. Stopping Services @@ -76,6 +96,11 @@ This command will stop and remove the containers, networks, and volumes created - **Port Conflicts**: If you encounter port conflicts, ensure the host ports specified in docker-compose.yml (e.g., 3000:3000) are not in use by other applications. - **Environment Variables Not Loaded**: Ensure the `.env` files are in the correct directories as found in the `docker-compose.yml` file. +- **Command execution failed**: When you try running test cases or submitting the code in the collaborative environment, if you encounter the following error message: + ```bash + Command execution failed: Unable to find image 'apps-python-sandbox:latest' locally docker: Error response from daemon: pull access denied for apps-python-sandbox, repository does not exist or may require 'docker login': denied: requested access to the resource is denied. See 'docker run --help'. : exit status 125 + ``` + Ensure that you have **Docker Compose V2** enabled for your Docker Desktop application. Please refer to the Docker Compose setup guide above to enable it locally. ### Known Issues diff --git a/apps/docker-compose.yml b/apps/docker-compose.yml index 12d02b5661..77399d8bbc 100644 --- a/apps/docker-compose.yml +++ b/apps/docker-compose.yml @@ -129,6 +129,15 @@ services: - apps_network container_name: python-sandbox + node-sandbox: + build: + context: ./execution-service/execution/node + dockerfile: Dockerfile + networks: + - apps_network + container_name: node-sandbox + stdin_open: true # Enables interactive mode for passing standard input + networks: apps_network: diff --git a/apps/execution-service/README.md b/apps/execution-service/README.md index d99ba7b92b..b1629b0b08 100644 --- a/apps/execution-service/README.md +++ b/apps/execution-service/README.md @@ -1,5 +1,59 @@ # Execution Service +The Execution Service provides backend functionality for running and validating code executions or submissions within a coding platform. It enables users to execute code against test cases and receive feedback on the correctness of their solutions. + +The Execution Service incorporates a code execution mechanism designed to run user-submitted solutions within an isolated, sandboxed environment. This approach enhances security by preventing arbitrary code from interacting with the host system directly and allows for performance monitoring + +### Technology Stack + +- Golang (Go): Statically typed, compiled language with low latency. Fast and efficient processing is ideal for high-read, high-write environments like in Execution Service, when many users run tests or submit tests. +- Rest Server: chi router was utilized which supports CORS, logging and timeout via middlewares. It is stateless, which reduces coupling and enhances scalability and reliability, simplicity and flexibility. For example, clients may make requests to different server instances when scaled. +- Firebase Firestore: NoSQL Document database that is designed for automatic horizontal scaling and schema-less design that allows for flexibility as number of tests increases or more users run tests. +- Docker: used to containerize the Execution Service to simplify deployment. Additionally used to provide a sandboxed execution environment for user-submitted code, ensuring security by limiting code access to the host system and managing dependencies independently. + +### Execution Process + +For execution of user code (running of test cases without submission), only visible (public) and custom test cases are executed. + +![Diagram of code execution process](../../docs/exeuction_process.png) + +### Submission Process + +For submission of user code, both visible (public) and hidden testcases are executed, before calling the history-service API to submit the submission data, code and test results. + +![Diagram of code submission process](../../docs/submission_process.png) + +### Design Decisions + +1. **Docker Containerisation** + a. Upon receiving a code execution request, the service dynamically creates a Docker container with a controlled environment tailored to Python + b. The Docker container is set up with only the minimal permissions and resources needed to execute the code, restricting the execution environment to reduce risk + c. This containerized environment is automatically destroyed after execution, ensuring no residual data or state remains between executions + +2. **Security and Isolation** + a. Containers provide isolation from the host system, limiting any interaction between user code and the underlying infrastructure + b. Only essential files and libraries required for code execution are included, reducing potential attack surfaces within each container. The sandboxed, container-based execution system provides a secure and efficient way to run user code submissions. + +The sandboxed, container-based execution system provides a secure and efficient way to run user code submissions. + +### Communication between Execution and History Service + +The communication between the Execution service and the History service is implemented through a RabbitMQ Message Queue. RabbitMQ is ideal for message queues in microservices due to its reliability, flexible routing, and scalability. It ensures messages aren’t lost through durable queues and supports complex routing to handle diverse messaging needs. + +Asynchronous communication was chosen as a user’s submission history did not need to be updated immediately. Instead of waiting for a response, the Execution Service can put the message in a queue and continue processing other requests. + +![RabbitMQ Message Queue](./../../docs/rabbit_mq_queue.png) + +A message queue allows services to communicate without depending on each other's availability. The Execution Service can send a message to the queue, and the History Service can process it when it’s ready. This decoupling promotes loose coupling and reduces dependencies between services, which helps maintain a robust and adaptable system. + +--- + +## Setup + +### Prerequisites + +Ensure you have Go installed on your machine. + ### Installation 1. Install dependencies: @@ -61,10 +115,10 @@ The server will be available at http://localhost:8083. ## API Endpoints -- `POST /tests/populate` -- `GET /tests/{questionDocRefId}/` -- `POST /tests/{questionDocRefId}/execute` -- `POST /tests/{questionDocRefId}/submit` +- `POST: /tests/populate`: Deletes and repopulates all tests in Firebase +- `GET: /{questionDocRefId}`: Reads the public testcases for the question, identified by the question reference ID +- `POST: /{questionDocRefId}/execute`: Executes the public testcases for the question, identified by the question reference ID +- `POST: /{questionDocRefId}/submit`: Executes the public and hidden testcases for the question, identified by the question reference ID, and submits the code submission to History Service ## Managing Firebase diff --git a/apps/execution-service/constants/constant.go b/apps/execution-service/constants/constant.go index 46d3face08..1e3810a3e4 100644 --- a/apps/execution-service/constants/constant.go +++ b/apps/execution-service/constants/constant.go @@ -1,11 +1,11 @@ package constants const ( - JAVA = "Java" - PYTHON = "Python" - GOLANG = "Golang" - JAVASCRIPT = "Javascript" - CPP = "C++" + JAVA = "java" + PYTHON = "python" + GOLANG = "golang" + JAVASCRIPT = "javascript" + CPP = "c++" ) const ( @@ -17,6 +17,6 @@ var IS_VALID_LANGUAGE = map[string]bool{ PYTHON: true, //JAVA: true, //GOLANG: true, - //JAVASCRIPT: true, + JAVASCRIPT: true, //CPP: true, } diff --git a/apps/execution-service/execution/node/Dockerfile b/apps/execution-service/execution/node/Dockerfile new file mode 100644 index 0000000000..59ef6fcdee --- /dev/null +++ b/apps/execution-service/execution/node/Dockerfile @@ -0,0 +1,11 @@ +# Use a slim Node.js image +FROM node:18-slim + +# Set the working directory +WORKDIR /app + +# Install any dependencies if necessary (you can skip if no dependencies) +# COPY package*.json ./ +# RUN npm install + +# No entry point or CMD needed as you'll provide the command at runtime diff --git a/apps/execution-service/execution/node/javascript.go b/apps/execution-service/execution/node/javascript.go new file mode 100644 index 0000000000..c41c73c068 --- /dev/null +++ b/apps/execution-service/execution/node/javascript.go @@ -0,0 +1,33 @@ +package node + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +func RunJavaScriptCode(code string, input string) (string, string, error) { + cmd := exec.Command( + "docker", "run", "--rm", + "-i", // allows standard input to be passed in + "apps-node-sandbox", // Docker image with Node.js environment + "node", "-e", code, // Runs JavaScript code with Node.js + ) + + // Pass input to the JavaScript script + cmd.Stdin = bytes.NewBufferString(input) + + // Capture standard output and error output + var output bytes.Buffer + var errorOutput bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &errorOutput + + // Run the command + if err := cmd.Run(); err != nil { + return "", fmt.Sprintf("Command execution failed: %s: %v", errorOutput.String(), err), nil + } + + return strings.TrimSuffix(output.String(), "\n"), strings.TrimSuffix(errorOutput.String(), "\n"), nil +} diff --git a/apps/execution-service/utils/executeTest.go b/apps/execution-service/utils/executeTest.go index b411ba7298..f2bfd1cf50 100644 --- a/apps/execution-service/utils/executeTest.go +++ b/apps/execution-service/utils/executeTest.go @@ -2,6 +2,7 @@ package utils import ( "execution-service/constants" + "execution-service/execution/node" "execution-service/execution/python" "execution-service/models" "fmt" @@ -15,6 +16,8 @@ func ExecuteVisibleAndCustomTests(code models.Code, test models.Test) (models.Ex case constants.PYTHON: testResults, err = getVisibleAndCustomTestResults(code, test, python.RunPythonCode) break + case constants.JAVASCRIPT: + testResults, err = getVisibleAndCustomTestResults(code, test, node.RunJavaScriptCode) default: return models.ExecutionResults{}, fmt.Errorf("unsupported language: %s", code.Language) } @@ -33,6 +36,8 @@ func ExecuteVisibleAndHiddenTests(code models.Code, test models.Test) (models.Su case constants.PYTHON: testResults, err = getVisibleAndHiddenTestResults(code, test, python.RunPythonCode) break + case constants.JAVASCRIPT: + testResults, err = getVisibleAndHiddenTestResults(code, test, node.RunJavaScriptCode) default: return models.SubmissionResults{}, fmt.Errorf("unsupported language: %s", code.Language) } diff --git a/apps/frontend/README.md b/apps/frontend/README.md index 2572193fb1..a763465b0d 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -1,11 +1,27 @@ -This is the frontend for the question service. +# Frontend -## Tech Stack +![Home page](../../docs/home_page.png) -- Next.js -- TypeScript -- Ant Design -- SCSS +### Tech Stack + +- React: React is one of the most popular UI libraries that allows the creation of reusable UI functional components. Its community ecosystem also offers React hooks that simplify the implementation of some of our frontend components, such as websockets. +- Next.js: A React framework for building single-page applications. It comes with several useful features such as automatic page routing based on filesystem. +- Ant Design: An enterprise-level design system that comes with several extensible UI components and solutions out-of-the-box, which allows us to quickly create nice-looking components that can be adjusted according to our requirements. +- Typescript: A language extension of Javascript that allows us to perform static type-checking, to ensure that issues with incorrectly used types are caught and resolved as early as possible, improving code maintainability. + +### Authorization-based Route Protection with Next.js Middleware + +Middleware is a Next.js feature that allows the webpage server to intercept page requests and perform checks before serving the webpage. We used this feature to protect page access from unauthenticated users. This was done by checking the request’s JWT token (passed as a cookie) against the user service and redirecting users without authorized access to a public route (namely, the login page). + +### User Flow and Communication between Microservices + +Clients interact with the microservices through dedicated endpoints, with each microservice managing its own database for independent reading and writing. + +Having individual databases per microservice improves data security, scalability, fault isolation, flexibility in database choice, and development efficiency. This approach allows each microservice to operate independently, optimizing stability, performance, and adaptability in the system. + +![Diagram for user flow and communication between microservices](../../docs/userflow.png) + +--- ## Getting Started diff --git a/apps/frontend/__tests__/browser-tests/browser.test.ts b/apps/frontend/__tests__/browser-tests/browser.test.ts new file mode 100644 index 0000000000..145c036b81 --- /dev/null +++ b/apps/frontend/__tests__/browser-tests/browser.test.ts @@ -0,0 +1,82 @@ +import { Actions, Browser, Builder, By, Capabilities, Key, until, WebDriver } from "selenium-webdriver" + +import {Options as ChromeOptions} from "selenium-webdriver/chrome" +import {Options as EdgeOptions} from "selenium-webdriver/edge" +import {Options as FirefoxOptions} from "selenium-webdriver/firefox" + +const URL = 'http://localhost:3000/'; +const ETERNAL_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTk5fQ.Z4_FVGQ5lIcouP3m4YLMr6pGMF17IJFfo2yOTiN58DY" + +const CHROME_OPTIONS = new ChromeOptions() + .addArguments("--headless=new") as ChromeOptions; // uncomment locally to see the steps in action +const EDGE_OPTIONS = new EdgeOptions() + .setBinaryPath("/opt/hostedtoolcache/msedge/stable/x64/msedge") // need to point to the correct path + .addArguments("--headless=new") as EdgeOptions; + +const FIREFOX_OPTIONS = new FirefoxOptions() + .addArguments("--headless") as FirefoxOptions; + +const builder = new Builder() + .setChromeOptions(CHROME_OPTIONS) + .setEdgeOptions(EDGE_OPTIONS) + .setFirefoxOptions(FIREFOX_OPTIONS) + +describe.each([Browser.CHROME, Browser.EDGE, Browser.FIREFOX])("%s driver test", (browser) => { + let driver: WebDriver; + beforeAll(() => { + const cap = new Capabilities().setBrowserName(browser) + builder.withCapabilities(cap); + }) + + beforeEach(async () => { + console.log(browser + ": building..."); + driver = await builder.build(); + console.log(browser + ": built"); + }, 20000) + + afterEach(async () => { + if (driver) { + await driver.quit(); + } + }) + + describe("webdriver installed correctly", () => { + it("does google search", async () => { + await driver.get('http://www.google.com'); + await driver.findElement(By.name('q')).sendKeys('webdriver', Key.RETURN); + await driver.wait(until.titleIs('webdriver - Google Search'), 1000); + }, 10000); + + it.skip("does another google search", async () => { + await driver.get('http://www.google.com'); + await driver.findElement(By.name('q')).sendKeys('webdriver', Key.RETURN); + await driver.wait(until.titleIs('webdriver - Google Search'), 1000); + }, 10000); + }); + + describe("browser-test", () => { + it("accesses and login to peerprep", async () => { + await driver.get(URL); + await driver.wait(until.urlIs(`${URL}login`)); + + const [email, password] = await driver.findElements(By.css("input")) + const submit = await driver.findElement(By.css("button[type=\"submit\"]")) + + await email.sendKeys("admin@gmail.com"); + await password.sendKeys("admin"); + + await submit.click(); + await driver.wait(until.urlIs(`${URL}`)); + + const slogan1 = await driver.findElement(By.xpath("/html/body/div[1]/main/div/div[1]/div[2]/span[1]")).then(ele => ele.getText()) + const slogan2 = await driver.findElement(By.xpath("/html/body/div[1]/main/div/div[1]/div[2]/span[2]")).then(ele => ele.getText()) + + expect(slogan1).toBe("A better way to prepare for coding interviews with"); + expect(slogan2).toBe("peers"); + }, 10000); + }) +}, 60000) + + + + diff --git a/apps/frontend/__tests__/Datetime.test.ts b/apps/frontend/__tests__/unit-tests/Datetime.test.ts similarity index 100% rename from apps/frontend/__tests__/Datetime.test.ts rename to apps/frontend/__tests__/unit-tests/Datetime.test.ts diff --git a/apps/frontend/__tests__/dependencymocking.test.ts b/apps/frontend/__tests__/unit-tests/dependencymocking.test.ts similarity index 100% rename from apps/frontend/__tests__/dependencymocking.test.ts rename to apps/frontend/__tests__/unit-tests/dependencymocking.test.ts diff --git a/apps/frontend/__tests__/question.test.ts b/apps/frontend/__tests__/unit-tests/question.test.ts similarity index 93% rename from apps/frontend/__tests__/question.test.ts rename to apps/frontend/__tests__/unit-tests/question.test.ts index 417b4cc7aa..970407f3ec 100644 --- a/apps/frontend/__tests__/question.test.ts +++ b/apps/frontend/__tests__/unit-tests/question.test.ts @@ -126,7 +126,7 @@ describe("GetQuestions", () => { }); - it("gets all questions on the 2nd page with (2) call", async () => { + it("formats (page=2) params correctly", async () => { const res = await GetQuestions(2) @@ -138,7 +138,7 @@ describe("GetQuestions", () => { }]]) }); - it("gets all questions on the 2nd page with (limit=3) call", async () => { + it("formats (limit=3) params correctly", async () => { await GetQuestions(undefined, 3) @@ -150,7 +150,7 @@ describe("GetQuestions", () => { }]]) }); - it("gets all questions on the 2nd page with (limit=3) call", async () => { + it("formats (difficulty asc) params correctly", async () => { await GetQuestions(undefined, undefined, "difficulty asc") @@ -162,7 +162,7 @@ describe("GetQuestions", () => { }]]) }); - it("gets all questions on the 2nd page with (limit=3) call", async () => { + it("formats ([\"easy\", \"hard\"]) params correctly", async () => { await GetQuestions(undefined, undefined, undefined, ["easy", "hard"]) @@ -174,7 +174,7 @@ describe("GetQuestions", () => { }]]) }); - it("formats urls for categories", async () => { + it("formats cat params correctly", async () => { await GetQuestions(undefined, undefined, undefined, undefined, ["CatA", "CatB"]) @@ -189,7 +189,7 @@ describe("GetQuestions", () => { ]]) }); - it("formats url for title", async () => { + it("formats title params correctly", async () => { await GetQuestions(undefined, undefined, undefined, undefined, undefined, "The Title Name") @@ -210,9 +210,11 @@ describe("GetSingleQuestion", () => { const DOCREF = "mockdocref"; beforeEach(() => { global.fetch = jest.fn().mockResolvedValue({ + ok: true, // Ensure `ok` is true to hit the success branch async json() { return QUESTIONS[0] - } + }, + text: () => Promise.resolve('mocked response'), }); }); @@ -238,6 +240,7 @@ describe("CreateQuestion", () => { global.fetch = jest.fn().mockResolvedValue({ status: 200, statusText: "OK", + ok: true, // Ensure `ok` is true to hit the success branch async json() { return createdQuestion } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 517787304d..3f1aaa4c0b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "jest" + "test": "jest", + "unit-test": "jest --verbose __tests__/unit-tests", + "browser-test": "jest --verbose __tests__/browser-tests" }, "dependencies": { "@ant-design/icons": "^5.5.1", @@ -41,18 +43,21 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@types/chromedriver": "^81.0.5", "@types/codemirror": "^5.60.15", "@types/jest": "^29.5.14", "@types/node": "^20", "@types/peerjs": "^1.1.0", "@types/react": "^18.3.8", "@types/react-dom": "^18.3.0", + "@types/selenium-webdriver": "^4.1.27", "eslint": "^8", "eslint-config-next": "14.2.13", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "selenium-webdriver": "^4.26.0", "ts-node": "^10.9.2", "typescript": "^5" }, "packageManager": "pnpm@9.1.4+sha512.9df9cf27c91715646c7d675d1c9c8e41f6fce88246f1318c1aa6a1ed1aeb3c4f032fcdf4ba63cc69c4fe6d634279176b5358727d8f2cc1e65b65f43ce2f8bfb0" -} +} \ No newline at end of file diff --git a/apps/frontend/pnpm-lock.yaml b/apps/frontend/pnpm-lock.yaml index fec069f7da..2462485e68 100644 --- a/apps/frontend/pnpm-lock.yaml +++ b/apps/frontend/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@testing-library/react': specifier: ^16.0.1 version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.8)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@types/chromedriver': + specifier: ^81.0.5 + version: 81.0.5 '@types/codemirror': specifier: ^5.60.15 version: 5.60.15 @@ -114,6 +117,9 @@ importers: '@types/react-dom': specifier: ^18.3.0 version: 18.3.0 + '@types/selenium-webdriver': + specifier: ^4.1.27 + version: 4.1.27 eslint: specifier: ^8 version: 8.0.0 @@ -126,6 +132,9 @@ importers: jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 + selenium-webdriver: + specifier: ^4.26.0 + version: 4.26.0 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.0.0)(typescript@5.0.2) @@ -347,6 +356,9 @@ packages: resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} engines: {node: '>=6.9.0'} + '@bazel/runfiles@6.3.1': + resolution: {integrity: sha512-1uLNT5NZsUVIGS4syuHwTzZ8HycMPyr6POA3FCE4GbMtc4rhoJk8aZKtNIRthJYfL+iioppi+rTfH3olMPr9nA==} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -765,6 +777,9 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/chromedriver@81.0.5': + resolution: {integrity: sha512-VwV+WTTFHYZotBn57QQ8gd4TE7CGJ15KPM+xJJrKbiQQSccTY7zVXuConSBlyWrO+AFpVxuzmluK3xvzxGmkCw==} + '@types/codemirror@5.60.15': resolution: {integrity: sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA==} @@ -808,6 +823,9 @@ packages: '@types/react@18.3.8': resolution: {integrity: sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==} + '@types/selenium-webdriver@4.1.27': + resolution: {integrity: sha512-ALqsj8D7Swb6MnBQuAQ58J3KC3yh6fLGtAmpBmnZX8j+0kmP7NaLt56CuzBw2W2bXPrvHFTgn8iekOQFUKXEQA==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -817,6 +835,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/ws@8.5.13': + resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1161,6 +1182,9 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1720,6 +1744,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immutable@4.3.7: resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} @@ -1873,6 +1900,9 @@ packages: resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2104,6 +2134,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -2135,6 +2168,9 @@ packages: engines: {node: '>=16'} hasBin: true + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2322,6 +2358,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2395,6 +2434,9 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -2679,6 +2721,9 @@ packages: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2759,6 +2804,9 @@ packages: resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -2787,6 +2835,10 @@ packages: sdp@3.2.0: resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==} + selenium-webdriver@4.26.0: + resolution: {integrity: sha512-nA7jMRIPV17mJmAiTDBWN96Sy0Uxrz5CCLb7bLVV6PpL417SyBMPc2Zo/uoREc2EOHlzHwHwAlFtgmSngSY4WQ==} + engines: {node: '>= 14.21.0'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2804,6 +2856,9 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2895,6 +2950,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -2975,6 +3033,10 @@ packages: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -3485,6 +3547,8 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@bazel/runfiles@6.3.1': {} + '@bcoe/v8-coverage@0.2.3': {} '@codemirror/autocomplete@6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.3)': @@ -4060,6 +4124,10 @@ snapshots: dependencies: '@babel/types': 7.26.0 + '@types/chromedriver@81.0.5': + dependencies: + '@types/node': 20.0.0 + '@types/codemirror@5.60.15': dependencies: '@types/tern': 0.23.9 @@ -4110,6 +4178,11 @@ snapshots: '@types/prop-types': 15.7.13 csstype: 3.1.3 + '@types/selenium-webdriver@4.1.27': + dependencies: + '@types/node': 20.0.0 + '@types/ws': 8.5.13 + '@types/stack-utils@2.0.3': {} '@types/tern@0.23.9': @@ -4118,6 +4191,10 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/ws@8.5.13': + dependencies: + '@types/node': 20.0.0 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -4589,6 +4666,8 @@ snapshots: dependencies: toggle-selection: 1.0.6 + core-util-is@1.0.3: {} + create-jest@29.7.0(@types/node@20.0.0)(ts-node@10.9.2(@types/node@20.0.0)(typescript@5.0.2)): dependencies: '@jest/types': 29.6.3 @@ -4909,7 +4988,7 @@ snapshots: debug: 4.3.7 enhanced-resolve: 5.17.1 eslint: 8.0.0 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.0.0) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -4922,7 +5001,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.0.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -4944,7 +5023,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.0.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint@8.0.0))(eslint@8.0.0))(eslint@8.0.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.0(eslint@8.0.0)(typescript@5.0.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.0.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -5309,6 +5388,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + immutable@4.3.7: {} import-fresh@3.3.0: @@ -5447,6 +5528,8 @@ snapshots: call-bind: 1.0.7 get-intrinsic: 1.2.4 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -5903,6 +5986,13 @@ snapshots: object.assign: 4.1.5 object.values: 1.2.0 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwt-decode@4.0.0: {} keyv@4.5.4: @@ -5928,6 +6018,10 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lines-and-columns@1.2.4: {} locate-path@5.0.0: @@ -6109,6 +6203,8 @@ snapshots: p-try@2.2.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6178,6 +6274,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + process-nextick-args@2.0.1: {} + progress@2.0.3: {} prompts@2.4.2: @@ -6546,6 +6644,16 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -6627,6 +6735,8 @@ snapshots: has-symbols: 1.0.3 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-regex-test@1.0.3: @@ -6657,6 +6767,16 @@ snapshots: sdp@3.2.0: {} + selenium-webdriver@4.26.0: + dependencies: + '@bazel/runfiles': 6.3.1 + jszip: 3.10.1 + tmp: 0.2.3 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + semver@6.3.1: {} semver@7.6.3: {} @@ -6677,6 +6797,8 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6794,6 +6916,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -6853,6 +6979,8 @@ snapshots: throttle-debounce@5.0.2: {} + tmp@0.2.3: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: diff --git a/apps/frontend/src/app/collaboration/[id]/page.tsx b/apps/frontend/src/app/collaboration/[id]/page.tsx index 739eac08a6..f538285987 100644 --- a/apps/frontend/src/app/collaboration/[id]/page.tsx +++ b/apps/frontend/src/app/collaboration/[id]/page.tsx @@ -12,6 +12,7 @@ import { Tag, Typography, Spin, + Tooltip, } from "antd"; import { Content } from "antd/es/layout/layout"; import "./styles.scss"; @@ -70,7 +71,7 @@ export default function CollaborationPage(props: CollaborationProps) { const [complexity, setComplexity] = useState(undefined); const [categories, setCategories] = useState([]); // Store the selected filter categories const [description, setDescription] = useState(undefined); - const [selectedLanguage, setSelectedLanguage] = useState("Python"); // State to hold the selected language item + const [selectedLanguage, setSelectedLanguage] = useState("python"); // State to hold the selected language item // Session states const [collaborationId, setCollaborationId] = useState( @@ -168,6 +169,13 @@ export default function CollaborationPage(props: CollaborationProps) { }); }; + const errorMessage = (message: string) => { + messageApi.open({ + type: "error", + content: message, + }); + }; + const sendSubmissionResultsToMatchedUser = (data: SubmissionResults) => { if (!providerRef.current) { throw new Error("Provider not initialized"); @@ -209,33 +217,44 @@ export default function CollaborationPage(props: CollaborationProps) { }; const updateSubmissionResults = (data: SubmissionResults) => { - setSubmissionHiddenTestResultsAndStatus({ + const submissionHiddenTestResultsAndStatus: SubmissionHiddenTestResultsAndStatus = { hiddenTestResults: data.hiddenTestResults, status: data.status, - }); + } + setSubmissionHiddenTestResultsAndStatus(submissionHiddenTestResultsAndStatus); + localStorage.setItem("submissionHiddenTestResultsAndStatus", JSON.stringify(submissionHiddenTestResultsAndStatus)); setVisibleTestCases(data.visibleTestResults); + localStorage.setItem("visibleTestResults", JSON.stringify(data.visibleTestResults)); }; const updateExecutionResults = (data: ExecutionResults) => { setVisibleTestCases(data.visibleTestResults); + localStorage.setItem("visibleTestResults", JSON.stringify(data.visibleTestResults)); }; + const updateLangauge = (data: string) => { + setSelectedLanguage(data); + } + const handleRunTestCases = async () => { if (!questionDocRefId) { throw new Error("Question ID not found"); } setIsLoadingTestCase(true); sendExecutingStateToMatchedUser(true); - const data = await ExecuteVisibleAndCustomTests(questionDocRefId, { - code: code, - language: selectedLanguage, - customTestCases: "", - }); - setVisibleTestCases(data.visibleTestResults); - infoMessage("Test cases executed. Review the results below."); - sendExecutionResultsToMatchedUser(data); - setIsLoadingTestCase(false); - sendExecutingStateToMatchedUser(false); + try { + const data = await ExecuteVisibleAndCustomTests(questionDocRefId, { + code: code, + language: selectedLanguage, + customTestCases: "", + }); + updateExecutionResults(data); + infoMessage("Test cases executed. Review the results below."); + sendExecutionResultsToMatchedUser(data); + } finally { + setIsLoadingTestCase(false); + sendExecutingStateToMatchedUser(false); + } }; const handleSubmitCode = async () => { @@ -244,25 +263,28 @@ export default function CollaborationPage(props: CollaborationProps) { } setIsLoadingSubmission(true); sendSubmittingStateToMatchedUser(true); - const data = await ExecuteVisibleAndHiddenTestsAndSubmit(questionDocRefId, { - code: code, - language: selectedLanguage, - user: currentUser ?? "", - matchedUser: matchedUser ?? "", - matchedTopics: matchedTopics ?? [], - title: questionTitle ?? "", - questionDifficulty: complexity ?? "", - questionTopics: categories, - }); - setVisibleTestCases(data.visibleTestResults); - setSubmissionHiddenTestResultsAndStatus({ - hiddenTestResults: data.hiddenTestResults, - status: data.status, - }); - sendSubmissionResultsToMatchedUser(data); - successMessage("Code saved successfully!"); - setIsLoadingSubmission(false); - sendSubmittingStateToMatchedUser(false); + try { + const data = await ExecuteVisibleAndHiddenTestsAndSubmit(questionDocRefId, { + code: code, + language: selectedLanguage, + user: currentUser ?? "", + matchedUser: matchedUser ?? "", + matchedTopics: matchedTopics ?? [], + title: questionTitle ?? "", + questionDifficulty: complexity ?? "", + questionTopics: categories, + }); + updateExecutionResults({ + visibleTestResults: data.visibleTestResults, + customTestResults: [], + }); + updateSubmissionResults(data); + sendSubmissionResultsToMatchedUser(data); + successMessage("Code saved successfully!"); + } finally { + setIsLoadingSubmission(false); + sendSubmittingStateToMatchedUser(false); + } }; const handleCodeChange = (code: string) => { @@ -283,6 +305,11 @@ export default function CollaborationPage(props: CollaborationProps) { const currentUser: string = localStorage.getItem("user") ?? ""; const matchedTopics: string[] = localStorage.getItem("matchedTopics")?.split(",") ?? []; + const submissionHiddenTestResultsAndStatus: SubmissionHiddenTestResultsAndStatus | undefined = + localStorage.getItem("submissionHiddenTestResultsAndStatus") + ? JSON.parse(localStorage.getItem("submissionHiddenTestResultsAndStatus") as string) + : undefined; + const visibleTestCases: Test[] = JSON.parse(localStorage.getItem("visibleTestResults") ?? "[]") ?? []; // Set states from localstorage setCollaborationId(collabId); @@ -290,6 +317,8 @@ export default function CollaborationPage(props: CollaborationProps) { setCurrentUser(currentUser); setMatchedTopics(matchedTopics); setQuestionDocRefId(questionDocRefId); + setSubmissionHiddenTestResultsAndStatus(submissionHiddenTestResultsAndStatus); + setVisibleTestCases(visibleTestCases); GetSingleQuestion(questionDocRefId).then((data: Question) => { setQuestionTitle(`${data.id}. ${data.title}`); @@ -298,9 +327,13 @@ export default function CollaborationPage(props: CollaborationProps) { setDescription(data.description); }); - GetVisibleTests(questionDocRefId).then((data: Test[]) => { - setVisibleTestCases(data); - }); + if (visibleTestCases.length == 0) { + GetVisibleTests(questionDocRefId).then((data: Test[]) => { + setVisibleTestCases(data); + }).catch((e) => { + errorMessage(e.message); + }); + } // Start stopwatch startStopwatch(); @@ -384,6 +417,9 @@ export default function CollaborationPage(props: CollaborationProps) { localStorage.removeItem("collabId"); localStorage.removeItem("questionDocRefId"); localStorage.removeItem("matchedTopics"); + localStorage.removeItem("submissionHiddenTestResultsAndStatus"); + localStorage.removeItem("visibleTestResults"); + localStorage.removeItem("editor-language"); // Remove editor language type when session closed }; return ( @@ -479,7 +515,7 @@ export default function CollaborationPage(props: CollaborationProps) { ref={editorRef} user={currentUser} collaborationId={collaborationId} - language={selectedLanguage} + updateLanguage={updateLangauge} setMatchedUser={setMatchedUser} handleCloseCollaboration={handleCloseCollaboration} providerRef={providerRef} @@ -492,7 +528,9 @@ export default function CollaborationPage(props: CollaborationProps) { /> )}
- + + + { - + @@ -144,7 +144,11 @@ const ProfilePage = (props: ProfilePageProps): JSX.Element => { - + { + const errorMessage = (message: string) => { messageApi.open({ type: "error", content: message, @@ -129,13 +129,20 @@ export default function QuestionPage() { setCategories(data.categories); setDescription(data.description); }) + .catch((e) => { + errorMessage(e.message); + }) .finally(() => { setIsLoading(false); }); - GetVisibleTests(questionDocRefId).then((data: Test[]) => { - setVisibleTestCases(data); - }); + GetVisibleTests(questionDocRefId) + .then((data: Test[]) => { + setVisibleTestCases(data); + }) + .catch((e) => { + errorMessage(e.message); + }); }, [questionDocRefId]); useEffect(() => { diff --git a/apps/frontend/src/app/services/execute.ts b/apps/frontend/src/app/services/execute.ts index cde725feec..1e05da1632 100644 --- a/apps/frontend/src/app/services/execute.ts +++ b/apps/frontend/src/app/services/execute.ts @@ -70,71 +70,71 @@ export interface Submission { export const GetVisibleTests = async ( questionDocRefId: string ): Promise => { - const response = await fetch( - `${EXECUTION_SERVICE_URL}tests/${questionDocRefId}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - } - ); - - if (response.status === 200) { - return response.json(); - } else { - throw new Error( - `Error fetching test cases: ${response.status} ${response.statusText}` + const response = await fetch( + `${EXECUTION_SERVICE_URL}tests/${questionDocRefId}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } ); - } + + if (response.ok) { + return response.json(); + } else { + throw new Error( + `Error fetching test cases: ${await response.text()}` + ); + } }; export const ExecuteVisibleAndCustomTests = async ( questionDocRefId: string, code: Code ): Promise => { - const response = await fetch( - `${EXECUTION_SERVICE_URL}tests/${questionDocRefId}/execute`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(code), - } - ); - - if (response.status === 200) { - return response.json(); - } else { - throw new Error( - `Error executing code: ${response.status} ${response.statusText}` + const response = await fetch( + `${EXECUTION_SERVICE_URL}tests/${questionDocRefId}/execute`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(code), + } ); - } + + if (response.ok) { + return response.json(); + } else { + throw new Error( + `Error executing code: ${response.status} ${response.statusText}` + ); + } }; export const ExecuteVisibleAndHiddenTestsAndSubmit = async ( questionDocRefId: string, collaboration: Submission ): Promise => { - const response = await fetch( - `${EXECUTION_SERVICE_URL}tests/${questionDocRefId}/submit`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(collaboration), - } - ); - - if (response.status === 200) { - return response.json(); - } else { - throw new Error( - `Error submitting code: ${response.status} ${response.statusText}` + const response = await fetch( + `${EXECUTION_SERVICE_URL}tests/${questionDocRefId}/submit`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(collaboration), + } ); - } + + if (response.ok) { + return response.json(); + } else { + throw new Error( + `Error submitting code: ${response.status} ${response.statusText}` + ); + } }; export const CreateTestcases = async ( diff --git a/apps/frontend/src/app/services/question.ts b/apps/frontend/src/app/services/question.ts index 833b8fc558..98c10be481 100644 --- a/apps/frontend/src/app/services/question.ts +++ b/apps/frontend/src/app/services/question.ts @@ -98,8 +98,13 @@ export const GetSingleQuestion = async (docRef: string): Promise => { } } ); - const data = await response.json(); - return data; + if (response.ok) { + return response.json(); + } else { + throw new Error( + `Error reading question: ${await response.text()}` + ); + } }; // Upload single question (TODO: Sean) @@ -115,7 +120,7 @@ export const CreateQuestion = async ( body: JSON.stringify(question), }); - if (response.status === 200) { + if (response.ok) { return response.json(); } else { throw new Error( @@ -140,7 +145,7 @@ export const EditQuestion = async ( } ); - if (response.status === 200) { + if (response.ok) { return response.json(); } else { throw new Error( diff --git a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx index d32f67612d..b88531ebb3 100644 --- a/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx +++ b/apps/frontend/src/components/CollaborativeEditor/CollaborativeEditor.tsx @@ -32,7 +32,7 @@ import { ExecutionResults, SubmissionResults } from "@/app/services/execute"; interface CollaborativeEditorProps { user: string; collaborationId: string; - language: string; + updateLanguage: (language: string) => void; setMatchedUser: Dispatch>; handleCloseCollaboration: (type: string) => void; providerRef: MutableRefObject; @@ -77,6 +77,10 @@ interface Awareness { submitting: boolean; id: number; }; + editorState: { + language: string; + id: number; + }; } export const usercolors = [ @@ -101,8 +105,10 @@ const CollaborativeEditor = forwardRef( ) => { const editorRef = useRef(null); // const providerRef = useRef(null); - const [selectedLanguage, setSelectedLanguage] = useState("Python"); + const [selectedLanguage, setSelectedLanguage] = useState("python"); + const [mounted, setMounted] = useState(false); let sessionEndNotified = false; + let sessionEndTimeout: any; const languageConf = new Compartment(); @@ -113,57 +119,25 @@ const CollaborativeEditor = forwardRef( }); // Referenced: https://codemirror.net/examples/config/#dynamic-configuration - // const autoLanguage = EditorState.transactionExtender.of((tr) => { - // if (!tr.docChanged) return null; - - // const snippet = tr.newDoc.sliceString(0, 100); - - // // Handle code change - // props.onCodeChange(tr.newDoc.toString()); - - // // Test for various language - // const docIsPython = /^\s*(def|class)\s/.test(snippet); - // const docIsJava = /^\s*(class|public\s+static\s+void\s+main)\s/.test( - // snippet - // ); // Java has some problems - // const docIsCpp = /^\s*(#include|namespace|int\s+main)\s/.test(snippet); // Yet to test c++ - // const docIsGo = /^(package|import|func|type|var|const)\s/.test(snippet); - - // let newLanguage; - // let languageType; - // let languageLabel; - - // if (docIsPython) { - // newLanguage = python(); - // languageLabel = "Python"; - // languageType = pythonLanguage; - // } else if (docIsJava) { - // newLanguage = java(); - // languageLabel = "Java"; - // languageType = javaLanguage; - // } else if (docIsGo) { - // newLanguage = go(); - // languageLabel = "Go"; - // languageType = goLanguage; - // } else if (docIsCpp) { - // newLanguage = cpp(); - // languageLabel = "C++"; - // languageType = cppLanguage; - // } else { - // newLanguage = javascript(); // Default to JavaScript - // languageLabel = "JavaScript"; - // languageType = javascriptLanguage; - // } - - // const stateLanguage = tr.startState.facet(language); - // if (languageType == stateLanguage) return null; - - // setSelectedLanguage(languageLabel); - - // return { - // effects: languageConf.reconfigure(newLanguage), - // }; - // }); + const autoLanguage = EditorState.transactionExtender.of((tr) => { + if (!tr.docChanged) return null; + const editorLanguage = localStorage.getItem("editor-language") ?? ""; + let stateIsJs = tr.startState.facet(language) == javascriptLanguage; + let stateIsPython = tr.startState.facet(language) == pythonLanguage; + if ( + (stateIsJs && editorLanguage.toLowerCase() === "javascript") || + (stateIsPython && editorLanguage.toLowerCase() === "python") + ) + return null; + + return { + effects: languageConf.reconfigure( + editorLanguage.toLowerCase() === "javascript" + ? javascript() + : python() + ), + }; + }); const [messageApi, contextHolder] = message.useMessage(); @@ -199,6 +173,7 @@ const CollaborativeEditor = forwardRef( let latestSubmissionId: number = new Date(0).getTime(); let latestExecutingId: number = new Date(0).getTime(); let latestSubmittingId: number = new Date(0).getTime(); + let latestLanguageChangeId: number = new Date(0).getTime(); useImperativeHandle(ref, () => ({ endSession: () => { @@ -219,7 +194,21 @@ const CollaborativeEditor = forwardRef( }, })); - let sessionEndTimeout: any; + useEffect(() => { + localStorage.setItem("editor-language", selectedLanguage); + + if (props.providerRef.current && mounted) { + latestLanguageChangeId = Date.now(); + props.providerRef.current.awareness.setLocalStateField("editorState", { + language: selectedLanguage, + id: latestLanguageChangeId, + }); + props.updateLanguage(selectedLanguage); + success(`Changed Code Editor's language to ${selectedLanguage}`); + } else { + setMounted(true); + } + }, [selectedLanguage]); useEffect(() => { if (process.env.NEXT_PUBLIC_SIGNALLING_SERVICE_URL === undefined) { @@ -309,6 +298,21 @@ const CollaborativeEditor = forwardRef( .getStates() .get(clientID) as Awareness; + // New section to check for changes in language + if ( + state && + state.editorState && + state.editorState.id !== latestLanguageChangeId + ) { + latestSubmissionId = state.editorState.id; + setSelectedLanguage(state.editorState.language); + // if (props.user === state.user.name) { + // console.log("ownself update ownself"); + // } else { + // console.log("others update ownself"); + // } + } + if ( state && state.submissionResultsState && @@ -382,8 +386,8 @@ const CollaborativeEditor = forwardRef( extensions: [ basicSetup, languageConf.of(python()), - // languageConf.of(javascript()), - // autoLanguage, + // languageConf.of(node()), + autoLanguage, yCollab(ytext, provider.awareness, { undoManager }), keymap.of([indentWithTab]), codeChangeListener, @@ -410,10 +414,12 @@ const CollaborativeEditor = forwardRef(
Select Language: