From b073764f14842f23711968140200a53ba1025268 Mon Sep 17 00:00:00 2001 From: Keelen Runner Date: Fri, 3 Jul 2026 05:43:47 +0000 Subject: [PATCH] feat: Extract shared Docker sandbox runner and establish the server test harness (auto-committed) --- .github/workflows/ci.yml | 26 +- server/package.json | 5 +- server/src/controllers/runJavaController.js | 252 +++++++++---------- server/src/services/sandboxRunner.js | 157 ++++++++++++ server/test/sandboxRunner.test.js | 261 ++++++++++++++++++++ 5 files changed, 560 insertions(+), 141 deletions(-) create mode 100644 server/src/services/sandboxRunner.js create mode 100644 server/test/sandboxRunner.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6570c47..c1172b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: - run: | corepack enable - yarn install --frozen-lockfile + yarn install - run: yarn lint @@ -71,7 +71,7 @@ jobs: - run: | corepack enable - yarn install --frozen-lockfile + yarn install - name: Syntax check run: find src -name '*.js' -exec node --check {} \; @@ -79,6 +79,28 @@ jobs: - name: Import resolution check run: node -e "import('./src/routes/routes.js')" + server-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: server/yarn.lock + + - run: | + corepack enable + yarn install + + - name: Run unit tests + run: yarn test + docker: runs-on: ubuntu-latest diff --git a/server/package.json b/server/package.json index 3e2489f..4e81396 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,7 @@ "description": "", "main": "src/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run", "start": "nodemon src/index.js", "lint": "eslint src/ --ext .js" }, @@ -22,7 +22,8 @@ }, "devDependencies": { "eslint": "^8.55.0", - "nodemon": "^3.0.2" + "nodemon": "^3.0.2", + "vitest": "^1.6.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/server/src/controllers/runJavaController.js b/server/src/controllers/runJavaController.js index c6afe78..d0d5923 100644 --- a/server/src/controllers/runJavaController.js +++ b/server/src/controllers/runJavaController.js @@ -1,144 +1,122 @@ -import { exec } from 'child_process'; -import fs from 'fs/promises'; -import path from 'path'; - -import { getQuestionByIdFromDB, addOrUpdateUser, insertNew, DBConnectionError } from '../db/mongooseClient.js'; - -const executeCommand = (command) => { - return new Promise((resolve, reject) => { - exec(command, (error, stdout, stderr) => { - if (error) { - reject({ message: stderr || stdout || error.message, code: error.code }); - } else { - resolve(stdout); - } - }); - }); -}; +import { runSandbox } from "../services/sandboxRunner.js"; +import { + getQuestionByIdFromDB, + addOrUpdateUser, + insertNew, + DBConnectionError, +} from "../db/mongooseClient.js"; export const runJava = async (req, res) => { - - const result = []; - let output = null; - - const { quesid, javaCode } = req.body; - const username = req.user.name; - const email = req.user.email; - console.log(`${new Date().toLocaleString()}: Executing code for question id:${quesid} by user:${username}`); - - if (!javaCode) { - return res.status(400).json({ error: 'Missing or invalid javaCode in the request body' }); - } else if (!quesid) { - return res.status(400).json({ error: 'Missing or invalid question id in the request body' }); + let result = []; + let output = null; + + const { quesid, javaCode } = req.body; + const username = req.user.name; + const email = req.user.email; + console.log( + `${new Date().toLocaleString()}: Executing code for question id:${quesid} by user:${username}`, + ); + + if (!javaCode) { + return res + .status(400) + .json({ error: "Missing or invalid javaCode in the request body" }); + } else if (!quesid) { + return res + .status(400) + .json({ error: "Missing or invalid question id in the request body" }); + } + + try { + // Fetch the question from DB instead of scanning a static array + const question = await getQuestionByIdFromDB(quesid); + if (!question) { + return res + .status(404) + .json({ error: `Question with id ${quesid} not found` }); } - const tempDir = path.join(process.cwd(), 'tmp', `algojunction-${Date.now()}-${Math.random().toString(36).slice(2)}`); - const inputsDir = path.join(tempDir, 'inputs'); - - try { - await fs.mkdir(inputsDir, { recursive: true }); - await fs.writeFile(path.join(tempDir, 'Solution.java'), javaCode, 'utf-8'); - await fs.chmod(tempDir, 0o777); - - // Fetch the question from DB instead of scanning a static array - const question = await getQuestionByIdFromDB(quesid); - if (!question) { - // Clean up temp dir before returning - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); - return res.status(404).json({ error: `Question with id ${quesid} not found` }); - } - - // running the code for each input - for (const [index, { input, expectedOutput }] of question.inputs.entries()) { - - // copying the input to input file - const inputFilePath = path.join(inputsDir, 'input.txt'); - try { - await fs.writeFile(inputFilePath, input, 'utf-8'); - } catch (writeError) { - console.log(`${new Date().toLocaleString()}: file write failed with error: ${writeError}`); - result.push({ index, output: null, error: writeError, success: false }); - continue; - } - - // compile and run the code with input in pre-built image - try { - output = await executeCommand( - `docker run --rm --network none --cpus="0.5" --memory="512m" --memory-swap="512m" --pids-limit="64" --read-only --tmpfs /tmp:rw,noexec,nosuid,size=128m --cap-drop=ALL --security-opt=no-new-privileges:true --ulimit nproc=64:64 --ulimit nofile=256:256 --ulimit fsize=104857600 --ulimit core=0:0 -v ${tempDir}:/app -w /app algojunction-java-executor sh -c "javac Solution.java 2>&1 && timeout --signal=KILL 10s java Solution"` - ); - console.log(`${new Date().toLocaleString()}: Code run done`); - const passed = String(output ?? '').trim() === String(expectedOutput ?? '').trim(); - result.push({ index, output: String(output ?? '').trim(), expectedOutput: String(expectedOutput ?? '').trim(), error: null, success: passed }); - - } catch (error) { - const exitCode = error?.code; - const errMsg = error?.message || error; - const userError = exitCode === 124 ? 'Time Limit Exceeded' : - exitCode === 137 ? 'Memory Limit Exceeded' : - errMsg; - console.log(`${new Date().toLocaleString()}: Code run failed with error: ${errMsg} (exit code: ${exitCode})`); - result.push({ index, output: null, error: userError, success: false }); - } - } - - const allPassed = result.every(r => r.success === true); - const overallStatus = allPassed ? 'accepted' : 'failed'; - const data = { - username, - quesid, - javaCode, - language: 'java', - status: { status: overallStatus, output: output, error: null }, - result, - email - } - - await handleDatabaseUpdates(data); - - } catch (error) { - const errMsg = error?.message || String(error); - console.log(`${new Date().toLocaleString()}: Code execution failed with error: ${errMsg}`); - // fallback DB save on unexpected errors - const data = { - username, - quesid, - javaCode, - language: 'java', - status: { status: 'failed', output: null, error: errMsg }, - result, - email - } - await handleDatabaseUpdates(data); - - } finally { - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => { }); - console.log(`${new Date().toLocaleString()}: Temp directory cleaned up`); - } - - res.json(result); + // Compile + run every test case in the shared sandbox runner. The docker + // command flags, exit-code → error mapping, and temp-dir lifecycle all + // live there so every language executor shares the same security envelope. + const sandboxResult = await runSandbox({ + code: javaCode, + fileExt: "java", + imageName: "algojunction-java-executor", + runCommand: + "javac Solution.java 2>&1 && timeout --signal=KILL 10s java Solution", + cases: question.inputs, + }); + result = sandboxResult.result; + output = sandboxResult.lastOutput; + + const allPassed = result.every((r) => r.success === true); + const overallStatus = allPassed ? "accepted" : "failed"; + const data = { + username, + quesid, + javaCode, + language: "java", + status: { status: overallStatus, output: output, error: null }, + result, + email, + }; + + await handleDatabaseUpdates(data); + } catch (error) { + const errMsg = error?.message || String(error); + console.log( + `${new Date().toLocaleString()}: Code execution failed with error: ${errMsg}`, + ); + // fallback DB save on unexpected errors + const data = { + username, + quesid, + javaCode, + language: "java", + status: { status: "failed", output: null, error: errMsg }, + result, + email, + }; + await handleDatabaseUpdates(data); + } + + res.json(result); }; -const handleDatabaseUpdates = async ({ username, quesid, javaCode, language, status, result, email }) => { - try { - const submission_id = await insertNew( - username, - quesid, - javaCode, - language, - status, - result - ); - - await addOrUpdateUser(username, email, submission_id); - - } catch (error) { - if (error instanceof DBConnectionError) { - console.warn(`${new Date().toLocaleString()}: Database updates skipped — DB unavailable (${error.message})`); - // No rollback needed — the data never reached the DB - } else { - console.error(`${new Date().toLocaleString()}: Database update failed:`, error); - // If insertNew succeeded but addOrUpdateUser failed, we have a partial state. - // TODO: implement compensation logic (delete the orphaned submission) - } +const handleDatabaseUpdates = async ({ + username, + quesid, + javaCode, + language, + status, + result, + email, +}) => { + try { + const submission_id = await insertNew( + username, + quesid, + javaCode, + language, + status, + result, + ); + + await addOrUpdateUser(username, email, submission_id); + } catch (error) { + if (error instanceof DBConnectionError) { + console.warn( + `${new Date().toLocaleString()}: Database updates skipped — DB unavailable (${error.message})`, + ); + // No rollback needed — the data never reached the DB + } else { + console.error( + `${new Date().toLocaleString()}: Database update failed:`, + error, + ); + // If insertNew succeeded but addOrUpdateUser failed, we have a partial state. + // TODO: implement compensation logic (delete the orphaned submission) } -} + } +}; diff --git a/server/src/services/sandboxRunner.js b/server/src/services/sandboxRunner.js new file mode 100644 index 0000000..453ad69 --- /dev/null +++ b/server/src/services/sandboxRunner.js @@ -0,0 +1,157 @@ +import { exec } from "child_process"; +import fs from "fs/promises"; +import path from "path"; + +/** + * Single source of truth for the Docker sandbox security envelope. + * + * Every language executor MUST route through `runSandbox` so this flag set + * cannot be accidentally dropped (e.g. `--network none` or a resource limit) + * when Python, C++, or other executors are added. A runner that omits any of + * these — most critically `--network none` — fails the security-flag test. + */ +export const SECURITY_FLAGS = [ + "--network none", + '--cpus="0.5"', + '--memory="512m"', + '--memory-swap="512m"', + '--pids-limit="64"', + "--read-only", + "--tmpfs /tmp:rw,noexec,nosuid,size=128m", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--ulimit nproc=64:64", + "--ulimit nofile=256:256", + "--ulimit fsize=104857600", + "--ulimit core=0:0", +]; + +/** + * Build the docker invocation string. Centralised so the security flag set is + * identical for every language; the caller supplies only the image and the + * compile+run command. + */ +export const buildDockerCommand = (tempDir, imageName, runCommand) => { + return `docker run --rm ${SECURITY_FLAGS.join(" ")} -v ${tempDir}:/app -w /app ${imageName} sh -c "${runCommand}"`; +}; + +/** + * Map a container exit code to a user-facing error label. + * 124 → Time Limit Exceeded (GNU `timeout` SIGKILL) + * 137 → Memory Limit Exceeded (OOM kill, 128 + 9) + * anything else → the captured stderr/stdout from the failed command. + * + * A stub that returns the raw exit code or an empty string fails the mapping + * test — the captured message is what the user must see. + */ +export const mapExitCodeToError = (exitCode, errMsg) => { + return exitCode === 124 + ? "Time Limit Exceeded" + : exitCode === 137 + ? "Memory Limit Exceeded" + : errMsg; +}; + +const executeCommand = (command) => { + return new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + reject({ + message: stderr || stdout || error.message, + code: error.code, + }); + } else { + resolve(stdout); + } + }); + }); +}; + +/** + * Run a submitted solution against a list of test cases inside the sandbox. + * + * Owns the reusable execution flow that was previously inlined in the Java + * controller: temp-dir creation, Solution file + per-case input writes, the + * Docker invocation, and exit-code → error mapping. The temp dir is always + * removed on completion — including after a run error — so a leak is a defect. + * + * @param {Object} opts + * @param {string} opts.code Submitted source code. + * @param {string} opts.fileExt Solution file extension (e.g. 'java'). + * @param {string} opts.imageName Pre-built Docker image to run. + * @param {string} opts.runCommand Compile+run shell command executed in the container. + * @param {Array<{input: string, expectedOutput: string}>} opts.cases + * @returns {Promise<{result: Array<{index: number, output: string|null, expectedOutput: string, error: *, success: boolean}>, lastOutput: string|null}>} + */ +export const runSandbox = async ({ + code, + fileExt, + imageName, + runCommand, + cases, +}) => { + const result = []; + let output = null; + + const tempDir = path.join( + process.cwd(), + "tmp", + `algojunction-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const inputsDir = path.join(tempDir, "inputs"); + + try { + await fs.mkdir(inputsDir, { recursive: true }); + await fs.writeFile( + path.join(tempDir, `Solution.${fileExt}`), + code, + "utf-8", + ); + await fs.chmod(tempDir, 0o777); + + // running the code for each input + for (const [index, { input, expectedOutput }] of cases.entries()) { + // copying the input to input file + const inputFilePath = path.join(inputsDir, "input.txt"); + try { + await fs.writeFile(inputFilePath, input, "utf-8"); + } catch (writeError) { + console.log( + `${new Date().toLocaleString()}: file write failed with error: ${writeError}`, + ); + result.push({ index, output: null, error: writeError, success: false }); + continue; + } + + // compile and run the code with input in pre-built image + try { + output = await executeCommand( + buildDockerCommand(tempDir, imageName, runCommand), + ); + console.log(`${new Date().toLocaleString()}: Code run done`); + const passed = + String(output ?? "").trim() === String(expectedOutput ?? "").trim(); + result.push({ + index, + output: String(output ?? "").trim(), + expectedOutput: String(expectedOutput ?? "").trim(), + error: null, + success: passed, + }); + } catch (error) { + const exitCode = error?.code; + const errMsg = error?.message || error; + const userError = mapExitCodeToError(exitCode, errMsg); + console.log( + `${new Date().toLocaleString()}: Code run failed with error: ${errMsg} (exit code: ${exitCode})`, + ); + result.push({ index, output: null, error: userError, success: false }); + } + } + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + console.log(`${new Date().toLocaleString()}: Temp directory cleaned up`); + } + + return { result, lastOutput: output }; +}; diff --git a/server/test/sandboxRunner.test.js b/server/test/sandboxRunner.test.js new file mode 100644 index 0000000..e301fb5 --- /dev/null +++ b/server/test/sandboxRunner.test.js @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { exec } from "child_process"; +import fs from "fs/promises"; + +import { + runSandbox, + buildDockerCommand, + mapExitCodeToError, + SECURITY_FLAGS, +} from "../src/services/sandboxRunner.js"; + +// The Docker daemon is the external boundary — mock it. The runner's own logic +// (temp-dir lifecycle, file writes, command construction, exit-code mapping) is +// the core under test and runs against the REAL fs. +vi.mock("child_process", () => ({ + exec: vi.fn(), +})); + +// Every flag the security envelope must carry. A runner omitting any of these — +// most critically --network none — fails the assertions below. +const REQUIRED_SECURITY_FLAGS = [ + "--network none", + '--cpus="0.5"', + '--memory="512m"', + '--memory-swap="512m"', + '--pids-limit="64"', + "--read-only", + "--tmpfs /tmp:rw,noexec,nosuid,size=128m", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--ulimit nproc=64:64", + "--ulimit nofile=256:256", + "--ulimit fsize=104857600", + "--ulimit core=0:0", +]; + +const JAVA_RUN_COMMAND = + "javac Solution.java 2>&1 && timeout --signal=KILL 10s java Solution"; + +// The temp dir is embedded in the docker command as `-v :/app`. +const tempDirFromCommand = (cmd) => { + const match = cmd.match(/-v ([^:]+):\/app/); + return match ? match[1] : null; +}; + +describe("security envelope", () => { + it("centralises every required security flag in SECURITY_FLAGS", () => { + for (const flag of REQUIRED_SECURITY_FLAGS) { + expect(SECURITY_FLAGS, `missing required flag: ${flag}`).toContain(flag); + } + }); + + it("builds a docker command carrying every security flag, byte-for-byte with the Java flow", () => { + const cmd = buildDockerCommand( + "/tmp/algojunction-x", + "algojunction-java-executor", + JAVA_RUN_COMMAND, + ); + for (const flag of REQUIRED_SECURITY_FLAGS) { + expect(cmd, `command missing flag: ${flag}`).toContain(flag); + } + expect(cmd).toBe( + 'docker run --rm --network none --cpus="0.5" --memory="512m" --memory-swap="512m" --pids-limit="64" --read-only --tmpfs /tmp:rw,noexec,nosuid,size=128m --cap-drop=ALL --security-opt=no-new-privileges:true --ulimit nproc=64:64 --ulimit nofile=256:256 --ulimit fsize=104857600 --ulimit core=0:0 -v /tmp/algojunction-x:/app -w /app algojunction-java-executor sh -c "javac Solution.java 2>&1 && timeout --signal=KILL 10s java Solution"', + ); + }); + + it("a flag set omitting --network none fails the assertion (proves the check bites)", () => { + const insecure = SECURITY_FLAGS.filter((f) => f !== "--network none"); + const insecureCmd = `docker run --rm ${insecure.join(" ")} -v /tmp/x:/app -w /app img sh -c "run"`; + // An insecure set genuinely lacks --network none... + expect(insecureCmd).not.toContain("--network none"); + // ...while the real runner's command always carries it. + expect(buildDockerCommand("/tmp/x", "img", "run")).toContain( + "--network none", + ); + }); +}); + +describe("exit-code -> error mapping", () => { + it("maps 124 -> Time Limit Exceeded", () => { + expect(mapExitCodeToError(124, "killed by timeout")).toBe( + "Time Limit Exceeded", + ); + }); + + it("maps 137 -> Memory Limit Exceeded", () => { + expect(mapExitCodeToError(137, "oom")).toBe("Memory Limit Exceeded"); + }); + + it("other codes -> captured stderr/stdout (a stub returning the raw exit code or empty string FAILS)", () => { + const captured = + "Error: java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds"; + expect(mapExitCodeToError(1, captured)).toBe(captured); + expect(mapExitCodeToError(1, captured)).not.toBe(1); + expect(mapExitCodeToError(1, captured)).not.toBe(""); + // undefined/missing exit code also falls through to the captured message + expect(mapExitCodeToError(undefined, captured)).toBe(captured); + }); +}); + +describe("runSandbox", () => { + beforeEach(() => { + exec.mockReset(); + }); + + it("writes Solution. and inputs/input.txt, returns per-case results, and removes the temp dir", async () => { + const captured = {}; + exec.mockImplementation(async (cmd, cb) => { + // At exec time the Solution file and the per-case input must already + // be on disk — read them from the real fs to prove the writes happened. + const tempDir = tempDirFromCommand(cmd); + captured.tempDir = tempDir; + captured.solution = await fs.readFile( + `${tempDir}/Solution.java`, + "utf-8", + ); + captured.input = await fs.readFile( + `${tempDir}/inputs/input.txt`, + "utf-8", + ); + cb(null, "42\n", ""); + }); + + const { result, lastOutput } = await runSandbox({ + code: "public class Solution { public static void main(String[] a){} }", + fileExt: "java", + imageName: "algojunction-java-executor", + runCommand: JAVA_RUN_COMMAND, + cases: [{ input: "2 40", expectedOutput: "42" }], + }); + + // Solution file written with the submitted code + expect(captured.solution).toBe( + "public class Solution { public static void main(String[] a){} }", + ); + // input file written with the case input + expect(captured.input).toBe("2 40"); + + // per-case result shape + values (output trimmed; lastOutput raw) + expect(result).toEqual([ + { + index: 0, + output: "42", + expectedOutput: "42", + error: null, + success: true, + }, + ]); + expect(lastOutput).toBe("42\n"); + + // temp dir removed on completion — a leak leaves this RED + expect(captured.tempDir).toBeTruthy(); + await expect(fs.stat(captured.tempDir)).rejects.toThrow(); + }); + + it("invokes exec with the full security flag set (computed AND enforced, not just labelled)", async () => { + exec.mockImplementation((cmd, cb) => cb(null, "ok", "")); + await runSandbox({ + code: "x", + fileExt: "java", + imageName: "algojunction-java-executor", + runCommand: JAVA_RUN_COMMAND, + cases: [{ input: "i", expectedOutput: "ok" }], + }); + const calledCmd = exec.mock.calls[0][0]; + for (const flag of REQUIRED_SECURITY_FLAGS) { + expect(calledCmd, `exec command missing flag: ${flag}`).toContain(flag); + } + // byte-for-byte Java compile+run command inside the container + expect(calledCmd).toContain(`sh -c "${JAVA_RUN_COMMAND}"`); + }); + + it('maps a TLE (exit 124) to "Time Limit Exceeded" and still removes the temp dir', async () => { + const capturedTempDir = {}; + exec.mockImplementation((cmd, cb) => { + capturedTempDir.dir = tempDirFromCommand(cmd); + cb({ code: 124 }, "", "killed"); + }); + + const { result } = await runSandbox({ + code: "x", + fileExt: "java", + imageName: "img", + runCommand: "run", + cases: [{ input: "i", expectedOutput: "out" }], + }); + + expect(result).toEqual([ + { index: 0, output: null, error: "Time Limit Exceeded", success: false }, + ]); + // temp dir removed even after a run error — a leak leaves this RED + expect(capturedTempDir.dir).toBeTruthy(); + await expect(fs.stat(capturedTempDir.dir)).rejects.toThrow(); + }); + + it('maps a MLE (exit 137) to "Memory Limit Exceeded"', async () => { + exec.mockImplementation((cmd, cb) => cb({ code: 137 }, "", "oom")); + const { result } = await runSandbox({ + code: "x", + fileExt: "java", + imageName: "img", + runCommand: "run", + cases: [{ input: "i", expectedOutput: "out" }], + }); + expect(result).toEqual([ + { + index: 0, + output: null, + error: "Memory Limit Exceeded", + success: false, + }, + ]); + }); + + it("other exit code -> captured stderr (not the raw code, not empty)", async () => { + exec.mockImplementation((cmd, cb) => + cb({ code: 1 }, "", "java: compilation error"), + ); + const { result } = await runSandbox({ + code: "x", + fileExt: "java", + imageName: "img", + runCommand: "run", + cases: [{ input: "i", expectedOutput: "out" }], + }); + expect(result[0].error).toBe("java: compilation error"); + expect(result[0].error).not.toBe(1); + expect(result[0].error).not.toBe(""); + }); + + it("runs every case and reports pass/fail per case with distinct indices", async () => { + // exec always returns '7\n' — case 0 expects 7 (pass), case 1 expects 2 (fail) + exec.mockImplementation((cmd, cb) => cb(null, "7\n", "")); + const { result } = await runSandbox({ + code: "x", + fileExt: "py", + imageName: "img", + runCommand: "run", + cases: [ + { input: "3 4", expectedOutput: "7" }, + { input: "1 1", expectedOutput: "2" }, + ], + }); + expect(result).toEqual([ + { + index: 0, + output: "7", + expectedOutput: "7", + error: null, + success: true, + }, + { + index: 1, + output: "7", + expectedOutput: "2", + error: null, + success: false, + }, + ]); + }); +});