diff --git a/.freeCodeCamp/plugin/index.js b/.freeCodeCamp/plugin/index.js index 4f08ad30..094988d7 100644 --- a/.freeCodeCamp/plugin/index.js +++ b/.freeCodeCamp/plugin/index.js @@ -34,14 +34,14 @@ import { logover } from '../tooling/logger.js'; * @typedef {Object} Lesson * @property {{watch?: string[]; ignore?: string[]} | undefined} meta * @property {string} description - * @property {[[string, string]]} tests + * @property {Array<{ text: string; runner: string; code: string; }>} tests * @property {string[]} hints * @property {[{filePath: string; fileSeed: string} | string]} seed * @property {boolean?} isForce - * @property {string?} beforeAll - * @property {string?} afterAll - * @property {string?} beforeEach - * @property {string?} afterEach + * @property {{ runner: string; code: string; } | null} beforeAll + * @property {{ runner: string; code: string; } | null} afterAll + * @property {{ runner: string; code: string; } | null} beforeEach + * @property {{ runner: string; code: string; } | null} afterEach */ export const pluginEvents = { @@ -141,10 +141,9 @@ export const pluginEvents = { const { afterAll, afterEach, beforeAll, beforeEach, isForce, meta } = lesson; const description = parseMarkdown(lesson.description).trim(); - const tests = lesson.tests.map(([testText, test]) => [ - parseMarkdown(testText).trim(), - test - ]); + const tests = lesson.tests.map(t => { + return { ...t, text: parseMarkdown(t.text).trim() }; + }); const hints = lesson.hints.map(h => parseMarkdown(h).trim()); return { meta, diff --git a/.freeCodeCamp/tooling/parser.js b/.freeCodeCamp/tooling/parser.js index 3501599e..215a111e 100644 --- a/.freeCodeCamp/tooling/parser.js +++ b/.freeCodeCamp/tooling/parser.js @@ -183,6 +183,7 @@ export class CoffeeDown { * Get first code block text from tokens * * Meant to be used with `getBeforeAll`, `getAfterAll`, `getBeforeEach`, and `getAfterEach` + * @returns {{ runner: string; code: string; } | null} */ get code() { const callers = [ @@ -198,7 +199,33 @@ export class CoffeeDown { }` ); } - return this.tokens.find(t => t.type === 'code')?.text; + + for (const token of this.tokens) { + if (token.type === 'code') { + let runner = 'node'; + switch (token.lang) { + case 'js': + case 'javascript': + runner = 'Node'; + break; + case 'py': + case 'python': + runner = 'Python'; + break; + default: + break; + } + + const code = token.text; + const test = { + runner, + code + }; + return test; + } + } + + return null; } get seed() { @@ -210,6 +237,9 @@ export class CoffeeDown { return seedToIterator(this.tokens); } + /** + * @returns {Array<{ text: string; runner: string; code: string; }>} + */ get tests() { if (this.caller !== 'getTests') { throw new Error( @@ -217,18 +247,36 @@ export class CoffeeDown { ); } const textTokens = []; - const testTokens = []; + const tests = []; for (const token of this.tokens) { if (token.type === 'paragraph') { textTokens.push(token); } if (token.type === 'code') { - testTokens.push(token); + let runner = 'node'; + switch (token.lang) { + case 'js': + case 'javascript': + runner = 'Node'; + break; + case 'py': + case 'python': + runner = 'Python'; + break; + default: + break; + } + + const code = token.text; + const test = { + runner, + code + }; + tests.push(test); } } const texts = textTokens.map(t => t.text); - const tests = testTokens.map(t => t.text); - return texts.map((text, i) => [text, tests[i]]); + return texts.map((text, i) => ({ text, ...tests[i] })); } get hints() { @@ -332,7 +380,7 @@ marked.use( ); export function parseMarkdown(markdown) { - return marked.parse(markdown, { gfm: true }); + return marked.parse(markdown, { gfm: true, async: false }); } const TOKENS = [ diff --git a/.freeCodeCamp/tooling/tests/test-worker.js b/.freeCodeCamp/tooling/tests/Node.js similarity index 100% rename from .freeCodeCamp/tooling/tests/test-worker.js rename to .freeCodeCamp/tooling/tests/Node.js diff --git a/.freeCodeCamp/tooling/tests/Python.js b/.freeCodeCamp/tooling/tests/Python.js new file mode 100644 index 00000000..d00c6d9d --- /dev/null +++ b/.freeCodeCamp/tooling/tests/Python.js @@ -0,0 +1,20 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import { runPython } from './utils.js'; + +const { beforeEach = '' } = workerData; + +parentPort.on('message', async ({ testCode, testId }) => { + let passed = false; + let error = null; + try { + const _out = await runPython(` +${beforeEach?.code ?? ''} + +${testCode} +`); + passed = true; + } catch (e) { + error = e; + } + parentPort.postMessage({ passed, testId, error }); +}); diff --git a/.freeCodeCamp/tooling/tests/main.js b/.freeCodeCamp/tooling/tests/main.js index db7c135c..2beeadab 100644 --- a/.freeCodeCamp/tooling/tests/main.js +++ b/.freeCodeCamp/tooling/tests/main.js @@ -17,6 +17,7 @@ import { join } from 'node:path'; import { Worker } from 'node:worker_threads'; import { pluginEvents } from '../../plugin/index.js'; import { t } from '../t.js'; +import { runPython } from './utils.js'; try { const plugins = freeCodeCampConfig.tooling?.plugins; @@ -51,10 +52,35 @@ export async function runTests(ws, projectDashedName) { ); const { beforeAll, beforeEach, afterAll, afterEach, hints, tests } = lesson; + const testers = [beforeAll, beforeEach, afterAll, afterEach, ...tests]; + let firstRunner = testers.filter(t => t !== null).at(0).runner; + + for (const tester of testers) { + if (!tester) { + continue; + } + const runner = tester.runner; + + if (runner !== firstRunner) { + throw new Error( + `All tests and hooks must use the same runner. Found: ${runner}, expected: ${firstRunner}` + ); + } + } + if (beforeAll) { try { logover.debug('Starting: --before-all-- hook'); - await eval(`(async () => {${beforeAll}})()`); + switch (beforeAll.runner) { + case 'Node': + await eval(`(async () => {${beforeAll.code}})()`); + break; + case 'Python': + await runPython(beforeAll.code); + break; + default: + throw new Error(`Unsupported runner: ${beforeAll.runner}`); + } logover.debug('Finished: --before-all-- hook'); } catch (e) { logover.error('--before-all-- hook failed to run:'); @@ -63,10 +89,10 @@ export async function runTests(ws, projectDashedName) { } // toggleLoaderAnimation(ws); - testsState = tests.map((text, i) => { + testsState = tests.map((t, i) => { return { passed: false, - testText: text[0], + testText: t.text, testId: i, isLoading: !project.blockingTests }; @@ -80,7 +106,10 @@ export async function runTests(ws, projectDashedName) { // Create one worker for each test if non-blocking. // TODO: See if holding pool of workers is better. if (project.blockingTests) { - const worker = createWorker('blocking-worker', { beforeEach, project }); + const worker = createWorker('blocking-worker', firstRunner, { + beforeEach, + project + }); WORKER_POOL.push(worker); // When result is received back from worker, update the client state @@ -104,20 +133,23 @@ export async function runTests(ws, projectDashedName) { }); for (let i = 0; i < tests.length; i++) { - const [_text, testCode] = tests[i]; + const { code } = tests[i]; testsState[i].isLoading = true; updateTest(ws, testsState[i]); - worker.postMessage({ testCode, testId: i }); + worker.postMessage({ testCode: code, testId: i }); } } else { // Run tests in parallel, and in own worker threads for (let i = 0; i < tests.length; i++) { - const [_text, testCode] = tests[i]; + const { code, runner } = tests[i]; testsState[i].isLoading = true; updateTest(ws, testsState[i]); - const worker = createWorker(`worker-${i}`, { beforeEach, project }); + const worker = createWorker(`worker-${i}`, runner, { + beforeEach, + project + }); WORKER_POOL.push(worker); // When result is received back from worker, update the client state @@ -141,7 +173,7 @@ export async function runTests(ws, projectDashedName) { }); }); - worker.postMessage({ testCode, testId: i }); + worker.postMessage({ testCode: code, testId: i }); } } @@ -150,9 +182,7 @@ export async function runTests(ws, projectDashedName) { testsState[testId].isLoading = false; testsState[testId].passed = passed; if (error) { - if (error.type !== 'AssertionError') { - logover.error(`Test #${testId}:`, error); - } + logover.error(`Test #${testId}:`, error); if (error.message) { const assertionTranslation = await t(error.message, {}); @@ -217,7 +247,16 @@ async function checkTestsCallback({ if (afterAll) { try { logover.debug('Starting: --after-all-- hook'); - await eval(`(async () => {${afterAll}})()`); + switch (afterAll.runner) { + case 'Node': + await eval(`(async () => {${afterAll.code}})()`); + break; + case 'Python': + await runPython(afterAll.code); + break; + default: + throw new Error(`Unsupported runner: ${afterAll.runner}`); + } logover.debug('Finished: --after-all-- hook'); } catch (e) { logover.error('--after-all-- hook failed to run:'); @@ -236,11 +275,11 @@ async function checkTestsCallback({ * @param {number} param0.exitCode * @param {Array} param0.testsState * @param {number} param0.i - * @param {string} param0.afterEach + * @param {{ runner: string; code: string;} | null} param0.afterEach * @param {object} param0.error * @param {object} param0.project * @param {Array} param0.hints - * @param {string} param0.afterAll + * @param {{ runner: string; code: string;} | null} param0.afterAll * @param {number} param0.lessonNumber */ async function handleWorkerExit({ @@ -280,8 +319,22 @@ async function handleWorkerExit({ updateTests(ws, testsState); } // Run afterEach even if tests are cancelled + // TODO: Double-check this is not run twice try { - const _afterEachOut = await eval(`(async () => { ${afterEach} })();`); + if (afterEach) { + logover.debug('Starting: --after-each-- hook'); + switch (afterEach.runner) { + case 'Node': + await eval(`(async () => {${afterEach.code}})()`); + break; + case 'Python': + await runPython(afterEach.code); + break; + default: + throw new Error(`Unsupported runner: ${afterEach.runner}`); + } + logover.debug('Finished: --after-each-- hook'); + } } catch (e) { logover.error('--after-each-- hook failed to run:'); logover.error(e); @@ -297,13 +350,20 @@ async function handleWorkerExit({ }); } -function createWorker(name, workerData) { +/** + * + * @param {string} name unique name for the worker + * @param {string} runner runner to use + * @param {*} workerData + * @returns {Worker} + */ +function createWorker(name, runner, workerData) { return new Worker( join( ROOT, 'node_modules/@freecodecamp/freecodecamp-os', '.freeCodeCamp/tooling/tests', - 'test-worker.js' + `${runner}.js` ), { name, diff --git a/.freeCodeCamp/tooling/tests/utils.js b/.freeCodeCamp/tooling/tests/utils.js new file mode 100644 index 00000000..62a740e2 --- /dev/null +++ b/.freeCodeCamp/tooling/tests/utils.js @@ -0,0 +1,34 @@ +import { spawn } from 'node:child_process'; + +/** + * Runs the provided Python code. + * + * If the python code raises an error, this function rejects with that error as a string. + * Otherwise, resolves with the python process stdout. + * + * @param {string} code + */ +export async function runPython(code) { + const child = spawn('python3', ['-c', code]); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', data => { + process.stdout.write(data); + stdout += data; + }); + + child.stderr.on('data', data => { + stderr += data; + }); + + return new Promise((resolve, reject) => { + child.on('close', code => { + if (stderr) { + reject(stderr); + } + resolve(stdout); + }); + }); +} diff --git a/.freeCodeCamp/tooling/validate.js b/.freeCodeCamp/tooling/validate.js index daa41b90..0866496e 100644 --- a/.freeCodeCamp/tooling/validate.js +++ b/.freeCodeCamp/tooling/validate.js @@ -389,28 +389,29 @@ export async function validateCurriculum() { ); } else { for (const test of tests) { - if (test.length !== 2) { + const { text, code, runner } = test; + + if (typeof text !== 'string') { + panic( + `Invalid test text in lesson ${i} of ${dashedName}`, + text, + 'Test text should be a string' + ); + } + if (typeof code !== 'string') { panic( `Invalid test in lesson ${i} of ${dashedName}`, - test, - 'Test should be an array of two strings' + code, + 'Test should be a string' + ); + } + + if (runner && typeof runner !== 'string') { + panic( + `Invalid runner in lesson ${i} of ${dashedName}`, + runner, + 'Runner should be a string' ); - } else { - const [testText, testCode] = test; - if (typeof testText !== 'string') { - panic( - `Invalid test text in lesson ${i} of ${dashedName}`, - testText, - 'Test text should be a string' - ); - } - if (typeof testCode !== 'string') { - panic( - `Invalid test in lesson ${i} of ${dashedName}`, - testCode, - 'Test should be a string' - ); - } } } } @@ -463,33 +464,73 @@ export async function validateCurriculum() { ); } } - if (beforeAll && typeof beforeAll !== 'string') { - panic( - `Invalid beforeAll in lesson ${i} of ${dashedName}`, - beforeAll, - 'beforeAll should be a string' - ); + if (beforeAll) { + const { runner, code } = beforeAll; + if (typeof runner !== 'string') { + panic( + `Invalid beforeAll in lesson ${i} of ${dashedName}`, + runner, + 'Runner should be a string' + ); + } + if (typeof code !== 'string') { + panic( + `Invalid beforeAll in lesson ${i} of ${dashedName}`, + code, + 'Code should be a string' + ); + } } - if (afterAll && typeof afterAll !== 'string') { - panic( - `Invalid afterAll in lesson ${i} of ${dashedName}`, - afterAll, - 'afterAll should be a string' - ); + if (afterAll) { + const { runner, code } = afterAll; + if (typeof runner !== 'string') { + panic( + `Invalid afterAll in lesson ${i} of ${dashedName}`, + runner, + 'Runner should be a string' + ); + } + if (typeof code !== 'string') { + panic( + `Invalid afterAll in lesson ${i} of ${dashedName}`, + code, + 'Code should be a string' + ); + } } - if (beforeEach && typeof beforeEach !== 'string') { - panic( - `Invalid beforeEach in lesson ${i} of ${dashedName}`, - beforeEach, - 'beforeEach should be a string' - ); + if (beforeEach) { + const { runner, code } = beforeEach; + if (typeof runner !== 'string') { + panic( + `Invalid beforeEach in lesson ${i} of ${dashedName}`, + runner, + 'Runner should be a string' + ); + } + if (typeof code !== 'string') { + panic( + `Invalid beforeEach in lesson ${i} of ${dashedName}`, + code, + 'Code should be a string' + ); + } } - if (afterEach && typeof afterEach !== 'string') { - panic( - `Invalid afterEach in lesson ${i} of ${dashedName}`, - afterEach, - 'afterEach should be a string' - ); + if (afterEach) { + const { runner, code } = afterEach; + if (typeof runner !== 'string') { + panic( + `Invalid afterEach in lesson ${i} of ${dashedName}`, + runner, + 'Runner should be a string' + ); + } + if (typeof code !== 'string') { + panic( + `Invalid afterEach in lesson ${i} of ${dashedName}`, + code, + 'Code should be a string' + ); + } } if (meta?.watch && meta?.ignore) { panic( diff --git a/.npmignore b/.npmignore index c65dba4a..6a4e3059 100644 --- a/.npmignore +++ b/.npmignore @@ -17,3 +17,4 @@ self CONTRIBUTING.md Dockerfile renovate.json +runner diff --git a/docs/src/project-syntax.md b/docs/src/project-syntax.md index 4e49ea36..cebb2755 100644 --- a/docs/src/project-syntax.md +++ b/docs/src/project-syntax.md @@ -84,7 +84,7 @@ This is the description content. -```js +``` ``` ```` @@ -102,6 +102,8 @@ assert.equal(true, true); ```` ````` +Available runners: `javascript`, `js`, `python`, `py` + ### `### --seed--` ````markdown @@ -332,15 +334,21 @@ assert.equal(true, true); ### --description-- -Description. +This test uses a Python runner ### --tests-- -You should ... +`a` should be 100. -```js -await new Promise(resolve => setTimeout(resolve, 2000)); -assert.equal(true, true); +```python +if a != 100: + raise Exception('a is not 100') +``` + +### --before-each-- + +```python +a = 100 ``` ## 5 diff --git a/package-lock.json b/package-lock.json index b0487e9f..8421903b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@freecodecamp/freecodecamp-os", - "version": "3.5.1", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@freecodecamp/freecodecamp-os", - "version": "3.5.1", + "version": "4.0.0", "dependencies": { "chai": "4.5.0", "chokidar": "3.6.0", "express": "4.21.2", "logover": "2.0.0", - "marked": "9.1.6", + "marked": "15.0.7", "marked-highlight": "2.2.1", "prismjs": "1.30.0", "ws": "8.18.1" @@ -4672,14 +4672,15 @@ } }, "node_modules/marked": { - "version": "9.1.6", - "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", - "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.7.tgz", + "integrity": "sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg==", + "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 16" + "node": ">= 18" } }, "node_modules/marked-highlight": { diff --git a/package.json b/package.json index 19a061a5..a7a265e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@freecodecamp/freecodecamp-os", "author": "freeCodeCamp", - "version": "3.5.1", + "version": "4.0.0", "description": "Package used for freeCodeCamp projects with the freeCodeCamp Courses VSCode extension", "scripts": { "build:client": "NODE_ENV=production webpack --config ./.freeCodeCamp/webpack.config.cjs", @@ -17,7 +17,7 @@ "chokidar": "3.6.0", "express": "4.21.2", "logover": "2.0.0", - "marked": "9.1.6", + "marked": "15.0.7", "marked-highlight": "2.2.1", "prismjs": "1.30.0", "ws": "8.18.1" @@ -52,4 +52,4 @@ "url": "https://github.com/freeCodeCamp/freeCodeCampOS" }, "type": "module" -} +} \ No newline at end of file diff --git a/runner/.gitignore b/runner/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/runner/.gitignore @@ -0,0 +1 @@ +/target diff --git a/runner/Cargo.toml b/runner/Cargo.toml new file mode 100644 index 00000000..af224bf6 --- /dev/null +++ b/runner/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "runner" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +tempfile = "3.18.0" diff --git a/runner/README.md b/runner/README.md new file mode 100644 index 00000000..40858155 --- /dev/null +++ b/runner/README.md @@ -0,0 +1,37 @@ +# Runner + +## Requirements + +- Tests with different runners may not depend on one another + - Runners are grouped by type, and run together + +## API + +A test outputs: + +```json +{ + "id": , + "error": null | "" +} +``` + +The binary outputs all successful runs to `stdout`. `stderr` is used for bad inputs, and anything preventing the runner from being called. + +`stdout` either consists of a JSON-serializable test output: + +```json +{ + "id": , + "test_state": { + "kind": "passed" | "failed" | "cancelled" | "neutral", + "message": "" + } +} +``` + +Or, the output from the process `stdout`/`stderr`: + +```bash + +``` diff --git a/runner/scripts/node.js b/runner/scripts/node.js new file mode 100644 index 00000000..cbf2addb --- /dev/null +++ b/runner/scripts/node.js @@ -0,0 +1,51 @@ +async function main() { + try { + // --runner-start-before_all-- + // --runner-end-before_all-- + // + const settled = []; + // --runner-start-tests-- + // --runner-end-tests-- + const prom = await Promise.allSettled(settled); + prom.forEach((p, i) => { + if (p.status === "rejected") { + handle_error(p.reason, i); + } + }); + // + // --runner-start-tests-break_on_failure-- + // --runner-end-tests-break_on_failure-- + // + // --runner-start-tests-blocking_tests-- + // --runner-end-tests-blocking_tests-- + // + // --runner-start-tests-break_on_failure-blocking_tests-- + // --runner-end-tests-break_on_failure-blocking_tests-- + // + // --runner-start-after_all-- + // --runner-end-after_all-- + // + // --runner-start-test_functions-- + // --runner-end-test_functions-- + } catch (e) { + throw e; + } +} + +function handle_error(e, id) { + const error = { + error: {}, + id: id, + }; + Object.getOwnPropertyNames(e).forEach((key) => { + error.error[key] = e[key]; + }); + // Cannot pass `e` "as is", because classes cannot be passed between threads + // TODO: Switch for `instanceof AssertionError` + // error.type = e instanceof Error ? "Error" : "AssertionError"; + console.error(JSON.stringify(error)); +} + +main() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); diff --git a/runner/scripts/python.py b/runner/scripts/python.py new file mode 100644 index 00000000..f426c924 --- /dev/null +++ b/runner/scripts/python.py @@ -0,0 +1,22 @@ +import sys + +def handle_error(e, id): + print('{{ "error": "{}", "id": {} }}'.format(e, id), file=sys.stderr) + +try: + # --runner-start-before_all-- + # --runner-end-before_all-- + # + # --runner-start-test_functions-- + # --runner-end-test_functions-- + # + # --runner-start-tests-- + # --runner-end-tests-- + # + # --runner-start-tests-break_on_failure-- + # --runner-end-tests-break_on_failure-- + # + # --runner-start-after_all-- + # --runner-end-after_all-- +except: + handle_error('Error in Python script', "") diff --git a/runner/src/config.rs b/runner/src/config.rs new file mode 100644 index 00000000..4a1d10cb --- /dev/null +++ b/runner/src/config.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub project: ProjectConfig, + pub lesson: Lesson, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct ProjectConfig { + #[serde(default)] + pub break_on_failure: bool, + #[serde(default)] + pub blocking_tests: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Lesson { + #[serde(default)] + pub after_all: Vec, + #[serde(default)] + pub after_each: Vec, + #[serde(default)] + pub before_all: Vec, + #[serde(default)] + pub before_each: Vec, + pub id: usize, + pub tests: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Test { + pub code: String, + pub id: usize, + pub runner: Runner, + #[serde(default)] + pub state: TestState, + /// Whether or not to run immediately. Runs out of context. + #[serde(default)] + pub force: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Runner { + Node, + Rust, + Bash, + Python, +} + +pub trait Script { + fn new(content: String, config: ProjectConfig) -> Self; + fn handle_before_all(&mut self, test: &Test); + fn handle_test(&mut self, test: &Test, before_each: Option<&Test>, after_each: Option<&Test>); + fn handle_after_all(&mut self, test: &Test); + fn run(&self) -> Result<(), std::io::Error>; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestOut { + pub id: usize, + pub state: TestState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Runout { + pub id: usize, + pub error: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(content = "reason", tag = "condition")] +pub enum TestState { + /// Test successfully executed + Passed, + /// Test failed to execute + Failed { message: String }, + /// Test started, but was ended before completion + Cancelled, + /// Test was not executed + Neutral, +} + +impl Default for TestState { + fn default() -> Self { + TestState::Neutral + } +} diff --git a/runner/src/main.rs b/runner/src/main.rs new file mode 100644 index 00000000..83db98e9 --- /dev/null +++ b/runner/src/main.rs @@ -0,0 +1,100 @@ +use config::{Config, Runner, Script, Test}; +use node::NodeFile; +use python::PythonFile; +use std::io::Read; + +mod config; +mod node; +mod python; +mod utils; + +const NODE_SCRIPT: &str = include_str!("../scripts/node.js"); +const PYTHON_SCRIPT: &str = include_str!("../scripts/python.py"); + +fn main() { + let mut stdin = std::io::stdin(); + let mut buffer = String::new(); + + stdin.read_to_string(&mut buffer).unwrap(); + + let config: Config = serde_json::from_str(&buffer).unwrap(); + let lesson = config.lesson; + + let mut node_file = NodeFile::new(NODE_SCRIPT.to_string(), config.project.clone()); + let mut python_file = PythonFile::new(PYTHON_SCRIPT.to_string(), config.project.clone()); + + for before_all in lesson.before_all { + match before_all { + Test { + runner: Runner::Node, + .. + } => { + node_file.handle_before_all(&before_all); + } + Test { + runner: Runner::Python, + .. + } => { + python_file.handle_before_all(&before_all); + } + Test { runner, .. } => { + unimplemented!("Invalid runner ({runner:?}) for `before_all`") + } + } + } + + for test in lesson.tests { + match test.runner { + Runner::Node => { + let before_each = lesson + .before_each + .iter() + .find(|test| test.runner == Runner::Node); + + let after_each = lesson + .after_each + .iter() + .find(|test| test.runner == Runner::Node); + node_file.handle_test(&test, before_each, after_each); + } + Runner::Python => { + let before_each = lesson + .before_each + .iter() + .find(|test| test.runner == Runner::Python); + + let after_each = lesson + .after_each + .iter() + .find(|test| test.runner == Runner::Python); + python_file.handle_test(&test, before_each, after_each); + } + _ => { + unimplemented!("Invalid runner ({:?}) for `test`", test.runner) + } + } + } + + for after_all in lesson.after_all { + match after_all { + Test { + runner: Runner::Node, + .. + } => { + node_file.handle_after_all(&after_all); + } + Test { + runner: Runner::Python, + .. + } => { + python_file.handle_after_all(&after_all); + } + Test { runner, .. } => { + unimplemented!("Invalid runner ({runner:?}) for `after_all`") + } + } + } + + let _node_res = node_file.run(); + let _python_res = python_file.run(); +} diff --git a/runner/src/node.rs b/runner/src/node.rs new file mode 100644 index 00000000..0b1cce25 --- /dev/null +++ b/runner/src/node.rs @@ -0,0 +1,242 @@ +use std::{ + io::{BufRead, BufReader}, + process::Stdio, + sync::{Arc, Mutex}, + thread, +}; + +use crate::config::{ProjectConfig, Runner, Runout, Script, Test, TestOut, TestState}; + +#[derive(Debug)] +pub struct NodeFile { + pub content: String, + pub config: ProjectConfig, +} + +impl Script for NodeFile { + fn handle_before_all(&mut self, test: &Test) { + let before_all_str = "// --runner-end-before_all--"; + let idx = self + .content + .find(before_all_str) + .expect(&format!("string '{before_all_str}' should exist")); + + match test { + Test { + runner: Runner::Node, + code, + .. + } => { + self.content.insert_str(idx, &format!("{code}\n ")); + } + _ => { + unimplemented!("Seed for {self:?} must be a file or Runner::Node.") + } + } + } + + fn handle_test(&mut self, test: &Test, before_each: Option<&Test>, after_each: Option<&Test>) { + let test_function_str = "// --runner-end-test_functions--"; + let idx = self + .content + .find(test_function_str) + .expect(&format!("string '{test_function_str}' should exist")); + + let before_each = match before_each { + Some(Test { + runner: Runner::Node, + code, + .. + }) => code, + _ => "", + }; + + let after_each = match after_each { + Some(Test { + runner: Runner::Node, + code, + .. + }) => code, + _ => "", + }; + + let test_id = test.id; + let test_code = &test.code; + + let function = format!( + " + async function test_{test_id}() {{ + // --debug-before_each-- + {before_each} + {test_code} + // --debug-after_each-- + {after_each} + + console.log(JSON.stringify({{ + id: {test_id}, + error: null, + }})); + }} +" + ); + + self.content.insert_str(idx, &format!("{function}\n")); + match self.config { + ProjectConfig { + blocking_tests: true, + break_on_failure: true, + } => { + todo!() + } + ProjectConfig { + blocking_tests: false, + break_on_failure: false, + } => { + let tests_str = "// --runner-end-tests--"; + let idx = self + .content + .find(tests_str) + .expect(&format!("string '{tests_str}' should exist")); + let call = format!("settled.push(test_{}());", test.id); + + self.content.insert_str(idx, &format!("{call}\n ")); + } + ProjectConfig { + blocking_tests: true, + break_on_failure: false, + } => { + let tests_str = "// --runner-end-tests-blocking_tests"; + let idx = self + .content + .find(tests_str) + .expect(&format!("string '{tests_str}' should exist")); + + let test_id = test.id; + let call = format!( + " + try {{ + await test_{test_id}(); + }} catch (e) {{ + handle_error(e, {test_id}); + }} +", + ); + + self.content.insert_str(idx, &format!("{call}\n ")); + } + ProjectConfig { + blocking_tests: false, + break_on_failure: true, + } => { + todo!() + } + } + } + + fn handle_after_all(&mut self, test: &Test) { + let after_all_str = "// --runner-end-after_all--"; + let idx = self + .content + .find(after_all_str) + .expect(&format!("string '{after_all_str}' should exist")); + + match test { + Test { + runner: Runner::Node, + code, + .. + } => { + self.content.insert_str(idx, &format!("{code}\n")); + } + _ => { + unimplemented!("Seed for {self:?} must be a file or Runner::Node.") + } + } + } + + fn run(&self) -> Result<(), std::io::Error> { + let mut child = std::process::Command::new("node") + .arg("-e") + .arg(&self.content) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let stdout = child.stdout.take().unwrap(); + let stdout = Arc::new(Mutex::new(BufReader::new(stdout))); + let stderr = child.stderr.take().unwrap(); + let stderr = Arc::new(Mutex::new(BufReader::new(stderr))); + + let stdout_clone = Arc::clone(&stdout); + let stdout_handle = thread::spawn(move || { + let mut reader = stdout_clone.lock().unwrap(); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + if let Ok(runout) = serde_json::from_str::(&line) { + // Test passed + let test_out: TestOut = TestOut { + id: runout.id, + state: TestState::Passed, + }; + println!("{}", serde_json::to_string(&test_out).unwrap()); + } else { + // STDOUT from process + println!("{}", line.trim()); + } + } + Err(e) => { + eprintln!("Error reading stdout: {}", e); + break; + } + } + } + }); + + let stderr_clone = Arc::clone(&stderr); + let stderr_handle = thread::spawn(move || { + let mut reader = stderr_clone.lock().unwrap(); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + if let Ok(runout) = serde_json::from_str::(&line) { + // Test failed + let test_out: TestOut = TestOut { + id: runout.id, + state: TestState::Failed { + message: runout.error.to_string(), + }, + }; + println!("{}", serde_json::to_string(&test_out).unwrap()); + } else { + // STDERR from process + println!("{}", line.trim()); + } + } + Err(e) => { + eprintln!("Error reading stderr: {}", e); + break; + } + } + } + }); + + let handles = vec![stdout_handle, stderr_handle]; + + for handle in handles { + handle.join().unwrap(); + } + + Ok(()) + } + + fn new(content: String, config: ProjectConfig) -> Self { + NodeFile { content, config } + } +} diff --git a/runner/src/python.rs b/runner/src/python.rs new file mode 100644 index 00000000..f0d0c6fd --- /dev/null +++ b/runner/src/python.rs @@ -0,0 +1,230 @@ +use std::{ + io::{BufRead, BufReader, Write}, + process::Stdio, + sync::{Arc, Mutex}, + thread, +}; + +use tempfile::NamedTempFile; + +use crate::config::{ProjectConfig, Runner, Runout, Script, Test, TestOut, TestState}; + +#[derive(Debug)] +pub struct PythonFile { + pub content: String, + pub config: ProjectConfig, +} + +impl Script for PythonFile { + fn handle_before_all(&mut self, test: &Test) { + let before_all_str = "# --runner-end-before_all--"; + let idx = self + .content + .find(before_all_str) + .expect(&format!("string '{before_all_str}' should exist")); + + match test { + Test { + runner: Runner::Python, + code, + .. + } => { + self.content.insert_str(idx, &format!("{code}\n{:4}", " ")); + } + _ => { + unimplemented!("Test for {self:?} must be a file or Runner::Python.") + } + } + } + + fn handle_test(&mut self, test: &Test, before_each: Option<&Test>, after_each: Option<&Test>) { + let test_function_str = "# --runner-end-test_functions--"; + let idx = self + .content + .find(test_function_str) + .expect(&format!("string '{test_function_str}' should exist")); + + let before_each = match before_each { + Some(Test { + runner: Runner::Python, + code, + .. + }) => code, + _ => "", + } + .replace("\n", "\n "); + + let after_each = match after_each { + Some(Test { + runner: Runner::Python, + code, + .. + }) => code, + _ => "", + } + .replace("\n", "\n "); + + let test_id = test.id; + let test_code = &test.code.replace("\n", "\n "); + + let function = format!( + " + def test_{test_id}(): + # --debug-before_each-- + {before_each} + {test_code} + # --debug-after_each-- + {after_each} + + print('{{\"id\": {test_id}, \"error\": null}}') +", + ); + + self.content.insert_str(idx, &format!("{function}\n")); + match self.config { + ProjectConfig { + blocking_tests: false, + .. + } => { + unimplemented!("all tests must be blocking if the Python runner is used") + } + ProjectConfig { + break_on_failure: false, + .. + } => { + let tests_str = "# --runner-end-tests--"; + let idx = self + .content + .find(tests_str) + .expect(&format!("string '{tests_str}' should exist")); + + let test_id = test.id; + let call = format!( + " + try: + test_{test_id}() + except Exception as e: + handle_error(e, {test_id}) +" + ); + + self.content.insert_str(idx, &format!("{call}\n{:4}", " ")); + } + ProjectConfig { + break_on_failure: true, + .. + } => { + let _tests_str = "# --runner-end-tests-break_on_failure--"; + todo!() + } + } + } + + fn handle_after_all(&mut self, test: &Test) { + let after_all_str = "# --runner-end-after_all--"; + let idx = self + .content + .find(after_all_str) + .expect(&format!("string '{after_all_str}' should exist")); + + match test { + Test { + runner: Runner::Python, + code, + .. + } => { + self.content.insert_str(idx, &format!("{code}\n")); + } + _ => { + unimplemented!("Seed for {self:?} must be a file or Runner::Python.") + } + } + } + + fn run(&self) -> Result<(), std::io::Error> { + let mut file = NamedTempFile::with_suffix(".py").unwrap(); + file.write_all(&self.content.as_bytes()).unwrap(); + let mut child = std::process::Command::new("python3") + .arg(file.path().to_str().unwrap()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let stdout = child.stdout.take().unwrap(); + let stdout = Arc::new(Mutex::new(BufReader::new(stdout))); + let stderr = child.stderr.take().unwrap(); + let stderr = Arc::new(Mutex::new(BufReader::new(stderr))); + + let stdout_clone = Arc::clone(&stdout); + let stdout_handle = thread::spawn(move || { + let mut reader = stdout_clone.lock().unwrap(); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + if let Ok(runout) = serde_json::from_str::(&line) { + // Test passed + let test_out: TestOut = TestOut { + id: runout.id, + state: TestState::Passed, + }; + println!("{}", serde_json::to_string(&test_out).unwrap()); + } else { + // STDOUT from process + println!("{}", line.trim()); + } + } + Err(e) => { + println!("Error reading stdout: {}", e); + break; + } + } + } + }); + + let stderr_clone = Arc::clone(&stderr); + let stderr_handle = thread::spawn(move || { + let mut reader = stderr_clone.lock().unwrap(); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + if let Ok(runout) = serde_json::from_str::(&line) { + // Test failed + let test_out: TestOut = TestOut { + id: runout.id, + state: TestState::Failed { + message: runout.error.to_string(), + }, + }; + println!("{}", serde_json::to_string(&test_out).unwrap()); + } else { + // STDERR from process + println!("{}", line.trim()); + } + } + Err(e) => { + println!("Error reading stderr: {}", e); + break; + } + } + } + }); + + let handles = vec![stdout_handle, stderr_handle]; + + for handle in handles { + handle.join().unwrap(); + } + + Ok(()) + } + + fn new(content: String, config: ProjectConfig) -> Self { + PythonFile { content, config } + } +} diff --git a/runner/src/utils.rs b/runner/src/utils.rs new file mode 100644 index 00000000..0d3af75d --- /dev/null +++ b/runner/src/utils.rs @@ -0,0 +1,36 @@ +use serde::{de::value::Error, Deserialize}; + +/// Extracts all bytes from the input string which can be deserialized into a `T`. Then, returns the non-deserialized parts of the string. +pub fn deserialize_and_extract<'de, T: Deserialize<'de>>( + input: &'de str, +) -> Result<(Option, &'de str), Error> { + let mut deserialized = Option::None; + let mut remaining = input; + + while !remaining.is_empty() { + match serde_json::from_str::(remaining) { + Ok(value) => { + deserialized = Some(value); + remaining = &remaining[remaining.find('}').unwrap() + 1..]; + } + Err(_) => break, + } + } + + Ok((deserialized, remaining)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_and_extract() { + let input = r#"{"a": 1}{"b": 2}{"c": 3}"#; + let (deserialized, remaining) = + deserialize_and_extract::(input).unwrap(); + + assert_eq!(deserialized, Some(serde_json::json!({"a": 1}))); + assert_eq!(remaining, r#"{"b": 2}{"c": 3}"#); + } +} diff --git a/runner/test.json b/runner/test.json new file mode 100644 index 00000000..74b64495 --- /dev/null +++ b/runner/test.json @@ -0,0 +1,48 @@ +{ + "lesson": { + "after_all": [], + "after_each": [], + "before_all": [ + { + "id": 0, + "runner": "Node", + "code": "const a = 1;", + "force": false + }, + { + "id": 1, + "runner": "Python", + "code": "a = 1", + "force": false + } + ], + "before_each": [], + "id": 0, + "tests": [ + { + "code": "const b = 2;\nconsole.log(a + b);", + "id": 0, + "runner": "Node" + }, + { + "code": "if (a !== 2) { throw new Error(`${a} is not equal to ${2}`); }", + "id": 1, + "runner": "Node" + }, + { + "code": "b = 2\nprint(a + b)", + "id": 2, + "runner": "Python" + }, + { + "code": "if a != 2:\n raise Exception(f'{a} is not equal to {2}')", + "id": 3, + "runner": "Python" + } + ] + }, + "project": { + "blocking_tests": true, + "break_on_failure": false + } +} \ No newline at end of file diff --git a/self/config/projects.json b/self/config/projects.json index 94ab5d6a..c1735a44 100644 --- a/self/config/projects.json +++ b/self/config/projects.json @@ -68,5 +68,20 @@ "blockingTests": false, "breakOnFailure": false, "numberOfLessons": 3 + }, + { + "id": 5, + "dashedName": "runners", + "isIntegrated": false, + "isPublic": true, + "currentLesson": 2, + "runTestsOnWatch": false, + "seedEveryLesson": false, + "isResetEnabled": false, + "numberofLessons": null, + "blockingTests": false, + "breakOnFailure": false, + "numberOfLessons": 3, + "completedDate": 1742377225547 } -] +] \ No newline at end of file diff --git a/self/curriculum/locales/english/runners.md b/self/curriculum/locales/english/runners.md new file mode 100644 index 00000000..e524c8e4 --- /dev/null +++ b/self/curriculum/locales/english/runners.md @@ -0,0 +1,96 @@ +# Runners + +This tests all the runners available with freecodecamp-os. + +## 0 + +### --description-- + +The following tests run Python code. + +### --tests-- + +I never raise an error. + +```python +a = 1 +print(a) +``` + +I always raise an error. + +```python +a = 2 +if a == 2: + raise Exception('This is a custom test assertion message. Click the > button to go to the next lesson') +``` + +## 1 + +### --description-- + +The following tests run Python code with hooks. + +### --tests-- + +If I pass, the before-all hook successfully run. + +```python +# Check if file exists and contains "hello world" +with open('./test.txt', 'r') as f: + contents = f.read() + assert contents == 'hello world' +``` + +If I pass, the before-each hook successfully run. + +```python +if a != 100: + raise Exception('a is not 100') +``` + +### --before-all-- + +```python +# write "hello world" to ./test.txt +# if it does not exist, create it +with open('./test.txt', 'w') as f: + f.write('hello world') +``` + +### --before-each-- + +```python +a = 100 +``` + +### --after-each-- + +```python +print(a) +``` + +### --after-all-- + +```python +# remove ./test.txt +import os +os.remove('./test.txt') +``` + +## 2 + +### --description-- + +Well done. + +### --tests-- + +When you are done, type `done` in the terminal. + +```js +const lastCommand = await __helpers.getLastCommand(); +assert.include(lastCommand, 'done'); +``` + +## --fcc-end-- diff --git a/self/runners/.gitkeep b/self/runners/.gitkeep new file mode 100644 index 00000000..e69de29b