Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use script command for terminal logs #539

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .freeCodeCamp/tests/watcher.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { it, describe, before, beforeEach, after, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import {
compareEquivalency,
maybeAddRelativePath,
shouldWatch,
PATHS_TO_WATCH,
PATHS_TO_IGNORE,
unwatchPath,
watchPath
} from '../tooling/watcher/watcher.js';

import { watcher } from '../tooling/hot-reload.js';

describe('watcher', async () => {
let initial_PTI;
before(() => {
initial_PTI = [...PATHS_TO_IGNORE];
});
beforeEach(() => {
PATHS_TO_IGNORE.splice(0, PATHS_TO_IGNORE.length);
});
afterEach(() => {
PATHS_TO_IGNORE.splice(0, PATHS_TO_IGNORE.length, ...initial_PTI);
});
after(() => {
watcher.close();
});

it('compareEquivalency', () => {
const pti = ['/a', 'b', '/c/'];
const equivalent_paths = ['/a/1', '/a/b', '/c/1/2'];
const unequivalent_paths = ['a/1', 'c', '/d'];

pti.forEach((p, i) => {
const ep = equivalent_paths[i];
assert(compareEquivalency(p, ep), `Expected ${p} ~= ${ep}`);
const up = unequivalent_paths[i];
assert(!compareEquivalency(p, up), `Expected ${p} !~= ${up}`);
});
});

it('maybeAddRelativePath', () => {
assert.deepEqual(PATHS_TO_IGNORE, []);
maybeAddRelativePath(PATHS_TO_IGNORE, '/node_modules/');
assert.deepEqual(PATHS_TO_IGNORE, ['/node_modules/']);
maybeAddRelativePath(PATHS_TO_IGNORE, 'node_modules');
assert.deepEqual(PATHS_TO_IGNORE, ['node_modules']);
maybeAddRelativePath(PATHS_TO_IGNORE, '/node_modules/foo/bar');
assert.deepEqual(PATHS_TO_IGNORE, ['node_modules']);
maybeAddRelativePath(PATHS_TO_IGNORE, 'a/node_modules');
assert.deepEqual(PATHS_TO_IGNORE, ['node_modules']);
maybeAddRelativePath(PATHS_TO_IGNORE, '/a/node_modules/b/');
assert.deepEqual(PATHS_TO_IGNORE, ['node_modules']);

maybeAddRelativePath(PATHS_TO_IGNORE, '/a/b/');
assert.deepEqual(PATHS_TO_IGNORE, ['node_modules', '/a/b/']);
});

it('shouldWatch', () => {
assert(shouldWatch('node_modules/foo/bar'));
unwatchPath('/node_modules/');
assert(!shouldWatch('/node_modules/foo/bar'));
assert(shouldWatch('example/node_modules/foo/bar'));
unwatchPath('node_modules');
assert(!shouldWatch('example/node_modules/foo/bar'));
watchPath('/node_modules/foo');
assert(shouldWatch('/node_modules/foo/bar'));
});
});
103 changes: 11 additions & 92 deletions .freeCodeCamp/tooling/hot-reload.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@
// This file handles the watching of the /curriculum folder for changes
// and executing the command to run the tests for the next (current) lesson
import { getState, getProjectConfig, ROOT, freeCodeCampConfig } from './env.js';
import { getState, getProjectConfig, ROOT } from './env.js';
import { runLesson } from './lesson.js';
import { runTests } from './tests/main.js';
// `watch` from `node:fs` was investigated, but it fired too many events multiple times
// as of node^21
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/',
'/target/',
'/test-ledger/'
];

export const pathsToIgnore =
freeCodeCampConfig.hotReload?.ignore || defaultPathsToIgnore;
import { shouldWatch } from './watcher/watcher.js';
import { join } from 'path';

export const watcher = watch(ROOT, {
ignoreInitial: true,
ignored: path => pathsToIgnore.some(p => path.includes(p))
ignored: join(ROOT, 'node_modules/@freecodecamp/freecodecamp-os')
});

