diff --git a/.freeCodeCamp/plugin/index.js b/.freeCodeCamp/plugin/index.js index 88062d8f..4f08ad30 100644 --- a/.freeCodeCamp/plugin/index.js +++ b/.freeCodeCamp/plugin/index.js @@ -32,6 +32,7 @@ import { logover } from '../tooling/logger.js'; /** * @typedef {Object} Lesson + * @property {{watch?: string[]; ignore?: string[]} | undefined} meta * @property {string} description * @property {[[string, string]]} tests * @property {string[]} hints @@ -96,8 +97,10 @@ export const pluginEvents = { const coffeeDown = new CoffeeDown(projectFile); const projectMeta = coffeeDown.getProjectMeta(); // Remove `

` tags if present - const title = parseMarkdown(projectMeta.title).replace(/

|<\/p>/g, ''); - const description = parseMarkdown(projectMeta.description); + const title = parseMarkdown(projectMeta.title) + .replace(/

|<\/p>/g, '') + .trim(); + const description = parseMarkdown(projectMeta.description).trim(); const tags = projectMeta.tags; const numberOfLessons = projectMeta.numberOfLessons; return { title, description, numberOfLessons, tags }; @@ -135,14 +138,16 @@ export const pluginEvents = { } } } - const { afterAll, afterEach, beforeAll, beforeEach, isForce } = lesson; - const description = parseMarkdown(lesson.description); + const { afterAll, afterEach, beforeAll, beforeEach, isForce, meta } = + lesson; + const description = parseMarkdown(lesson.description).trim(); const tests = lesson.tests.map(([testText, test]) => [ - parseMarkdown(testText), + parseMarkdown(testText).trim(), test ]); - const hints = lesson.hints.map(parseMarkdown); + const hints = lesson.hints.map(h => parseMarkdown(h).trim()); return { + meta, description, tests, hints, diff --git a/.freeCodeCamp/tests/parser.test.js b/.freeCodeCamp/tests/parser.test.js index 1643b3f7..e674465b 100644 --- a/.freeCodeCamp/tests/parser.test.js +++ b/.freeCodeCamp/tests/parser.test.js @@ -1,4 +1,5 @@ /// Tests can be run from `self/` +/// node ../.freeCodeCamp/tests/parser.test.js import { assert } from 'chai'; import { Logger } from 'logover'; import { pluginEvents } from '../plugin/index.js'; @@ -17,6 +18,7 @@ try { numberOfLessons } = await pluginEvents.getProjectMeta('build-x-using-y'); const { + meta, description: lessonDescription, tests, hints, @@ -29,40 +31,37 @@ try { } = await pluginEvents.getLesson('build-x-using-y', 0); assert.deepEqual(title, 'Build X Using Y'); + assert.deepEqual(meta, { + watch: ['some/file.js'], + ignore: ['another/file.js'] + }); assert.deepEqual( projectDescription, - 'In this course, you will build x using y.' + '

In this course, you will build x using y.

' ); assert.deepEqual(numberOfLessons, 1); assert.deepEqual( lessonDescription, - `Some description here. - -\`\`\`rust -fn main() { - println!("Hello, world!"); -} -\`\`\` - -Here is an image: - - - -` + `

Some description here.

+
fn main() {
+    println!("Hello, world!");
+}
+

Here is an image:

+` ); const expectedTests = [ [ - 'First test using Chai.js `assert`.', + '

First test using Chai.js assert.

', '// 0\n// Timeout for 3 seconds\nawait new Promise(resolve => setTimeout(resolve, 3000));\nassert.equal(true, true);' ], [ - 'Second test using global variables passed from `before` hook.', + '

Second test using global variables passed from before hook.

', "// 1\nawait new Promise(resolve => setTimeout(resolve, 4000));\nassert.equal(__projectLoc, 'example global variable for tests');" ], [ - 'Dynamic helpers should be imported.', + '

Dynamic helpers should be imported.

', "// 2\nawait new Promise(resolve => setTimeout(resolve, 1000));\nassert.equal(__helpers.testDynamicHelper(), 'Helper success!');" ] ]; @@ -73,8 +72,10 @@ Here is an image: } const expectedHints = [ - 'Inline hint with `some` code `blocks`.\n\n', - 'Multi-line hint with:\n\n```js\nconst code_block = true;\n```\n\n' + '

Inline hint with some code blocks.

', + `

Multi-line hint with:

+
const code_block = true;
+
` ]; for (const [i, hint] of hints.entries()) { diff --git a/.freeCodeCamp/tooling/env.js b/.freeCodeCamp/tooling/env.js index d69a4316..c71aaf89 100644 --- a/.freeCodeCamp/tooling/env.js +++ b/.freeCodeCamp/tooling/env.js @@ -34,7 +34,10 @@ export async function getState() { // or not is based on the current lesson matching the last seeded lesson // So, to ensure the first lesson is seeded, this is -1 lessonNumber: -1 - } + }, + // All lessons start at 0, but the logic for whether to run certain effects + // is based on the current lesson matching the last lesson + lastWatchChange: -1 }; try { const state = JSON.parse( diff --git a/.freeCodeCamp/tooling/hot-reload.js b/.freeCodeCamp/tooling/hot-reload.js index 43016d4f..b0f2a40e 100644 --- a/.freeCodeCamp/tooling/hot-reload.js +++ b/.freeCodeCamp/tooling/hot-reload.js @@ -5,17 +5,19 @@ import { runLesson } from './lesson.js'; import { runTests } from './tests/main.js'; import { watch } from 'chokidar'; import { logover } from './logger.js'; +import path from 'path'; +import { readdir } from 'fs/promises'; const defaultPathsToIgnore = [ '.logs/.temp.log', 'config/', '/node_modules/', - '.git', + '.git/', '/target/', '/test-ledger/' ]; -const pathsToIgnore = +export const pathsToIgnore = freeCodeCampConfig.hotReload?.ignore || defaultPathsToIgnore; export const watcher = watch(ROOT, { @@ -63,3 +65,76 @@ export function hotReload(ws, pathsToIgnore = defaultPathsToIgnore) { } }); } + +/** + * Stops the global `watcher` from watching the entire workspace. + */ +export function unwatchAll() { + const watched = watcher.getWatched(); + for (const [dir, files] of Object.entries(watched)) { + for (const file of files) { + watcher.unwatch(path.join(dir, file)); + } + } +} + +// Need to handle +// From ROOT, must add all directories before file/s +// path.dirname... all the way to ROOT +// path.isAbsolute to find out if what was passed into `meta` is absolute or relative +// path.parse to get the dir and base +// path.relative(ROOT, path) to get the relative path from ROOT +// path.resolve directly on `meta`? +/** + * **Example:** + * - Assuming ROOT is `/home/freeCodeCampOS/self` + * - Takes `lesson-watcher/src/watched.js` + * - Calls `watcher.add` on each of these in order: + * - `/home/freeCodeCampOS/self` + * - `/home/freeCodeCampOS/self/lesson-watcher` + * - `/home/freeCodeCampOS/self/lesson-watcher/src` + * - `/home/freeCodeCampOS/self/lesson-watcher/src/watched.js` + * @param {string} pathRelativeToRoot + */ +export function watchPathRelativeToRoot(pathRelativeToRoot) { + const paths = getAllPathsWithRoot(pathRelativeToRoot); + for (const path of paths) { + watcher.add(path); + } +} + +function getAllPathsWithRoot(pathRelativeToRoot) { + const paths = []; + let currentPath = pathRelativeToRoot; + while (currentPath !== ROOT) { + paths.push(currentPath); + currentPath = path.dirname(currentPath); + } + paths.push(ROOT); + // The order does not _seem_ to matter, but the theory says it should + return paths.reverse(); +} + +/** + * Adds all folders and files to the `watcher` instance. + * + * Does nothing with the `pathsToIgnore`, because they are already ignored by the `watcher`. + */ +export async function watchAll() { + await watchPath(ROOT); +} + +async function watchPath(rootPath) { + const paths = await readdir(rootPath, { withFileTypes: true }); + for (const p of paths) { + const fullPath = path.join(rootPath, p.name); + // if (pathsToIgnore.find(i => fullPath.includes(i))) { + // console.log('Ignoring: ', fullPath); + // continue; + // } + watcher.add(fullPath); + if (p.isDirectory()) { + await watchPath(fullPath); + } + } +} diff --git a/.freeCodeCamp/tooling/lesson.js b/.freeCodeCamp/tooling/lesson.js index eb7763eb..29ee50d8 100644 --- a/.freeCodeCamp/tooling/lesson.js +++ b/.freeCodeCamp/tooling/lesson.js @@ -8,10 +8,16 @@ import { updateError, resetBottomPanel } from './client-socks.js'; -import { ROOT, getState, getProjectConfig, freeCodeCampConfig } from './env.js'; +import { ROOT, getState, getProjectConfig, setState } from './env.js'; import { logover } from './logger.js'; import { seedLesson } from './seed.js'; import { pluginEvents } from '../plugin/index.js'; +import { + unwatchAll, + watchAll, + watchPathRelativeToRoot, + watcher +} from './hot-reload.js'; /** * Runs the lesson from the `projectDashedName` config. @@ -21,12 +27,14 @@ import { pluginEvents } from '../plugin/index.js'; export async function runLesson(ws, projectDashedName) { const project = await getProjectConfig(projectDashedName); const { isIntegrated, dashedName, seedEveryLesson, currentLesson } = project; - const { lastSeed } = await getState(); + const { lastSeed, lastWatchChange } = await getState(); try { - const { description, seed, isForce, tests } = await pluginEvents.getLesson( - projectDashedName, - currentLesson - ); + const { description, seed, isForce, tests, meta } = + await pluginEvents.getLesson(projectDashedName, currentLesson); + + // TODO: Consider performance optimizations + // - Do not run at all if whole project does not contain any `meta`. + await handleWatcher(meta, { lastWatchChange, currentLesson }); if (currentLesson === 0) { await pluginEvents.onProjectStart(project); @@ -74,3 +82,23 @@ export async function runLesson(ws, projectDashedName) { logover.error(err); } } + +async function handleWatcher(meta, { lastWatchChange, currentLesson }) { + // Calling `watcher` methods takes a performance hit. So, check is behind a check that the lesson has changed. + if (lastWatchChange !== currentLesson) { + if (meta?.watch) { + unwatchAll(); + for (const path of meta.watch) { + const toWatch = join(ROOT, path); + watchPathRelativeToRoot(toWatch); + } + } else if (meta?.ignore) { + await watchAll(); + watcher.unwatch(meta.ignore); + } else { + // Reset watcher back to default/freecodecamp.conf.json + await watchAll(); + } + } + await setState({ lastWatchChange: currentLesson }); +} diff --git a/.freeCodeCamp/tooling/parser.js b/.freeCodeCamp/tooling/parser.js index 644516bc..3501599e 100644 --- a/.freeCodeCamp/tooling/parser.js +++ b/.freeCodeCamp/tooling/parser.js @@ -96,7 +96,10 @@ export class CoffeeDown { const afterAll = lesson.getAfterAll().code; const beforeEach = lesson.getBeforeEach().code; const afterEach = lesson.getAfterEach().code; + + const meta = lesson.getMeta(); return { + meta, description, tests, seed, @@ -165,6 +168,17 @@ export class CoffeeDown { return this.getHeading(3, '--after-each--', 'getAfterEach'); } + getMeta() { + const firstHeadingMarker = this.tokens.findIndex(t => { + return t.type === 'heading' && t.depth === 3; + }); + const tokensBeforeFirstHeading = this.tokens.slice(0, firstHeadingMarker); + const jsonMeta = + tokensBeforeFirstHeading.find(t => t.type === 'code' && t.lang === 'json') + ?.text ?? '{}'; + return JSON.parse(jsonMeta); + } + /** * Get first code block text from tokens * diff --git a/.freeCodeCamp/tooling/test-utils.js b/.freeCodeCamp/tooling/test-utils.js index 61fd773f..146602de 100644 --- a/.freeCodeCamp/tooling/test-utils.js +++ b/.freeCodeCamp/tooling/test-utils.js @@ -8,10 +8,10 @@ import { logover } from './logger.js'; // --------------- // GENERIC HELPERS // --------------- -const PATH_TERMINAL_OUT = join(ROOT, '.logs/.terminal_out.log'); -const PATH_BASH_HISTORY = join(ROOT, '.logs/.bash_history.log'); -const PATH_CWD = join(ROOT, '.logs/.cwd.log'); -const PATH_TEMP = join(ROOT, '.logs/.temp.log'); +export const PATH_TERMINAL_OUT = join(ROOT, '.logs/.terminal_out.log'); +export const PATH_BASH_HISTORY = join(ROOT, '.logs/.bash_history.log'); +export const PATH_CWD = join(ROOT, '.logs/.cwd.log'); +export const PATH_TEMP = join(ROOT, '.logs/.temp.log'); /** * @typedef ControlWrapperOptions diff --git a/.freeCodeCamp/tooling/validate.js b/.freeCodeCamp/tooling/validate.js index 06e15494..daa41b90 100644 --- a/.freeCodeCamp/tooling/validate.js +++ b/.freeCodeCamp/tooling/validate.js @@ -371,7 +371,8 @@ export async function validateCurriculum() { afterAll, beforeEach, afterEach, - isForce + isForce, + meta } = lesson; if (!description) { warn( @@ -490,6 +491,13 @@ export async function validateCurriculum() { 'afterEach should be a string' ); } + if (meta?.watch && meta?.ignore) { + panic( + `Invalid meta in lesson ${i} of ${dashedName}`, + meta, + 'Lesson should not have both `watch` and `ignore`' + ); + } } } diff --git a/docs/src/CHANGELOG.md b/docs/src/CHANGELOG.md index 4d18a508..d01b79f4 100644 --- a/docs/src/CHANGELOG.md +++ b/docs/src/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [3.6.0] - + +### Add + +- Use bash's `script` command to record terminal input and output + - Gated behind a feature flag + - Existing `.logs/` files will be deprecated in favour of `script` command in `4.0` + +## [3.5.1] - + +### Fix + +- Use `worker.postMessage` to cause side-effects in main thread + +## [3.5.0] - 2024-03-18 + +### Add + +- `meta` to `getLesson` + - `meta.watch` and `meta.ignore` to alter watch behaviour when lesson loads + +### Fix + +- Add `/` to end of `.git` in `defaultPathsToIgnore` to prevent files starting with `.git` from being ignored +- Trim `description` and `title` fields from parser + ## [3.4.1] - 2024-03-11 ### Fix diff --git a/docs/src/plugin-system.md b/docs/src/plugin-system.md index 0d7626d5..28c52c3c 100644 --- a/docs/src/plugin-system.md +++ b/docs/src/plugin-system.md @@ -65,6 +65,7 @@ This function can be called multiple times per lesson. Therefore, it is expected ```typescript (projectDashedName: string, lessonNumber: number) => Promise<{ + meta?: { watch?: string[]; ignore?: string[] }; description: string; tests: [[string, string]]; hints: string[]; @@ -77,6 +78,8 @@ This function can be called multiple times per lesson. Therefore, it is expected }>; ``` +The `meta` field is expected to be an object with either a `watch` or `ignore` field. The `watch` field is expected to be an array of strings, and the `ignore` field is expected to be an array of strings. + The `description` field is expected to be either a plain string, or an HTML string which is then rendered in the client. The `tests[][0]` field is the test text, and the `tests[][1]` field is the test code. The test text is expected to be either a plain string, or an HTML string. diff --git a/docs/src/project-syntax.md b/docs/src/project-syntax.md index f5e395bd..4e49ea36 100644 --- a/docs/src/project-syntax.md +++ b/docs/src/project-syntax.md @@ -42,6 +42,25 @@ Zero-based numbering, because of course ``` ```` +### `meta` + +````markdown +## + +```json +{ + "watch": ["path/relative/to/root"], + "ignore": ["path/relative/to/root"] +} +``` +```` + +The `meta.watch` field is used to specify specific files to watch during a lesson. The `meta.ignore` field is used to specify specific files to ignore during a lesson. The watcher is affected once on lesson load. + +```admonish note title="" +The `watch` and `ignore` fields are optional. It does not make sense to provide both at the same time. +``` + ### `### --description--` ```markdown @@ -219,6 +238,12 @@ await __helpers.javascriptTest(filePath, test, cb); ## 1 +```json +{ + "watch": ["learn-x-by-building-y/test/index.js"] +} +``` + ### --description-- Create a new directory named `test`, and create a file `test/index.ts`. diff --git a/package-lock.json b/package-lock.json index e722f8ca..05b56967 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@freecodecamp/freecodecamp-os", - "version": "3.4.1", + "version": "3.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@freecodecamp/freecodecamp-os", - "version": "3.4.1", + "version": "3.5.0", "dependencies": { "chai": "4.4.1", "chokidar": "3.6.0", diff --git a/package.json b/package.json index b4be6bb4..b72c6406 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@freecodecamp/freecodecamp-os", "author": "freeCodeCamp", - "version": "3.4.1", + "version": "3.5.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", diff --git a/self/config/projects.json b/self/config/projects.json index 2db6f2a3..94ab5d6a 100644 --- a/self/config/projects.json +++ b/self/config/projects.json @@ -54,5 +54,19 @@ "blockingTests": false, "breakOnFailure": false, "numberOfLessons": 4 + }, + { + "id": 4, + "dashedName": "lesson-watch", + "isIntegrated": false, + "isPublic": true, + "currentLesson": 0, + "runTestsOnWatch": true, + "seedEveryLesson": false, + "isResetEnabled": false, + "numberofLessons": null, + "blockingTests": false, + "breakOnFailure": false, + "numberOfLessons": 3 } ] diff --git a/self/curriculum/locales/english/lesson-watch.md b/self/curriculum/locales/english/lesson-watch.md new file mode 100644 index 00000000..012c44b9 --- /dev/null +++ b/self/curriculum/locales/english/lesson-watch.md @@ -0,0 +1,62 @@ +# Lesson Watch + +Watch and ignore specific files for each lesson. + +## 0 + + + +```json +{ + "watch": ["lesson-watch/watched.js"] +} +``` + +### --description-- + +Making changes to `watched.js` should run the tests, but changing `unwatched.js` should do nothing. + +### --tests-- + +Placeholder test. + +```js +// TODO: Test `watcher.watched()` for what should be watched +assert.fail(); +``` + +## 1 + +```json +{ + "ignore": ["lesson-watch/unwatched.js"] +} +``` + +### --description-- + +Making any change should run the tests, but changing `unwatched.js` should do nothing. + +### --tests-- + +Placeholder test text. + +```js +assert.fail(); +``` + +## 2 + +### --description-- + +The default option to watch and ignore are reset. + +### --tests-- + +This always fails. + +```js +assert.fail(); +``` + +## --fcc-end-- diff --git a/self/freecodecamp.conf.json b/self/freecodecamp.conf.json index 93ce8704..36b831da 100644 --- a/self/freecodecamp.conf.json +++ b/self/freecodecamp.conf.json @@ -43,10 +43,10 @@ ".logs/.temp.log", "config/", "/node_modules/", - ".git", + ".git/", "/target/", "/test-ledger/", - ".vscode", + ".vscode/", "freecodecamp.conf.json" ] }, diff --git a/self/lesson-watch/unwatched.js b/self/lesson-watch/unwatched.js new file mode 100644 index 00000000..e69de29b diff --git a/self/lesson-watch/watched.js b/self/lesson-watch/watched.js new file mode 100644 index 00000000..e69de29b