From c16607488abf88516aab6c76099890c79582dc01 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Wed, 21 Jun 2023 18:28:55 +0200 Subject: [PATCH 1/5] Minor changes to watch mode * Remove undocumented ability to start watch mode via the config. Require the CLI flag instead * Watch mode is no longer 'relatively new' * Add ava.config.mjs to default watcher ignore patterns * Ignore changes to failed-tests file in watcher Logger cleanup: * Remove obsolete clearLogOnNextRun option * Track firstRun for reporter --- docs/recipes/watch-mode.md | 4 -- lib/api.js | 6 +-- lib/cli.js | 10 ++-- lib/globs.js | 1 + lib/reporters/default.js | 2 +- lib/scheduler.js | 15 +++++- lib/watcher.js | 24 +-------- test-tap/helper/report.js | 6 +-- test-tap/watcher.js | 99 ++++++++++++++------------------------ 9 files changed, 66 insertions(+), 101 deletions(-) diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 4dc55ea08..26a3adabe 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -56,10 +56,6 @@ Sometimes watch mode does something surprising like rerunning all tests when you $ DEBUG=ava:watcher npx ava --watch ``` -## Help us make watch mode better - -Watch mode is relatively new and there might be some rough edges. Please [report](https://github.com/avajs/ava/issues) any issues you encounter. Thanks! - [`chokidar`]: https://github.com/paulmillr/chokidar [Install Troubleshooting]: https://github.com/paulmillr/chokidar#install-troubleshooting [`ignore-by-default`]: https://github.com/novemberborn/ignore-by-default diff --git a/lib/api.js b/lib/api.js index ace789d08..797c750f2 100644 --- a/lib/api.js +++ b/lib/api.js @@ -197,7 +197,6 @@ export default class Api extends Emittery { await this.emit('run', { bailWithoutReporting: debugWithoutSpecificFile, - clearLogOnNextRun: runtimeOptions.clearLogOnNextRun === true, debug: Boolean(this.options.debug), failFastEnabled: failFast, filePathPrefix: getFilePathPrefix(selectedFiles), @@ -205,7 +204,7 @@ export default class Api extends Emittery { matching: apiOptions.match.length > 0, previousFailures: runtimeOptions.previousFailures || 0, runOnlyExclusive: runtimeOptions.runOnlyExclusive === true, - runVector: runtimeOptions.runVector || 0, + firstRun: runtimeOptions.firstRun ?? true, status: runStatus, }); @@ -303,7 +302,8 @@ export default class Api extends Emittery { // Allow shared workers to clean up before the run ends. await Promise.all(deregisteredSharedWorkers); - scheduler.storeFailedTestFiles(runStatus, this.options.cacheEnabled === false ? null : this._createCacheDir()); + const files = scheduler.storeFailedTestFiles(runStatus, this.options.cacheEnabled === false ? null : this._createCacheDir()); + runStatus.emitStateChange({type: 'touched-files', files}); } catch (error) { if (error && error.name === 'AggregateError') { for (const error_ of error.errors) { diff --git a/lib/cli.js b/lib/cli.js index 88cc185b6..97190496b 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -324,6 +324,10 @@ export default async function loadCli() { // eslint-disable-line complexity exit('’sortTestFiles’ must be a comparator function.'); } + if (Object.hasOwn(conf, 'watch')) { + exit('’watch’ must not be configured, use the --watch CLI flag instead.'); + } + if (!combined.tap && Object.keys(experiments).length > 0) { console.log(chalk.magenta(` ${figures.warning} Experiments are enabled. These are unsupported and may change or be removed at any time.`)); } @@ -432,7 +436,7 @@ export default async function loadCli() { // eslint-disable-line complexity workerArgv: argv['--'], }); - const reporter = combined.tap && !combined.watch && debug === null ? new TapReporter({ + const reporter = combined.tap && !argv.watch && debug === null ? new TapReporter({ extensions: globs.extensions, projectDir, reportStream: process.stdout, @@ -442,7 +446,7 @@ export default async function loadCli() { // eslint-disable-line complexity projectDir, reportStream: process.stdout, stdStream: process.stderr, - watching: combined.watch, + watching: argv.watch, }); api.on('run', plan => { @@ -464,7 +468,7 @@ export default async function loadCli() { // eslint-disable-line complexity }); }); - if (combined.watch) { + if (argv.watch) { const watcher = new Watcher({ api, filter, diff --git a/lib/globs.js b/lib/globs.js index 4a751db6c..c1a88cf7b 100644 --- a/lib/globs.js +++ b/lib/globs.js @@ -26,6 +26,7 @@ const defaultIgnoredByWatcherPatterns = [ '**/*.snap.md', // No need to rerun tests when the Markdown files change. 'ava.config.js', // Config is not reloaded so avoid rerunning tests when it changes. 'ava.config.cjs', // Config is not reloaded so avoid rerunning tests when it changes. + 'ava.config.mjs', // Config is not reloaded so avoid rerunning tests when it changes. ]; const buildExtensionPattern = extensions => extensions.length === 1 ? extensions[0] : `{${extensions.join(',')}}`; diff --git a/lib/reporters/default.js b/lib/reporters/default.js index 804e285cc..82d6cdd25 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -148,7 +148,7 @@ export default class Reporter { this.consumeStateChange(evt); }); - if (this.watching && plan.runVector > 1) { + if (this.watching && !plan.firstRun) { this.lineWriter.write(chalk.gray.dim('\u2500'.repeat(this.lineWriter.columns)) + os.EOL); } diff --git a/lib/scheduler.js b/lib/scheduler.js index b64c69225..c78dd3234 100644 --- a/lib/scheduler.js +++ b/lib/scheduler.js @@ -13,9 +13,22 @@ const scheduler = { return; } + const filename = path.join(cacheDir, FILENAME); + // Given that we're writing to a cache directory, consider this file + // temporary. + const temporaryFiles = [filename]; try { - writeFileAtomic.sync(path.join(cacheDir, FILENAME), JSON.stringify(runStatus.getFailedTestFiles())); + writeFileAtomic.sync(filename, JSON.stringify(runStatus.getFailedTestFiles()), { + tmpfileCreated(tmpfile) { + temporaryFiles.push(tmpfile); + }, + }); } catch {} + + return { + changedFiles: [], + temporaryFiles, + }; }, // Order test-files, so that files with failing tests come first diff --git a/lib/watcher.js b/lib/watcher.js index 29107a632..2038ab6fc 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -89,7 +89,6 @@ export default class Watcher { constructor({api, filter = [], globs, projectDir, providers, reporter}) { this.debouncer = new Debouncer(this); - this.clearLogOnNextRun = true; this.runVector = 0; this.previousFiles = []; this.globs = {cwd: projectDir, ...globs}; @@ -98,11 +97,6 @@ export default class Watcher { this.providers = providers; this.run = (specificFiles = [], updateSnapshots = false) => { - const clearLogOnNextRun = this.clearLogOnNextRun && this.runVector > 0; - if (this.runVector > 0) { - this.clearLogOnNextRun = true; - } - this.runVector++; let runOnlyExclusive = false; @@ -135,28 +129,15 @@ export default class Watcher { files: specificFiles, filter, runtimeOptions: { - clearLogOnNextRun, previousFailures: this.sumPreviousFailures(this.runVector), runOnlyExclusive, - runVector: this.runVector, + firstRun: this.runVector === 1, updateSnapshots: updateSnapshots === true, }, }) - .then(runStatus => { + .then(() => { reporter.endRun(); reporter.lineWriter.writeLine(END_MESSAGE); - - if (this.clearLogOnNextRun && ( - runStatus.stats.failedHooks > 0 - || runStatus.stats.failedTests > 0 - || runStatus.stats.failedWorkers > 0 - || runStatus.stats.internalErrors > 0 - || runStatus.stats.timeouts > 0 - || runStatus.stats.uncaughtExceptions > 0 - || runStatus.stats.unhandledRejections > 0 - )) { - this.clearLogOnNextRun = false; - } }) .catch(rethrowAsync); }; @@ -372,7 +353,6 @@ export default class Watcher { // Cancel the debouncer again, it might have restarted while waiting for // the busy promise to fulfil this.debouncer.cancel(); - this.clearLogOnNextRun = false; if (data === 'u') { this.updatePreviousSnapshots(); } else { diff --git a/test-tap/helper/report.js b/test-tap/helper/report.js index 1fb4e541e..913fc37dc 100644 --- a/test-tap/helper/report.js +++ b/test-tap/helper/report.js @@ -107,12 +107,12 @@ const run = async (type, reporter, {match = [], filter} = {}) => { } // Mimick watch mode - return api.run({files, filter, runtimeOptions: {clearLogOnNextRun: false, previousFailures: 0, runVector: 1}}).then(() => { + return api.run({files, filter, runtimeOptions: {previousFailures: 0, firstRun: true}}).then(() => { reporter.endRun(); - return api.run({files, filter, runtimeOptions: {clearLogOnNextRun: true, previousFailures: 2, runVector: 2}}); + return api.run({files, filter, runtimeOptions: {previousFailures: 2, firstRun: false}}); }).then(() => { reporter.endRun(); - return api.run({files, filter, runtimeOptions: {clearLogOnNextRun: false, previousFailures: 0, runVector: 3}}); + return api.run({files, filter, runtimeOptions: {previousFailures: 0, firstRun: false}}); }).then(() => { reporter.endRun(); }); diff --git a/test-tap/watcher.js b/test-tap/watcher.js index c83449d4b..44f7dd9e3 100644 --- a/test-tap/watcher.js +++ b/test-tap/watcher.js @@ -120,10 +120,9 @@ group('chokidar', (beforeEach, test, group) => { 'test/**/*.cjs', ]; defaultApiOptions = { - clearLogOnNextRun: false, previousFailures: 0, runOnlyExclusive: false, - runVector: 1, + firstRun: true, updateSnapshots: false, }; @@ -178,7 +177,7 @@ group('chokidar', (beforeEach, test, group) => { ['**/*'], { cwd: process.cwd(), - ignored: [...defaultIgnore.map(dir => `${dir}/**/*`), '**/node_modules/**/*', '**/*.snap.md', 'ava.config.js', 'ava.config.cjs'], + ignored: [...defaultIgnore.map(dir => `${dir}/**/*`), '**/node_modules/**/*', '**/*.snap.md', 'ava.config.js', 'ava.config.cjs', 'ava.config.mjs'], ignoreInitial: true, }, ]); @@ -194,7 +193,7 @@ group('chokidar', (beforeEach, test, group) => { ['**/*'], { cwd: process.cwd(), - ignored: [...defaultIgnore.map(dir => `${dir}/**/*`), '**/node_modules/**/*', '**/*.snap.md', 'ava.config.js', 'ava.config.cjs', 'bar.cjs', 'qux.cjs'], + ignored: [...defaultIgnore.map(dir => `${dir}/**/*`), '**/node_modules/**/*', '**/*.snap.md', 'ava.config.js', 'ava.config.cjs', 'ava.config.mjs', 'bar.cjs', 'qux.cjs'], ignoreInitial: true, }, ]); @@ -284,8 +283,7 @@ group('chokidar', (beforeEach, test, group) => { // No explicit files are provided t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); // Finish is only called after the run promise fulfils @@ -326,8 +324,7 @@ group('chokidar', (beforeEach, test, group) => { return debounce().then(() => { t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: false, - runVector: 2, + firstRun: false, }}]); change(); @@ -335,8 +332,7 @@ group('chokidar', (beforeEach, test, group) => { }).then(() => { t.strictSame(api.run.thirdCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 3, + firstRun: false, }}]); }); }); @@ -464,8 +460,7 @@ group('chokidar', (beforeEach, test, group) => { // The `test.js` file is provided t.strictSame(api.run.secondCall.args, [{files: [path.resolve('test.cjs')], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); // The endRun method is only called after the run promise fulfills @@ -492,8 +487,7 @@ group('chokidar', (beforeEach, test, group) => { // The test files are provided t.strictSame(api.run.secondCall.args, [{files: [path.resolve('test-one.cjs'), path.resolve('test-two.cjs')], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -510,8 +504,7 @@ group('chokidar', (beforeEach, test, group) => { // No explicit files are provided t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -540,8 +533,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [path.resolve('foo-bar.cjs'), path.resolve('foo-baz.cjs')], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -559,8 +551,7 @@ group('chokidar', (beforeEach, test, group) => { // `_foo.bar` cannot be a test file, thus the initial tests are run t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -574,13 +565,13 @@ group('chokidar', (beforeEach, test, group) => { stdin.write(`${input}\n`); return delay().then(() => { t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: {...defaultApiOptions, runVector: 2}}]); + t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: {...defaultApiOptions, firstRun: false}}]); stdin.write(`\t${input} \n`); return delay(); }).then(() => { t.ok(api.run.calledThrice); - t.strictSame(api.run.thirdCall.args, [{files: [], filter: [], runtimeOptions: {...defaultApiOptions, runVector: 3}}]); + t.strictSame(api.run.thirdCall.args, [{files: [], filter: [], runtimeOptions: {...defaultApiOptions, firstRun: false}}]); }); }); } @@ -599,13 +590,13 @@ group('chokidar', (beforeEach, test, group) => { await delay(); t.ok(api.run.calledThrice); - t.strictSame(api.run.thirdCall.args, [{files: [path.resolve('test-one.cjs')], filter: [], runtimeOptions: {...options, runVector: 3}}]); + t.strictSame(api.run.thirdCall.args, [{files: [path.resolve('test-one.cjs')], filter: [], runtimeOptions: {...options, firstRun: false}}]); stdin.write('\tu \n'); await delay(); t.equal(api.run.callCount, 4); - t.strictSame(api.run.lastCall.args, [{files: [path.resolve('test-one.cjs')], filter: [], runtimeOptions: {...options, runVector: 4}}]); + t.strictSame(api.run.lastCall.args, [{files: [path.resolve('test-one.cjs')], filter: [], runtimeOptions: {...options, firstRun: false}}]); }); for (const input of ['r', 'rs', 'u']) { @@ -619,8 +610,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: false, - runVector: 2, + firstRun: false, updateSnapshots: input === 'u', }}]); }); @@ -837,8 +827,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [path.resolve(path.join('test', '1.cjs'))], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -852,8 +841,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -871,8 +859,7 @@ group('chokidar', (beforeEach, test, group) => { filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }, }]); }); @@ -888,8 +875,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [path.resolve(path.join('test', '1.cjs'))], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -904,8 +890,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [path.resolve(path.join('test', '2.cjs'))], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -920,8 +905,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [path.resolve(path.join('test', '1.cjs'))], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -950,8 +934,7 @@ group('chokidar', (beforeEach, test, group) => { // dependency t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -972,8 +955,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [path.join('test', '1.cjs')], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1012,8 +994,7 @@ group('chokidar', (beforeEach, test, group) => { // are expected to be rerun t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1116,8 +1097,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [t1Absolute], filter: [], runtimeOptions: { ...options, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1221,8 +1201,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [t1Absolute, t2Absolute, t3Absolute, t4Absolute], filter: [], runtimeOptions: { ...options, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1238,8 +1217,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [t1Absolute, t2Absolute, t4Absolute], filter: [], runtimeOptions: { ...options, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1254,8 +1232,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [t1Absolute, t2Absolute], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1273,8 +1250,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [t3Absolute, t4Absolute], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1291,8 +1267,7 @@ group('chokidar', (beforeEach, test, group) => { t.ok(api.run.calledTwice); t.strictSame(api.run.secondCall.args, [{files: [t3Absolute, t4Absolute], filter: [], runtimeOptions: { ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1404,8 +1379,7 @@ group('chokidar', (beforeEach, test, group) => { t.strictSame(api.run.secondCall.args, [{files: [path.resolve(other)], filter: [], runtimeOptions: { ...defaultApiOptions, previousFailures: 2, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1434,8 +1408,7 @@ group('chokidar', (beforeEach, test, group) => { t.strictSame(api.run.secondCall.args, [{files: [path.resolve(first)], filter: [], runtimeOptions: { ...defaultApiOptions, previousFailures: 1, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1464,8 +1437,7 @@ group('chokidar', (beforeEach, test, group) => { t.strictSame(api.run.secondCall.args, [{files: [path.resolve(same)], filter: [], runtimeOptions: { ...defaultApiOptions, previousFailures: 0, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); @@ -1498,8 +1470,7 @@ group('chokidar', (beforeEach, test, group) => { t.strictSame(api.run.secondCall.args, [{files: [path.resolve(other)], filter: [], runtimeOptions: { ...defaultApiOptions, previousFailures: 0, - clearLogOnNextRun: true, - runVector: 2, + firstRun: false, }}]); }); }); From 9cd5ff8466c842a2e4f2fe917ea52fdf50d167fc Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Sun, 23 Oct 2022 15:56:17 +0200 Subject: [PATCH 2/5] Require opt-in to AVA 5's watcher and separate install of chokidar Restrict @ava/typescript to the ava-3.2 protocol, since the legacy code is not compatible with the ava-6 protocol. Remove brittle tests for the legacy code. --- docs/recipes/watch-mode.md | 19 +- lib/{watcher.js => ava5-watcher.js} | 21 +- lib/cli.js | 32 +- lib/eslint-plugin-helper-worker.js | 2 +- lib/provider-manager.js | 16 +- lib/runner.js | 2 +- ...-tracker.js => ava5-dependency-tracker.js} | 0 lib/worker/base.js | 21 +- package-lock.json | 47 +- package.json | 8 +- test-tap/api.js | 34 - .../fixture/{watcher => tap}/package.json | 0 test-tap/fixture/{watcher => tap}/test.cjs | 0 .../fixture/watcher/ignored-files/ignore.cjs | 0 .../fixture/watcher/ignored-files/ignored.cjs | 0 .../watcher/ignored-files/package.json | 10 - .../fixture/watcher/ignored-files/source.cjs | 0 .../fixture/watcher/ignored-files/test.cjs | 3 - .../fixture/watcher/tap-in-conf/package.json | 5 - test-tap/fixture/watcher/tap-in-conf/test.cjs | 5 - .../watcher/with-dependencies/source.cjs | 2 - .../watcher/with-dependencies/test-1.cjs | 7 - .../watcher/with-dependencies/test-2.cjs | 5 - test-tap/integration/assorted.js | 2 +- test-tap/integration/debug.js | 6 +- test-tap/integration/watcher.js | 226 --- test-tap/watcher.js | 1478 ----------------- 27 files changed, 95 insertions(+), 1856 deletions(-) rename lib/{watcher.js => ava5-watcher.js} (96%) rename lib/worker/{dependency-tracker.js => ava5-dependency-tracker.js} (100%) rename test-tap/fixture/{watcher => tap}/package.json (100%) rename test-tap/fixture/{watcher => tap}/test.cjs (100%) delete mode 100644 test-tap/fixture/watcher/ignored-files/ignore.cjs delete mode 100644 test-tap/fixture/watcher/ignored-files/ignored.cjs delete mode 100644 test-tap/fixture/watcher/ignored-files/package.json delete mode 100644 test-tap/fixture/watcher/ignored-files/source.cjs delete mode 100644 test-tap/fixture/watcher/ignored-files/test.cjs delete mode 100644 test-tap/fixture/watcher/tap-in-conf/package.json delete mode 100644 test-tap/fixture/watcher/tap-in-conf/test.cjs delete mode 100644 test-tap/fixture/watcher/with-dependencies/source.cjs delete mode 100644 test-tap/fixture/watcher/with-dependencies/test-1.cjs delete mode 100644 test-tap/fixture/watcher/with-dependencies/test-2.cjs delete mode 100644 test-tap/integration/watcher.js delete mode 100644 test-tap/watcher.js diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 26a3adabe..7ed1a968c 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -4,6 +4,17 @@ Translations: [Français](https://github.com/avajs/ava-docs/blob/main/fr_FR/docs AVA comes with an intelligent watch mode. It watches for files to change and runs just those tests that are affected. +AVA 6 is introducing a new watch mode that relies on recurse file watching in Node.js. To use the old watch mode, set the implementation to `ava5+chokidar` and install [`chokidar`] alongside AVA: + +`ava.config.mjs`: +```js +export default { + watchMode: { + implementation: 'ava5+chokidar', + }, +} +``` + ## Running tests with watch mode enabled You can enable watch mode using the `--watch` or `-w` flags: @@ -16,7 +27,9 @@ Please note that integrated debugging and the TAP reporter are unavailable when ## Requirements -AVA uses [`chokidar`] as the file watcher. Note that even if you see warnings about optional dependencies failing during install, it will still work fine. Please refer to the *[Install Troubleshooting]* section of `chokidar` documentation for how to resolve the installation problems with chokidar. +AVA 5 uses [`chokidar`] as the file watcher. Note that even if you see warnings about optional dependencies failing during install, it will still work fine. Please refer to the *[Install Troubleshooting]* section of `chokidar` documentation for how to resolve the installation problems with chokidar. + +The same applies with AVA 6 when using the `ava5+chokidar` watcher. However you'll need to install `chokidar` separately. ## Ignoring changes @@ -30,7 +43,9 @@ If your tests write to disk they may trigger the watcher to rerun your tests. Co AVA tracks which source files your test files depend on. If you change such a dependency only the test file that depends on it will be rerun. AVA will rerun all tests if it cannot determine which test file depends on the changed source file. -Dependency tracking works for required modules. Custom extensions and transpilers are supported, provided you [added them in your `package.json` or `ava.config.*` file][config], and not from inside your test file. Files accessed using the `fs` module are not tracked. +AVA 5 (and the `ava5+chokidar` watcher in AVA 6) spies on `require()` calls to track dependencies. Custom extensions and transpilers are supported, provided you [added them in your `package.json` or `ava.config.*` file][config], and not from inside your test file. + +Files accessed using the `fs` module are not tracked. ## Watch mode and the `.only` modifier diff --git a/lib/watcher.js b/lib/ava5-watcher.js similarity index 96% rename from lib/watcher.js rename to lib/ava5-watcher.js index 2038ab6fc..c3f4dca92 100644 --- a/lib/watcher.js +++ b/lib/ava5-watcher.js @@ -1,20 +1,12 @@ import nodePath from 'node:path'; -import chokidar_ from 'chokidar'; +import chokidar from 'chokidar'; import createDebug from 'debug'; import {chalk} from './chalk.js'; import {applyTestFileFilter, classify, getChokidarIgnorePatterns} from './globs.js'; -let chokidar = chokidar_; -export function _testOnlyReplaceChokidar(replacement) { - chokidar = replacement; -} - -let debug = createDebug('ava:watcher'); -export function _testOnlyReplaceDebug(replacement) { - debug = replacement('ava:watcher'); -} +const debug = createDebug('ava:watcher'); function rethrowAsync(error) { // Don't swallow exceptions. Note that any @@ -177,11 +169,16 @@ export default class Watcher { trackTestDependencies(api) { api.on('run', plan => { plan.status.on('stateChange', evt => { - if (evt.type !== 'dependencies') { + let dependencies; + if (evt.type === 'dependencies') { + dependencies = evt.dependencies; + } else if (evt.type === 'accessed-snapshots') { + dependencies = [evt.filename]; + } else { return; } - const dependencies = evt.dependencies.filter(filePath => { + dependencies = dependencies.filter(filePath => { const {isIgnoredByWatcher} = classify(filePath, this.globs); return !isIgnoredByWatcher; }); diff --git a/lib/cli.js b/lib/cli.js index 97190496b..fd86db291 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -24,7 +24,6 @@ import pkg from './pkg.cjs'; import providerManager from './provider-manager.js'; import DefaultReporter from './reporters/default.js'; import TapReporter from './reporters/tap.js'; -import Watcher from './watcher.js'; function exit(message) { console.error(`\n ${chalk.red(figures.cross)} ${message}`); @@ -346,9 +345,10 @@ export default async function loadCli() { // eslint-disable-line complexity const providers = []; if (Object.hasOwn(conf, 'typescript')) { try { - const {level, main} = await providerManager.typescript(projectDir); + const {identifier: protocol, level, main} = await providerManager.typescript(projectDir, {fullConfig: conf}); providers.push({ level, + protocol, main: main({config: conf.typescript}), type: 'typescript', }); @@ -414,6 +414,7 @@ export default async function loadCli() { // eslint-disable-line complexity concurrency: combined.concurrency || 0, workerThreads: combined.workerThreads !== false, debug, + enableAva5DependencyTracking: argv.watch && conf.watchMode?.implementation === 'ava5+chokidar', environmentVariables, experiments, extensions, @@ -469,15 +470,24 @@ export default async function loadCli() { // eslint-disable-line complexity }); if (argv.watch) { - const watcher = new Watcher({ - api, - filter, - globs, - projectDir, - providers, - reporter, - }); - watcher.observeStdin(process.stdin); + if (Object.hasOwn(conf, 'watchMode') && Object.hasOwn(conf.watchMode, 'implementation')) { + if (conf.watchMode.implementation === 'ava5+chokidar') { + const {default: Watcher} = await import('./ava5-watcher.js'); + const watcher = new Watcher({ + api, + filter, + globs, + projectDir, + providers, + reporter, + }); + watcher.observeStdin(process.stdin); + } else { + exit('The “watchMode.implementation” option must be set to “ava5+chokidar”'); + } + } else { + exit('TODO'); + } } else { let debugWithoutSpecificFile = false; api.on('run', plan => { diff --git a/lib/eslint-plugin-helper-worker.js b/lib/eslint-plugin-helper-worker.js index 24bd85240..afe616a00 100644 --- a/lib/eslint-plugin-helper-worker.js +++ b/lib/eslint-plugin-helper-worker.js @@ -13,7 +13,7 @@ const configCache = new Map(); const collectProviders = async ({conf, projectDir}) => { const providers = []; if (Object.hasOwn(conf, 'typescript')) { - const {level, main} = await providerManager.typescript(projectDir); + const {level, main} = await providerManager.typescript(projectDir, {fullConfig: conf}); providers.push({ level, main: main({config: conf.typescript}), diff --git a/lib/provider-manager.js b/lib/provider-manager.js index bf982f60d..18c31eae9 100644 --- a/lib/provider-manager.js +++ b/lib/provider-manager.js @@ -13,7 +13,7 @@ const levelsByProtocol = { 'ava-3.2': levels.levelIntegersAreCurrentlyUnused, }; -async function load(providerModule, projectDir) { +async function load(providerModule, projectDir, selectProtocol = () => true) { const ava = {version: pkg.version}; const {default: makeProvider} = await import(providerModule); @@ -21,7 +21,8 @@ async function load(providerModule, projectDir) { let level; const provider = makeProvider({ negotiateProtocol(identifiers, {version}) { - const identifier = identifiers.find(identifier => Object.hasOwn(levelsByProtocol, identifier)); + const identifier = identifiers + .find(identifier => selectProtocol(identifier) && Object.hasOwn(levelsByProtocol, identifier)); if (identifier === undefined) { fatal = new Error(`This version of AVA (${ava.version}) is not compatible with ${providerModule}@${version}`); @@ -51,8 +52,15 @@ async function load(providerModule, projectDir) { const providerManager = { levels, - async typescript(projectDir) { - return load('@ava/typescript', projectDir); + async typescript(projectDir, {fullConfig, protocol}) { + const legacy = fullConfig?.watchMode?.implementation === 'ava5+chokidar'; + return load('@ava/typescript', projectDir, identifier => { + if (protocol === undefined) { + return !legacy || identifier === 'ava-3.2'; + } + + return identifier === protocol; + }); }, }; diff --git a/lib/runner.js b/lib/runner.js index bb5dd647e..e5e878376 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -209,7 +209,7 @@ export default class Runner extends Emittery { updating: this.updateSnapshots, }); if (snapshots.snapPath !== undefined) { - this.emit('dependency', snapshots.snapPath); + this.emit('accessed-snapshots', snapshots.snapPath); } this._snapshots = snapshots; diff --git a/lib/worker/dependency-tracker.js b/lib/worker/ava5-dependency-tracker.js similarity index 100% rename from lib/worker/dependency-tracker.js rename to lib/worker/ava5-dependency-tracker.js diff --git a/lib/worker/base.js b/lib/worker/base.js index 63c8a7db5..d9f181050 100644 --- a/lib/worker/base.js +++ b/lib/worker/base.js @@ -14,8 +14,9 @@ import providerManager from '../provider-manager.js'; import Runner from '../runner.js'; import serializeError from '../serialize-error.js'; +// TODO: Delete along with ava5+chokidar watcher. +import dependencyTracking from './ava5-dependency-tracker.js'; import channel from './channel.cjs'; -import dependencyTracking from './dependency-tracker.js'; import lineNumberSelection from './line-numbers.js'; import {set as setOptions} from './options.cjs'; import {flags, refs, sharedWorkerTeardowns} from './state.cjs'; @@ -99,7 +100,11 @@ const run = async options => { runner.interrupt(); }); - runner.on('dependency', dependencyTracking.track); + runner.on('accessed-snapshots', filename => channel.send({type: 'accessed-snapshots', filename})); + if (options.enableAva5DependencyTracking) { + runner.on('dependency', dependencyTracking.track); + } + runner.on('stateChange', state => channel.send(state)); runner.on('error', error => { @@ -152,9 +157,9 @@ const run = async options => { // require configuration the *compiled* helper will be loaded. const {projectDir, providerStates = []} = options; const providers = []; - await Promise.all(providerStates.map(async ({type, state}) => { + await Promise.all(providerStates.map(async ({type, state, protocol}) => { if (type === 'typescript') { - const provider = await providerManager.typescript(projectDir); + const provider = await providerManager.typescript(projectDir, {protocol}); providers.push(provider.worker({extensionsToLoadAsModules, state})); } })); @@ -229,9 +234,11 @@ const run = async options => { } } - // Install dependency tracker after the require configuration has been evaluated - // to make sure we also track dependencies with custom require hooks - dependencyTracking.install(require.extensions, testPath); + if (options.enableAva5DependencyTracking) { + // Install dependency tracker after the require configuration has been evaluated + // to make sure we also track dependencies with custom require hooks + dependencyTracking.install(require.extensions, testPath); + } if (options.debug && options.debug.port !== undefined && options.debug.host !== undefined) { // If an inspector was active when the main process started, and is diff --git a/package-lock.json b/package-lock.json index 8b5446dcf..0de6d725a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "callsites": "^4.0.0", "cbor": "^8.1.0", "chalk": "^5.2.0", - "chokidar": "^3.5.3", "chunkd": "^2.0.1", "ci-info": "^3.8.0", "ci-parallel-vars": "^1.0.1", @@ -67,7 +66,6 @@ "sinon": "^15.1.0", "tap": "^16.3.4", "tempy": "^3.0.0", - "touch": "^3.1.0", "tsd": "^0.28.1", "typescript": "^4.9.5", "xo": "^0.54.2", @@ -77,11 +75,15 @@ "node": "^16.18 || ^18.16 || ^20.3" }, "peerDependencies": { - "@ava/typescript": "*" + "@ava/typescript": "*", + "chokidar": "^3.5.3" }, "peerDependenciesMeta": { "@ava/typescript": { "optional": true + }, + "chokidar": { + "optional": true } } }, @@ -1390,12 +1392,6 @@ "dev": true, "peer": true }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true - }, "node_modules/acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", @@ -1516,6 +1512,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "devOptional": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -1691,6 +1688,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "devOptional": true, "engines": { "node": ">=8" } @@ -2002,6 +2000,7 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "devOptional": true, "funding": [ { "type": "individual", @@ -4585,6 +4584,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "devOptional": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -6147,21 +6147,6 @@ "node": ">=12.19" } }, - "node_modules/nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", - "dev": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", @@ -6181,6 +6166,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -7436,6 +7422,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "devOptional": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -10291,18 +10278,6 @@ "node": ">=8.0" } }, - "node_modules/touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", - "dev": true, - "dependencies": { - "nopt": "~1.0.10" - }, - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", diff --git a/package.json b/package.json index 2a83b5f68..b1a4be33e 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,6 @@ "callsites": "^4.0.0", "cbor": "^8.1.0", "chalk": "^5.2.0", - "chokidar": "^3.5.3", "chunkd": "^2.0.1", "ci-info": "^3.8.0", "ci-parallel-vars": "^1.0.1", @@ -136,18 +135,21 @@ "sinon": "^15.1.0", "tap": "^16.3.4", "tempy": "^3.0.0", - "touch": "^3.1.0", "tsd": "^0.28.1", "typescript": "^4.9.5", "xo": "^0.54.2", "zen-observable": "^0.10.0" }, "peerDependencies": { - "@ava/typescript": "*" + "@ava/typescript": "*", + "chokidar": "^3.5.3" }, "peerDependenciesMeta": { "@ava/typescript": { "optional": true + }, + "chokidar": { + "optional": true } }, "volta": { diff --git a/test-tap/api.js b/test-tap/api.js index 0697b2257..6cc7daf89 100644 --- a/test-tap/api.js +++ b/test-tap/api.js @@ -418,40 +418,6 @@ for (const opt of options) { }); }); - test(`emits dependencies for test files - workerThreads: ${opt.workerThreads}`, async t => { - t.plan(8); - - const api = await apiCreator({ - ...opt, - files: ['test-tap/fixture/with-dependencies/*test*.cjs'], - require: [path.resolve('test-tap/fixture/with-dependencies/require-custom.cjs')], - }); - - const testFiles = new Set([ - path.resolve('test-tap/fixture/with-dependencies/no-tests.cjs'), - path.resolve('test-tap/fixture/with-dependencies/test.cjs'), - path.resolve('test-tap/fixture/with-dependencies/test-failure.cjs'), - path.resolve('test-tap/fixture/with-dependencies/test-uncaught-exception.cjs'), - ]); - - const sourceFiles = [ - path.resolve('test-tap/fixture/with-dependencies/dep-1.js'), - path.resolve('test-tap/fixture/with-dependencies/dep-2.js'), - path.resolve('test-tap/fixture/with-dependencies/dep-3.custom'), - ]; - - api.on('run', plan => { - plan.status.on('stateChange', evt => { - if (evt.type === 'dependencies') { - t.ok(testFiles.has(evt.testFile)); - t.strictSame(evt.dependencies.filter(dep => !dep.endsWith('.snap')).slice(-3), sourceFiles); - } - }); - }); - - return api.run(); - }); - test(`verify test count - workerThreads: ${opt.workerThreads}`, async t => { t.plan(4); diff --git a/test-tap/fixture/watcher/package.json b/test-tap/fixture/tap/package.json similarity index 100% rename from test-tap/fixture/watcher/package.json rename to test-tap/fixture/tap/package.json diff --git a/test-tap/fixture/watcher/test.cjs b/test-tap/fixture/tap/test.cjs similarity index 100% rename from test-tap/fixture/watcher/test.cjs rename to test-tap/fixture/tap/test.cjs diff --git a/test-tap/fixture/watcher/ignored-files/ignore.cjs b/test-tap/fixture/watcher/ignored-files/ignore.cjs deleted file mode 100644 index e69de29bb..000000000 diff --git a/test-tap/fixture/watcher/ignored-files/ignored.cjs b/test-tap/fixture/watcher/ignored-files/ignored.cjs deleted file mode 100644 index e69de29bb..000000000 diff --git a/test-tap/fixture/watcher/ignored-files/package.json b/test-tap/fixture/watcher/ignored-files/package.json deleted file mode 100644 index a0358103a..000000000 --- a/test-tap/fixture/watcher/ignored-files/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "ava": { - "files": [ - "test.cjs" - ], - "ignoredByWatcher": [ - "ignored.cjs" - ] - } -} diff --git a/test-tap/fixture/watcher/ignored-files/source.cjs b/test-tap/fixture/watcher/ignored-files/source.cjs deleted file mode 100644 index e69de29bb..000000000 diff --git a/test-tap/fixture/watcher/ignored-files/test.cjs b/test-tap/fixture/watcher/ignored-files/test.cjs deleted file mode 100644 index e0d287c19..000000000 --- a/test-tap/fixture/watcher/ignored-files/test.cjs +++ /dev/null @@ -1,3 +0,0 @@ -const test = require('../../../../entrypoints/main.cjs'); - -test('pass', t => t.pass()); diff --git a/test-tap/fixture/watcher/tap-in-conf/package.json b/test-tap/fixture/watcher/tap-in-conf/package.json deleted file mode 100644 index bff282992..000000000 --- a/test-tap/fixture/watcher/tap-in-conf/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ava": { - "tap": true - } -} diff --git a/test-tap/fixture/watcher/tap-in-conf/test.cjs b/test-tap/fixture/watcher/tap-in-conf/test.cjs deleted file mode 100644 index cda49e4d2..000000000 --- a/test-tap/fixture/watcher/tap-in-conf/test.cjs +++ /dev/null @@ -1,5 +0,0 @@ -const test = require('../../../../entrypoints/main.cjs'); - -test('works', t => { - t.pass(); -}); diff --git a/test-tap/fixture/watcher/with-dependencies/source.cjs b/test-tap/fixture/watcher/with-dependencies/source.cjs deleted file mode 100644 index 80b9ab875..000000000 --- a/test-tap/fixture/watcher/with-dependencies/source.cjs +++ /dev/null @@ -1,2 +0,0 @@ -'use strict'; -module.exports = true; diff --git a/test-tap/fixture/watcher/with-dependencies/test-1.cjs b/test-tap/fixture/watcher/with-dependencies/test-1.cjs deleted file mode 100644 index d610372ed..000000000 --- a/test-tap/fixture/watcher/with-dependencies/test-1.cjs +++ /dev/null @@ -1,7 +0,0 @@ -const test = require('../../../../entrypoints/main.cjs'); - -const dependency = require('./source.cjs'); - -test('works', t => { - t.truthy(dependency); -}); diff --git a/test-tap/fixture/watcher/with-dependencies/test-2.cjs b/test-tap/fixture/watcher/with-dependencies/test-2.cjs deleted file mode 100644 index cda49e4d2..000000000 --- a/test-tap/fixture/watcher/with-dependencies/test-2.cjs +++ /dev/null @@ -1,5 +0,0 @@ -const test = require('../../../../entrypoints/main.cjs'); - -test('works', t => { - t.pass(); -}); diff --git a/test-tap/integration/assorted.js b/test-tap/integration/assorted.js index 643d7e75b..b2442ccb3 100644 --- a/test-tap/integration/assorted.js +++ b/test-tap/integration/assorted.js @@ -49,7 +49,7 @@ test('--match works', t => { for (const tapFlag of ['--tap', '-t']) { test(`${tapFlag} should produce TAP output`, t => { - execCli([tapFlag, 'test.cjs'], {dirname: 'fixture/watcher'}, error => { + execCli([tapFlag, 'test.cjs'], {dirname: 'fixture/tap'}, error => { t.ok(!error); t.end(); }); diff --git a/test-tap/integration/debug.js b/test-tap/integration/debug.js index 5f755a0a0..0395249d0 100644 --- a/test-tap/integration/debug.js +++ b/test-tap/integration/debug.js @@ -3,7 +3,7 @@ import {test} from 'tap'; import {execCli} from '../helper/cli.js'; test('bails when using --watch while while debugging', t => { - execCli(['debug', '--watch', 'test.cjs'], {dirname: 'fixture/watcher', env: {AVA_FORCE_CI: 'not-ci'}}, (error, stdout, stderr) => { + execCli(['debug', '--watch', 'test.cjs'], {dirname: 'fixture/tap', env: {AVA_FORCE_CI: 'not-ci'}}, (error, stdout, stderr) => { t.equal(error.code, 1); t.match(stderr, 'Watch mode is not available when debugging.'); t.end(); @@ -11,7 +11,7 @@ test('bails when using --watch while while debugging', t => { }); test('bails when debugging in CI', t => { - execCli(['debug', 'test.cjs'], {dirname: 'fixture/watcher', env: {AVA_FORCE_CI: 'ci'}}, (error, stdout, stderr) => { + execCli(['debug', 'test.cjs'], {dirname: 'fixture/tap', env: {AVA_FORCE_CI: 'ci'}}, (error, stdout, stderr) => { t.equal(error.code, 1); t.match(stderr, 'Debugging is not available in CI.'); t.end(); @@ -19,7 +19,7 @@ test('bails when debugging in CI', t => { }); test('bails when --tap reporter is used while debugging', t => { - execCli(['debug', '--tap', 'test.cjs'], {dirname: 'fixture/watcher', env: {AVA_FORCE_CI: 'not-ci'}}, (error, stdout, stderr) => { + execCli(['debug', '--tap', 'test.cjs'], {dirname: 'fixture/tap', env: {AVA_FORCE_CI: 'not-ci'}}, (error, stdout, stderr) => { t.equal(error.code, 1); t.match(stderr, 'The TAP reporter is not available when debugging.'); t.end(); diff --git a/test-tap/integration/watcher.js b/test-tap/integration/watcher.js deleted file mode 100644 index e54292fbf..000000000 --- a/test-tap/integration/watcher.js +++ /dev/null @@ -1,226 +0,0 @@ -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -import {test} from 'tap'; -import touch from 'touch'; - -import {execCli} from '../helper/cli.js'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); - -const END_MESSAGE = 'Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n'; - -test('watcher reruns test files upon change', t => { - let killed = false; - - const child = execCli(['--watch', 'test.cjs'], {dirname: 'fixture/watcher', env: {AVA_FORCE_CI: 'not-ci'}}, error => { - t.ok(killed); - t.error(error); - t.end(); - }); - - let buffer = ''; - let passedFirst = false; - child.stdout.on('data', string => { - buffer += string; - if (buffer.includes('1 test passed')) { - if (!passedFirst) { - touch.sync(path.join(__dirname, '../fixture/watcher/test.cjs')); - buffer = ''; - passedFirst = true; - } else if (!killed) { - child.kill(); - killed = true; - } - } - }); -}); - -test('watcher reruns test files when source dependencies change', t => { - let killed = false; - - const child = execCli(['--watch', 'test-1.cjs', 'test-2.cjs'], {dirname: 'fixture/watcher/with-dependencies', env: {AVA_FORCE_CI: 'not-ci'}}, error => { - t.ok(killed); - t.error(error); - t.end(); - }); - - let buffer = ''; - let passedFirst = false; - child.stdout.on('data', string => { - buffer += string; - if (buffer.includes('2 tests passed') && !passedFirst) { - touch.sync(path.join(__dirname, '../fixture/watcher/with-dependencies/source.cjs')); - buffer = ''; - passedFirst = true; - } else if (buffer.includes('1 test passed') && !killed) { - child.kill(); - killed = true; - } - }); -}); - -test('watcher does not rerun test files when they write snapshot files', t => { - let killed = false; - - const child = execCli(['--watch', '--update-snapshots', 'test.cjs'], {dirname: 'fixture/snapshots/watcher-rerun', env: {AVA_FORCE_CI: 'not-ci'}}, error => { - t.ok(killed); - t.error(error); - t.end(); - }); - - let buffer = ''; - let passedFirst = false; - child.stdout.on('data', string => { - buffer += string; - if (buffer.includes('2 tests passed') && !passedFirst) { - buffer = ''; - passedFirst = true; - setTimeout(() => { - child.kill(); - killed = true; - }, 500); - } else if (passedFirst && !killed) { - t.equal(buffer.replace(/\s/g, '').replace(END_MESSAGE.replace(/\s/g, ''), ''), ''); - } - }); -}); - -test('watcher does not rerun test files when they unlink snapshot files', t => { - // Run fixture as template to generate snapshots - execCli( - ['--update-snapshots'], - { - dirname: 'fixture/snapshots/watcher-rerun-unlink', - env: {AVA_FORCE_CI: 'not-ci', TEMPLATE: 'true'}, - }, - error => { - t.error(error); - - // Run fixture in watch mode; snapshots should be removed, and watcher should not rerun - let killed = false; - - const child = execCli( - ['--watch', '--update-snapshots', 'test.cjs'], - { - dirname: 'fixture/snapshots/watcher-rerun-unlink', - env: {AVA_FORCE_CI: 'not-ci'}, - }, - error => { - t.ok(killed); - t.error(error); - t.end(); - }, - ); - - let buffer = ''; - let passedFirst = false; - child.stdout.on('data', string => { - buffer += string; - if (buffer.includes('2 tests passed') && !passedFirst) { - buffer = ''; - passedFirst = true; - setTimeout(() => { - child.kill(); - killed = true; - }, 500); - } else if (passedFirst && !killed) { - t.equal(buffer.replace(/\s/g, '').replace(END_MESSAGE.replace(/\s/g, ''), ''), ''); - } - }); - }, - ); -}); - -test('watcher does not rerun test files when ignored files change', t => { - let killed = false; - - const child = execCli(['--watch'], {dirname: 'fixture/watcher/ignored-files', env: {AVA_FORCE_CI: 'not-ci'}}, error => { - t.ok(killed); - t.error(error); - t.end(); - }); - - let buffer = ''; - let passedFirst = false; - child.stdout.on('data', string => { - buffer += string; - if (buffer.includes('1 test passed') && !passedFirst) { - touch.sync(path.join(__dirname, '../fixture/watcher/ignored-files/ignored.cjs')); - buffer = ''; - passedFirst = true; - setTimeout(() => { - child.kill(); - killed = true; - }, 500); - } else if (passedFirst && !killed) { - t.equal(buffer.replace(/\s/g, '').replace(END_MESSAGE.replace(/\s/g, ''), ''), ''); - } - }); -}); - -test('watcher reruns test files when snapshot dependencies change', t => { - let killed = false; - - const child = execCli(['--watch', '--update-snapshots', 'test.cjs'], {dirname: 'fixture/snapshots/watcher-rerun', env: {AVA_FORCE_CI: 'not-ci'}}, error => { - t.ok(killed); - t.error(error); - t.end(); - }); - - let buffer = ''; - let passedFirst = false; - child.stdout.on('data', string => { - buffer += string; - if (buffer.includes('2 tests passed')) { - buffer = ''; - if (passedFirst) { - child.kill(); - killed = true; - } else { - passedFirst = true; - setTimeout(() => { - touch.sync(path.join(__dirname, '../fixture/snapshots/watcher-rerun/test.js.snap')); - }, 500); - } - } - }); -}); - -test('`"tap": true` config is ignored when --watch is given', t => { - let killed = false; - - const child = execCli(['--watch', 'test.cjs'], {dirname: 'fixture/watcher/tap-in-conf', env: {AVA_FORCE_CI: 'not-ci'}}, () => { - t.ok(killed); - t.end(); - }); - - let combined = ''; - const testOutput = output => { - combined += output; - t.notMatch(combined, /TAP/); - if (combined.includes('works')) { - child.kill(); - killed = true; - } - }; - - child.stdout.on('data', testOutput); - child.stderr.on('data', testOutput); -}); - -test('bails when --tap reporter is used while --watch is given', t => { - execCli(['--tap', '--watch', 'test.cjs'], {dirname: 'fixture/watcher', env: {AVA_FORCE_CI: 'not-ci'}}, (error, stdout, stderr) => { - t.equal(error.code, 1); - t.match(stderr, 'The TAP reporter is not available when using watch mode.'); - t.end(); - }); -}); - -test('bails when CI is used while --watch is given', t => { - execCli(['--watch', 'test.cjs'], {dirname: 'fixture/watcher', env: {AVA_FORCE_CI: 'ci'}}, (error, stdout, stderr) => { - t.equal(error.code, 1); - t.match(stderr, 'Watch mode is not available in CI, as it prevents AVA from terminating.'); - t.end(); - }); -}); diff --git a/test-tap/watcher.js b/test-tap/watcher.js deleted file mode 100644 index 44f7dd9e3..000000000 --- a/test-tap/watcher.js +++ /dev/null @@ -1,1478 +0,0 @@ -import EventEmitter from 'node:events'; -import path from 'node:path'; -import {PassThrough} from 'node:stream'; - -import ignoreByDefault from 'ignore-by-default'; -import sinon from 'sinon'; -import {test} from 'tap'; - -import {normalizeGlobs} from '../lib/globs.js'; -import timers from '../lib/now-and-timers.cjs'; -import Watcher, {_testOnlyReplaceChokidar, _testOnlyReplaceDebug} from '../lib/watcher.js'; - -const {setImmediate} = timers; -const defaultIgnore = ignoreByDefault.directories(); - -// Helper to make using beforeEach less arduous -function makeGroup(test) { - return (desc, fn) => { - test(desc, t => { - const beforeEach = fn => { - t.beforeEach(() => { - fn(); - }); - }; - - const pending = []; - const test = (name, fn) => { - pending.push(t.test(name, fn)); - }; - - fn(beforeEach, test, makeGroup(test)); - - return Promise.all(pending); - }); - }; -} - -const group = makeGroup(test); - -group('chokidar', (beforeEach, test, group) => { - let chokidar; - let debug; - let reporter; - let api; - let Subject; - let runStatus; - let resetRunStatus; - let clock; - let chokidarEmitter; - let stdin; - let files; - let defaultApiOptions; - - beforeEach(() => { - chokidar = { - watch: sinon.stub(), - }; - _testOnlyReplaceChokidar(chokidar); - - debug = sinon.spy(); - _testOnlyReplaceDebug(name => (...args) => debug(name, ...args)); - - reporter = { - endRun: sinon.spy(), - lineWriter: { - writeLine: sinon.spy(), - }, - }; - - api = { - on() {}, - run: sinon.stub(), - }; - - resetRunStatus = () => { - runStatus = { - stats: { - byFile: new Map(), - declaredTests: 0, - failedHooks: 0, - failedTests: 0, - failedWorkers: 0, - files, - finishedWorkers: 0, - internalErrors: 0, - remainingTests: 0, - passedKnownFailingTests: 0, - passedTests: 0, - selectedTests: 0, - skippedTests: 0, - timeouts: 0, - todoTests: 0, - uncaughtExceptions: 0, - unhandledRejections: 0, - }, - }; - - return runStatus; - }; - - if (clock) { - clock.uninstall(); - } - - clock = sinon.useFakeTimers({ - toFake: [ - 'setImmediate', - 'setTimeout', - 'clearTimeout', - ], - }); - - chokidarEmitter = new EventEmitter(); - chokidar.watch.returns(chokidarEmitter); - - api.run.returns(new Promise(() => {})); - files = [ - 'test.cjs', - 'test-*.cjs', - 'test/**/*.cjs', - ]; - defaultApiOptions = { - previousFailures: 0, - runOnlyExclusive: false, - firstRun: true, - updateSnapshots: false, - }; - - resetRunStatus(); - - stdin = new PassThrough(); - stdin.pause(); - - Subject = Watcher; - }); - - const start = ignoredByWatcher => new Subject({reporter, api, filter: [], globs: normalizeGlobs({files, ignoredByWatcher, extensions: ['cjs'], providers: []}), projectDir: process.cwd(), providers: []}); - - const emitChokidar = (event, path) => { - chokidarEmitter.emit('all', event, path); - }; - - const add = path => { - emitChokidar('add', path || 'source.cjs'); - }; - - const change = path => { - emitChokidar('change', path || 'source.cjs'); - }; - - const unlink = path => { - emitChokidar('unlink', path || 'source.cjs'); - }; - - const delay = () => new Promise(resolve => { - setImmediate(resolve); - }); - - // Advance the clock to get past the debounce timeout, then wait for a promise - // to be resolved to get past the `busy.then()` delay - const debounce = times => { - times = times >= 0 ? times : 1; - clock.next(); - return delay().then(() => { - if (times > 1) { - return debounce(times - 1); - } - }); - }; - - test('watches for all file changes, except for the ignored ones', t => { - t.plan(2); - start(); - - t.ok(chokidar.watch.calledOnce); - t.strictSame(chokidar.watch.firstCall.args, [ - ['**/*'], - { - cwd: process.cwd(), - ignored: [...defaultIgnore.map(dir => `${dir}/**/*`), '**/node_modules/**/*', '**/*.snap.md', 'ava.config.js', 'ava.config.cjs', 'ava.config.mjs'], - ignoreInitial: true, - }, - ]); - }); - - test('ignored files are configurable', t => { - t.plan(2); - const ignoredByWatcher = ['!foo.cjs', 'bar.cjs', '!baz.cjs', 'qux.cjs']; - start(ignoredByWatcher); - - t.ok(chokidar.watch.calledOnce); - t.strictSame(chokidar.watch.firstCall.args, [ - ['**/*'], - { - cwd: process.cwd(), - ignored: [...defaultIgnore.map(dir => `${dir}/**/*`), '**/node_modules/**/*', '**/*.snap.md', 'ava.config.js', 'ava.config.cjs', 'ava.config.mjs', 'bar.cjs', 'qux.cjs'], - ignoreInitial: true, - }, - ]); - }); - - test('starts running the initial tests', t => { - t.plan(6); - - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - start(); - t.ok(api.run.calledOnce); - t.strictSame(api.run.firstCall.args, [{files: [], filter: [], runtimeOptions: defaultApiOptions}]); - - // The endRun and lineWriter.writeLine methods are only called after the run promise fulfils - t.ok(reporter.endRun.notCalled); - t.ok(reporter.lineWriter.writeLine.notCalled); - done(); - return delay().then(() => { - t.ok(reporter.endRun.calledOnce); - t.ok(reporter.lineWriter.writeLine.calledOnce); - }); - }); - - for (const variant of [ - { - label: 'is added', - fire: add, - event: 'add', - }, - { - label: 'changes', - fire: change, - event: 'change', - }, - { - label: 'is removed', - fire: unlink, - event: 'unlink', - }, - ]) { - test(`logs a debug message when a file is ${variant.label}`, t => { - t.plan(2); - start(); - - variant.fire('file.cjs'); - t.ok(debug.calledOnce); - t.strictSame(debug.firstCall.args, ['ava:watcher', 'Detected %s of %s', variant.event, 'file.cjs']); - }); - } - - for (const variant of [ - { - label: 'is added', - fire: add, - }, - { - label: 'changes', - fire: change, - }, - { - label: 'is removed', - fire: unlink, - }, - ]) { - test(`reruns initial tests when a source file ${variant.label}`, t => { - t.plan(4); - - api.run.returns(Promise.resolve(runStatus)); - start(); - - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - variant.fire(); - return debounce().then(() => { - t.ok(api.run.calledTwice); - // No explicit files are provided - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - - // Finish is only called after the run promise fulfils - t.ok(reporter.endRun.calledOnce); - - resetRunStatus(); - done(); - return delay(); - }).then(() => { - t.ok(reporter.endRun.calledTwice); - }); - }); - } - - for (const variant of [ - { - label: 'failures', - prop: 'failedTests', - }, - { - label: 'rejections', - prop: 'unhandledRejections', - }, - { - label: 'exceptions', - prop: 'uncaughtExceptions', - }, - ]) { - test(`does not clear log if the previous run had ${variant.label}`, t => { - t.plan(2); - - runStatus.stats[variant.prop] = 1; - api.run.returns(Promise.resolve(runStatus)); - start(); - - api.run.returns(Promise.resolve(resetRunStatus())); - change(); - return debounce().then(() => { - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - - change(); - return debounce(); - }).then(() => { - t.strictSame(api.run.thirdCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - } - - test('debounces by 100ms', t => { - t.plan(1); - api.run.returns(Promise.resolve(runStatus)); - start(); - - change(); - const before = clock.now; - return debounce().then(() => { - t.equal(clock.now - before, 100); - }); - }); - - test('debounces again if changes occur in the interval', t => { - t.plan(4); - api.run.returns(Promise.resolve(runStatus)); - start(); - - change(); - change(); - - const before = clock.now; - return debounce().then(() => { - change(); - return debounce(); - }).then(() => { - t.equal(clock.now - before, 150); - change(); - return debounce(); - }).then(() => { - t.equal(clock.now - before, 175); - change(); - return debounce(); - }).then(() => { - t.equal(clock.now - before, 187); - change(); - return debounce(); - }).then(() => { - t.equal(clock.now - before, 197); - }); - }); - - test('only reruns tests once the initial run has finished', t => { - t.plan(2); - - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - start(); - - change(); - clock.next(); - return delay().then(() => { - t.ok(api.run.calledOnce); - - done(); - return delay(); - }).then(() => { - t.ok(api.run.calledTwice); - }); - }); - - test('only reruns tests once the previous run has finished', t => { - t.plan(3); - api.run.returns(Promise.resolve(runStatus)); - start(); - - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - change(); - return debounce().then(() => { - t.ok(api.run.calledTwice); - - change(); - clock.next(); - return delay(); - }).then(() => { - t.ok(api.run.calledTwice); - - done(); - return delay(); - }).then(() => { - t.ok(api.run.calledThrice); - }); - }); - - for (const variant of [ - { - label: 'is added', - fire: add, - }, - { - label: 'changes', - fire: change, - }, - ]) { - test(`(re)runs a test file when it ${variant.label}`, t => { - t.plan(4); - - api.run.returns(Promise.resolve(runStatus)); - start(); - - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - variant.fire('test.cjs'); - return debounce().then(() => { - t.ok(api.run.calledTwice); - // The `test.js` file is provided - t.strictSame(api.run.secondCall.args, [{files: [path.resolve('test.cjs')], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - - // The endRun method is only called after the run promise fulfills - t.ok(reporter.endRun.calledOnce); - - resetRunStatus(); - done(); - return delay(); - }).then(() => { - t.ok(reporter.endRun.calledTwice); - }); - }); - } - - test('(re)runs several test files when they are added or changed', t => { - t.plan(2); - api.run.returns(Promise.resolve(runStatus)); - start(); - - add('test-one.cjs'); - change('test-two.cjs'); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - // The test files are provided - t.strictSame(api.run.secondCall.args, [{files: [path.resolve('test-one.cjs'), path.resolve('test-two.cjs')], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('reruns initial tests if both source and test files are added or changed', t => { - t.plan(2); - api.run.returns(Promise.resolve(runStatus)); - start(); - - add('test.cjs'); - unlink('source.cjs'); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - // No explicit files are provided - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('does nothing if tests are deleted', t => { - t.plan(1); - api.run.returns(Promise.resolve(runStatus)); - start(); - - unlink('test.cjs'); - return debounce().then(() => { - t.ok(api.run.calledOnce); - }); - }); - - test('determines whether changed files are tests based on the initial files patterns', t => { - t.plan(2); - - files = ['foo-{bar,baz}.cjs']; - api.run.returns(Promise.resolve(runStatus)); - start(); - - add('foo-bar.cjs'); - add('foo-baz.cjs'); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve('foo-bar.cjs'), path.resolve('foo-baz.cjs')], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('test files must not start with an underscore', t => { - t.plan(2); - - api.files = ['_foo.bar']; - api.run.returns(Promise.resolve(runStatus)); - start(); - - add('_foo.bar'); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - // `_foo.bar` cannot be a test file, thus the initial tests are run - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - for (const input of ['r', 'rs']) { - test(`reruns initial tests when "${input}" is entered on stdin`, t => { - t.plan(4); - api.run.returns(Promise.resolve(runStatus)); - start().observeStdin(stdin); - - stdin.write(`${input}\n`); - return delay().then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: {...defaultApiOptions, firstRun: false}}]); - - stdin.write(`\t${input} \n`); - return delay(); - }).then(() => { - t.ok(api.run.calledThrice); - t.strictSame(api.run.thirdCall.args, [{files: [], filter: [], runtimeOptions: {...defaultApiOptions, firstRun: false}}]); - }); - }); - } - - test('reruns previous tests and update snapshots when "u" is entered on stdin', async t => { - const options = {...defaultApiOptions, updateSnapshots: true}; - t.plan(5); - api.run.returns(Promise.resolve(runStatus)); - start().observeStdin(stdin); - - add('test-one.cjs'); - await debounce(); - t.ok(api.run.calledTwice); - - stdin.write('u\n'); - await delay(); - - t.ok(api.run.calledThrice); - t.strictSame(api.run.thirdCall.args, [{files: [path.resolve('test-one.cjs')], filter: [], runtimeOptions: {...options, firstRun: false}}]); - - stdin.write('\tu \n'); - await delay(); - - t.equal(api.run.callCount, 4); - t.strictSame(api.run.lastCall.args, [{files: [path.resolve('test-one.cjs')], filter: [], runtimeOptions: {...options, firstRun: false}}]); - }); - - for (const input of ['r', 'rs', 'u']) { - test(`entering "${input}" on stdin prevents the log from being cleared`, t => { - t.plan(2); - api.run.returns(Promise.resolve(runStatus)); - start().observeStdin(stdin); - - stdin.write(`${input}\n`); - return delay().then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - updateSnapshots: input === 'u', - }}]); - }); - }); - - test(`entering "${input}" on stdin cancels any debouncing`, t => { - t.plan(7); - api.run.returns(Promise.resolve(runStatus)); - start().observeStdin(stdin); - - let before = clock.now; - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - add(); - stdin.write(`${input}\n`); - return delay().then(() => { - // Processing "rs" caused a new run - t.ok(api.run.calledTwice); - - // Try to advance the clock. This is *after* input was processed. The - // debounce timeout should have been canceled, so the clock can't have - // advanced. - clock.next(); - t.equal(before, clock.now); - - add(); - // Advance clock *before* input is received. Note that the previous run - // hasn't finished yet. - clock.next(); - stdin.write(`${input}\n`); - - return delay(); - }).then(() => { - // No new runs yet - t.ok(api.run.calledTwice); - // Though the clock has advanced - t.equal(clock.now - before, 100); - before = clock.now; - - const previous = done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - // Finish the previous run - previous(); - - return delay(); - }).then(() => { - // There's only one new run - t.ok(api.run.calledThrice); - - stdin.write(`${input}\n`); - return delay(); - }).then(() => { - add(); - - // Finish the previous run. This should cause a new run due to the - // input. - done(); - - return delay(); - }).then(() => { - // Again there's only one new run - t.equal(api.run.callCount, 4); - - // Try to advance the clock. This is *after* input was processed. The - // debounce timeout should have been canceled, so the clock can't have - // advanced. - clock.next(); - t.equal(before, clock.now); - }); - }); - } - - test('does nothing if anything other than "rs" is entered on stdin', t => { - t.plan(1); - api.run.returns(Promise.resolve(runStatus)); - start().observeStdin(stdin); - - stdin.write('foo\n'); - return debounce().then(() => { - t.ok(api.run.calledOnce); - }); - }); - - test('ignores unexpected events from chokidar', t => { - t.plan(1); - api.run.returns(Promise.resolve(runStatus)); - start(); - - emitChokidar('foo', 'foo.cjs'); - return debounce().then(() => { - t.ok(api.run.calledOnce); - }); - }); - - test('initial run rejects', t => { - t.plan(1); - const expected = new Error(); - api.run.returns(Promise.reject(expected)); - start(); - - return delay().then(() => { - // The error is rethrown asynchronously, using setImmediate. The clock has - // faked setTimeout, so if we call clock.next() it'll invoke and rethrow - // the error, which can then be caught here. - try { - clock.next(); - } catch (error) { - t.equal(error, expected); - } - }); - }); - - test('subsequent run rejects', t => { - t.plan(1); - api.run.returns(Promise.resolve(runStatus)); - start(); - - const expected = new Error(); - api.run.returns(Promise.reject(expected)); - - add(); - return debounce().then(() => { - // The error is rethrown asynchronously, using setImmediate. The clock has - // faked setTimeout, so if we call clock.next() it'll invoke and rethrow - // the error, which can then be caught here. - try { - clock.next(); - } catch (error) { - t.equal(error, expected); - } - }); - }); - - group('tracks test dependencies', (beforeEach, test) => { - let apiEmitter; - let runStatus; - let runStatusEmitter; - beforeEach(() => { - apiEmitter = new EventEmitter(); - api.on = (event, fn) => { - apiEmitter.on(event, fn); - }; - - runStatusEmitter = new EventEmitter(); - runStatus = { - stats: { - byFile: new Map(), - declaredTests: 0, - failedHooks: 0, - failedTests: 0, - failedWorkers: 0, - files, - finishedWorkers: 0, - internalErrors: 0, - remainingTests: 0, - passedKnownFailingTests: 0, - passedTests: 0, - selectedTests: 0, - skippedTests: 0, - timeouts: 0, - todoTests: 0, - uncaughtExceptions: 0, - unhandledRejections: 0, - }, - on(event, fn) { - runStatusEmitter.on(event, fn); - }, - }; - }); - - const emitDependencies = (testFile, dependencies) => { - runStatusEmitter.emit('stateChange', {type: 'dependencies', testFile, dependencies}); - }; - - const seed = ignoredByWatcher => { - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - const watcher = start(ignoredByWatcher); - const files = [path.join('test', '1.cjs'), path.join('test', '2.cjs')]; - const absFiles = files.map(relFile => path.resolve(relFile)); - apiEmitter.emit('run', { - files: absFiles, - status: runStatus, - }); - emitDependencies(path.resolve(files[0]), [path.resolve('dep-1.cjs'), path.resolve('dep-3.cjs')]); - emitDependencies(path.resolve(files[1]), [path.resolve('dep-2.cjs'), path.resolve('dep-3.cjs')]); - - done(); - api.run.returns(new Promise(() => {})); - return watcher; - }; - - test('runs specific tests that depend on changed sources', t => { - t.plan(2); - seed(); - - change('dep-1.cjs'); - return debounce().then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve(path.join('test', '1.cjs'))], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('reruns all tests if a source cannot be mapped to a particular test', t => { - t.plan(2); - seed(); - - change('cannot-be-mapped.cjs'); - return debounce().then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('runs changed tests and tests that depend on changed sources', t => { - t.plan(2); - seed(); - - change('dep-1.cjs'); - change(path.join('test', '2.cjs')); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{ - files: [path.resolve(path.join('test', '2.cjs')), path.resolve(path.join('test', '1.cjs'))], - filter: [], - runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }, - }]); - }); - }); - - test('avoids duplication when both a test and a source dependency change', t => { - t.plan(2); - seed(); - - change(path.join('test', '1.cjs')); - change('dep-1.cjs'); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve(path.join('test', '1.cjs'))], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('stops tracking unlinked tests', t => { - t.plan(2); - seed(); - - unlink(path.join('test', '1.cjs')); - change('dep-3.cjs'); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve(path.join('test', '2.cjs'))], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('updates test dependencies', t => { - t.plan(2); - seed(); - - emitDependencies(path.resolve(path.join('test', '1.cjs')), [path.resolve('dep-4.cjs')]); - change('dep-4.cjs'); - return debounce().then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve(path.join('test', '1.cjs'))], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - for (const variant of [ - { - desc: 'does not track ignored dependencies', - ignoredByWatcher: ['dep-2.cjs'], - }, - { - desc: 'exclusion patterns affect tracked source dependencies', - ignoredByWatcher: ['dep-2.cjs'], - }, - ]) { - test(variant.desc, t => { - t.plan(2); - seed(variant.ignoredByWatcher); - - // `dep-2.js` isn't treated as a source and therefore it's not tracked as - // a dependency for `test/2.js`. Pretend Chokidar detected a change to - // verify (normally Chokidar would also be ignoring this file but hey). - change('dep-2.cjs'); - return debounce().then(() => { - t.ok(api.run.calledTwice); - // Expect all tests to be rerun since `dep-2.js` is not a tracked - // dependency - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - } - - test('uses default ignoredByWatcher patterns', t => { - t.plan(2); - seed(); - - emitDependencies(path.join('test', '1.cjs'), [path.resolve('package.json'), path.resolve('index.cjs'), path.resolve('lib/util.cjs')]); - emitDependencies(path.join('test', '2.cjs'), [path.resolve('foo.bar')]); - change('package.json'); - change('index.cjs'); - change(path.join('lib', 'util.cjs')); - - api.run.returns(Promise.resolve(runStatus)); - return debounce(3).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.join('test', '1.cjs')], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('uses default exclusion patterns', t => { - t.plan(2); - - // Ensure each directory is treated as containing sources - seed(); - - // Synthesize an excluded file for each directory that's ignored by - // default. Apply deeper nesting for each file. - const excludedFiles = defaultIgnore.map((dir, index) => { - let relPath = dir; - for (let i = index; i >= 0; i--) { - relPath = path.join(relPath, String(i)); - } - - return `${relPath}.js`; - }); - - // Ensure `test/1.js` also depends on the excluded files - emitDependencies( - path.join('test', '1.cjs'), - [...excludedFiles.map(relPath => path.resolve(relPath)), 'dep-1.cjs'], - ); - - // Modify all excluded files - for (const x of excludedFiles) { - change(x); - } - - return debounce(excludedFiles.length).then(() => { - t.ok(api.run.calledTwice); - // Since the excluded files are not tracked as a dependency, all tests - // are expected to be rerun - t.strictSame(api.run.secondCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('logs a debug message when a dependent test is found', t => { - t.plan(2); - seed(); - - change('dep-1.cjs'); - return debounce().then(() => { - t.ok(debug.calledTwice); - t.strictSame(debug.secondCall.args, ['ava:watcher', '%s is a dependency of %s', path.resolve('dep-1.cjs'), path.resolve(path.join('test', '1.cjs'))]); - }); - }); - - test('logs a debug message when sources remain without dependent tests', t => { - t.plan(3); - seed(); - - change('cannot-be-mapped.cjs'); - return debounce().then(() => { - t.ok(debug.calledThrice); - t.strictSame(debug.secondCall.args, ['ava:watcher', 'Files remain that cannot be traced to specific tests: %O', [path.resolve('cannot-be-mapped.cjs')]]); - t.strictSame(debug.thirdCall.args, ['ava:watcher', 'Rerunning all tests']); - }); - }); - }); - - group('failure counts are correctly reset', (beforeEach, test) => { - let apiEmitter; - let runStatus; - let runStatusEmitter; - beforeEach(() => { - apiEmitter = new EventEmitter(); - api.on = (event, fn) => { - apiEmitter.on(event, fn); - }; - - runStatusEmitter = new EventEmitter(); - runStatus = { - stats: { - byFile: new Map(), - declaredTests: 0, - failedHooks: 0, - failedTests: 0, - failedWorkers: 0, - files, - finishedWorkers: 0, - internalErrors: 0, - remainingTests: 0, - passedKnownFailingTests: 0, - passedTests: 0, - selectedTests: 0, - skippedTests: 0, - timeouts: 0, - todoTests: 0, - uncaughtExceptions: 0, - unhandledRejections: 0, - }, - on(event, fn) { - runStatusEmitter.on(event, fn); - }, - }; - }); - - const t1 = path.join('test', '1.cjs'); - const t1Absolute = path.resolve(t1); - - const seed = () => { - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - const watcher = start(); - apiEmitter.emit('run', { - files: [t1Absolute], - status: runStatus, - }); - - runStatusEmitter.emit('stateChange', { - type: 'test-failed', - testFile: t1Absolute, - }); - - done(); - api.run.returns(new Promise(() => {})); - return watcher; - }; - - test('when failed test is changed', t => { - const options = {...defaultApiOptions}; - t.plan(2); - seed(); - - change(t1); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [t1Absolute], filter: [], runtimeOptions: { - ...options, - firstRun: false, - }}]); - }); - }); - }); - - group('.only is sticky', (beforeEach, test) => { - let apiEmitter; - let runStatus; - let runStatusEmitter; - beforeEach(() => { - apiEmitter = new EventEmitter(); - api.on = (event, fn) => { - apiEmitter.on(event, fn); - }; - - runStatusEmitter = new EventEmitter(); - runStatus = { - stats: { - byFile: new Map(), - declaredTests: 0, - failedHooks: 0, - failedTests: 0, - failedWorkers: 0, - files, - finishedWorkers: 0, - internalErrors: 0, - remainingTests: 0, - passedKnownFailingTests: 0, - passedTests: 0, - selectedTests: 0, - skippedTests: 0, - timeouts: 0, - todoTests: 0, - uncaughtExceptions: 0, - unhandledRejections: 0, - }, - on(event, fn) { - runStatusEmitter.on(event, fn); - }, - }; - }); - - const emitStats = (testFile, hasExclusive) => { - runStatus.stats.byFile.set(testFile, { - declaredTests: 2, - failedHooks: 0, - failedTests: 0, - internalErrors: 0, - remainingTests: 0, - passedKnownFailingTests: 0, - passedTests: 0, - selectedTests: hasExclusive ? 1 : 2, - skippedTests: 0, - todoTests: 0, - uncaughtExceptions: 0, - unhandledRejections: 0, - }); - runStatusEmitter.emit('stateChange', {type: 'worker-finished', testFile}); - }; - - const t1 = path.join('test', '1.cjs'); - const t2 = path.join('test', '2.cjs'); - const t3 = path.join('test', '3.cjs'); - const t4 = path.join('test', '4.cjs'); - const t1Absolute = path.resolve(t1); - const t2Absolute = path.resolve(t2); - const t3Absolute = path.resolve(t3); - const t4Absolute = path.resolve(t4); - - const seed = () => { - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - const watcher = start(); - apiEmitter.emit('run', { - files: [t1Absolute, t2Absolute, t3Absolute, t4Absolute], - status: runStatus, - }); - emitStats(t1Absolute, true); - emitStats(t2Absolute, true); - emitStats(t3Absolute, false); - emitStats(t4Absolute, false); - - done(); - api.run.returns(new Promise(() => {})); - return watcher; - }; - - test('changed test files (none of which previously contained .only) are run in exclusive mode', t => { - const options = {...defaultApiOptions, runOnlyExclusive: true}; - t.plan(2); - seed(); - - change(t3); - change(t4); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [t1Absolute, t2Absolute, t3Absolute, t4Absolute], filter: [], runtimeOptions: { - ...options, - firstRun: false, - }}]); - }); - }); - - test('changed test files (comprising some, but not all, files that previously contained .only) are run in exclusive mode', t => { - const options = {...defaultApiOptions, runOnlyExclusive: true}; - t.plan(2); - seed(); - - change(t1); - change(t4); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [t1Absolute, t2Absolute, t4Absolute], filter: [], runtimeOptions: { - ...options, - firstRun: false, - }}]); - }); - }); - - test('changed test files (comprising all files that previously contained .only) are run in regular mode', t => { - t.plan(2); - seed(); - - change(t1); - change(t2); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [t1Absolute, t2Absolute], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('once no test files contain .only, further changed test files are run in regular mode', t => { - t.plan(2); - seed(); - - emitStats(t1Absolute, false); - emitStats(t2Absolute, false); - - change(t3); - change(t4); - return debounce(2).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [t3Absolute, t4Absolute], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - - test('once test files containing .only are removed, further changed test files are run in regular mode', t => { - t.plan(2); - seed(); - - unlink(t1); - unlink(t2); - change(t3); - change(t4); - return debounce(4).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [t3Absolute, t4Absolute], filter: [], runtimeOptions: { - ...defaultApiOptions, - firstRun: false, - }}]); - }); - }); - }); - - group('tracks previous failures', (beforeEach, test) => { - let apiEmitter; - let runStatus; - let runStatusEmitter; - beforeEach(() => { - apiEmitter = new EventEmitter(); - api.on = (event, fn) => { - apiEmitter.on(event, fn); - }; - - runStatusEmitter = new EventEmitter(); - runStatus = { - stats: { - byFile: new Map(), - declaredTests: 0, - failedHooks: 0, - failedTests: 0, - failedWorkers: 0, - files, - finishedWorkers: 0, - internalErrors: 0, - remainingTests: 0, - passedKnownFailingTests: 0, - passedTests: 0, - selectedTests: 0, - skippedTests: 0, - timeouts: 0, - todoTests: 0, - uncaughtExceptions: 0, - unhandledRejections: 0, - }, - on(event, fn) { - runStatusEmitter.on(event, fn); - }, - }; - }); - - const seed = seedFailures => { - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - const watcher = start(); - const files = [path.join('test', '1.cjs'), path.join('test', '2.cjs')]; - const filesAbsolute = [path.join('test', '1.cjs'), path.join('test', '2.cjs')].map(file => path.resolve(file)); - apiEmitter.emit('run', { - files, - status: runStatus, - }); - - if (seedFailures) { - seedFailures(files, filesAbsolute); - } - - done(); - api.run.returns(new Promise(() => {})); - return watcher; - }; - - const rerun = function (file, fileAbsolute) { - runStatus = {on: runStatus.on}; - let done; - api.run.returns(new Promise(resolve => { - done = () => { - resolve(runStatus); - }; - })); - - change(file); - return debounce().then(() => { - apiEmitter.emit('run', { - files: [fileAbsolute], - status: runStatus, - }); - done(); - - api.run.returns(new Promise(() => {})); - }); - }; - - test('runs with previousFailures set to number of prevous failures', t => { - t.plan(2); - - let other; - seed((files, filesAbsolute) => { - runStatusEmitter.emit('stateChange', { - type: 'test-failed', - testFile: filesAbsolute[0], - }); - - runStatusEmitter.emit('stateChange', { - type: 'uncaught-exception', - testFile: filesAbsolute[0], - }); - - other = files[1]; - }); - - return rerun(other, path.resolve(other)).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve(other)], filter: [], runtimeOptions: { - ...defaultApiOptions, - previousFailures: 2, - firstRun: false, - }}]); - }); - }); - - test('tracks failures from multiple files', t => { - t.plan(2); - - let first; - - seed((files, filesAbsolute) => { - runStatusEmitter.emit('stateChange', { - type: 'test-failed', - testFile: filesAbsolute[0], - }); - - runStatusEmitter.emit('stateChange', { - type: 'test-failed', - testFile: filesAbsolute[1], - }); - - first = files[0]; - }); - - return rerun(first, path.resolve(first)).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve(first)], filter: [], runtimeOptions: { - ...defaultApiOptions, - previousFailures: 1, - firstRun: false, - }}]); - }); - }); - - test('previous failures don’t count when that file is rerun', t => { - t.plan(2); - - let same; - - seed((files, filesAbsolute) => { - runStatusEmitter.emit('stateChange', { - type: 'test-failed', - testFile: filesAbsolute[0], - }); - - runStatusEmitter.emit('stateChange', { - type: 'uncaught-exception', - testFile: filesAbsolute[0], - }); - - same = files[0]; - }); - - return rerun(same, path.resolve(same)).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve(same)], filter: [], runtimeOptions: { - ...defaultApiOptions, - previousFailures: 0, - firstRun: false, - }}]); - }); - }); - - test('previous failures don’t count when that file is deleted', t => { - t.plan(2); - - let same; - let other; - - seed((files, filesAbsolute) => { - runStatusEmitter.emit('stateChange', { - type: 'test-failed', - testFile: filesAbsolute[0], - }); - - runStatusEmitter.emit('stateChange', { - type: 'uncaught-exception', - testFile: filesAbsolute[0], - }); - - same = files[0]; - other = files[1]; - }); - - unlink(same); - - return debounce().then(() => rerun(other, path.resolve(other))).then(() => { - t.ok(api.run.calledTwice); - t.strictSame(api.run.secondCall.args, [{files: [path.resolve(other)], filter: [], runtimeOptions: { - ...defaultApiOptions, - previousFailures: 0, - firstRun: false, - }}]); - }); - }); - }); -}); From e0fdd2d75adad86d5716708986e072308c6cb51c Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 23 Jun 2023 16:46:52 +0200 Subject: [PATCH 3/5] Add a brand new watcher Rely on recursive fs.watch(), rather than Chokidar. On Linux this is supported from Node.js 20 onwards. It won't work for network shares and Docker volume mounts which would require polling, we'll find out if that's a problem or not. (For now, the previous implementation is still available.) Use @vercel/nft to perform static dependency analysis, supporting ESM and CJS imports for JavaScript & TypeScript source files. This is a huge improvement over the previous runtime tracking of CJS imports, which did not support ESM. Rewrite the change handling logic to be easier to follow (though it's still pretty complicated). Improve integration with `@ava/typescript`. The watcher can now detect a change to a TypeScript source file, then wait for the corresponding build output to change before re-running tests. --- docs/recipes/watch-mode.md | 4 + lib/api.js | 2 +- lib/ava5-watcher.js | 2 +- lib/cli.js | 36 +- lib/glob-helpers.cjs | 14 +- lib/globs.js | 12 + lib/provider-manager.js | 17 +- lib/run-status.js | 5 + lib/watcher.js | 605 ++++++++++++++++++ package-lock.json | 485 ++++++++++++-- package.json | 4 +- test-tap/globs.js | 20 +- test/config/integration.js | 2 +- test/helpers/exec.js | 133 ++-- .../fixtures/fixed-snapshot-dir/test.js | 2 +- .../fixtures/no-snapshots/test.js | 2 +- .../fixtures/only-test/test.js | 2 +- .../snapshot-removal/fixtures/removal/test.js | 2 +- .../fixtures/skipped-snapshots-in-try/test.js | 2 +- .../fixtures/skipped-snapshots/test.js | 2 +- .../fixtures/skipped-tests/test.js | 2 +- .../fixtures/snapshot-dir/test/test.js | 2 +- test/snapshot-removal/fixtures/try/test.js | 2 +- test/snapshot-tests/fixtures/corrupt/test.js | 2 +- test/snapshot-tests/fixtures/large/test.js | 2 +- .../fixtures/multiline-snapshot-label/test.js | 2 +- .../normalized-title-in-snapshots/test.js | 2 +- .../normalized-title-in-stdout/test.js | 2 +- .../fixtures/adding-skipped-snapshots/test.js | 2 +- .../fixtures/adding-snapshots/test.js | 2 +- .../fixtures/adding-test/test.js | 2 +- .../fixtures/changing-label/test.js | 2 +- .../fixtures/changing-title/test.js | 2 +- .../fixtures/commit-skip/test.js | 2 +- .../fixtures/discard-skip/test.js | 2 +- .../fixtures/filling-in-blanks/test.js | 2 +- .../fixtures/first-run/test.js | 2 +- .../fixtures/invalid-snapfile/test.js | 2 +- .../fixtures/removing-all-snapshots/test.js | 2 +- .../fixtures/removing-snapshots/test.js | 2 +- .../fixtures/removing-test/test.js | 2 +- .../fixtures/reorder/test.js | 2 +- .../fixtures/select-test-update/test.js | 2 +- .../fixtures/skipping-snapshot-update/test.js | 2 +- .../fixtures/skipping-snapshot/test.js | 2 +- .../fixtures/skipping-test-update/test.js | 2 +- .../fixtures/skipping-test/test.js | 2 +- test/watch-mode/availability.js | 28 + test/watch-mode/basic-functionality.js | 80 +++ test/watch-mode/fixtures/basic/ava.config.js | 3 + .../fixtures/basic/ignored-by-watcher.js | 3 + .../fixtures/basic/not-depended-on.js | 3 + test/watch-mode/fixtures/basic/package.json | 3 + test/watch-mode/fixtures/basic/source.js | 1 + test/watch-mode/fixtures/basic/source.test.js | 7 + test/watch-mode/fixtures/basic/test.js | 9 + test/watch-mode/fixtures/exclusive/a.test.js | 9 + .../fixtures/exclusive/ava.config.js | 3 + test/watch-mode/fixtures/exclusive/b.test.js | 9 + test/watch-mode/fixtures/exclusive/c.test.js | 9 + .../fixtures/exclusive/package.json | 3 + .../fixtures/typescript-inline/.gitignore | 1 + .../fixtures/typescript-inline/ava.config.js | 8 + .../fixtures/typescript-inline/package.json | 3 + .../fixtures/typescript-inline/src/test.ts | 10 + .../fixtures/typescript-inline/tsconfig.json | 13 + .../typescript-precompiled/ava.config.js | 11 + .../typescript-precompiled/build/test.js | 5 + .../typescript-precompiled/package.json | 3 + .../typescript-precompiled/src/test.ts | 11 + .../typescript-precompiled/tsconfig.json | 12 + test/watch-mode/helpers/watch.js | 166 +++++ test/watch-mode/scenarios.js | 126 ++++ test/watch-mode/typescript.js | 84 +++ 74 files changed, 1885 insertions(+), 145 deletions(-) create mode 100644 lib/watcher.js create mode 100644 test/watch-mode/availability.js create mode 100644 test/watch-mode/basic-functionality.js create mode 100644 test/watch-mode/fixtures/basic/ava.config.js create mode 100644 test/watch-mode/fixtures/basic/ignored-by-watcher.js create mode 100644 test/watch-mode/fixtures/basic/not-depended-on.js create mode 100644 test/watch-mode/fixtures/basic/package.json create mode 100644 test/watch-mode/fixtures/basic/source.js create mode 100644 test/watch-mode/fixtures/basic/source.test.js create mode 100644 test/watch-mode/fixtures/basic/test.js create mode 100644 test/watch-mode/fixtures/exclusive/a.test.js create mode 100644 test/watch-mode/fixtures/exclusive/ava.config.js create mode 100644 test/watch-mode/fixtures/exclusive/b.test.js create mode 100644 test/watch-mode/fixtures/exclusive/c.test.js create mode 100644 test/watch-mode/fixtures/exclusive/package.json create mode 100644 test/watch-mode/fixtures/typescript-inline/.gitignore create mode 100644 test/watch-mode/fixtures/typescript-inline/ava.config.js create mode 100644 test/watch-mode/fixtures/typescript-inline/package.json create mode 100644 test/watch-mode/fixtures/typescript-inline/src/test.ts create mode 100644 test/watch-mode/fixtures/typescript-inline/tsconfig.json create mode 100644 test/watch-mode/fixtures/typescript-precompiled/ava.config.js create mode 100644 test/watch-mode/fixtures/typescript-precompiled/build/test.js create mode 100644 test/watch-mode/fixtures/typescript-precompiled/package.json create mode 100644 test/watch-mode/fixtures/typescript-precompiled/src/test.ts create mode 100644 test/watch-mode/fixtures/typescript-precompiled/tsconfig.json create mode 100644 test/watch-mode/helpers/watch.js create mode 100644 test/watch-mode/scenarios.js create mode 100644 test/watch-mode/typescript.js diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 7ed1a968c..09ff0f125 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -31,6 +31,8 @@ AVA 5 uses [`chokidar`] as the file watcher. Note that even if you see warnings The same applies with AVA 6 when using the `ava5+chokidar` watcher. However you'll need to install `chokidar` separately. +Otherwise, AVA 6 uses `fs.watch()`. Support for `recursive` mode is required. Note that this has only become available on Linux since Node.js 20. [Other caveats apply](https://nodejs.org/api/fs.html#caveats), for example this won't work well on network filesystems and Docker host mounts. + ## Ignoring changes By default AVA watches for changes to all files, except for those with a `.snap.md` extension, `ava.config.*` and files in [certain directories](https://github.com/novemberborn/ignore-by-default/blob/master/index.js) as provided by the [`ignore-by-default`] package. @@ -45,6 +47,8 @@ AVA tracks which source files your test files depend on. If you change such a de AVA 5 (and the `ava5+chokidar` watcher in AVA 6) spies on `require()` calls to track dependencies. Custom extensions and transpilers are supported, provided you [added them in your `package.json` or `ava.config.*` file][config], and not from inside your test file. +With AVA 6, dependency tracking works for `require()` and `import` syntax, as supported by [@vercel/nft](https://github.com/vercel/nft). `import()` is supported but dynamic paths such as `import(myVariable)` are not. + Files accessed using the `fs` module are not tracked. ## Watch mode and the `.only` modifier diff --git a/lib/api.js b/lib/api.js index 797c750f2..d4e8c2b20 100644 --- a/lib/api.js +++ b/lib/api.js @@ -315,7 +315,7 @@ export default class Api extends Emittery { } timeoutTrigger.discard(); - return runStatus; + return runStatus.end(); } _getLocalCacheDir() { diff --git a/lib/ava5-watcher.js b/lib/ava5-watcher.js index c3f4dca92..643b11cf3 100644 --- a/lib/ava5-watcher.js +++ b/lib/ava5-watcher.js @@ -4,7 +4,7 @@ import chokidar from 'chokidar'; import createDebug from 'debug'; import {chalk} from './chalk.js'; -import {applyTestFileFilter, classify, getChokidarIgnorePatterns} from './globs.js'; +import {applyTestFileFilter, classifyAva5Watcher as classify, getChokidarIgnorePatterns} from './globs.js'; const debug = createDebug('ava:watcher'); diff --git a/lib/cli.js b/lib/cli.js index fd86db291..969463429 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -296,7 +296,7 @@ export default async function loadCli() { // eslint-disable-line complexity exit('Watch mode is not available in CI, as it prevents AVA from terminating.'); } - if (debug !== null) { + if (debug !== null && !process.env.TEST_AVA) { exit('Watch mode is not available when debugging.'); } } @@ -453,7 +453,7 @@ export default async function loadCli() { // eslint-disable-line complexity api.on('run', plan => { reporter.startRun(plan); - if (process.env.AVA_EMIT_RUN_STATUS_OVER_IPC === 'I\'ll find a payphone baby / Take some time to talk to you') { + if (process.env.TEST_AVA) { const bufferedSend = controlFlow(process); plan.status.on('stateChange', evt => { @@ -483,10 +483,38 @@ export default async function loadCli() { // eslint-disable-line complexity }); watcher.observeStdin(process.stdin); } else { - exit('The “watchMode.implementation” option must be set to “ava5+chokidar”'); + exit('The ’watchMode.implementation’ option must be set to “ava5+chokidar”'); } } else { - exit('TODO'); + const {available, start} = await import('./watcher.js'); + if (!available(projectDir)) { + exit('Watch mode requires support for recursive fs.watch()'); + return; + } + + let abortController; + if (process.env.TEST_AVA) { + const {takeCoverage} = await import('node:v8'); + abortController = new AbortController(); + process.on('message', message => { + if (message === 'abort-watcher') { + abortController.abort(); + takeCoverage(); + } + }); + process.channel?.unref(); + } + + start({ + api, + filter, + globs, + projectDir, + providers, + reporter, + stdin: process.stdin, + signal: abortController.signal, + }); } } else { let debugWithoutSpecificFile = false; diff --git a/lib/glob-helpers.cjs b/lib/glob-helpers.cjs index f093563c5..5c869e5b9 100644 --- a/lib/glob-helpers.cjs +++ b/lib/glob-helpers.cjs @@ -46,12 +46,21 @@ const processMatchingPatterns = input => { exports.processMatchingPatterns = processMatchingPatterns; +function classify(file, {cwd, extensions, filePatterns}) { + file = normalizeFileForMatching(cwd, file); + return { + isTest: hasExtension(extensions, file) && !isHelperish(file) && filePatterns.length > 0 && matches(file, filePatterns), + }; +} + +exports.classify = classify; + const matchesIgnorePatterns = (file, patterns) => { const {matchNoIgnore} = processMatchingPatterns(patterns); return matchNoIgnore(file) || defaultMatchNoIgnore(file); }; -function classify(file, {cwd, extensions, filePatterns, ignoredByWatcherPatterns}) { +function classifyAva5Watcher(file, {cwd, extensions, filePatterns, ignoredByWatcherPatterns}) { file = normalizeFileForMatching(cwd, file); return { isIgnoredByWatcher: matchesIgnorePatterns(file, ignoredByWatcherPatterns), @@ -59,7 +68,8 @@ function classify(file, {cwd, extensions, filePatterns, ignoredByWatcherPatterns }; } -exports.classify = classify; +// TODO: Delete along with ava5+chokidar watcher. +exports.classifyAva5Watcher = classifyAva5Watcher; const hasExtension = (extensions, file) => extensions.includes(path.extname(file).slice(1)); diff --git a/lib/globs.js b/lib/globs.js index c1a88cf7b..70bbdebd8 100644 --- a/lib/globs.js +++ b/lib/globs.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {globby, globbySync} from 'globby'; +import picomatch from 'picomatch'; import { defaultIgnorePatterns, @@ -13,6 +14,7 @@ import { export { classify, + classifyAva5Watcher, isHelperish, matches, normalizePattern, @@ -126,6 +128,7 @@ export async function findTests({cwd, extensions, filePatterns}) { return files.filter(file => !path.basename(file).startsWith('_')); } +// TODO: Delete along with ava5+chokidar watcher. export function getChokidarIgnorePatterns({ignoredByWatcherPatterns}) { return [ ...defaultIgnorePatterns.map(pattern => `${pattern}/**/*`), @@ -133,6 +136,15 @@ export function getChokidarIgnorePatterns({ignoredByWatcherPatterns}) { ]; } +export function buildIgnoreMatcher({ignoredByWatcherPatterns}) { + const patterns = [ + ...defaultIgnorePatterns.map(pattern => `${pattern}/**/*`), + ...ignoredByWatcherPatterns.filter(pattern => !pattern.startsWith('!')), + ]; + + return picomatch(patterns, {dot: true}); +} + export function applyTestFileFilter({ // eslint-disable-line complexity cwd, expandDirectories = true, diff --git a/lib/provider-manager.js b/lib/provider-manager.js index 18c31eae9..eaf4220a2 100644 --- a/lib/provider-manager.js +++ b/lib/provider-manager.js @@ -1,17 +1,17 @@ import * as globs from './globs.js'; import pkg from './pkg.cjs'; -const levels = { +export const levels = { // As the protocol changes, comparing levels by integer allows AVA to be - // compatible with different versions. Currently there is only one supported - // version, so this is effectively unused. The infrastructure is retained for - // future use. - levelIntegersAreCurrentlyUnused: 0, + // compatible with different versions. + ava3Stable: 1, + ava6: 2, }; -const levelsByProtocol = { - 'ava-3.2': levels.levelIntegersAreCurrentlyUnused, -}; +const levelsByProtocol = Object.assign(Object.create(null), { + 'ava-3.2': levels.ava3Stable, + 'ava-6': levels.ava6, +}); async function load(providerModule, projectDir, selectProtocol = () => true) { const ava = {version: pkg.version}; @@ -51,7 +51,6 @@ async function load(providerModule, projectDir, selectProtocol = () => true) { } const providerManager = { - levels, async typescript(projectDir, {fullConfig, protocol}) { const legacy = fullConfig?.watchMode?.implementation === 'ava5+chokidar'; return load('@ava/typescript', projectDir, identifier => { diff --git a/lib/run-status.js b/lib/run-status.js index 84713456a..f58c9bcbc 100644 --- a/lib/run-status.js +++ b/lib/run-status.js @@ -209,6 +209,11 @@ export default class RunStatus extends Emittery { this.emit('stateChange', event); } + end() { + this.emitStateChange({type: 'end'}); + return this; + } + suggestExitCode(circumstances) { if (this.emptyParallelRun) { return 0; diff --git a/lib/watcher.js b/lib/watcher.js new file mode 100644 index 000000000..2ad0aa668 --- /dev/null +++ b/lib/watcher.js @@ -0,0 +1,605 @@ +import fs from 'node:fs'; +import nodePath from 'node:path'; +import process from 'node:process'; +import v8 from 'node:v8'; + +import {nodeFileTrace} from '@vercel/nft'; +import createDebug from 'debug'; + +import {chalk} from './chalk.js'; +import {applyTestFileFilter, classify, buildIgnoreMatcher, findTests} from './globs.js'; +import {levels as providerLevels} from './provider-manager.js'; + +const debug = createDebug('ava:watcher'); + +// In order to get reliable code coverage for the tests of the watcher, we need +// to make Node.js write out interim reports in various places. +const takeCoverageForSelfTests = process.env.TEST_AVA ? v8.takeCoverage : undefined; + +const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n'); + +export function available(projectDir) { + try { + fs.watch(projectDir, {recursive: true, signal: AbortSignal.abort()}); + } catch (error) { + if (error.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') { + return false; + } + + throw error; + } + + return true; +} + +export async function start({api, filter, globs, projectDir, providers, reporter, stdin, signal}) { + providers = providers.filter(({level}) => level >= providerLevels.ava6); + for await (const {files, ...runtimeOptions} of plan({api, filter, globs, projectDir, providers, stdin, abortSignal: signal})) { + await api.run({files, filter, runtimeOptions}); + reporter.endRun(); + reporter.lineWriter.writeLine(END_MESSAGE); + } +} + +async function * plan({api, filter, globs, projectDir, providers, stdin, abortSignal}) { + const fileTracer = new FileTracer({base: projectDir}); + const isIgnored = buildIgnoreMatcher(globs); + const patternFilters = filter.map(({pattern}) => pattern); + + const statsCache = new Map(); + const fileStats = path => { + if (statsCache.has(path)) { + return statsCache.get(path); // N.B. `undefined` is a valid value! + } + + const stats = fs.statSync(nodePath.join(projectDir, path), {throwIfNoEntry: false}); + statsCache.set(path, stats); + return stats; + }; + + const fileExists = path => fileStats(path) !== undefined; + const cwdAndGlobs = {cwd: projectDir, ...globs}; + const changeFromPath = path => { + const {isTest} = classify(path, cwdAndGlobs); + const stats = fileStats(path); + return {path, isTest, exists: stats !== undefined, isFile: stats?.isFile() ?? false}; + }; + + // Begin a file trace in the background. + fileTracer.update(findTests(cwdAndGlobs).then(testFiles => testFiles.map(path => ({ + path: nodePath.relative(projectDir, path), + isTest: true, + exists: true, + })))); + + // State tracked for test runs. + const filesWithExclusiveTests = new Set(); + const touchedFiles = new Set(); + const temporaryFiles = new Set(); + const failureCounts = new Map(); + + // Observe all test runs. + api.on('run', ({status}) => { + status.on('stateChange', evt => { + switch (evt.type) { + case 'accessed-snapshots': { + fileTracer.addDependency(nodePath.relative(projectDir, evt.testFile), nodePath.relative(projectDir, evt.filename)); + break; + } + + case 'touched-files': { + for (const file of evt.files.changedFiles) { + touchedFiles.add(nodePath.relative(projectDir, file)); + } + + for (const file of evt.files.temporaryFiles) { + temporaryFiles.add(nodePath.relative(projectDir, file)); + } + + break; + } + + case 'hook-failed': + case 'internal-error': + case 'process-exit': + case 'test-failed': + case 'uncaught-exception': + case 'unhandled-rejection': + case 'worker-failed': { + failureCounts.set(evt.testFile, 1 + (failureCounts.get(evt.testFile) ?? 0)); + break; + } + + case 'worker-finished': { + const fileStats = status.stats.byFile.get(evt.testFile); + if (fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests) { + filesWithExclusiveTests.add(nodePath.relative(projectDir, evt.testFile)); + } else { + filesWithExclusiveTests.delete(nodePath.relative(projectDir, evt.testFile)); + } + + break; + } + + default: { + break; + } + } + }); + }); + + // State for subsequent test runs. + let signalChanged; + let changed = Promise.resolve({}); + let firstRun = true; + let runAll = true; + let updateSnapshots = false; + + const reset = () => { + changed = new Promise(resolve => { + signalChanged = resolve; + }); + firstRun = false; + runAll = false; + updateSnapshots = false; + }; + + // Support interactive commands. + stdin.setEncoding('utf8'); + stdin.on('data', data => { + data = data.trim().toLowerCase(); + runAll ||= data === 'r'; + updateSnapshots ||= data === 'u'; + if (runAll || updateSnapshots) { + signalChanged({}); + } + }); + stdin.unref(); + + // Whether tests are currently running. Used to control when the next run + // is prepared. + let testsAreRunning = false; + + // Tracks file paths we know have changed since the previous test run. + const dirtyPaths = new Set(); + const debounce = setTimeout(() => { + // The callback is invoked for a variety of reasons, not necessarily because + // there are dirty paths. But if there are none, then there's nothing to do. + if (dirtyPaths.size === 0) { + takeCoverageForSelfTests?.(); + return; + } + + // Equally, if tests are currently running, then keep accumulating changes. + // The timer is refreshed after tests finish running. + if (testsAreRunning) { + takeCoverageForSelfTests?.(); + return; + } + + // If the file tracer is still analyzing dependencies, wait for that to + // complete. + if (fileTracer.busy !== null) { + fileTracer.busy.then(() => debounce.refresh()); + takeCoverageForSelfTests?.(); + return; + } + + // Identify the changes. + const changes = [...dirtyPaths].filter(path => { + if (temporaryFiles.has(path)) { + debug('Ignoring known temporary file %s', path); + return false; + } + + if (touchedFiles.has(path)) { + debug('Ignoring known touched file %s', path); + return false; + } + + for (const {main} of providers) { + switch (main.interpretChange(nodePath.join(projectDir, path))) { + case main.changeInterpretations.ignoreCompiled: { + debug('Ignoring compilation output %s', path); + return false; + } + + case main.changeInterpretations.waitForOutOfBandCompilation: { + if (!fileExists(path)) { + debug('Not waiting for out-of-band compilation of deleted %s', path); + return true; + } + + debug('Waiting for out-of-band compilation of %s', path); + return false; + } + + default: { + continue; + } + } + } + + if (isIgnored(path)) { + debug('%s is ignored by patterns', path); + return false; + } + + return true; + }).flatMap(path => { + const change = changeFromPath(path); + + for (const {main} of providers) { + const sources = main.resolvePossibleOutOfBandCompilationSources(nodePath.join(projectDir, path)); + if (sources === null) { + continue; + } + + if (sources.length === 1) { + const [source] = sources; + const newPath = nodePath.relative(projectDir, source); + if (change.exists) { + debug('Interpreting %s as %s', path, newPath); + return changeFromPath(newPath); + } + + debug('Interpreting deleted %s as deletion of %s', path, newPath); + return {...changeFromPath(newPath), exists: false}; + } + + const relativeSources = sources.map(source => nodePath.relative(projectDir, source)); + debug('Change of %s could be due to deletion of multiple source files %j', path, relativeSources); + return relativeSources.filter(possiblePath => fileTracer.has(possiblePath)).map(newPath => { + debug('Interpreting %s as deletion of %s', path, newPath); + return changeFromPath(newPath); + }); + } + + return change; + }).filter(change => { + // Filter out changes to directories. However, if a directory was deleted, + // we cannot tell that it used to be a directory. + if (change.exists && !change.isFile) { + debug('%s is not a file', change.path); + return false; + } + + return true; + }); + + // Stats only need to be cached while we identify changes. + statsCache.clear(); + + // Identify test files that need to be run next, and whether there are + // non-ignored file changes that mean we should run all test files. + const uniqueTestFiles = new Set(); + const deletedTestFiles = new Set(); + const nonTestFiles = []; + for (const {path, isTest, exists} of changes) { + if (!exists) { + debug('%s was deleted', path); + } + + if (isTest) { + debug('%s is a test file', path); + if (exists) { + uniqueTestFiles.add(path); + } else { + failureCounts.delete(path); // Stop tracking failures for deleted tests. + deletedTestFiles.add(path); + } + } else { + debug('%s is not a test file', path); + + const dependingTestFiles = fileTracer.traceToTestFile(path); + if (dependingTestFiles.length > 0) { + debug('%s is depended on by test files %o', path, dependingTestFiles); + for (const testFile of dependingTestFiles) { + uniqueTestFiles.add(testFile); + } + } else { + debug('%s is not known to be depended on by test files', path); + nonTestFiles.push(path); + } + } + } + + // One more pass to make sure deleted test files are not run. This is needed + // because test files are selected when files they depend on are changed. + for (const path of deletedTestFiles) { + uniqueTestFiles.delete(path); + } + + // Clear state from the previous run and detected file changes. + dirtyPaths.clear(); + temporaryFiles.clear(); + touchedFiles.clear(); + + // In the background, update the file tracer to reflect the changes. + if (changes.length > 0) { + fileTracer.update(changes); + } + + // Select the test files to run, and how to run them. + let testFiles = [...uniqueTestFiles]; + let runOnlyExclusive = false; + + if (testFiles.length > 0) { + const exclusiveFiles = testFiles.filter(path => filesWithExclusiveTests.has(path)); + runOnlyExclusive = exclusiveFiles.length !== filesWithExclusiveTests.size; + if (runOnlyExclusive) { + // The test files that previously contained exclusive tests are always + // run, together with the test files. + debug('Running exclusive tests in %o', [...filesWithExclusiveTests]); + testFiles = [...new Set([...filesWithExclusiveTests, ...testFiles])]; + } + } + + if (filter.length > 0) { + testFiles = applyTestFileFilter({ + cwd: projectDir, + expandDirectories: false, + filter: patternFilters, + testFiles, + treatFilterPatternsAsFiles: false, + }); + } + + if (nonTestFiles.length > 0) { + debug('Non-test files changed, running all tests'); + failureCounts.clear(); // All tests are run, so clear previous failures. + signalChanged({runOnlyExclusive}); + } else if (testFiles.length > 0) { + // Remove previous failures for tests that will run again. + for (const path of testFiles) { + failureCounts.delete(path); + } + + signalChanged({runOnlyExclusive, testFiles}); + } + + takeCoverageForSelfTests?.(); + }, 100).unref(); + + // Detect changed files. + fs.watch(projectDir, {recursive: true, signal: abortSignal}, (_, filename) => { + if (filename !== null) { + dirtyPaths.add(filename); + debug('Detected change in %s', filename); + debounce.refresh(); + } + }); + + abortSignal.addEventListener('abort', () => { + signalChanged?.({}); + }); + + // And finally, the watch loop. + while (!abortSignal.aborted) { + const {testFiles: files = [], runOnlyExclusive = false} = await changed; // eslint-disable-line no-await-in-loop + + if (abortSignal.aborted) { + break; + } + + let previousFailures = 0; + for (const count of failureCounts.values()) { + previousFailures += count; + } + + const instructions = { + files: files.map(file => nodePath.join(projectDir, file)), + firstRun, // Value is changed by refresh() so record now. + previousFailures, + runOnlyExclusive, + updateSnapshots, // Value is changed by refresh() so record now. + }; + reset(); // Make sure the next run can be triggered. + testsAreRunning = true; + yield instructions; // Let the tests run. + testsAreRunning = false; + debounce.refresh(); // Trigger the callback, which if there were changes will run the tests again. + } +} + +// State management for file tracer. +class Node { + #children = new Map(); + #parents = new Map(); + isTest = false; + + constructor(path) { + this.path = path; + } + + get parents() { + return this.#parents.keys(); + } + + addChild(node) { + this.#children.set(node.path, node); + node.#addParent(this); + } + + #addParent(node) { + this.#parents.set(node.path, node); + } + + prune() { + for (const child of this.#children.values()) { + child.#removeParent(this); + } + + for (const parent of this.#parents.values()) { + parent.#removeChild(this); + } + } + + #removeChild(node) { + this.#children.delete(node.path); + } + + #removeParent(node) { + this.#parents.delete(node.path); + } +} + +class Tree extends Map { + get(path) { + if (!this.has(path)) { + this.set(path, new Node(path)); + } + + return super.get(path); + } + + delete(path) { + const node = this.get(path); + node?.prune(); + super.delete(path); + } +} + +// Track file dependencies to determine which test files to run. +class FileTracer { + #base; + #cache = Object.create(null); + #pendingTrace = null; + #updateRunning; + #signalUpdateRunning; + #tree = new Tree(); + + constructor({base}) { + this.#base = base; + this.#updateRunning = new Promise(resolve => { + this.#signalUpdateRunning = resolve; + }); + } + + get busy() { + return this.#pendingTrace; + } + + traceToTestFile(startingPath) { + const todo = [startingPath]; + const testFiles = new Set(); + const visited = new Set(); + for (const path of todo) { + if (visited.has(path)) { + continue; + } + + visited.add(path); + + const node = this.#tree.get(path); + if (node === undefined) { + continue; + } + + if (node.isTest) { + testFiles.add(node.path); + } else { + todo.push(...node.parents); + } + } + + return [...testFiles]; + } + + addDependency(testFile, path) { + const testNode = this.#tree.get(testFile); + testNode.isTest = true; + + const node = this.#tree.get(path); + testNode.addChild(node); + } + + has(path) { + return this.#tree.has(path); + } + + update(changes) { + const current = this.#update(changes).finally(() => { + if (this.#pendingTrace === current) { + this.#pendingTrace = null; + this.#updateRunning = new Promise(resolve => { + this.#signalUpdateRunning = resolve; + }); + } + }); + + this.#pendingTrace = current; + } + + async #update(changes) { + await this.#pendingTrace; // Guard against race conditions. + this.#signalUpdateRunning(); + + let reuseCache = true; + const knownTestFiles = new Set(); + const deletedFiles = new Set(); + const filesToTrace = new Set(); + for (const {path, isTest, exists} of await changes) { + if (exists) { + if (isTest) { + knownTestFiles.add(path); + } + + filesToTrace.add(path); + } else { + deletedFiles.add(path); + } + + // The cache can be reused as long as the changes are just for new files. + reuseCache = reuseCache && !this.#tree.has(path); + } + + // Remove deleted files from the tree. + for (const path of deletedFiles) { + this.#tree.delete(path); + } + + // Create a new cache if the old one can't be reused. + if (!reuseCache) { + this.#cache = Object.create(null); + } + + // If all changes are deletions then there is no more work to do. + if (filesToTrace.size === 0) { + return; + } + + // Always retrace all test files, in case a file was deleted and then replaced. + for (const node of this.#tree.values()) { + if (node.isTest) { + filesToTrace.add(node.path); + } + } + + // Trace any new and changed files. + const {fileList, reasons} = await nodeFileTrace([...filesToTrace], { + analysis: { // Only trace exact imports. + emitGlobs: false, + computeFileReferences: false, + evaluatePureExpressions: true, + }, + base: this.#base, + cache: this.#cache, + conditions: ['node'], + exportsOnly: true, // Disregard "main" in package files when "exports" is present. + ignore: ['**/node_modules/**'], // Don't trace through installed dependencies. + }); + + // Update the tree. + for (const path of fileList) { + const node = this.#tree.get(path); + node.isTest = knownTestFiles.has(path); + + const {parents} = reasons.get(path); + for (const parent of parents) { + const parentNode = this.#tree.get(parent); + parentNode.addChild(node); + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 0de6d725a..6d51535cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "5.3.1", "license": "MIT", "dependencies": { + "@vercel/nft": "^0.22.6", "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "ansi-styles": "^6.2.1", @@ -57,8 +58,9 @@ }, "devDependencies": { "@ava/test": "github:avajs/test", - "@ava/typescript": "^4.0.0", + "@ava/typescript": "^4.1.0", "@sindresorhus/tsconfig": "^3.0.1", + "@types/node": "^20.3.2", "ansi-escapes": "^6.2.0", "c8": "^7.13.0", "execa": "^7.1.1", @@ -113,16 +115,16 @@ } }, "node_modules/@ava/typescript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@ava/typescript/-/typescript-4.0.0.tgz", - "integrity": "sha512-QFIPeqkEbdvn7Pob0wVeYpeZD0eXd8nDYdCl+knJVaIJrHdF2fXa58vFaig26cmYwnsEN0KRNTYJKbqW1B0lfg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ava/typescript/-/typescript-4.1.0.tgz", + "integrity": "sha512-1iWZQ/nr9iflhLK9VN8H+1oDZqe93qxNnyYUz+jTzkYPAHc5fdZXBrqmNIgIfFhWYXK5OaQ5YtC7OmLeTNhVEg==", "dev": true, "dependencies": { "escape-string-regexp": "^5.0.0", - "execa": "^7.1.0" + "execa": "^7.1.1" }, "engines": { - "node": ">=14.19 <15 || >=16.15 <17 || >=18" + "node": "^14.19 || ^16.15 || ^18 || ^20" } }, "node_modules/@ava/v4": { @@ -1018,6 +1020,25 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", + "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1050,6 +1071,18 @@ "node": ">= 8" } }, + "node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -1185,9 +1218,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.3.tgz", - "integrity": "sha512-pg9d0yC4rVNWQzX8U7xb4olIOFuuVL9za3bzMT2pu2SU0SNEi66i2qrvhE2qt0HvkhuCaWJu7pLNOt/Pj8BIrw==", + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz", + "integrity": "sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==", "dev": true }, "node_modules/@types/normalize-package-data": { @@ -1217,6 +1250,30 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "node_modules/@vercel/nft": { + "version": "0.22.6", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.22.6.tgz", + "integrity": "sha512-gTsFnnT4mGxodr4AUlW3/urY+8JKKB452LwF3m477RFUJTAaDmcz2JqFuInzvdybYIeyIv1sSONEJxsxnbQ5JQ==", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.5", + "@rollup/pluginutils": "^4.0.0", + "acorn": "^8.6.0", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.2", + "node-gyp-build": "^4.2.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -1392,6 +1449,11 @@ "dev": true, "peer": true }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, "node_modules/acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", @@ -1430,6 +1492,17 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/aggregate-error": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-4.0.1.tgz", @@ -1533,12 +1606,29 @@ "node": ">=8" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1666,6 +1756,11 @@ "node": ">=10" } }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==" + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -1681,8 +1776,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -1702,6 +1796,14 @@ "node": ">=10" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/blueimp-md5": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", @@ -1711,7 +1813,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2023,6 +2124,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -2206,7 +2315,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, "bin": { "color-support": "bin.js" } @@ -2232,8 +2340,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concordance": { "version": "5.0.4", @@ -2259,6 +2366,11 @@ "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", "dev": true }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2564,6 +2676,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "node_modules/detect-libc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", + "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", @@ -3721,6 +3846,11 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3857,6 +3987,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3980,11 +4115,21 @@ "integrity": "sha512-kSxoARUDn4F2RPXX48UXnaFKwVU7Ivd/6qpzZL29MCDmr9sTvybv4gFCp+qaI4fM9m0z9fgz/yJvi56GAz+BZg==", "dev": true }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.2", @@ -4038,6 +4183,70 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4141,7 +4350,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -4243,8 +4451,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -4342,6 +4549,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -4397,6 +4609,18 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", @@ -4492,7 +4716,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -4501,8 +4724,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.0.5", @@ -5811,7 +6033,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, "dependencies": { "semver": "^6.0.0" }, @@ -5826,7 +6047,6 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, "bin": { "semver": "bin/semver.js" } @@ -6017,7 +6237,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6061,7 +6280,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -6069,11 +6287,22 @@ "node": ">=8" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -6121,6 +6350,35 @@ "type-detect": "4.0.8" } }, + "node_modules/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", + "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -6147,6 +6405,20 @@ "node": ">=12.19" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", @@ -6198,6 +6470,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nyc": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", @@ -6492,6 +6775,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -6549,7 +6840,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -6907,7 +7197,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7418,6 +7707,19 @@ "node": ">=8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7586,7 +7888,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -7623,7 +7924,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -7637,8 +7937,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "peer": true + ] }, "node_modules/safe-regex": { "version": "2.1.1", @@ -7734,8 +8033,7 @@ "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -7775,8 +8073,7 @@ "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, "node_modules/sinon": { "version": "15.1.0", @@ -7912,6 +8209,14 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -10095,6 +10400,30 @@ "node": ">=0.6" } }, + "node_modules/tar": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", + "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/tcompare": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz", @@ -10278,6 +10607,11 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -10535,6 +10869,11 @@ "url": "https://github.com/fisker/url-or-path?sponsor=1" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -10582,6 +10921,11 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, "node_modules/webpack": { "version": "5.83.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.83.1.tgz", @@ -10696,6 +11040,15 @@ "node": ">=6" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10753,6 +11106,59 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wide-align/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", @@ -10856,8 +11262,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "5.0.1", diff --git a/package.json b/package.json index b1a4be33e..9caa52a58 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "typescript" ], "dependencies": { + "@vercel/nft": "^0.22.6", "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "ansi-styles": "^6.2.1", @@ -126,8 +127,9 @@ }, "devDependencies": { "@ava/test": "github:avajs/test", - "@ava/typescript": "^4.0.0", + "@ava/typescript": "^4.1.0", "@sindresorhus/tsconfig": "^3.0.1", + "@types/node": "^20.3.2", "ansi-escapes": "^6.2.0", "c8": "^7.13.0", "execa": "^7.1.1", diff --git a/test-tap/globs.js b/test-tap/globs.js index 73db32d47..486e7085d 100644 --- a/test-tap/globs.js +++ b/test-tap/globs.js @@ -208,11 +208,11 @@ test('isIgnoredByWatcher with defaults', t => { }; function isIgnoredByWatcher(file) { - t.ok(globs.classify(fixture(file), options).isIgnoredByWatcher, `${file} should be ignored`); + t.ok(globs.classifyAva5Watcher(fixture(file), options).isIgnoredByWatcher, `${file} should be ignored`); } function notIgnored(file) { - t.notOk(globs.classify(fixture(file), options).isIgnoredByWatcher, `${file} should not be ignored`); + t.notOk(globs.classifyAva5Watcher(fixture(file), options).isIgnoredByWatcher, `${file} should not be ignored`); } notIgnored('foo-bar.js'); @@ -247,9 +247,9 @@ test('isIgnoredByWatcher with patterns', t => { cwd: fixture(), }; - t.ok(globs.classify(fixture('node_modules/foo/foo.js'), options).isIgnoredByWatcher); - t.ok(globs.classify(fixture('bar.js'), options).isIgnoredByWatcher); - t.ok(globs.classify(fixture('foo/bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('node_modules/foo/foo.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('foo/bar.js'), options).isIgnoredByWatcher); t.end(); }); @@ -264,9 +264,9 @@ test('isIgnoredByWatcher (pattern starts with directory)', t => { cwd: fixture(), }; - t.ok(globs.classify(fixture('node_modules/foo/foo.js'), options).isIgnoredByWatcher); - t.notOk(globs.classify(fixture('bar.js'), options).isIgnoredByWatcher); - t.ok(globs.classify(fixture('foo/bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('node_modules/foo/foo.js'), options).isIgnoredByWatcher); + t.notOk(globs.classifyAva5Watcher(fixture('bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('foo/bar.js'), options).isIgnoredByWatcher); t.end(); }); @@ -291,8 +291,8 @@ test('isIgnoredByWatcher after provider modifications', t => { cwd: fixture(), }; - t.ok(globs.classify(fixture('foo.js'), options).isIgnoredByWatcher); - t.notOk(globs.classify(fixture('bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('foo.js'), options).isIgnoredByWatcher); + t.notOk(globs.classifyAva5Watcher(fixture('bar.js'), options).isIgnoredByWatcher); t.end(); }); diff --git a/test/config/integration.js b/test/config/integration.js index a36676e9a..13f18ad30 100644 --- a/test/config/integration.js +++ b/test/config/integration.js @@ -67,7 +67,7 @@ test('use current working directory if `package.json` is not found', async t => const cwd = temporaryDirectory(); const testFilePath = path.join(cwd, 'test.js'); - fs.writeFileSync(testFilePath, 'const test = require(process.env.TEST_AVA_IMPORT_FROM);\ntest(\'test name\', t => { t.pass(); });'); + fs.writeFileSync(testFilePath, 'const test = require(process.env.TEST_AVA_REQUIRE_FROM);\ntest(\'test name\', t => { t.pass(); });'); const options = { cwd, diff --git a/test/helpers/exec.js b/test/helpers/exec.js index ae4777687..76fb27725 100644 --- a/test/helpers/exec.js +++ b/test/helpers/exec.js @@ -1,6 +1,8 @@ import {Buffer} from 'node:buffer'; +import {on} from 'node:events'; import path from 'node:path'; -import {fileURLToPath} from 'node:url'; +import {Writable} from 'node:stream'; +import {fileURLToPath, pathToFileURL} from 'node:url'; import test from '@ava/test'; import {execaNode} from 'execa'; @@ -8,7 +10,8 @@ import {execaNode} from 'execa'; const cliPath = fileURLToPath(new URL('../../entrypoints/cli.mjs', import.meta.url)); const ttySimulator = fileURLToPath(new URL('simulate-tty.cjs', import.meta.url)); -const TEST_AVA_IMPORT_FROM = path.join(process.cwd(), 'entrypoints/main.cjs'); +const TEST_AVA_IMPORT_FROM = pathToFileURL(path.join(process.cwd(), 'entrypoints/main.mjs')); +const TEST_AVA_REQUIRE_FROM = path.join(process.cwd(), 'entrypoints/main.cjs'); const normalizePosixPath = string => string.replaceAll('\\', '/'); const normalizePath = (root, file) => normalizePosixPath(path.posix.normalize(path.relative(root, file))); @@ -34,35 +37,13 @@ export const cleanOutput = string => string.replace(/^\W+/, '').replace(/\W+\n+$ const NO_FORWARD_PREFIX = Buffer.from('🤗', 'utf8'); -const forwardErrorOutput = async from => { - for await (const message of from) { - if (NO_FORWARD_PREFIX.compare(message, 0, 4) !== 0) { - process.stderr.write(message); - } +const forwardErrorOutput = chunk => { + if (chunk.length < 4 || NO_FORWARD_PREFIX.compare(chunk, 0, 4) !== 0) { + process.stderr.write(chunk); } }; -export const fixture = async (args, options = {}) => { - const workingDir = options.cwd || cwd(); - const running = execaNode(cliPath, args, { - ...options, - env: { - ...options.env, - AVA_EMIT_RUN_STATUS_OVER_IPC: 'I\'ll find a payphone baby / Take some time to talk to you', - TEST_AVA_IMPORT_FROM, - }, - cwd: workingDir, - serialization: 'advanced', - nodeOptions: ['--require', ttySimulator], - }); - - // Besides buffering stderr, if this environment variable is set, also pipe - // to stderr. This can be useful when debugging the tests. - if (process.env.DEBUG_TEST_AVA) { - // Running.stderr.pipe(process.stderr); - forwardErrorOutput(running.stderr); - } - +const initState = () => { const errors = new WeakMap(); const logs = new WeakMap(); const stats = { @@ -84,8 +65,80 @@ export const fixture = async (args, options = {}) => { }, }; - running.on('message', statusEvent => { + return {errors, logs, stats, stdout: '', stderr: ''}; +}; + +const sortStats = stats => { + stats.failed.sort(compareStatObjects); + stats.failedHooks.sort(compareStatObjects); + stats.passed.sort(compareStatObjects); + stats.skipped.sort(compareStatObjects); + stats.todo.sort(compareStatObjects); +}; + +export async function * exec(args, options) { + const workingDir = options.cwd ?? cwd(); + const execaProcess = execaNode(cliPath, args, { + ...options, + env: { + ...options.env, + TEST_AVA: 'true', + TEST_AVA_IMPORT_FROM, + TEST_AVA_REQUIRE_FROM, + }, + cwd: workingDir, + serialization: 'advanced', + nodeOptions: ['--require', ttySimulator], + }); + + let {errors, logs, stats, stdout, stderr} = initState(); + + execaProcess.pipeStdout(new Writable({ + write(chunk) { + stdout += chunk; + }, + })); + execaProcess.pipeStderr(new Writable({ + write(chunk) { + stderr += chunk; + + // Besides buffering stderr, if this environment variable is set, also pipe + // to stderr. This can be useful when debugging the tests. + if (process.env.DEBUG_TEST_AVA) { + forwardErrorOutput(chunk); + } + }, + })); + + let runCount = 0; + const statusEvents = on(execaProcess, 'message'); + const done = execaProcess.then(result => ({execa: true, result}), error => { + sortStats(stats); + throw Object.assign(error, {stats, runCount}); + }); + + while (true) { + const item = await Promise.race([done, statusEvents.next()]); // eslint-disable-line no-await-in-loop + if (item.execa) { + sortStats(stats); + yield {process: execaProcess, stats, stdout, stderr, runCount}; + break; + } + + if (item.done && !item.value) { + break; + } + + const {value: [statusEvent]} = item; switch (statusEvent.type) { + case 'end': { + sortStats(stats); + runCount++; + yield {process: execaProcess, stats, stdout, stderr, runCount}; + ({errors, logs, stats, stdout, stderr} = initState()); + break; + } + case 'hook-failed': { const {title, testFile} = statusEvent; const statObject = {title, file: normalizePath(workingDir, testFile)}; @@ -96,7 +149,7 @@ export const fixture = async (args, options = {}) => { case 'internal-error': { const {testFile} = statusEvent; - const statObject = {file: normalizePath(workingDir, testFile)}; + const statObject = {file: normalizePath(workingDir, testFile ?? '')}; errors.set(statObject, statusEvent.err); stats.internalErrors.push(statObject); break; @@ -157,20 +210,14 @@ export const fixture = async (args, options = {}) => { break; } } - }); + } +} - try { +export async function fixture(args, options = {}) { + for await (const {process, ...result} of exec(args, options)) { // eslint-disable-line no-unreachable-loop return { - stats, - ...await running, + ...result, + ...await process, }; - } catch (error) { - throw Object.assign(error, {stats}); - } finally { - stats.failed.sort(compareStatObjects); - stats.failedHooks.sort(compareStatObjects); - stats.passed.sort(compareStatObjects); - stats.skipped.sort(compareStatObjects); - stats.todo.sort(compareStatObjects); } -}; +} diff --git a/test/snapshot-removal/fixtures/fixed-snapshot-dir/test.js b/test/snapshot-removal/fixtures/fixed-snapshot-dir/test.js index d6b1337f6..2f1562c68 100644 --- a/test/snapshot-removal/fixtures/fixed-snapshot-dir/test.js +++ b/test/snapshot-removal/fixtures/fixed-snapshot-dir/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('some snapshots', t => { diff --git a/test/snapshot-removal/fixtures/no-snapshots/test.js b/test/snapshot-removal/fixtures/no-snapshots/test.js index f5d53ceb5..8f3c1586a 100644 --- a/test/snapshot-removal/fixtures/no-snapshots/test.js +++ b/test/snapshot-removal/fixtures/no-snapshots/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('without snapshots', t => { t.pass(); diff --git a/test/snapshot-removal/fixtures/only-test/test.js b/test/snapshot-removal/fixtures/only-test/test.js index 214398c61..271351342 100644 --- a/test/snapshot-removal/fixtures/only-test/test.js +++ b/test/snapshot-removal/fixtures/only-test/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('some snapshots', t => { diff --git a/test/snapshot-removal/fixtures/removal/test.js b/test/snapshot-removal/fixtures/removal/test.js index d6b1337f6..2f1562c68 100644 --- a/test/snapshot-removal/fixtures/removal/test.js +++ b/test/snapshot-removal/fixtures/removal/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('some snapshots', t => { diff --git a/test/snapshot-removal/fixtures/skipped-snapshots-in-try/test.js b/test/snapshot-removal/fixtures/skipped-snapshots-in-try/test.js index c81aed5aa..dca74c7ea 100644 --- a/test/snapshot-removal/fixtures/skipped-snapshots-in-try/test.js +++ b/test/snapshot-removal/fixtures/skipped-snapshots-in-try/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('skipped snapshots in try', async t => { diff --git a/test/snapshot-removal/fixtures/skipped-snapshots/test.js b/test/snapshot-removal/fixtures/skipped-snapshots/test.js index bcc5605f8..d437185f3 100644 --- a/test/snapshot-removal/fixtures/skipped-snapshots/test.js +++ b/test/snapshot-removal/fixtures/skipped-snapshots/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('some snapshots', t => { diff --git a/test/snapshot-removal/fixtures/skipped-tests/test.js b/test/snapshot-removal/fixtures/skipped-tests/test.js index 2fd12708b..50cd928af 100644 --- a/test/snapshot-removal/fixtures/skipped-tests/test.js +++ b/test/snapshot-removal/fixtures/skipped-tests/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('some snapshots', t => { diff --git a/test/snapshot-removal/fixtures/snapshot-dir/test/test.js b/test/snapshot-removal/fixtures/snapshot-dir/test/test.js index d6b1337f6..2f1562c68 100644 --- a/test/snapshot-removal/fixtures/snapshot-dir/test/test.js +++ b/test/snapshot-removal/fixtures/snapshot-dir/test/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('some snapshots', t => { diff --git a/test/snapshot-removal/fixtures/try/test.js b/test/snapshot-removal/fixtures/try/test.js index f69b14d01..5b939d892 100644 --- a/test/snapshot-removal/fixtures/try/test.js +++ b/test/snapshot-removal/fixtures/try/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('snapshots in try', async t => { diff --git a/test/snapshot-tests/fixtures/corrupt/test.js b/test/snapshot-tests/fixtures/corrupt/test.js index 924af7772..78eb6ceff 100644 --- a/test/snapshot-tests/fixtures/corrupt/test.js +++ b/test/snapshot-tests/fixtures/corrupt/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); +const test = require(process.env.TEST_AVA_REQUIRE_FROM); test('a snapshot', t => { t.snapshot('foo'); diff --git a/test/snapshot-tests/fixtures/large/test.js b/test/snapshot-tests/fixtures/large/test.js index 5d86b33b7..3a2a2c4c8 100644 --- a/test/snapshot-tests/fixtures/large/test.js +++ b/test/snapshot-tests/fixtures/large/test.js @@ -1,6 +1,6 @@ const {Buffer} = require('node:buffer'); -const test = require(process.env.TEST_AVA_IMPORT_FROM); +const test = require(process.env.TEST_AVA_REQUIRE_FROM); for (let i = 0; i < 2; i++) { test(`large snapshot ${i}`, t => { diff --git a/test/snapshot-tests/fixtures/multiline-snapshot-label/test.js b/test/snapshot-tests/fixtures/multiline-snapshot-label/test.js index 434f03dda..65fc825ee 100644 --- a/test/snapshot-tests/fixtures/multiline-snapshot-label/test.js +++ b/test/snapshot-tests/fixtures/multiline-snapshot-label/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); +const test = require(process.env.TEST_AVA_REQUIRE_FROM); const f = () => [ 'Hello', diff --git a/test/snapshot-tests/fixtures/normalized-title-in-snapshots/test.js b/test/snapshot-tests/fixtures/normalized-title-in-snapshots/test.js index 90852edfc..636338f4e 100644 --- a/test/snapshot-tests/fixtures/normalized-title-in-snapshots/test.js +++ b/test/snapshot-tests/fixtures/normalized-title-in-snapshots/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); +const test = require(process.env.TEST_AVA_REQUIRE_FROM); test('test\r\n\ttitle', t => { t.snapshot('Hello, World!'); diff --git a/test/snapshot-tests/fixtures/normalized-title-in-stdout/test.js b/test/snapshot-tests/fixtures/normalized-title-in-stdout/test.js index 42c6fa316..fd70ee505 100644 --- a/test/snapshot-tests/fixtures/normalized-title-in-stdout/test.js +++ b/test/snapshot-tests/fixtures/normalized-title-in-stdout/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); +const test = require(process.env.TEST_AVA_REQUIRE_FROM); test(`a rather wordy test title that is wrapped to meet line length requirements in diff --git a/test/snapshot-workflow/fixtures/adding-skipped-snapshots/test.js b/test/snapshot-workflow/fixtures/adding-skipped-snapshots/test.js index 0e48f3a38..4f9201b48 100644 --- a/test/snapshot-workflow/fixtures/adding-skipped-snapshots/test.js +++ b/test/snapshot-workflow/fixtures/adding-skipped-snapshots/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/adding-snapshots/test.js b/test/snapshot-workflow/fixtures/adding-snapshots/test.js index 26992ba5f..f6fef3a98 100644 --- a/test/snapshot-workflow/fixtures/adding-snapshots/test.js +++ b/test/snapshot-workflow/fixtures/adding-snapshots/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/adding-test/test.js b/test/snapshot-workflow/fixtures/adding-test/test.js index e3a4b18b2..72ed25883 100644 --- a/test/snapshot-workflow/fixtures/adding-test/test.js +++ b/test/snapshot-workflow/fixtures/adding-test/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/changing-label/test.js b/test/snapshot-workflow/fixtures/changing-label/test.js index 19dc341d6..0ff131aca 100644 --- a/test/snapshot-workflow/fixtures/changing-label/test.js +++ b/test/snapshot-workflow/fixtures/changing-label/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot({foo: 'one'}, process.env.TEMPLATE ? undefined : 'a new message'); diff --git a/test/snapshot-workflow/fixtures/changing-title/test.js b/test/snapshot-workflow/fixtures/changing-title/test.js index 24d3a03c4..b3936d512 100644 --- a/test/snapshot-workflow/fixtures/changing-title/test.js +++ b/test/snapshot-workflow/fixtures/changing-title/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test(`a ${process.env.TEMPLATE ? '' : 'new '}title`, t => { t.snapshot({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/commit-skip/test.js b/test/snapshot-workflow/fixtures/commit-skip/test.js index 092f6c6a6..cdc9c20d4 100644 --- a/test/snapshot-workflow/fixtures/commit-skip/test.js +++ b/test/snapshot-workflow/fixtures/commit-skip/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('commit a skipped snapshot', async t => { t.snapshot(1); diff --git a/test/snapshot-workflow/fixtures/discard-skip/test.js b/test/snapshot-workflow/fixtures/discard-skip/test.js index 2dffbeb16..d86aa2cc1 100644 --- a/test/snapshot-workflow/fixtures/discard-skip/test.js +++ b/test/snapshot-workflow/fixtures/discard-skip/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('discard a skipped snapshot', async t => { t.snapshot(1); diff --git a/test/snapshot-workflow/fixtures/filling-in-blanks/test.js b/test/snapshot-workflow/fixtures/filling-in-blanks/test.js index 481d70133..e80d187b8 100644 --- a/test/snapshot-workflow/fixtures/filling-in-blanks/test.js +++ b/test/snapshot-workflow/fixtures/filling-in-blanks/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/first-run/test.js b/test/snapshot-workflow/fixtures/first-run/test.js index 2dffa64fd..6113ba97a 100644 --- a/test/snapshot-workflow/fixtures/first-run/test.js +++ b/test/snapshot-workflow/fixtures/first-run/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/invalid-snapfile/test.js b/test/snapshot-workflow/fixtures/invalid-snapfile/test.js index 2defbe2f5..6a7ba3544 100644 --- a/test/snapshot-workflow/fixtures/invalid-snapfile/test.js +++ b/test/snapshot-workflow/fixtures/invalid-snapfile/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot.skip({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/removing-all-snapshots/test.js b/test/snapshot-workflow/fixtures/removing-all-snapshots/test.js index 0ef1a31da..27279c875 100644 --- a/test/snapshot-workflow/fixtures/removing-all-snapshots/test.js +++ b/test/snapshot-workflow/fixtures/removing-all-snapshots/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { if (process.env.TEMPLATE) { diff --git a/test/snapshot-workflow/fixtures/removing-snapshots/test.js b/test/snapshot-workflow/fixtures/removing-snapshots/test.js index 2d5086737..0392e5ba3 100644 --- a/test/snapshot-workflow/fixtures/removing-snapshots/test.js +++ b/test/snapshot-workflow/fixtures/removing-snapshots/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/removing-test/test.js b/test/snapshot-workflow/fixtures/removing-test/test.js index 8bfaf71de..c812bfcfe 100644 --- a/test/snapshot-workflow/fixtures/removing-test/test.js +++ b/test/snapshot-workflow/fixtures/removing-test/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/reorder/test.js b/test/snapshot-workflow/fixtures/reorder/test.js index 40723abf8..7d0405f2c 100644 --- a/test/snapshot-workflow/fixtures/reorder/test.js +++ b/test/snapshot-workflow/fixtures/reorder/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. if (process.env.TEMPLATE) { test('first test', t => { diff --git a/test/snapshot-workflow/fixtures/select-test-update/test.js b/test/snapshot-workflow/fixtures/select-test-update/test.js index 994fa1115..10b2bc7b1 100644 --- a/test/snapshot-workflow/fixtures/select-test-update/test.js +++ b/test/snapshot-workflow/fixtures/select-test-update/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { t.snapshot(process.env.TEMPLATE ? {foo: 'one'} : {foo: 'new'}); diff --git a/test/snapshot-workflow/fixtures/skipping-snapshot-update/test.js b/test/snapshot-workflow/fixtures/skipping-snapshot-update/test.js index 94400c7a3..74bdcc4e8 100644 --- a/test/snapshot-workflow/fixtures/skipping-snapshot-update/test.js +++ b/test/snapshot-workflow/fixtures/skipping-snapshot-update/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { if (process.env.TEMPLATE) { diff --git a/test/snapshot-workflow/fixtures/skipping-snapshot/test.js b/test/snapshot-workflow/fixtures/skipping-snapshot/test.js index 97275b656..d6fa229e6 100644 --- a/test/snapshot-workflow/fixtures/skipping-snapshot/test.js +++ b/test/snapshot-workflow/fixtures/skipping-snapshot/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. test('foo', t => { (process.env.TEMPLATE ? t.snapshot : t.snapshot.skip)({foo: 'one'}); diff --git a/test/snapshot-workflow/fixtures/skipping-test-update/test.js b/test/snapshot-workflow/fixtures/skipping-test-update/test.js index 015827836..80c39dbf4 100644 --- a/test/snapshot-workflow/fixtures/skipping-test-update/test.js +++ b/test/snapshot-workflow/fixtures/skipping-test-update/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. (process.env.TEMPLATE ? test : test.skip)('foo', t => { t.snapshot(process.env.TEMPLATE ? {foo: 'one'} : ['something new']); diff --git a/test/snapshot-workflow/fixtures/skipping-test/test.js b/test/snapshot-workflow/fixtures/skipping-test/test.js index 463e6e514..1ea62eb93 100644 --- a/test/snapshot-workflow/fixtures/skipping-test/test.js +++ b/test/snapshot-workflow/fixtures/skipping-test/test.js @@ -1,4 +1,4 @@ -const test = require(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. +const test = require(process.env.TEST_AVA_REQUIRE_FROM); // This fixture is copied to a temporary directory, so require AVA through its configured path. (process.env.TEMPLATE ? test : test.skip)('foo', t => { t.snapshot({foo: 'one'}); diff --git a/test/watch-mode/availability.js b/test/watch-mode/availability.js new file mode 100644 index 000000000..b33deb2bc --- /dev/null +++ b/test/watch-mode/availability.js @@ -0,0 +1,28 @@ +import {fileURLToPath} from 'node:url'; + +import test from '@ava/test'; + +import {available} from '../../lib/watcher.js'; + +import {withFixture} from './helpers/watch.js'; + +if (available(fileURLToPath(import.meta.url))) { + test('when available, watch mode works', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1(result) { + t.true(result.stats.passed.length > 0); + await this.touch(result.stats.passed[0].file); + }, + + else(result) { + t.true(result.stats.passed.length > 0); + this.done(); + }, + }); + }); +} else { + test('an error is printed when unavailable', withFixture('basic'), async (t, fixture) => { + const result = await t.throwsAsync(fixture.run().next()); + t.true(result.stderr.trim().includes('Watch mode requires support for recursive fs.watch()')); + }); +} diff --git a/test/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js new file mode 100644 index 000000000..33b28a6a7 --- /dev/null +++ b/test/watch-mode/basic-functionality.js @@ -0,0 +1,80 @@ +import {platform} from 'node:process'; + +import {test, withFixture} from './helpers/watch.js'; + +test('prints results and instructions', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async else({process}) { + process.send('abort-watcher'); + const {stdout} = await process; + t.regex(stdout, /\d+ tests? passed/); + t.regex(stdout, /Type `r` and press enter to rerun tests/); + t.regex(stdout, /Type `u` and press enter to update snapshots/); + this.done(); + }, + }); +}); + +test('ctrl+c interrupts', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async else({process}) { + this.done(); + + process.kill('SIGINT'); + const {stdout} = await t.throwsAsync(process); + const result = await t.try(tt => { + tt.regex(stdout, /Exiting due to SIGINT/); + }); + if (platform === 'win32' && !result.passed) { + result.discard(); + t.pass('Most likely on Windows we did not capture stdout when the process was killed'); + } else { + result.commit(); + } + }, + }); +}); + +test('can rerun tests', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1(result) { + result.process.stdin.write('r\n'); + const {selectedTestCount, passed} = result.stats; + return {selectedTestCount, passed}; + }, + + async 2(result, statsSubset) { + result.process.stdin.write('R\n'); // Case-insensitive + t.like(result.stats, statsSubset); + return statsSubset; + }, + + async 3(result, statsSubset) { + t.like(result.stats, statsSubset); + this.done(); + }, + }); +}); + +test('can update snapshots', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1({process}) { + process.stdin.write('u\n'); + const {mtimeMs} = await this.stat('test.js.snap'); + return mtimeMs; + }, + + async 2({process}, previousMtimeMs) { + process.stdin.write('U\n'); // Case-insensitive + const {mtimeMs} = await this.stat('test.js.snap'); + t.true(mtimeMs > previousMtimeMs); + return mtimeMs; + }, + + async 3(_, previousMtimeMs) { + const {mtimeMs} = await this.stat('test.js.snap'); + t.true(mtimeMs > previousMtimeMs); + this.done(); + }, + }); +}); diff --git a/test/watch-mode/fixtures/basic/ava.config.js b/test/watch-mode/fixtures/basic/ava.config.js new file mode 100644 index 000000000..618a8b186 --- /dev/null +++ b/test/watch-mode/fixtures/basic/ava.config.js @@ -0,0 +1,3 @@ +export default { + ignoredByWatcher: ['ignored-by-watcher.js'], +}; diff --git a/test/watch-mode/fixtures/basic/ignored-by-watcher.js b/test/watch-mode/fixtures/basic/ignored-by-watcher.js new file mode 100644 index 000000000..0b491bb80 --- /dev/null +++ b/test/watch-mode/fixtures/basic/ignored-by-watcher.js @@ -0,0 +1,3 @@ +import process from 'node:process'; + +process.exit(1); // eslint-disable-line unicorn/no-process-exit diff --git a/test/watch-mode/fixtures/basic/not-depended-on.js b/test/watch-mode/fixtures/basic/not-depended-on.js new file mode 100644 index 000000000..0b491bb80 --- /dev/null +++ b/test/watch-mode/fixtures/basic/not-depended-on.js @@ -0,0 +1,3 @@ +import process from 'node:process'; + +process.exit(1); // eslint-disable-line unicorn/no-process-exit diff --git a/test/watch-mode/fixtures/basic/package.json b/test/watch-mode/fixtures/basic/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/watch-mode/fixtures/basic/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/watch-mode/fixtures/basic/source.js b/test/watch-mode/fixtures/basic/source.js new file mode 100644 index 000000000..d1ba1a094 --- /dev/null +++ b/test/watch-mode/fixtures/basic/source.js @@ -0,0 +1 @@ +export default 'source'; diff --git a/test/watch-mode/fixtures/basic/source.test.js b/test/watch-mode/fixtures/basic/source.test.js new file mode 100644 index 000000000..33594d787 --- /dev/null +++ b/test/watch-mode/fixtures/basic/source.test.js @@ -0,0 +1,7 @@ +import source from './source.js'; + +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('source', t => { + t.is(source, 'source'); +}); diff --git a/test/watch-mode/fixtures/basic/test.js b/test/watch-mode/fixtures/basic/test.js new file mode 100644 index 000000000..c34610900 --- /dev/null +++ b/test/watch-mode/fixtures/basic/test.js @@ -0,0 +1,9 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('pass', t => { + t.pass(); +}); + +test('snapshot', t => { + t.snapshot('snapshot'); +}); diff --git a/test/watch-mode/fixtures/exclusive/a.test.js b/test/watch-mode/fixtures/exclusive/a.test.js new file mode 100644 index 000000000..fd313167d --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/a.test.js @@ -0,0 +1,9 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('pass', t => { + t.pass(); +}); + +test('fail', t => { + t.fail(); +}); diff --git a/test/watch-mode/fixtures/exclusive/ava.config.js b/test/watch-mode/fixtures/exclusive/ava.config.js new file mode 100644 index 000000000..618a8b186 --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/ava.config.js @@ -0,0 +1,3 @@ +export default { + ignoredByWatcher: ['ignored-by-watcher.js'], +}; diff --git a/test/watch-mode/fixtures/exclusive/b.test.js b/test/watch-mode/fixtures/exclusive/b.test.js new file mode 100644 index 000000000..e0aa2b911 --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/b.test.js @@ -0,0 +1,9 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test.only('pass', t => { + t.pass(); +}); + +test('fail', t => { + t.fail(); +}); diff --git a/test/watch-mode/fixtures/exclusive/c.test.js b/test/watch-mode/fixtures/exclusive/c.test.js new file mode 100644 index 000000000..fd313167d --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/c.test.js @@ -0,0 +1,9 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('pass', t => { + t.pass(); +}); + +test('fail', t => { + t.fail(); +}); diff --git a/test/watch-mode/fixtures/exclusive/package.json b/test/watch-mode/fixtures/exclusive/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/watch-mode/fixtures/typescript-inline/.gitignore b/test/watch-mode/fixtures/typescript-inline/.gitignore new file mode 100644 index 000000000..378eac25d --- /dev/null +++ b/test/watch-mode/fixtures/typescript-inline/.gitignore @@ -0,0 +1 @@ +build diff --git a/test/watch-mode/fixtures/typescript-inline/ava.config.js b/test/watch-mode/fixtures/typescript-inline/ava.config.js new file mode 100644 index 000000000..cef213c98 --- /dev/null +++ b/test/watch-mode/fixtures/typescript-inline/ava.config.js @@ -0,0 +1,8 @@ +export default { + typescript: { + rewritePaths: { + 'src/': 'build/', + }, + compile: 'tsc', + }, +}; diff --git a/test/watch-mode/fixtures/typescript-inline/package.json b/test/watch-mode/fixtures/typescript-inline/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/watch-mode/fixtures/typescript-inline/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/watch-mode/fixtures/typescript-inline/src/test.ts b/test/watch-mode/fixtures/typescript-inline/src/test.ts new file mode 100644 index 000000000..fe2d0f3be --- /dev/null +++ b/test/watch-mode/fixtures/typescript-inline/src/test.ts @@ -0,0 +1,10 @@ +import process from 'node:process'; + +// This fixture is copied to a temporary directory, so manually type the test +// function and import AVA through its configured path. +type Test = (title: string, implementation: (t: {pass(): void}) => void) => void; +const {default: test} = await import(process.env['TEST_AVA_IMPORT_FROM'] ?? '') as {default: Test}; + +test('pass', t => { + t.pass(); +}); diff --git a/test/watch-mode/fixtures/typescript-inline/tsconfig.json b/test/watch-mode/fixtures/typescript-inline/tsconfig.json new file mode 100644 index 000000000..053f136e7 --- /dev/null +++ b/test/watch-mode/fixtures/typescript-inline/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@sindresorhus/tsconfig", + "compilerOptions": { + "outDir": "build", + }, + "types": [ + "node_modules/@types/node", + "../../../../node_modules/@types/node", + ], + "include": [ + "src" + ], +} diff --git a/test/watch-mode/fixtures/typescript-precompiled/ava.config.js b/test/watch-mode/fixtures/typescript-precompiled/ava.config.js new file mode 100644 index 000000000..d5d368943 --- /dev/null +++ b/test/watch-mode/fixtures/typescript-precompiled/ava.config.js @@ -0,0 +1,11 @@ +import process from 'node:process'; + +export default { + typescript: { + extensions: process.env.JUST_TS_EXTENSION ? ['ts'] : ['ts', 'js'], + rewritePaths: { + 'src/': 'build/', + }, + compile: false, + }, +}; diff --git a/test/watch-mode/fixtures/typescript-precompiled/build/test.js b/test/watch-mode/fixtures/typescript-precompiled/build/test.js new file mode 100644 index 000000000..579d7a5e9 --- /dev/null +++ b/test/watch-mode/fixtures/typescript-precompiled/build/test.js @@ -0,0 +1,5 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('pass', t => { + t.pass(); +}); diff --git a/test/watch-mode/fixtures/typescript-precompiled/package.json b/test/watch-mode/fixtures/typescript-precompiled/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/watch-mode/fixtures/typescript-precompiled/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/watch-mode/fixtures/typescript-precompiled/src/test.ts b/test/watch-mode/fixtures/typescript-precompiled/src/test.ts new file mode 100644 index 000000000..1602ce338 --- /dev/null +++ b/test/watch-mode/fixtures/typescript-precompiled/src/test.ts @@ -0,0 +1,11 @@ +import process from 'node:process'; + +import type ava from 'ava'; + +// This fixture is copied to a temporary directory, so import AVA through its +// configured path. +const {default: test} = await (import(process.env['TEST_AVA_IMPORT_FROM'] ?? '') as Promise<{default: typeof ava}>); + +test('pass', t => { + t.pass(); +}); diff --git a/test/watch-mode/fixtures/typescript-precompiled/tsconfig.json b/test/watch-mode/fixtures/typescript-precompiled/tsconfig.json new file mode 100644 index 000000000..f681bdacd --- /dev/null +++ b/test/watch-mode/fixtures/typescript-precompiled/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@sindresorhus/tsconfig", + "compilerOptions": { + "outDir": "build", + }, + "types": [ + "../../../../node_modules/@types/node", + ], + "include": [ + "src" + ], +} diff --git a/test/watch-mode/helpers/watch.js b/test/watch-mode/helpers/watch.js new file mode 100644 index 000000000..bd7cd21d6 --- /dev/null +++ b/test/watch-mode/helpers/watch.js @@ -0,0 +1,166 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {setTimeout as delay} from 'node:timers/promises'; +import {fileURLToPath} from 'node:url'; + +import ava from '@ava/test'; +import {temporaryDirectoryTask} from 'tempy'; + +import {available} from '../../../lib/watcher.js'; +import {cwd, exec} from '../../helpers/exec.js'; + +export const test = available(fileURLToPath(import.meta.url)) ? ava : ava.skip; +export const serial = available(fileURLToPath(import.meta.url)) ? ava.serial : ava.serial.skip; + +export const withFixture = fixture => async (t, task) => { + let completedTask = false; + await temporaryDirectoryTask(async dir => { + await fs.cp(cwd(fixture), dir, {recursive: true}); + + async function * run(args = [], options = {}) { + yield * exec(['--watch', ...args], {...options, cwd: dir, env: {AVA_FORCE_CI: 'not-ci', ...options.env}}); + } + + async function mkdir(file, options = {}) { + await fs.mkdir(path.join(dir, file), options); + } + + async function read(file) { + return fs.readFile(path.join(dir, file), 'utf8'); + } + + async function rm(file, options = {}) { + await fs.rm(path.join(dir, file), options); + } + + async function stat(file) { + return fs.stat(path.join(dir, file)); + } + + async function touch(file) { + const time = new Date(); + await fs.utimes(path.join(dir, file), time, time); + } + + async function write(file, contents = '') { + await fs.writeFile(path.join(dir, file), contents); + } + + const operations = { + mkdir, + read, + rm, + stat, + touch, + write, + }; + + let activeWatchCount = 0; + await task(t, { + ...operations, + dir, + run, + async watch(handlers, args = [], options = {}) { + activeWatchCount++; + + let signalDone; + const donePromise = new Promise(resolve => { + signalDone = resolve; + }); + let isDone = false; + const done = () => { + activeWatchCount--; + isDone = true; + signalDone({done: true}); + }; + + let idlePromise = new Promise(() => {}); + let assertingIdle = false; + let failedIdleAssertion = false; + const assertIdle = async next => { + assertingIdle = true; + + // TODO: When testing using AVA 6, enable for better managed timeouts. + // t.timeout(10_000); + + const promise = Promise.all([delay(5000, null, {ref: false}), next?.()]).finally(() => { + if (idlePromise === promise) { + idlePromise = new Promise(() => {}); + assertingIdle = false; + // TODO: When testing using AVA 6, enable for better managed timeouts. + // t.timeout(0); + if (failedIdleAssertion) { + failedIdleAssertion = false; + t.fail('Watcher performed a test run while it should have been idle'); + } + } + + return {}; + }); + idlePromise = promise; + + await promise; + }; + + let state = {}; + let pendingState; + + const results = run(args, options); + try { + let nextResult = results.next(); + while (true) { // eslint-disable-line no-constant-condition + const item = await Promise.race([nextResult, idlePromise, donePromise]); // eslint-disable-line no-await-in-loop + + if (item.value) { + failedIdleAssertion ||= assertingIdle; + + state = (await pendingState) ?? state; // eslint-disable-line no-await-in-loop + const result = item.value; + const {[result.runCount]: handler = handlers.else} = handlers; + pendingState = handler?.call({assertIdle, done, ...operations}, result, state); + + if (!item.done && !isDone) { + nextResult = results.next(); + } + } + + if (item.done || isDone) { + item.value?.process.send('abort-watcher'); + break; + } + } + } finally { + results.return(); + + // Handle outstanding promises in case they reject. + if (assertingIdle) { + await idlePromise; + } + + await pendingState; + } + }, + }); + + t.is(activeWatchCount, 0, 'Handlers for all watch() calls should have invoked `this.done()` to end their tests'); + completedTask = true; + }).catch(error => { + if (!completedTask) { + throw error; + } + + switch (error.code) { // https://github.com/sindresorhus/tempy/issues/47 + case 'EBUSY': + case 'EMFILE': + case 'ENFILE': + case 'ENOTEMPTY': + case 'EPERM ': { + return; + } + + default: { + throw error; + } + } + }); +}; diff --git a/test/watch-mode/scenarios.js b/test/watch-mode/scenarios.js new file mode 100644 index 000000000..b9daa8113 --- /dev/null +++ b/test/watch-mode/scenarios.js @@ -0,0 +1,126 @@ +import {test, withFixture} from './helpers/watch.js'; + +test('waits for changes', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.assertIdle(); + this.done(); + }, + }); +}); + +test('watcher can be configured to ignore files', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.assertIdle(async () => { + await this.touch('ignored-by-watcher.js'); + }); + this.done(); + }, + }); +}); + +test('new, empty directories are ignored', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.assertIdle(async () => { + await this.mkdir('empty-directory'); + }); + this.done(); + }, + }); +}); + +test('runs test files that depend on the changed file', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.touch('source.js'); + }, + async 2({stats}) { + t.deepEqual(stats.passed, [{file: 'source.test.js', title: 'source'}]); + this.done(); + }, + }); +}); + +test('runs all test files if a file is changed that is not depended on', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1({stats}) { + await this.touch('not-depended-on.js'); + return stats.passed; + }, + async 2({stats}, previousPassed) { + t.deepEqual(stats.passed, previousPassed); + this.done(); + }, + }); +}); + +test('runs all test files if a new file is added', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1({stats}) { + await this.write('new-file.js'); + return stats.passed; + }, + async 2({stats}, previousPassed) { + t.deepEqual(stats.passed, previousPassed); + this.done(); + }, + }); +}); + +test('does not run deleted test file, even if source it previously depended on is changed', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.assertIdle(async () => { + await this.rm('source.test.js'); + await this.touch('source.js'); + }); + this.done(); + }, + }); +}); + +test('runs test file when source it depends on is deleted', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.rm('source.js'); + }, + async 2({stats}) { + t.is(stats.passed.length, 0); + t.is(stats.uncaughtExceptions.length, 1); + t.regex(stats.uncaughtExceptions[0].message, /Cannot find module.+source\.js.+imported from.+source\.test\.js/); + this.done(); + }, + }); +}); + +test('once test files containing .only() tests are encountered, always run those, but exclusively the .only tests', withFixture('exclusive'), async (t, fixture) => { + await fixture.watch({ + async 1({stats}) { + t.is(stats.failed.length, 2); + t.is(stats.passed.length, 3); + const contents = await this.read('a.test.js'); + await this.write('a.test.js', contents.replace('test(\'pass', 'test.only(\'pass')); + return stats.passed.filter(({file}) => file !== 'c.test.js'); + }, + async 2({stats}, passed) { + t.is(stats.failed.length, 0); + t.is(stats.passed.length, 2); + t.deepEqual(stats.passed, passed); + this.done(); + }, + }); +}); + +test('filters test files', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1({stats}) { + t.false(stats.passed.some(({file}) => file === 'test.js')); + await this.assertIdle(async () => { + await this.touch('test.js'); + }); + this.done(); + }, + }, ['source.test.js']); +}); diff --git a/test/watch-mode/typescript.js b/test/watch-mode/typescript.js new file mode 100644 index 000000000..fd3d96e11 --- /dev/null +++ b/test/watch-mode/typescript.js @@ -0,0 +1,84 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import {test, withFixture} from './helpers/watch.js'; + +test('waits for external compiler before re-running typescript test files', withFixture('typescript-precompiled'), async (t, fixture) => { + await fixture.watch({ + async 1({stats}) { + t.true(stats.passed.length > 0); + await this.assertIdle(async () => { + await this.touch('src/test.ts'); + }); + await this.touch('build/test.js'); + return stats.passed; + }, + async 2({stats}, previousPassed) { + t.deepEqual(stats.passed, previousPassed); + this.done(); + }, + }); +}); + +test('does not run precompiled files when sources are deleted', withFixture('typescript-precompiled'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.assertIdle(async () => { + await this.rm('src/test.ts'); + }); + this.done(); + }, + }); +}); + +test('handles deletion of precompiled and source files (multiple possible sources for precompiled file)', withFixture('typescript-precompiled'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.assertIdle(async () => { + await this.rm('src/test.ts'); + await this.rm('build/test.js'); + }); + this.done(); + }, + }); +}); + +test('handles deletion of precompiled and source files (single possible source for precompiled file)', withFixture('typescript-precompiled'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.assertIdle(async () => { + await this.rm('src/test.ts'); + await this.rm('build/test.js'); + }); + this.done(); + }, + }, [], {env: {JUST_TS_EXTENSION: 'true'}}); +}); + +test('handles inline compilation', withFixture('typescript-inline'), async (t, fixture) => { + await fs.symlink(new URL('../../node_modules', import.meta.url), path.join(fixture.dir, 'node_modules'), 'junction'); + await fixture.watch({ + async 1({stats}) { + t.true(stats.passed.length > 0); + await this.touch('src/test.ts'); + return stats.passed; + }, + async 2({stats}, previousPassed) { + t.deepEqual(stats.passed, previousPassed); + this.done(); + }, + }); +}); + +test('ignores changes to compiled files with inline compilation', withFixture('typescript-inline'), async (t, fixture) => { + await fs.symlink(new URL('../../node_modules', import.meta.url), path.join(fixture.dir, 'node_modules'), 'junction'); + await fixture.watch({ + async 1({stats}) { + t.true(stats.passed.length > 0); + await this.assertIdle(async () => { + await this.touch('build/test.js'); + }); + this.done(); + }, + }); +}); From 0330ce091091fad1dbd7eb452a156439ff9e78de Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Sat, 1 Jul 2023 18:07:02 +0200 Subject: [PATCH 4/5] Move ignoredByWatcher to watchMode.ignoreChanges --- docs/06-configuration.md | 2 +- docs/recipes/watch-mode.md | 12 +++++++++++- lib/cli.js | 6 +++++- lib/globs.js | 2 +- test-tap/api.js | 2 +- .../fixtures/ignored-by-watcher/package.json | 4 +++- test/globs/snapshots/test.js.md | 4 ++-- test/globs/snapshots/test.js.snap | Bin 434 -> 438 bytes test/globs/test.js | 2 +- test/watch-mode/fixtures/basic/ava.config.js | 4 +++- .../watch-mode/fixtures/exclusive/ava.config.js | 4 +++- 11 files changed, 31 insertions(+), 11 deletions(-) diff --git a/docs/06-configuration.md b/docs/06-configuration.md index a7b3fe457..1ef433e0a 100644 --- a/docs/06-configuration.md +++ b/docs/06-configuration.md @@ -43,7 +43,7 @@ Arguments passed to the CLI will always take precedence over the CLI options con ## Options - `files`: an array of glob patterns to select test files. Files with an underscore prefix are ignored. By default only selects files with `cjs`, `mjs` & `js` extensions, even if the pattern matches other files. Specify `extensions` to allow other file extensions -- `ignoredByWatcher`: an array of glob patterns to match files that, even if changed, are ignored by the watcher. See the [watch mode recipe for details](https://github.com/avajs/ava/blob/main/docs/recipes/watch-mode.md) +- `watchMode`: See the [watch mode recipe for details](https://github.com/avajs/ava/blob/main/docs/recipes/watch-mode.md) - `match`: not typically useful in the `package.json` configuration, but equivalent to [specifying `--match` on the CLI](./05-command-line.md#running-tests-with-matching-titles) - `cache`: defaults to `true` to cache compiled files under `node_modules/.cache/ava`. If `false`, files are cached in a temporary directory instead - `concurrency`: max number of test files running at the same time (default: CPU cores) diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 09ff0f125..710ce8996 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -37,7 +37,17 @@ Otherwise, AVA 6 uses `fs.watch()`. Support for `recursive` mode is required. No By default AVA watches for changes to all files, except for those with a `.snap.md` extension, `ava.config.*` and files in [certain directories](https://github.com/novemberborn/ignore-by-default/blob/master/index.js) as provided by the [`ignore-by-default`] package. -You can configure additional patterns for files to ignore in the [`ava` section of your `package.json`, or `ava.config.*` file][config], using the `ignoredByWatcher` key. +With AVA 5, you can configure additional patterns for files to ignore in the [`ava` section of your `package.json`, or `ava.config.*` file][config], using the `ignoredByWatcher` key. + +With AVA 6, place these patterns within the `watchMode` object: + +```js +export default { + watchMode: { + ignoreChanges: ['coverage'], + }, +}; +``` If your tests write to disk they may trigger the watcher to rerun your tests. Configuring additional ignore patterns helps avoid this. diff --git a/lib/cli.js b/lib/cli.js index 969463429..456b2e9e4 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -327,6 +327,10 @@ export default async function loadCli() { // eslint-disable-line complexity exit('’watch’ must not be configured, use the --watch CLI flag instead.'); } + if (Object.hasOwn(conf, 'ignoredByWatcher')) { + exit('’ignoredByWatcher’ has moved to ’watchMode.ignoreChanges’.'); + } + if (!combined.tap && Object.keys(experiments).length > 0) { console.log(chalk.magenta(` ${figures.warning} Experiments are enabled. These are unsupported and may change or be removed at any time.`)); } @@ -380,7 +384,7 @@ export default async function loadCli() { // eslint-disable-line complexity let globs; try { - globs = normalizeGlobs({files: conf.files, ignoredByWatcher: conf.ignoredByWatcher, extensions, providers}); + globs = normalizeGlobs({files: conf.files, ignoredByWatcher: conf.watchMode?.ignoreChanges, extensions, providers}); } catch (error) { exit(error.message); } diff --git a/lib/globs.js b/lib/globs.js index 70bbdebd8..c789dcc97 100644 --- a/lib/globs.js +++ b/lib/globs.js @@ -39,7 +39,7 @@ export function normalizeGlobs({extensions, files: filePatterns, ignoredByWatche } if (ignoredByWatcherPatterns !== undefined && (!Array.isArray(ignoredByWatcherPatterns) || ignoredByWatcherPatterns.length === 0)) { - throw new Error('The ’ignoredByWatcher’ configuration must be an array containing glob patterns.'); + throw new Error('The ’watchMode.ignoreChanges’ configuration must be an array containing glob patterns.'); } const extensionPattern = buildExtensionPattern(extensions); diff --git a/test-tap/api.js b/test-tap/api.js index 6cc7daf89..29e748c16 100644 --- a/test-tap/api.js +++ b/test-tap/api.js @@ -17,7 +17,7 @@ async function apiCreator(options = {}) { options.concurrency = 2; options.extensions = options.extensions || ['cjs']; options.experiments = {}; - options.globs = normalizeGlobs({files: options.files, ignoredByWatcher: options.ignoredByWatcher, extensions: options.extensions, providers: []}); + options.globs = normalizeGlobs({files: options.files, ignoredByWatcher: options.watchMode?.ignoreChanges, extensions: options.extensions, providers: []}); const instance = new Api(options); return instance; diff --git a/test/globs/fixtures/ignored-by-watcher/package.json b/test/globs/fixtures/ignored-by-watcher/package.json index eb6f557ce..4679552cc 100644 --- a/test/globs/fixtures/ignored-by-watcher/package.json +++ b/test/globs/fixtures/ignored-by-watcher/package.json @@ -1,6 +1,8 @@ { "type": "module", "ava": { - "ignoredByWatcher": [] + "watchMode": { + "ignoreChanges": [] + } } } diff --git a/test/globs/snapshots/test.js.md b/test/globs/snapshots/test.js.md index c185640ff..2dc58c3bc 100644 --- a/test/globs/snapshots/test.js.md +++ b/test/globs/snapshots/test.js.md @@ -10,11 +10,11 @@ Generated by [AVA](https://avajs.dev). 'The ’files’ configuration must be an array containing glob patterns.' -## errors if top-level ignoredByWatcher is an empty array +## errors if watchMode.ignoreChanges is an empty array > fails with message - 'The ’ignoredByWatcher’ configuration must be an array containing glob patterns.' + 'The ’watchMode.ignoreChanges’ configuration must be an array containing glob patterns.' ## files can be filtered by directory diff --git a/test/globs/snapshots/test.js.snap b/test/globs/snapshots/test.js.snap index 1064b5c1a65b8ec96fb41355205ba10001f113d8..f2145552613b864976a4b02d73002547eeae6331 100644 GIT binary patch literal 438 zcmV;n0ZINrRzVkWTa5WyZUkkr3STv(qY@XT=9K)`G*6jPUvTe%u}+ z#zrSH?OE=n(b(-dj}aB3W4YqD=Tb|ZVyaApIrpBdc5PBTB4raUmBUNvGwhOcJf)I) z_m%pR`#3u?g4#0G#?nE?b^0^<>>d62l>Q6BfAzCInRpzef?!BO9|?>GD5Ry>8~YZ2 zIKh9K;V(dCGsK;+R-xX4+J3!g?Q72pnmD#r0 zY%*A3P+`yrHI+4}hHZzT1Sjga|B z;Fhj!Y9EUT00000000BElD|&FFc8LlDJ}nm+6W%t#0nINfr$ksHUltfC$SN48PPC7+5TV#wj7G;7? z=R1l9q%e_+{ETPyI*h0goyZlx9haHRQcRVOFz3#Z$!wbxk4Tw>OJ(s|x(@cpTAotr zdjBieGR?FhaWuT(F7A-|2>|~9a5@8+SNd0$&FQpbFXaV&;`>NoG(;f{#ZH^|zDHBv zFO}~l2)2FP^J^U%A=D2GKLF*f*=#acVE_zP0S?NhTIyrZucxxHaT0a?br4kBR;$He zjX}Vm>1%=wsQEdCuuwQIldy8R>&Uux*UnYfCzxfnT$Pvn$c2!_PzL9AC9rCQ^4OntlD|-U~0Ci!|tpET3 diff --git a/test/globs/test.js b/test/globs/test.js index 795377621..dae4d621c 100644 --- a/test/globs/test.js +++ b/test/globs/test.js @@ -12,7 +12,7 @@ test('errors if top-level files is an empty array', async t => { t.snapshot(cleanOutput(result.stderr), 'fails with message'); }); -test('errors if top-level ignoredByWatcher is an empty array', async t => { +test('errors if watchMode.ignoreChanges is an empty array', async t => { const options = { cwd: cwd('ignored-by-watcher'), }; diff --git a/test/watch-mode/fixtures/basic/ava.config.js b/test/watch-mode/fixtures/basic/ava.config.js index 618a8b186..cfa22d60a 100644 --- a/test/watch-mode/fixtures/basic/ava.config.js +++ b/test/watch-mode/fixtures/basic/ava.config.js @@ -1,3 +1,5 @@ export default { - ignoredByWatcher: ['ignored-by-watcher.js'], + watchMode: { + ignoreChanges: ['ignored-by-watcher.js'], + }, }; diff --git a/test/watch-mode/fixtures/exclusive/ava.config.js b/test/watch-mode/fixtures/exclusive/ava.config.js index 618a8b186..cfa22d60a 100644 --- a/test/watch-mode/fixtures/exclusive/ava.config.js +++ b/test/watch-mode/fixtures/exclusive/ava.config.js @@ -1,3 +1,5 @@ export default { - ignoredByWatcher: ['ignored-by-watcher.js'], + watchMode: { + ignoreChanges: ['ignored-by-watcher.js'], + }, }; From 58848608090c71da351b3904d5337034d5a07750 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 27 Jun 2023 21:32:26 +0200 Subject: [PATCH 5/5] Set up CI to test the new watcher * Run tests on macOS * Use Bash shell * Run watch mode test separately and serially --- .github/workflows/ci.yml | 8 +++++--- ava.config.js | 6 +++++- package.json | 3 +-- scripts/ci.sh | 9 +++++++++ scripts/test.sh | 8 ++++++++ 5 files changed, 28 insertions(+), 6 deletions(-) create mode 100755 scripts/ci.sh create mode 100755 scripts/test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a3612a32..3d7654762 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: node-version: [^16.18, ^18.16, ^20.3] - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v3 - name: Enable symlinks @@ -29,7 +29,8 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm - run: npm install --no-audit - - run: npm run cover + - run: ./scripts/ci.sh + shell: bash - uses: codecov/codecov-action@v3 with: files: coverage/lcov.info @@ -83,7 +84,8 @@ jobs: with: node-version-file: package.json - run: npm install --no-package-lock --no-audit - - run: npm run cover + - run: ./scripts/ci.sh + shell: bash xo: name: Lint source files diff --git a/ava.config.js b/ava.config.js index 57992596c..559513382 100644 --- a/ava.config.js +++ b/ava.config.js @@ -1,5 +1,9 @@ +import process from 'node:process'; + +const skipWatchMode = process.env.TEST_AVA_SKIP_WATCH_MODE ? ['!test/watch-mode/**'] : []; + export default { // eslint-disable-line import/no-anonymous-default-export - files: ['test/**', '!test/**/{fixtures,helpers}/**'], + files: ['test/**', '!test/**/{fixtures,helpers}/**', ...skipWatchMode], ignoredByWatcher: ['{coverage,docs,media,test-types,test-tap}/**'], environmentVariables: { AVA_FAKE_SCM_ROOT: '.fake-root', // This is an internal test flag. diff --git a/package.json b/package.json index 9caa52a58..ad3fa2e9b 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,7 @@ "node": "^16.18 || ^18.16 || ^20.3" }, "scripts": { - "cover": "c8 --report=none test-ava && c8 --report=none --no-clean tap && c8 report", - "test": "xo && tsc --noEmit && npm run -s cover" + "test": "./scripts/test.sh" }, "files": [ "entrypoints", diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 000000000..390570328 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -ex + +TEST_AVA_SKIP_WATCH_MODE=1 npx c8 --report=none npx test-ava +# Reduce concurrency and be generous with timeouts to give watch mode tests a +# better chance of succeeding in a CI environment. +npx c8 --report=none --no-clean npx test-ava --serial --timeout 30s test/watch-mode +npx c8 --report=none --no-clean npx tap +npx c8 report diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 000000000..ccc8bd9ab --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -ex + +npx xo +npx tsc --noEmit +npx c8 --report=none npx test-ava +npx c8 --report=none --no-clean npx tap +npx c8 report