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/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 4dc55ea08..710ce8996 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,13 +27,27 @@ 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. + +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. -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. @@ -30,7 +55,11 @@ 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. + +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 @@ -56,10 +85,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..d4e8c2b20 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) { @@ -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 new file mode 100644 index 000000000..643b11cf3 --- /dev/null +++ b/lib/ava5-watcher.js @@ -0,0 +1,453 @@ +import nodePath from 'node:path'; + +import chokidar from 'chokidar'; +import createDebug from 'debug'; + +import {chalk} from './chalk.js'; +import {applyTestFileFilter, classifyAva5Watcher as classify, getChokidarIgnorePatterns} from './globs.js'; + +const debug = createDebug('ava:watcher'); + +function rethrowAsync(error) { + // Don't swallow exceptions. Note that any + // expected error should already have been logged + setImmediate(() => { + throw error; + }); +} + +const MIN_DEBOUNCE_DELAY = 10; +const INITIAL_DEBOUNCE_DELAY = 100; +const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n'); + +class Debouncer { + constructor(watcher) { + this.watcher = watcher; + this.timer = null; + this.repeat = false; + } + + debounce(delay) { + if (this.timer) { + this.again = true; + return; + } + + delay = delay ? Math.max(delay, MIN_DEBOUNCE_DELAY) : INITIAL_DEBOUNCE_DELAY; + + const timer = setTimeout(async () => { + await this.watcher.busy; + // Do nothing if debouncing was canceled while waiting for the busy + // promise to fulfil + if (this.timer !== timer) { + return; + } + + if (this.again) { + this.timer = null; + this.again = false; + this.debounce(delay / 2); + } else { + this.watcher.runAfterChanges(); + this.timer = null; + this.again = false; + } + }, delay); + + this.timer = timer; + } + + cancel() { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + this.again = false; + } + } +} + +class TestDependency { + constructor(file, dependencies) { + this.file = file; + this.dependencies = dependencies; + } + + contains(dependency) { + return this.dependencies.includes(dependency); + } +} + +export default class Watcher { + constructor({api, filter = [], globs, projectDir, providers, reporter}) { + this.debouncer = new Debouncer(this); + + this.runVector = 0; + this.previousFiles = []; + this.globs = {cwd: projectDir, ...globs}; + + const patternFilters = filter.map(({pattern}) => pattern); + + this.providers = providers; + this.run = (specificFiles = [], updateSnapshots = false) => { + this.runVector++; + + let runOnlyExclusive = false; + if (specificFiles.length > 0) { + const exclusiveFiles = specificFiles.filter(file => this.filesWithExclusiveTests.includes(file)); + runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length; + if (runOnlyExclusive) { + // The test files that previously contained exclusive tests are always + // run, together with the remaining specific files. + const remainingFiles = specificFiles.filter(file => !exclusiveFiles.includes(file)); + specificFiles = [...this.filesWithExclusiveTests, ...remainingFiles]; + } + + if (filter.length > 0) { + specificFiles = applyTestFileFilter({ + cwd: projectDir, + expandDirectories: false, + filter: patternFilters, + testFiles: specificFiles, + treatFilterPatternsAsFiles: false, + }); + } + + this.pruneFailures(specificFiles); + } + + this.touchedFiles.clear(); + this.previousFiles = specificFiles; + this.busy = api.run({ + files: specificFiles, + filter, + runtimeOptions: { + previousFailures: this.sumPreviousFailures(this.runVector), + runOnlyExclusive, + firstRun: this.runVector === 1, + updateSnapshots: updateSnapshots === true, + }, + }) + .then(() => { + reporter.endRun(); + reporter.lineWriter.writeLine(END_MESSAGE); + }) + .catch(rethrowAsync); + }; + + this.testDependencies = []; + this.trackTestDependencies(api); + + this.temporaryFiles = new Set(); + this.touchedFiles = new Set(); + this.trackTouchedFiles(api); + + this.filesWithExclusiveTests = []; + this.trackExclusivity(api); + + this.filesWithFailures = []; + this.trackFailures(api); + + this.dirtyStates = {}; + this.watchFiles(); + this.rerunAll(); + } + + watchFiles() { + chokidar.watch(['**/*'], { + cwd: this.globs.cwd, + ignored: getChokidarIgnorePatterns(this.globs), + ignoreInitial: true, + }).on('all', (event, path) => { + if (event === 'add' || event === 'change' || event === 'unlink') { + debug('Detected %s of %s', event, path); + this.dirtyStates[nodePath.join(this.globs.cwd, path)] = event; + this.debouncer.debounce(); + } + }); + } + + trackTestDependencies(api) { + api.on('run', plan => { + plan.status.on('stateChange', evt => { + let dependencies; + if (evt.type === 'dependencies') { + dependencies = evt.dependencies; + } else if (evt.type === 'accessed-snapshots') { + dependencies = [evt.filename]; + } else { + return; + } + + dependencies = dependencies.filter(filePath => { + const {isIgnoredByWatcher} = classify(filePath, this.globs); + return !isIgnoredByWatcher; + }); + this.updateTestDependencies(evt.testFile, dependencies); + }); + }); + } + + updateTestDependencies(file, dependencies) { + // Ensure the rewritten test file path is included in the dependencies, + // since changes to non-rewritten paths are ignored. + for (const {main} of this.providers) { + const rewritten = main.resolveTestFile(file); + if (!dependencies.includes(rewritten)) { + dependencies = [rewritten, ...dependencies]; + } + } + + if (dependencies.length === 0) { + this.testDependencies = this.testDependencies.filter(dep => dep.file !== file); + return; + } + + const isUpdate = this.testDependencies.some(dep => { + if (dep.file !== file) { + return false; + } + + dep.dependencies = dependencies; + + return true; + }); + + if (!isUpdate) { + this.testDependencies.push(new TestDependency(file, dependencies)); + } + } + + trackTouchedFiles(api) { + api.on('run', plan => { + plan.status.on('stateChange', evt => { + if (evt.type !== 'touched-files') { + return; + } + + for (const file of evt.files.changedFiles) { + this.touchedFiles.add(file); + } + + for (const file of evt.files.temporaryFiles) { + this.temporaryFiles.add(file); + } + }); + }); + } + + trackExclusivity(api) { + api.on('run', plan => { + plan.status.on('stateChange', evt => { + if (evt.type !== 'worker-finished') { + return; + } + + const fileStats = plan.status.stats.byFile.get(evt.testFile); + const ranExclusiveTests = fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests; + this.updateExclusivity(evt.testFile, ranExclusiveTests); + }); + }); + } + + updateExclusivity(file, hasExclusiveTests) { + const index = this.filesWithExclusiveTests.indexOf(file); + + if (hasExclusiveTests && index === -1) { + this.filesWithExclusiveTests.push(file); + } else if (!hasExclusiveTests && index !== -1) { + this.filesWithExclusiveTests.splice(index, 1); + } + } + + trackFailures(api) { + api.on('run', plan => { + this.pruneFailures(plan.files); + + const currentVector = this.runVector; + plan.status.on('stateChange', evt => { + if (!evt.testFile) { + return; + } + + switch (evt.type) { + case 'hook-failed': + case 'internal-error': + case 'process-exit': + case 'test-failed': + case 'uncaught-exception': + case 'unhandled-rejection': + case 'worker-failed': { + this.countFailure(evt.testFile, currentVector); + break; + } + + default: { + break; + } + } + }); + }); + } + + pruneFailures(files) { + const toPrune = new Set(files); + this.filesWithFailures = this.filesWithFailures.filter(state => !toPrune.has(state.file)); + } + + countFailure(file, vector) { + const isUpdate = this.filesWithFailures.some(state => { + if (state.file !== file) { + return false; + } + + state.count++; + return true; + }); + + if (!isUpdate) { + this.filesWithFailures.push({ + file, + vector, + count: 1, + }); + } + } + + sumPreviousFailures(beforeVector) { + let total = 0; + + for (const state of this.filesWithFailures) { + if (state.vector < beforeVector) { + total += state.count; + } + } + + return total; + } + + cleanUnlinkedTests(unlinkedTests) { + for (const testFile of unlinkedTests) { + this.updateTestDependencies(testFile, []); + this.updateExclusivity(testFile, false); + this.pruneFailures([testFile]); + } + } + + observeStdin(stdin) { + stdin.resume(); + stdin.setEncoding('utf8'); + + stdin.on('data', async data => { + data = data.trim().toLowerCase(); + if (data !== 'r' && data !== 'rs' && data !== 'u') { + return; + } + + // Cancel the debouncer, it might rerun specific tests whereas *all* tests + // need to be rerun + this.debouncer.cancel(); + await this.busy; + // Cancel the debouncer again, it might have restarted while waiting for + // the busy promise to fulfil + this.debouncer.cancel(); + if (data === 'u') { + this.updatePreviousSnapshots(); + } else { + this.rerunAll(); + } + }); + } + + rerunAll() { + this.dirtyStates = {}; + this.run(); + } + + updatePreviousSnapshots() { + this.dirtyStates = {}; + this.run(this.previousFiles, true); + } + + runAfterChanges() { + const {dirtyStates} = this; + this.dirtyStates = {}; + + let dirtyPaths = Object.keys(dirtyStates).filter(path => { + if (this.touchedFiles.has(path)) { + debug('Ignoring known touched file %s', path); + this.touchedFiles.delete(path); + return false; + } + + // Unlike touched files, temporary files are never cleared. We may see + // adds and unlinks detected separately, so we track the temporary files + // as long as AVA is running. + if (this.temporaryFiles.has(path)) { + debug('Ignoring known temporary file %s', path); + return false; + } + + return true; + }); + + for (const {main} of this.providers) { + dirtyPaths = dirtyPaths.filter(path => { + if (main.ignoreChange(path)) { + debug('Ignoring changed file %s', path); + return false; + } + + return true; + }); + } + + const dirtyHelpersAndSources = []; + const addedOrChangedTests = []; + const unlinkedTests = []; + for (const filePath of dirtyPaths) { + const {isIgnoredByWatcher, isTest} = classify(filePath, this.globs); + if (!isIgnoredByWatcher) { + if (isTest) { + if (dirtyStates[filePath] === 'unlink') { + unlinkedTests.push(filePath); + } else { + addedOrChangedTests.push(filePath); + } + } else { + dirtyHelpersAndSources.push(filePath); + } + } + } + + this.cleanUnlinkedTests(unlinkedTests); + + // No need to rerun tests if the only change is that tests were deleted + if (unlinkedTests.length === dirtyPaths.length) { + return; + } + + if (dirtyHelpersAndSources.length === 0) { + // Run any new or changed tests + this.run(addedOrChangedTests); + return; + } + + // Try to find tests that depend on the changed source files + const testsByHelpersOrSource = dirtyHelpersAndSources.map(path => this.testDependencies.filter(dep => dep.contains(path)).map(dep => { + debug('%s is a dependency of %s', path, dep.file); + return dep.file; + })).filter(tests => tests.length > 0); + + // Rerun all tests if source files were changed that could not be traced to + // specific tests + if (testsByHelpersOrSource.length !== dirtyHelpersAndSources.length) { + debug('Files remain that cannot be traced to specific tests: %O', dirtyHelpersAndSources); + debug('Rerunning all tests'); + this.run(); + return; + } + + // Run all affected tests + this.run([...new Set([addedOrChangedTests, testsByHelpersOrSource].flat(2))]); + } +} diff --git a/lib/cli.js b/lib/cli.js index 88cc185b6..456b2e9e4 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}`); @@ -297,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.'); } } @@ -324,6 +323,14 @@ 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 (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.`)); } @@ -342,9 +349,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', }); @@ -376,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); } @@ -410,6 +418,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, @@ -432,7 +441,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,13 +451,13 @@ 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 => { 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 => { @@ -464,16 +473,53 @@ export default async function loadCli() { // eslint-disable-line complexity }); }); - if (combined.watch) { - const watcher = new Watcher({ - api, - filter, - globs, - projectDir, - providers, - reporter, - }); - watcher.observeStdin(process.stdin); + if (argv.watch) { + 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 { + 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; 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/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 4a751db6c..c789dcc97 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, @@ -26,6 +28,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(',')}}`; @@ -36,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); @@ -125,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}/**/*`), @@ -132,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 bf982f60d..eaf4220a2 100644 --- a/lib/provider-manager.js +++ b/lib/provider-manager.js @@ -1,19 +1,19 @@ 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) { +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}`); @@ -50,9 +51,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/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/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/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/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..2ad0aa668 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -1,476 +1,605 @@ +import fs from 'node:fs'; import nodePath from 'node:path'; +import process from 'node:process'; +import v8 from 'node:v8'; -import chokidar_ from 'chokidar'; +import {nodeFileTrace} from '@vercel/nft'; import createDebug from 'debug'; import {chalk} from './chalk.js'; -import {applyTestFileFilter, classify, getChokidarIgnorePatterns} from './globs.js'; +import {applyTestFileFilter, classify, buildIgnoreMatcher, findTests} from './globs.js'; +import {levels as providerLevels} from './provider-manager.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 - // expected error should already have been logged - setImmediate(() => { - throw error; - }); -} +// 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 MIN_DEBOUNCE_DELAY = 10; -const INITIAL_DEBOUNCE_DELAY = 100; const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n'); -class Debouncer { - constructor(watcher) { - this.watcher = watcher; - this.timer = null; - this.repeat = false; - } - - debounce(delay) { - if (this.timer) { - this.again = true; - return; +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; } - delay = delay ? Math.max(delay, MIN_DEBOUNCE_DELAY) : INITIAL_DEBOUNCE_DELAY; - - const timer = setTimeout(async () => { - await this.watcher.busy; - // Do nothing if debouncing was canceled while waiting for the busy - // promise to fulfil - if (this.timer !== timer) { - return; - } - - if (this.again) { - this.timer = null; - this.again = false; - this.debounce(delay / 2); - } else { - this.watcher.runAfterChanges(); - this.timer = null; - this.again = false; - } - }, delay); - - this.timer = timer; + throw error; } - cancel() { - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - this.again = false; - } - } + return true; } -class TestDependency { - constructor(file, dependencies) { - this.file = file; - this.dependencies = dependencies; - } - - contains(dependency) { - return this.dependencies.includes(dependency); +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); } } -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}; +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 patternFilters = filter.map(({pattern}) => pattern); - - 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; - if (specificFiles.length > 0) { - const exclusiveFiles = specificFiles.filter(file => this.filesWithExclusiveTests.includes(file)); - runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length; - if (runOnlyExclusive) { - // The test files that previously contained exclusive tests are always - // run, together with the remaining specific files. - const remainingFiles = specificFiles.filter(file => !exclusiveFiles.includes(file)); - specificFiles = [...this.filesWithExclusiveTests, ...remainingFiles]; - } + const statsCache = new Map(); + const fileStats = path => { + if (statsCache.has(path)) { + return statsCache.get(path); // N.B. `undefined` is a valid value! + } - if (filter.length > 0) { - specificFiles = applyTestFileFilter({ - cwd: projectDir, - expandDirectories: false, - filter: patternFilters, - testFiles: specificFiles, - treatFilterPatternsAsFiles: false, - }); + 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; } - this.pruneFailures(specificFiles); - } - - this.touchedFiles.clear(); - this.previousFiles = specificFiles; - this.busy = api.run({ - files: specificFiles, - filter, - runtimeOptions: { - clearLogOnNextRun, - previousFailures: this.sumPreviousFailures(this.runVector), - runOnlyExclusive, - runVector: this.runVector, - updateSnapshots: updateSnapshots === true, - }, - }) - .then(runStatus => { - 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; + case 'touched-files': { + for (const file of evt.files.changedFiles) { + touchedFiles.add(nodePath.relative(projectDir, file)); } - }) - .catch(rethrowAsync); - }; - this.testDependencies = []; - this.trackTestDependencies(api); + for (const file of evt.files.temporaryFiles) { + temporaryFiles.add(nodePath.relative(projectDir, file)); + } - this.temporaryFiles = new Set(); - this.touchedFiles = new Set(); - this.trackTouchedFiles(api); + break; + } - this.filesWithExclusiveTests = []; - this.trackExclusivity(api); + 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; + } - this.filesWithFailures = []; - this.trackFailures(api); + 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)); + } - this.dirtyStates = {}; - this.watchFiles(); - this.rerunAll(); - } + break; + } - watchFiles() { - chokidar.watch(['**/*'], { - cwd: this.globs.cwd, - ignored: getChokidarIgnorePatterns(this.globs), - ignoreInitial: true, - }).on('all', (event, path) => { - if (event === 'add' || event === 'change' || event === 'unlink') { - debug('Detected %s of %s', event, path); - this.dirtyStates[nodePath.join(this.globs.cwd, path)] = event; - this.debouncer.debounce(); + default: { + break; + } } }); - } + }); - trackTestDependencies(api) { - api.on('run', plan => { - plan.status.on('stateChange', evt => { - if (evt.type !== 'dependencies') { - return; - } + // State for subsequent test runs. + let signalChanged; + let changed = Promise.resolve({}); + let firstRun = true; + let runAll = true; + let updateSnapshots = false; - const dependencies = evt.dependencies.filter(filePath => { - const {isIgnoredByWatcher} = classify(filePath, this.globs); - return !isIgnoredByWatcher; - }); - this.updateTestDependencies(evt.testFile, dependencies); - }); + 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; + } - updateTestDependencies(file, dependencies) { - // Ensure the rewritten test file path is included in the dependencies, - // since changes to non-rewritten paths are ignored. - for (const {main} of this.providers) { - const rewritten = main.resolveTestFile(file); - if (!dependencies.includes(rewritten)) { - dependencies = [rewritten, ...dependencies]; - } + // Equally, if tests are currently running, then keep accumulating changes. + // The timer is refreshed after tests finish running. + if (testsAreRunning) { + takeCoverageForSelfTests?.(); + return; } - if (dependencies.length === 0) { - this.testDependencies = this.testDependencies.filter(dep => dep.file !== file); + // If the file tracer is still analyzing dependencies, wait for that to + // complete. + if (fileTracer.busy !== null) { + fileTracer.busy.then(() => debounce.refresh()); + takeCoverageForSelfTests?.(); return; } - const isUpdate = this.testDependencies.some(dep => { - if (dep.file !== file) { + // Identify the changes. + const changes = [...dirtyPaths].filter(path => { + if (temporaryFiles.has(path)) { + debug('Ignoring known temporary file %s', path); return false; } - dep.dependencies = dependencies; + if (touchedFiles.has(path)) { + debug('Ignoring known touched file %s', path); + return false; + } - return true; - }); + for (const {main} of providers) { + switch (main.interpretChange(nodePath.join(projectDir, path))) { + case main.changeInterpretations.ignoreCompiled: { + debug('Ignoring compilation output %s', path); + return false; + } - if (!isUpdate) { - this.testDependencies.push(new TestDependency(file, dependencies)); - } - } + 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; + } - trackTouchedFiles(api) { - api.on('run', plan => { - plan.status.on('stateChange', evt => { - if (evt.type !== 'touched-files') { - return; + default: { + continue; + } } + } + + if (isIgnored(path)) { + debug('%s is ignored by patterns', path); + return false; + } + + return true; + }).flatMap(path => { + const change = changeFromPath(path); - for (const file of evt.files.changedFiles) { - this.touchedFiles.add(file); + for (const {main} of providers) { + const sources = main.resolvePossibleOutOfBandCompilationSources(nodePath.join(projectDir, path)); + if (sources === null) { + continue; } - for (const file of evt.files.temporaryFiles) { - this.temporaryFiles.add(file); + 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; }); - } - trackExclusivity(api) { - api.on('run', plan => { - plan.status.on('stateChange', evt => { - if (evt.type !== 'worker-finished') { - return; + // 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); + } + } + } - const fileStats = plan.status.stats.byFile.get(evt.testFile); - const ranExclusiveTests = fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests; - this.updateExclusivity(evt.testFile, ranExclusiveTests); + // 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, }); - }); - } + } - updateExclusivity(file, hasExclusiveTests) { - const index = this.filesWithExclusiveTests.indexOf(file); + 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); + } - if (hasExclusiveTests && index === -1) { - this.filesWithExclusiveTests.push(file); - } else if (!hasExclusiveTests && index !== -1) { - this.filesWithExclusiveTests.splice(index, 1); + signalChanged({runOnlyExclusive, testFiles}); } - } - trackFailures(api) { - api.on('run', plan => { - this.pruneFailures(plan.files); + takeCoverageForSelfTests?.(); + }, 100).unref(); - const currentVector = this.runVector; - plan.status.on('stateChange', evt => { - if (!evt.testFile) { - return; - } + // 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(); + } + }); - switch (evt.type) { - case 'hook-failed': - case 'internal-error': - case 'process-exit': - case 'test-failed': - case 'uncaught-exception': - case 'unhandled-rejection': - case 'worker-failed': { - this.countFailure(evt.testFile, currentVector); - break; - } + abortSignal.addEventListener('abort', () => { + signalChanged?.({}); + }); - default: { - break; - } - } - }); - }); + // 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; - pruneFailures(files) { - const toPrune = new Set(files); - this.filesWithFailures = this.filesWithFailures.filter(state => !toPrune.has(state.file)); + constructor(path) { + this.path = path; } - countFailure(file, vector) { - const isUpdate = this.filesWithFailures.some(state => { - if (state.file !== file) { - return false; - } + get parents() { + return this.#parents.keys(); + } - state.count++; - return true; - }); + addChild(node) { + this.#children.set(node.path, node); + node.#addParent(this); + } - if (!isUpdate) { - this.filesWithFailures.push({ - file, - vector, - count: 1, - }); - } + #addParent(node) { + this.#parents.set(node.path, node); } - sumPreviousFailures(beforeVector) { - let total = 0; + prune() { + for (const child of this.#children.values()) { + child.#removeParent(this); + } - for (const state of this.filesWithFailures) { - if (state.vector < beforeVector) { - total += state.count; - } + for (const parent of this.#parents.values()) { + parent.#removeChild(this); } + } - return total; + #removeChild(node) { + this.#children.delete(node.path); } - cleanUnlinkedTests(unlinkedTests) { - for (const testFile of unlinkedTests) { - this.updateTestDependencies(testFile, []); - this.updateExclusivity(testFile, false); - this.pruneFailures([testFile]); - } + #removeParent(node) { + this.#parents.delete(node.path); } +} - observeStdin(stdin) { - stdin.resume(); - stdin.setEncoding('utf8'); +class Tree extends Map { + get(path) { + if (!this.has(path)) { + this.set(path, new Node(path)); + } - stdin.on('data', async data => { - data = data.trim().toLowerCase(); - if (data !== 'r' && data !== 'rs' && data !== 'u') { - return; - } + return super.get(path); + } - // Cancel the debouncer, it might rerun specific tests whereas *all* tests - // need to be rerun - this.debouncer.cancel(); - await this.busy; - // 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 { - this.rerunAll(); - } - }); + delete(path) { + const node = this.get(path); + node?.prune(); + super.delete(path); } +} - rerunAll() { - this.dirtyStates = {}; - this.run(); +// 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; + }); } - updatePreviousSnapshots() { - this.dirtyStates = {}; - this.run(this.previousFiles, true); + get busy() { + return this.#pendingTrace; } - runAfterChanges() { - const {dirtyStates} = this; - this.dirtyStates = {}; + traceToTestFile(startingPath) { + const todo = [startingPath]; + const testFiles = new Set(); + const visited = new Set(); + for (const path of todo) { + if (visited.has(path)) { + continue; + } - let dirtyPaths = Object.keys(dirtyStates).filter(path => { - if (this.touchedFiles.has(path)) { - debug('Ignoring known touched file %s', path); - this.touchedFiles.delete(path); - return false; + visited.add(path); + + const node = this.#tree.get(path); + if (node === undefined) { + continue; } - // Unlike touched files, temporary files are never cleared. We may see - // adds and unlinks detected separately, so we track the temporary files - // as long as AVA is running. - if (this.temporaryFiles.has(path)) { - debug('Ignoring known temporary file %s', path); - return false; + if (node.isTest) { + testFiles.add(node.path); + } else { + todo.push(...node.parents); } + } - return true; + 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; + }); + } }); - for (const {main} of this.providers) { - dirtyPaths = dirtyPaths.filter(path => { - if (main.ignoreChange(path)) { - debug('Ignoring changed file %s', path); - return false; - } + this.#pendingTrace = current; + } - return true; - }); - } + async #update(changes) { + await this.#pendingTrace; // Guard against race conditions. + this.#signalUpdateRunning(); - const dirtyHelpersAndSources = []; - const addedOrChangedTests = []; - const unlinkedTests = []; - for (const filePath of dirtyPaths) { - const {isIgnoredByWatcher, isTest} = classify(filePath, this.globs); - if (!isIgnoredByWatcher) { + 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) { - if (dirtyStates[filePath] === 'unlink') { - unlinkedTests.push(filePath); - } else { - addedOrChangedTests.push(filePath); - } - } else { - dirtyHelpersAndSources.push(filePath); + 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); } - this.cleanUnlinkedTests(unlinkedTests); + // Remove deleted files from the tree. + for (const path of deletedFiles) { + this.#tree.delete(path); + } - // No need to rerun tests if the only change is that tests were deleted - if (unlinkedTests.length === dirtyPaths.length) { - return; + // Create a new cache if the old one can't be reused. + if (!reuseCache) { + this.#cache = Object.create(null); } - if (dirtyHelpersAndSources.length === 0) { - // Run any new or changed tests - this.run(addedOrChangedTests); + // If all changes are deletions then there is no more work to do. + if (filesToTrace.size === 0) { return; } - // Try to find tests that depend on the changed source files - const testsByHelpersOrSource = dirtyHelpersAndSources.map(path => this.testDependencies.filter(dep => dep.contains(path)).map(dep => { - debug('%s is a dependency of %s', path, dep.file); - return dep.file; - })).filter(tests => tests.length > 0); - - // Rerun all tests if source files were changed that could not be traced to - // specific tests - if (testsByHelpersOrSource.length !== dirtyHelpersAndSources.length) { - debug('Files remain that cannot be traced to specific tests: %O', dirtyHelpersAndSources); - debug('Rerunning all tests'); - this.run(); - 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); + } } - // Run all affected tests - this.run([...new Set([addedOrChangedTests, testsByHelpersOrSource].flat(2))]); + // 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/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..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", @@ -17,7 +18,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", @@ -58,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", @@ -67,7 +68,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 +77,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 } } }, @@ -111,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": { @@ -1016,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", @@ -1048,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", @@ -1183,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": { @@ -1215,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", @@ -1393,8 +1452,7 @@ "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 + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, "node_modules/acorn": { "version": "8.8.2", @@ -1434,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", @@ -1516,6 +1585,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" @@ -1536,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", @@ -1669,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", @@ -1684,13 +1776,13 @@ "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", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "devOptional": true, "engines": { "node": ">=8" } @@ -1704,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", @@ -1713,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" @@ -2002,6 +2101,7 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "devOptional": true, "funding": [ { "type": "individual", @@ -2024,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", @@ -2207,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" } @@ -2233,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", @@ -2260,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", @@ -2565,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", @@ -3722,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", @@ -3858,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", @@ -3981,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", @@ -4039,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", @@ -4142,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", @@ -4244,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", @@ -4343,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", @@ -4398,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", @@ -4493,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" @@ -4502,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", @@ -4585,6 +4806,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" }, @@ -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", @@ -6148,10 +6406,9 @@ } }, "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, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", "dependencies": { "abbrev": "1" }, @@ -6159,7 +6416,7 @@ "nopt": "bin/nopt.js" }, "engines": { - "node": "*" + "node": ">=6" } }, "node_modules/normalize-package-data": { @@ -6181,6 +6438,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" } @@ -6212,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", @@ -6506,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", @@ -6563,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" } @@ -6921,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" } @@ -7432,10 +7707,24 @@ "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", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "devOptional": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -7599,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" }, @@ -7636,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", @@ -7650,8 +7937,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "peer": true + ] }, "node_modules/safe-regex": { "version": "2.1.1", @@ -7747,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", @@ -7788,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", @@ -7925,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", @@ -10108,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", @@ -10291,17 +10607,10 @@ "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/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", @@ -10560,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", @@ -10607,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", @@ -10721,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", @@ -10778,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", @@ -10881,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 2a83b5f68..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", @@ -81,6 +80,7 @@ "typescript" ], "dependencies": { + "@vercel/nft": "^0.22.6", "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "ansi-styles": "^6.2.1", @@ -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", @@ -127,8 +126,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", @@ -136,18 +136,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/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 diff --git a/test-tap/api.js b/test-tap/api.js index 0697b2257..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; @@ -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/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-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/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 c83449d4b..000000000 --- a/test-tap/watcher.js +++ /dev/null @@ -1,1507 +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 = { - clearLogOnNextRun: false, - previousFailures: 0, - runOnlyExclusive: false, - runVector: 1, - 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'], - 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', '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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - - // 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, - clearLogOnNextRun: false, - runVector: 2, - }}]); - - change(); - return debounce(); - }).then(() => { - t.strictSame(api.run.thirdCall.args, [{files: [], filter: [], runtimeOptions: { - ...defaultApiOptions, - clearLogOnNextRun: true, - runVector: 3, - }}]); - }); - }); - } - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - - // 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, runVector: 2}}]); - - 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}}]); - }); - }); - } - - 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, runVector: 3}}]); - - 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}}]); - }); - - 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, - clearLogOnNextRun: false, - runVector: 2, - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }, - }]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - } - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - - 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, - clearLogOnNextRun: true, - runVector: 2, - }}]); - }); - }); - }); -}); 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/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 1064b5c1a..f21455526 100644 Binary files a/test/globs/snapshots/test.js.snap and b/test/globs/snapshots/test.js.snap differ 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/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..cfa22d60a --- /dev/null +++ b/test/watch-mode/fixtures/basic/ava.config.js @@ -0,0 +1,5 @@ +export default { + watchMode: { + ignoreChanges: ['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..cfa22d60a --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/ava.config.js @@ -0,0 +1,5 @@ +export default { + watchMode: { + ignoreChanges: ['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(); + }, + }); +});