export function hotReload(ws, pathsToIgnore = defaultPathsToIgnore) {
export function hotReload(ws) {
logover.info(`Watching for file changes on ${ROOT}`);
let isWait = false;
let testsRunning = false;
Expand All @@ -37,7 +26,10 @@ export function hotReload(ws, pathsToIgnore = defaultPathsToIgnore) {
watcher.removeAllListeners('all');

watcher.on('all', async (event, name) => {
if (name && !pathsToIgnore.find(p => name.includes(p))) {
// If path in `PATHS_TO_WATCH`, then watch - ignore `PATHS_TO_IGNORE`
// Else if path in `PATHS_TO_IGNORE`, then ignore
// Else, watch
if (name && shouldWatch(name)) {
if (isWait) return;
const { currentProject } = await getState();
if (!currentProject) {
Expand Down Expand Up @@ -65,76 +57,3 @@ 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);
}
}
}
42 changes: 15 additions & 27 deletions .freeCodeCamp/tooling/lesson.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// This file parses answer files for lesson content
import { join } from 'path';
import {
updateDescription,
updateProjectHeading,
Expand All @@ -8,16 +6,11 @@ import {
updateError,
resetBottomPanel
} from './client-socks.js';
import { ROOT, getState, getProjectConfig, setState } from './env.js';
import { getState, getProjectConfig } 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';
import { watchPath, unwatchPath, resetPathLists } from './watcher/watcher.js';

/**
* Runs the lesson from the `projectDashedName` config.
Expand All @@ -27,14 +20,14 @@ import {
export async function runLesson(ws, projectDashedName) {
const project = await getProjectConfig(projectDashedName);
const { isIntegrated, dashedName, seedEveryLesson, currentLesson } = project;
const { lastSeed, lastWatchChange } = await getState();
const { lastSeed } = await getState();
try {
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 });
await handleWatcher(meta);

if (currentLesson === 0) {
await pluginEvents.onProjectStart(project);
Expand Down Expand Up @@ -83,22 +76,17 @@ export async function runLesson(ws, projectDashedName) {
}
}

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();
async function handleWatcher(meta) {
if (meta?.watch) {
for (const path of meta.watch) {
watchPath(path);
}
} else if (meta?.ignore) {
for (const path of meta.ignore) {
unwatchPath(path);
}
} else {
// Reset watcher back to default/freecodecamp.conf.json
resetPathLists();
}
await setState({ lastWatchChange: currentLesson });
}
14 changes: 4 additions & 10 deletions .freeCodeCamp/tooling/seed.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
// This file handles seeding the lesson contents with the seed in markdown.
import { join } from 'path';
import {
ROOT,
getState,
freeCodeCampConfig,
getProjectConfig,
setState
} from './env.js';
import { ROOT, getProjectConfig, setState } from './env.js';
import { writeFile } from 'fs/promises';
import { promisify } from 'util';
import { exec } from 'child_process';
import { logover } from './logger.js';
import { updateLoader, updateError } from './client-socks.js';
import { watcher } from './hot-reload.js';
import { pluginEvents } from '../plugin/index.js';
import { resetPathLists, unwatchPath } from './watcher/watcher.js';
const execute = promisify(exec);

/**
Expand Down Expand Up @@ -104,9 +98,9 @@ export async function runLessonSeed(seed, currentLesson) {
} else {
const { filePath, fileSeed } = cmdOrFile;
// Stop watching file being seeded to prevent triggering tests on hot reload
watcher.unwatch(filePath);
unwatchPath(filePath);
await runSeed(fileSeed, filePath);
watcher.add(filePath);
resetPathLists();
}
}
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion .freeCodeCamp/tooling/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ const handle = {
const wss = new WebSocketServer({ server });

wss.on('connection', function connection(ws) {
hotReload(ws, freeCodeCampConfig.hotReload?.ignore);
hotReload(ws);
ws.on('message', function message(data) {
const parsedData = parseBuffer(data);
handle[parsedData.event]?.(ws, parsedData);
Expand Down
42 changes: 42 additions & 0 deletions .freeCodeCamp/tooling/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ 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');
export const PATH_SCRIPT_OUT = join(ROOT, '.logs/.script_out.log');
export const PATH_SCRIPT_IN = join(ROOT, '.logs/.script_in.log');

/**
* @typedef ControlWrapperOptions
Expand Down Expand Up @@ -110,9 +112,45 @@ async function getLastCWD(howManyBack = 0) {
return lastLog;
}

/**
* Get the `.logs/.script_in.log` file contents, or `throw` if not found
* @returns {Promise<string>} The `.script_in.log` file contents
*/
async function getScriptIn() {
const scriptLogs = await readFile(PATH_SCRIPT_IN, {
encoding: 'utf-8',
flag: 'a+'
});
return scriptLogs;
}

async function getScriptInEquivalent() {
const scriptIn = await getScriptIn();
// TODO: Decide if removing the `^C` is necessary
let scriptInEquivalent = scriptIn.replace('\u0003', '');
while (scriptInEquivalent.indexOf('\u007f') !== -1) {
scriptInEquivalent = scriptInEquivalent.replace(/.?\u007f/s, '');
}

return scriptInEquivalent;
}

/**
* Get the `.logs/.script_out.log` file contents, or `throw` if not found
* @returns {Promise<string>} The `.script_out.log` file contents
*/
async function getScriptOut() {
const scriptLogs = await readFile(PATH_SCRIPT_OUT, {
encoding: 'utf8',
flag: 'a+'
});
return scriptLogs;
}

/**
* Get the `.logs/.temp.log` file contents, or `throw` if not found
* @returns {Promise<string>} The `.temp.log` file contents
* @deprecated Use `getScriptOut` instead
*/
async function getTemp() {
const tempLogs = await readFile(PATH_TEMP, {
Expand All @@ -125,6 +163,7 @@ async function getTemp() {
/**
* Get the `.logs/.terminal_out.log` file contents, or `throw` if not found
* @returns {Promise<string>} The `.terminal_out.log` file contents
* @deprecated Use `getScriptOut` instead
*/
async function getTerminalOutput() {
const terminalLogs = await readFile(PATH_TERMINAL_OUT, {
Expand All @@ -151,6 +190,9 @@ const __helpers = {
getCWD,
getLastCommand,
getLastCWD,
getScriptIn,
getScriptInEquivalent,
getScriptOut,
getTemp,
getTerminalOutput,
importSansCache
Expand Down
Loading
Loading