diff --git a/packages/testwatch/index.js b/packages/testwatch/index.js index c5540a8..b5a3e5e 100755 --- a/packages/testwatch/index.js +++ b/packages/testwatch/index.js @@ -18,6 +18,7 @@ if (!isSupported) { } // eslint-disable-next-line import/no-unresolved, import/order const { spec: SpecReporter } = require('node:test/reporters'); +const WatchSuspendPlugin = require('./tests/fixtures/suspend-plugin'); const KEYS = { CTRLC: '\x03', @@ -41,6 +42,11 @@ process.stdin.setRawMode?.(true); class REPL { #controller = new AbortController(); + #hooks = { + shouldRunTestSuite: [], + onTestRunComplete: [], + }; + #filesFilter = process.argv[2] || ''; #testsFilter = ''; @@ -62,12 +68,20 @@ class REPL { this.#clear(); this.#controller.abort(); this.#controller = new AbortController(); + if (this.#hooks.shouldRunTestSuite.some((fn) => !fn())) { + this.#clear(); + this.#emitter.emit('drained'); + this.#hooks.onTestRunComplete.forEach((fn) => fn()); + return; + } + const filter = this.#filesFilter ? `**/${this.#filesFilter}*.*` : '**/?(*.)+(spec|test).[jt]s'; const files = await glob(filter, { ignore: 'node_modules/**' }); if (!files.length) { process.stdout.write(chalk.red(`\nNo files found for pattern ${filter}\n`)); this.#emitter.emit('drained'); + this.#hooks.onTestRunComplete.forEach((fn) => fn()); return; } @@ -92,6 +106,7 @@ class REPL { setImmediate(() => { this.#emitter.emit('drained'); drained = true; + this.#hooks.onTestRunComplete.forEach((fn) => fn()); }); } } @@ -239,9 +254,32 @@ ${Object.entries(this.#currentCommands) } } } + + registerPlugin(plugin) { + // TODO: should we be compatible (as much as possible) to Jest API for easy migration? + const usageInfo = plugin.getUsageInfo(); + + // TODO: enable override t,p overridable + this.#currentCommands = Object.freeze({ + [usageInfo.key]: { + fn: plugin.run.bind(plugin), + description: usageInfo.description, + }, + ...this.#currentCommands, + }); + + plugin.apply({ + shouldRunTestSuite: (fn) => this.#hooks.shouldRunTestSuite.push(fn), + onTestRunComplete: (fn) => this.#hooks.onTestRunComplete.push(fn), + }); + } } -new REPL().run() +const repl = new REPL(); +// TODO: only for testing/development. remove this after we have config. +repl.registerPlugin(new WatchSuspendPlugin()); + +repl.run() .then(() => process.exit(0)) .catch((error) => { /* c8 ignore next 2 */ diff --git a/packages/testwatch/tests/fixtures/suspend-plugin.js b/packages/testwatch/tests/fixtures/suspend-plugin.js new file mode 100644 index 0000000..75f7d3b --- /dev/null +++ b/packages/testwatch/tests/fixtures/suspend-plugin.js @@ -0,0 +1,33 @@ +'use strict'; + +const chalk = require('chalk'); + +module.exports = class WatchSuspendPlugin { + constructor() { + this.suspend = false; + } + + apply(hooks) { + hooks.shouldRunTestSuite(() => !this.suspend); + hooks.onTestRunComplete(() => { + if (this.suspend) { + console.info(chalk.bold('\nTest is suspended.')); + } + }); + } + + // eslint-disable-next-line class-methods-use-this + getUsageInfo() { + return { + key: 's', + description: 'suspend watch mode', + }; + } + + run() { + this.suspend = !this.suspend; + if (this.suspend) { + console.info(chalk.bold('\nTest is suspended.')); + } + } +}; diff --git a/packages/testwatch/tests/index.test.js b/packages/testwatch/tests/index.test.js index f599412..c43bd47 100644 --- a/packages/testwatch/tests/index.test.js +++ b/packages/testwatch/tests/index.test.js @@ -28,6 +28,8 @@ REPL Usage `; const mainMenuWithFilters = mainMenu.replace('REPL Usage', `REPL Usage › Press c to clear the filters.`); +const mainMenuWithPlugin = mainMenu.replace('REPL Usage', 'REPL Usage\n' + + ' › Press s to suspend watch mode'); const compactMenu = '\nREPL Usage: Press w to show more.'; const filterTestsPrompt = ` Filter Test @@ -84,7 +86,9 @@ async function spawnInteractive(commandSequence = 'q', args = []) { stdout += data; pending.stdout += data; const s = pending.stdout; - if (s.includes(mainMenu) || s.includes(mainMenuWithFilters) || s.includes(compactMenu)) { + // TODO: can we check only the last line of each possible option? + if (s.includes(mainMenu) || s.includes(mainMenuWithFilters) + || s.includes(compactMenu) || s.includes(mainMenuWithPlugin)) { pending.resolve(); } }); @@ -105,7 +109,6 @@ async function spawnInteractive(commandSequence = 'q', args = []) { }); }); } - describe('testwatch', { concurrency: true, skip: !isSupported ? 'unsupported node version' : false }, () => { it('should run all tests on initialization', async () => { const { outputs, stderr } = await spawnInteractive('q'); @@ -296,4 +299,18 @@ describe('testwatch', { concurrency: true, skip: !isSupported ? 'unsupported nod assert.match(outputs[5], /No files found for pattern \*\*\/noth1ing\*\.\*/); }); }); + + describe('Plugins', () => { + it('should suspend the watch mode', async () => { + const { outputs, stderr } = await spawnInteractive(['s', '\r', 'q']); + assert.strictEqual(stderr, ''); + assert.deepStrictEqual(outputs, [ + '', + '', + `${tests}\n${mainMenuWithPlugin}\nTest is suspended.\n`, + '', + `${compactMenu}\nTest is suspended.\n\n`, + ]); + }); + }); });