From 8b816f78b8569f9fafba2eb177c3c43952dfc9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Weslley=20Ara=C3=BAjo?= <46850407+wellwelwel@users.noreply.github.com> Date: Fri, 14 Jun 2024 02:32:08 -0300 Subject: [PATCH] feat: introduce `watch` mode (#393) * feat: introduce `watch` mode * ci: add `watch` tests * ci: fix Deno permissions * ci: fix tests * ci: fix tests * docs: add `--watch` and `--watch-interval` --- .gitignore | 1 + README.md | 1 + src/bin/index.ts | 41 ++- src/modules/each.ts | 2 +- src/polyfills/fs.ts | 30 +++ src/services/watch.ts | 131 +++++++++ test/docker/deno/latest.Dockerfile | 2 +- test/run.test.ts | 3 + test/unit/watch.test.ts | 248 ++++++++++++++++++ .../docs/documentation/poku/options/watch.mdx | 23 ++ 10 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 src/polyfills/fs.ts create mode 100644 src/services/watch.ts create mode 100644 test/unit/watch.test.ts create mode 100644 website/docs/documentation/poku/options/watch.mdx diff --git a/.gitignore b/.gitignore index ee862001..ab8161a1 100755 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ node_modules /coverage .env .nyc_output +/.temp diff --git a/README.md b/README.md index 95c5da86..0a14f4a3 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ That's it 🎉 - [**test**](https://poku.io/docs/documentation/helpers/test) - [**describe**](https://poku.io/docs/documentation/helpers/describe) and [**it**](https://poku.io/docs/documentation/helpers/it) - [**beforeEach**](https://poku.io/docs/category/beforeeach-and-aftereach) and [**afterEach**](https://poku.io/docs/category/beforeeach-and-aftereach) +- [**watch**](https://poku.io/docs/documentation/poku/options/watch) - **Processes** - [**kill**](https://poku.io/docs/documentation/processes/kill) (_terminate Ports, Port Ranges and PIDs_) - [**getPIDs**](https://poku.io/docs/documentation/processes/get-pids) (_get all processes IDs using ports and port ranges_) diff --git a/src/bin/index.ts b/src/bin/index.ts index 13340191..16a33e5b 100644 --- a/src/bin/index.ts +++ b/src/bin/index.ts @@ -9,13 +9,15 @@ import { hasArg, argToArray, } from '../helpers/get-arg.js'; -import { poku } from '../modules/poku.js'; -import { kill } from '../modules/processes.js'; +import { fileResults } from '../configs/files.js'; import { platformIsValid } from '../helpers/get-runtime.js'; import { format } from '../helpers/format.js'; import { write } from '../helpers/logs.js'; -import type { Configs } from '../@types/poku.js'; import { hr } from '../helpers/hr.js'; +import { watch } from '../services/watch.js'; +import { poku } from '../modules/poku.js'; +import { kill } from '../modules/processes.js'; +import type { Configs } from '../@types/poku.js'; (async () => { const dirs = (() => { @@ -46,6 +48,7 @@ import { hr } from '../helpers/hr.js'; const quiet = hasArg('quiet'); const debug = hasArg('debug'); const failFast = hasArg('fail-fast'); + const watchMode = hasArg('watch'); const concurrency = parallel ? Number(getArg('concurrency')) || undefined @@ -85,6 +88,7 @@ import { hr } from '../helpers/hr.js'; debug, failFast, concurrency, + noExit: watchMode, deno: { allow: denoAllow, deny: denoDeny, @@ -102,7 +106,36 @@ import { hr } from '../helpers/hr.js'; console.dir(options, { depth: null, colors: true }); } - poku(dirs, options); + await poku(dirs, options); + + if (watchMode) { + const executing = new Set(); + const interval = Number(getArg('watch-interval')) || 1500; + + fileResults.success.clear(); + fileResults.fail.clear(); + + hr(); + write(`Watching: ${dirs.join(', ')}`); + + dirs.forEach((dir) => { + watch(dir, (file, event) => { + if (event === 'change') { + if (executing.has(file)) return; + + executing.add(file); + fileResults.success.clear(); + fileResults.fail.clear(); + + poku(file, options).then(() => { + setTimeout(() => { + executing.delete(file); + }, interval); + }); + } + }); + }); + } })(); /* c8 ignore stop */ diff --git a/src/modules/each.ts b/src/modules/each.ts index 00e3e4ae..f773ab58 100644 --- a/src/modules/each.ts +++ b/src/modules/each.ts @@ -82,7 +82,7 @@ export const beforeEach = ( */ export const afterEach = ( callback: () => unknown, - options: Omit + options?: Omit ): Control => { each.after.test = typeof options?.test === 'boolean' ? options.test : true; each.after.assert = diff --git a/src/polyfills/fs.ts b/src/polyfills/fs.ts new file mode 100644 index 00000000..74e07374 --- /dev/null +++ b/src/polyfills/fs.ts @@ -0,0 +1,30 @@ +/* c8 ignore start */ + +import { + stat as nodeStat, + readdir as nodeReaddir, + type Dirent, + type Stats, +} from 'node:fs'; + +export const readdir = ( + path: string, + options: { withFileTypes: true } +): Promise => + new Promise((resolve, reject) => { + nodeReaddir(path, options, (err, entries) => { + if (err) reject(err); + else resolve(entries); + }); + }); + +export const stat = (path: string): Promise => { + return new Promise((resolve, reject) => { + nodeStat(path, (err, stats) => { + if (err) reject(err); + else resolve(stats); + }); + }); +}; + +/* c8 ignore stop */ diff --git a/src/services/watch.ts b/src/services/watch.ts new file mode 100644 index 00000000..6afa8e09 --- /dev/null +++ b/src/services/watch.ts @@ -0,0 +1,131 @@ +/* c8 ignore next */ +import { watch as nodeWatch, type FSWatcher } from 'node:fs'; +import { join } from 'node:path'; +import { readdir, stat } from '../polyfills/fs.js'; +import { listFiles } from '../modules/list-files.js'; + +/* c8 ignore next */ +export type WatchCallback = (file: string, event: string) => void; + +class Watcher { + private rootDir: string; + private files: string[] = []; + private fileWatchers: Map = new Map(); + private dirWatchers: Map = new Map(); + private callback: WatchCallback; + + constructor(rootDir: string, callback: WatchCallback) { + this.rootDir = rootDir; + this.callback = callback; + } + + private watchFile(filePath: string) { + /* c8 ignore next */ + if (this.fileWatchers.has(filePath)) return; + + const watcher = nodeWatch(filePath, (eventType) => { + this.callback(filePath, eventType); + }); + + /* c8 ignore start */ + watcher.on('error', () => { + return; + }); + /* c8 ignore stop */ + + this.fileWatchers.set(filePath, watcher); + } + + private unwatchFiles() { + for (const [filePath, watcher] of this.fileWatchers) { + watcher.close(); + this.fileWatchers.delete(filePath); + } + } + + private watchFiles(filePaths: string[]) { + this.unwatchFiles(); + + for (const filePath of filePaths) { + this.watchFile(filePath); + } + } + + private async watchDirectory(dir: string) { + if (this.dirWatchers.has(dir)) return; + + const watcher = nodeWatch(dir, async (_, filename) => { + if (filename) { + const fullPath = join(dir, filename); + + this.files = await listFiles(this.rootDir); + this.watchFiles(this.files); + + try { + const stats = await stat(fullPath); + if (stats.isDirectory()) await this.watchDirectory(fullPath); + /* c8 ignore start */ + } catch {} + /* c8 ignore stop */ + } + }); + + /* c8 ignore start */ + watcher.on('error', () => { + return; + }); + /* c8 ignore stop */ + + this.dirWatchers.set(dir, watcher); + + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const fullPath = join(dir, entry.name); + await this.watchDirectory(fullPath); + } + } + } + + public async start() { + try { + const stats = await stat(this.rootDir); + + if (stats.isDirectory()) { + this.files = await listFiles(this.rootDir); + + this.watchFiles(this.files); + await this.watchDirectory(this.rootDir); + } else this.watchFile(this.rootDir); + /* c8 ignore start */ + } catch (err) { + throw new Error( + `Path does not exist or is not accessible: ${this.rootDir}` + ); + } + /* c8 ignore stop */ + } + + public stop() { + this.unwatchFiles(); + this.unwatchDirectories(); + } + + private unwatchDirectories() { + for (const [dirPath, watcher] of this.dirWatchers) { + watcher.close(); + this.dirWatchers.delete(dirPath); + } + } + /* c8 ignore next */ +} + +/* c8 ignore next */ +export const watch = async (path: string, callback: WatchCallback) => { + const watcher = new Watcher(path, callback); + + await watcher.start(); + + return watcher; +}; diff --git a/test/docker/deno/latest.Dockerfile b/test/docker/deno/latest.Dockerfile index 4e56d965..04c161df 100644 --- a/test/docker/deno/latest.Dockerfile +++ b/test/docker/deno/latest.Dockerfile @@ -11,4 +11,4 @@ COPY ./fixtures ./fixtures RUN apk add lsof RUN deno run --allow-read --allow-write --allow-env --allow-run tools/compatibility/deno.ts -CMD ["deno", "run", "--allow-read", "--allow-env", "--allow-run", "test/run.test.ts"] +CMD ["deno", "run", "--allow-read", "--allow-write", "--allow-hrtime", "--allow-env", "--allow-run", "test/run.test.ts"] diff --git a/test/run.test.ts b/test/run.test.ts index 60c4d91e..20cf4130 100644 --- a/test/run.test.ts +++ b/test/run.test.ts @@ -3,4 +3,7 @@ import { poku } from '../src/modules/poku.js'; poku(['test/unit', 'test/integration', 'test/e2e'], { parallel: true, debug: true, + deno: { + allow: ['read', 'write', 'hrtime', 'env', 'run', 'net'], + }, }); diff --git a/test/unit/watch.test.ts b/test/unit/watch.test.ts new file mode 100644 index 00000000..7188b837 --- /dev/null +++ b/test/unit/watch.test.ts @@ -0,0 +1,248 @@ +import process from 'node:process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { it } from '../../src/modules/it.js'; +import { describe } from '../../src/modules/describe.js'; +import { beforeEach, afterEach } from '../../src/modules/each.js'; +import { assert } from '../../src/modules/assert.js'; +import { getRuntime, nodeVersion } from '../../src/helpers/get-runtime.js'; +import { watch, WatchCallback } from '../../src/services/watch.js'; + +if (nodeVersion && nodeVersion < 10) process.exit(0); + +const runtime = getRuntime(); + +const tmpDir = path.resolve('.', '.temp'); + +const createTempDir = () => { + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir, { recursive: true }); + } + fs.writeFileSync(path.join(tmpDir, 'file1.test.js'), 'export default {};'); + fs.writeFileSync(path.join(tmpDir, 'file2.test.js'), 'export default {};'); +}; + +const cleanTempDir = () => { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}; + +describe('Watcher Service', async () => { + let callbackResults: { file: string; event: string }[] = []; + const callback: WatchCallback = (file, event) => { + callbackResults.push({ file, event }); + }; + + beforeEach(() => { + createTempDir(); + callbackResults = []; + }); + + afterEach(() => { + callbackResults = []; + cleanTempDir(); + }); + + await it('should watch for file changes', async () => { + callbackResults = []; + const watcher = await watch(tmpDir, callback); + const filePath = path.join(tmpDir, 'file1.test.js'); + + fs.writeFileSync(filePath, 'export default {};'); + + await new Promise((resolve) => { + setTimeout(() => { + fs.writeFileSync(filePath, 'export default { updated: true };'); // update + resolve(undefined); + }, 1000); + }); + + return await new Promise((resolve) => { + setTimeout(() => { + assert( + callbackResults.length > 0, + 'Callback should be called on file change' + ); + assert( + callbackResults.some( + (result) => result.file === filePath && result.event === 'change' + ), + 'Callback should capture the correct file and event type' + ); + + watcher.stop(); + resolve(undefined); + }, 1000); + }); + }); + + await it('should watch for new files in directory', async () => { + if (runtime === 'bun') return; + if (runtime === 'deno') return; + + callbackResults = []; + + const watcher = await watch(tmpDir, callback); + const newFilePath = path.join(tmpDir, 'file3.test.js'); + + fs.writeFileSync(newFilePath, ''); // create (empty) + + await new Promise((resolve) => { + setTimeout(() => { + fs.writeFileSync(newFilePath, 'export default {};'); // update + resolve(undefined); + }, 1000); + }); + + return await new Promise((resolve) => { + setTimeout(() => { + assert( + callbackResults.length > 0, + 'Callback should be called on new file creation' + ); + assert( + callbackResults.some( + (result) => result.file === newFilePath && result.event === 'change' + ), + 'Callback should capture the correct file and event type' + ); + + watcher.stop(); + resolve(undefined); + }, 1000); + }); + }); + + await it('should stop watching files', async () => { + callbackResults = []; + const watcher = await watch(tmpDir, callback); + watcher.stop(); + const filePath = path.join(tmpDir, 'file1.test.js'); + fs.writeFileSync(filePath, 'export default { stopped: true };'); + + return await new Promise((resolve) => { + setTimeout(() => { + assert.strictEqual( + callbackResults.length, + 0, + 'Callback should not be called after watcher is stopped' + ); + resolve(undefined); + }, 1000); + }); + }); + + await it('should watch for changes in subdirectories', async () => { + if (runtime === 'bun') return; + if (runtime === 'deno') return; + + callbackResults = []; + const watcher = await watch(tmpDir, callback); + const subDirPath = path.join(tmpDir, 'subdir'); + const newFilePath = path.join(subDirPath, 'file4.test.js'); + + fs.mkdirSync(subDirPath); + fs.writeFileSync(newFilePath, ''); // create (empty) + + await new Promise((resolve) => { + setTimeout(() => { + fs.writeFileSync(newFilePath, 'export default {};'); // update + resolve(undefined); + }, 1000); + }); + + return new Promise((resolve) => { + setTimeout(() => { + assert( + callbackResults.length > 0, + 'Callback should be called on new directory creation' + ); + assert( + callbackResults.some( + (result) => result.file === newFilePath && result.event === 'change' + ), + 'Callback should capture the correct file and event type' + ); + + watcher.stop(); + resolve(undefined); + }, 1000); + }); + }); + + await it('should watch for changes in nested subdirectories', async () => { + if (runtime === 'bun') return; + if (runtime === 'deno') return; + + callbackResults = []; + const watcher = await watch(tmpDir, callback); + const nestedSubDirPath = path.join(tmpDir, 'subdir', 'nestedsubdir'); + const newNestedFilePath = path.join(nestedSubDirPath, 'file5.test.js'); + + fs.mkdirSync(nestedSubDirPath, { recursive: true }); + fs.writeFileSync(newNestedFilePath, ''); + + await new Promise((resolve) => { + setTimeout(() => { + fs.writeFileSync(newNestedFilePath, 'export default {};'); // update + resolve(undefined); + }, 1000); + }); + + return await new Promise((resolve) => { + setTimeout(() => { + assert( + callbackResults.length > 0, + 'Callback should be called on new nested directory creation' + ); + assert( + callbackResults.some( + (result) => + result.file === newNestedFilePath && result.event === 'change' + ), + 'Callback should capture the correct file and event type' + ); + + watcher.stop(); + resolve(undefined); + }, 1000); + }); + }); + + await it('should watch a single file directly', async () => { + if (runtime === 'bun') return; + + callbackResults = []; + const filePath = path.join(tmpDir, 'file1.test.js'); + + fs.writeFileSync(filePath, ''); + + const watcher = await watch(filePath, callback); + + await new Promise((resolve) => { + setTimeout(() => { + fs.writeFileSync(filePath, 'export default {};'); + resolve(undefined); + }, 1000); + }); + + return await new Promise((resolve) => { + setTimeout(() => { + assert( + callbackResults.length > 0, + 'Callback should be called on direct file change' + ); + assert( + callbackResults.some( + (result) => result.file === filePath && result.event === 'change' + ), + 'Callback should capture the correct file and event type' + ); + + watcher.stop(); + resolve(undefined); + }, 1000); + }); + }); +}); diff --git a/website/docs/documentation/poku/options/watch.mdx b/website/docs/documentation/poku/options/watch.mdx new file mode 100644 index 00000000..f73a7c57 --- /dev/null +++ b/website/docs/documentation/poku/options/watch.mdx @@ -0,0 +1,23 @@ +--- +sidebar_position: 9 +--- + +# `watch` + +Watches the events on test paths (after running all the tests), re-running the tests files that are updated for each new event. + +> Currently, `watch` mode only watches for events from test files and directories. + +## CLI + +```bash +npx poku --watch +``` + +You can use the flag `--watch-interval` to set a custom interval for `watch` events in milliseconds: + +> Default is `1500` + +```bash +npx poku --watch --watch-interval=1500 +```