From 426ac225010c0114136c8a5164b33a1b3330bae9 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 23 Jun 2023 16:46:52 +0200 Subject: [PATCH] Add a brand new watcher Rely on recursive fs.watch(), rather than Chokidar. On Linux this is supported from Node.js 20 onwards. It won't work for network shares and Docker volume mounts which would require polling, we'll find out if that's a problem or not. (For now, the previous implementation is still available.) Use @vercel/nft to perform static dependency analysis, supporting ESM and CJS imports for JavaScript & TypeScript source files. This is a huge improvement over the previous runtime tracking of CJS imports, which did not support ESM. Rewrite the change handling logic to be easier to follow (though it's still pretty complicated). Improve integration with `@ava/typescript`. The watcher can now detect a change to a TypeScript source file, then wait for the corresponding build output to change before re-running tests. --- docs/recipes/watch-mode.md | 4 + lib/api.js | 2 +- lib/ava5-watcher.js | 2 +- lib/cli.js | 28 +- lib/glob-helpers.cjs | 14 +- lib/globs.js | 12 + lib/provider-manager.js | 17 +- lib/run-status.js | 5 + lib/watcher.js | 605 ++++++++++++++++++ package-lock.json | 466 +++++++++++++- package.json | 1 + test-tap/globs.js | 20 +- test/config/integration.js | 2 +- test/helpers/exec.js | 131 ++-- .../fixtures/fixed-snapshot-dir/test.js | 2 +- .../fixtures/no-snapshots/test.js | 2 +- .../fixtures/only-test/test.js | 2 +- .../snapshot-removal/fixtures/removal/test.js | 2 +- .../fixtures/skipped-snapshots-in-try/test.js | 2 +- .../fixtures/skipped-snapshots/test.js | 2 +- .../fixtures/skipped-tests/test.js | 2 +- .../fixtures/snapshot-dir/test/test.js | 2 +- test/snapshot-removal/fixtures/try/test.js | 2 +- test/snapshot-tests/fixtures/corrupt/test.js | 2 +- test/snapshot-tests/fixtures/large/test.js | 2 +- .../fixtures/multiline-snapshot-label/test.js | 2 +- .../normalized-title-in-snapshots/test.js | 2 +- .../normalized-title-in-stdout/test.js | 2 +- .../fixtures/adding-skipped-snapshots/test.js | 2 +- .../fixtures/adding-snapshots/test.js | 2 +- .../fixtures/adding-test/test.js | 2 +- .../fixtures/changing-label/test.js | 2 +- .../fixtures/changing-title/test.js | 2 +- .../fixtures/commit-skip/test.js | 2 +- .../fixtures/discard-skip/test.js | 2 +- .../fixtures/filling-in-blanks/test.js | 2 +- .../fixtures/first-run/test.js | 2 +- .../fixtures/invalid-snapfile/test.js | 2 +- .../fixtures/removing-all-snapshots/test.js | 2 +- .../fixtures/removing-snapshots/test.js | 2 +- .../fixtures/removing-test/test.js | 2 +- .../fixtures/reorder/test.js | 2 +- .../fixtures/select-test-update/test.js | 2 +- .../fixtures/skipping-snapshot-update/test.js | 2 +- .../fixtures/skipping-snapshot/test.js | 2 +- .../fixtures/skipping-test-update/test.js | 2 +- .../fixtures/skipping-test/test.js | 2 +- test/watch-mode/availability.js | 28 + test/watch-mode/basic-functionality.js | 80 +++ test/watch-mode/fixtures/basic/ava.config.js | 3 + .../fixtures/basic/ignored-by-watcher.js | 3 + .../fixtures/basic/not-depended-on.js | 3 + test/watch-mode/fixtures/basic/package.json | 3 + test/watch-mode/fixtures/basic/source.js | 1 + test/watch-mode/fixtures/basic/source.test.js | 7 + test/watch-mode/fixtures/basic/test.js | 9 + test/watch-mode/fixtures/exclusive/a.test.js | 9 + .../fixtures/exclusive/ava.config.js | 3 + test/watch-mode/fixtures/exclusive/b.test.js | 9 + test/watch-mode/fixtures/exclusive/c.test.js | 9 + .../fixtures/exclusive/package.json | 3 + test/watch-mode/helpers/watch.js | 149 +++++ test/watch-mode/scenarios.js | 113 ++++ 63 files changed, 1675 insertions(+), 132 deletions(-) create mode 100644 lib/watcher.js create mode 100644 test/watch-mode/availability.js create mode 100644 test/watch-mode/basic-functionality.js create mode 100644 test/watch-mode/fixtures/basic/ava.config.js create mode 100644 test/watch-mode/fixtures/basic/ignored-by-watcher.js create mode 100644 test/watch-mode/fixtures/basic/not-depended-on.js create mode 100644 test/watch-mode/fixtures/basic/package.json create mode 100644 test/watch-mode/fixtures/basic/source.js create mode 100644 test/watch-mode/fixtures/basic/source.test.js create mode 100644 test/watch-mode/fixtures/basic/test.js create mode 100644 test/watch-mode/fixtures/exclusive/a.test.js create mode 100644 test/watch-mode/fixtures/exclusive/ava.config.js create mode 100644 test/watch-mode/fixtures/exclusive/b.test.js create mode 100644 test/watch-mode/fixtures/exclusive/c.test.js create mode 100644 test/watch-mode/fixtures/exclusive/package.json create mode 100644 test/watch-mode/helpers/watch.js create mode 100644 test/watch-mode/scenarios.js diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 3b6935f20..c2b82644c 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -29,6 +29,8 @@ AVA 5 uses [`chokidar`] as the file watcher. Note that even if you see warnings The same applies with AVA 6 when using the `ava5+chokidar` watcher. However you'll need to install `chokidar` separately. +Otherwise, AVA 6 uses `fs.watch()`. Support for `recursive` mode is required. Note that this has only become available on Linux since Node.js 20. [Other caveats apply](https://nodejs.org/api/fs.html#caveats), for example this won't work well on network filesystems and Docker host mounts. + ## Ignoring changes By default AVA watches for changes to all files, except for those with a `.snap.md` extension, `ava.config.*` and files in [certain directories](https://github.com/novemberborn/ignore-by-default/blob/master/index.js) as provided by the [`ignore-by-default`] package. @@ -43,6 +45,8 @@ AVA tracks which source files your test files depend on. If you change such a de AVA 5 (and the `ava5+chokidar` watcher in AVA 6) spies on `require()` calls to track dependencies. Custom extensions and transpilers are supported, provided you [added them in your `package.json` or `ava.config.*` file][config], and not from inside your test file. +With AVA 6, dependency tracking works for `require()` and `import` syntax, as supported by [@vercel/nft](https://github.com/vercel/nft). `import()` is supported but dynamic paths such as `import(myVariable)` are not. + Files accessed using the `fs` module are not tracked. ## Watch mode and the `.only` modifier diff --git a/lib/api.js b/lib/api.js index 797c750f2..d4e8c2b20 100644 --- a/lib/api.js +++ b/lib/api.js @@ -315,7 +315,7 @@ export default class Api extends Emittery { } timeoutTrigger.discard(); - return runStatus; + return runStatus.end(); } _getLocalCacheDir() { diff --git a/lib/ava5-watcher.js b/lib/ava5-watcher.js index c3f4dca92..643b11cf3 100644 --- a/lib/ava5-watcher.js +++ b/lib/ava5-watcher.js @@ -4,7 +4,7 @@ import chokidar from 'chokidar'; import createDebug from 'debug'; import {chalk} from './chalk.js'; -import {applyTestFileFilter, classify, getChokidarIgnorePatterns} from './globs.js'; +import {applyTestFileFilter, classifyAva5Watcher as classify, getChokidarIgnorePatterns} from './globs.js'; const debug = createDebug('ava:watcher'); diff --git a/lib/cli.js b/lib/cli.js index 303dda64a..409b2ad87 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -453,7 +453,7 @@ export default async function loadCli() { // eslint-disable-line complexity api.on('run', plan => { reporter.startRun(plan); - if (process.env.AVA_EMIT_RUN_STATUS_OVER_IPC === 'I\'ll find a payphone baby / Take some time to talk to you') { + if (process.env.TEST_AVA) { const bufferedSend = controlFlow(process); plan.status.on('stateChange', evt => { @@ -492,7 +492,31 @@ export default async function loadCli() { // eslint-disable-line complexity exit('The "watcher" option must be set to "ava5+chokidar"'); } } else { - exit('TODO'); + const {available, start} = await import('./watcher.js'); + if (!available(projectDir)) { + exit('Watch mode requires support for recursive fs.watch()'); + return; + } + + const abortController = new AbortController(); + process.on('message', message => { + if (message === 'abort-watcher') { + abortController.abort(); + v8.takeCoverage(); + } + }); + process.channel?.unref(); + + start({ + api, + filter, + globs, + projectDir, + providers, + reporter, + stdin: process.stdin, + signal: abortController.signal, + }); } } else { let debugWithoutSpecificFile = false; diff --git a/lib/glob-helpers.cjs b/lib/glob-helpers.cjs index f093563c5..5c869e5b9 100644 --- a/lib/glob-helpers.cjs +++ b/lib/glob-helpers.cjs @@ -46,12 +46,21 @@ const processMatchingPatterns = input => { exports.processMatchingPatterns = processMatchingPatterns; +function classify(file, {cwd, extensions, filePatterns}) { + file = normalizeFileForMatching(cwd, file); + return { + isTest: hasExtension(extensions, file) && !isHelperish(file) && filePatterns.length > 0 && matches(file, filePatterns), + }; +} + +exports.classify = classify; + const matchesIgnorePatterns = (file, patterns) => { const {matchNoIgnore} = processMatchingPatterns(patterns); return matchNoIgnore(file) || defaultMatchNoIgnore(file); }; -function classify(file, {cwd, extensions, filePatterns, ignoredByWatcherPatterns}) { +function classifyAva5Watcher(file, {cwd, extensions, filePatterns, ignoredByWatcherPatterns}) { file = normalizeFileForMatching(cwd, file); return { isIgnoredByWatcher: matchesIgnorePatterns(file, ignoredByWatcherPatterns), @@ -59,7 +68,8 @@ function classify(file, {cwd, extensions, filePatterns, ignoredByWatcherPatterns }; } -exports.classify = classify; +// TODO: Delete along with ava5+chokidar watcher. +exports.classifyAva5Watcher = classifyAva5Watcher; const hasExtension = (extensions, file) => extensions.includes(path.extname(file).slice(1)); diff --git a/lib/globs.js b/lib/globs.js index c1a88cf7b..70bbdebd8 100644 --- a/lib/globs.js +++ b/lib/globs.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {globby, globbySync} from 'globby'; +import picomatch from 'picomatch'; import { defaultIgnorePatterns, @@ -13,6 +14,7 @@ import { export { classify, + classifyAva5Watcher, isHelperish, matches, normalizePattern, @@ -126,6 +128,7 @@ export async function findTests({cwd, extensions, filePatterns}) { return files.filter(file => !path.basename(file).startsWith('_')); } +// TODO: Delete along with ava5+chokidar watcher. export function getChokidarIgnorePatterns({ignoredByWatcherPatterns}) { return [ ...defaultIgnorePatterns.map(pattern => `${pattern}/**/*`), @@ -133,6 +136,15 @@ export function getChokidarIgnorePatterns({ignoredByWatcherPatterns}) { ]; } +export function buildIgnoreMatcher({ignoredByWatcherPatterns}) { + const patterns = [ + ...defaultIgnorePatterns.map(pattern => `${pattern}/**/*`), + ...ignoredByWatcherPatterns.filter(pattern => !pattern.startsWith('!')), + ]; + + return picomatch(patterns, {dot: true}); +} + export function applyTestFileFilter({ // eslint-disable-line complexity cwd, expandDirectories = true, diff --git a/lib/provider-manager.js b/lib/provider-manager.js index bf982f60d..4b31af3bd 100644 --- a/lib/provider-manager.js +++ b/lib/provider-manager.js @@ -1,17 +1,17 @@ import * as globs from './globs.js'; import pkg from './pkg.cjs'; -const levels = { +export const levels = { // As the protocol changes, comparing levels by integer allows AVA to be - // compatible with different versions. Currently there is only one supported - // version, so this is effectively unused. The infrastructure is retained for - // future use. - levelIntegersAreCurrentlyUnused: 0, + // compatible with different versions. + ava3Stable: 1, + ava6: 2, }; -const levelsByProtocol = { - 'ava-3.2': levels.levelIntegersAreCurrentlyUnused, -}; +const levelsByProtocol = Object.assign(Object.create(null), { + 'ava-3.2': levels.ava3Stable, + 'ava-6': levels.ava6, +}); async function load(providerModule, projectDir) { const ava = {version: pkg.version}; @@ -50,7 +50,6 @@ async function load(providerModule, projectDir) { } const providerManager = { - levels, async typescript(projectDir) { return load('@ava/typescript', projectDir); }, diff --git a/lib/run-status.js b/lib/run-status.js index 84713456a..f58c9bcbc 100644 --- a/lib/run-status.js +++ b/lib/run-status.js @@ -209,6 +209,11 @@ export default class RunStatus extends Emittery { this.emit('stateChange', event); } + end() { + this.emitStateChange({type: 'end'}); + return this; + } + suggestExitCode(circumstances) { if (this.emptyParallelRun) { return 0; diff --git a/lib/watcher.js b/lib/watcher.js new file mode 100644 index 000000000..2ad0aa668 --- /dev/null +++ b/lib/watcher.js @@ -0,0 +1,605 @@ +import fs from 'node:fs'; +import nodePath from 'node:path'; +import process from 'node:process'; +import v8 from 'node:v8'; + +import {nodeFileTrace} from '@vercel/nft'; +import createDebug from 'debug'; + +import {chalk} from './chalk.js'; +import {applyTestFileFilter, classify, buildIgnoreMatcher, findTests} from './globs.js'; +import {levels as providerLevels} from './provider-manager.js'; + +const debug = createDebug('ava:watcher'); + +// In order to get reliable code coverage for the tests of the watcher, we need +// to make Node.js write out interim reports in various places. +const takeCoverageForSelfTests = process.env.TEST_AVA ? v8.takeCoverage : undefined; + +const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n'); + +export function available(projectDir) { + try { + fs.watch(projectDir, {recursive: true, signal: AbortSignal.abort()}); + } catch (error) { + if (error.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') { + return false; + } + + throw error; + } + + return true; +} + +export async function start({api, filter, globs, projectDir, providers, reporter, stdin, signal}) { + providers = providers.filter(({level}) => level >= providerLevels.ava6); + for await (const {files, ...runtimeOptions} of plan({api, filter, globs, projectDir, providers, stdin, abortSignal: signal})) { + await api.run({files, filter, runtimeOptions}); + reporter.endRun(); + reporter.lineWriter.writeLine(END_MESSAGE); + } +} + +async function * plan({api, filter, globs, projectDir, providers, stdin, abortSignal}) { + const fileTracer = new FileTracer({base: projectDir}); + const isIgnored = buildIgnoreMatcher(globs); + const patternFilters = filter.map(({pattern}) => pattern); + + const statsCache = new Map(); + const fileStats = path => { + if (statsCache.has(path)) { + return statsCache.get(path); // N.B. `undefined` is a valid value! + } + + const stats = fs.statSync(nodePath.join(projectDir, path), {throwIfNoEntry: false}); + statsCache.set(path, stats); + return stats; + }; + + const fileExists = path => fileStats(path) !== undefined; + const cwdAndGlobs = {cwd: projectDir, ...globs}; + const changeFromPath = path => { + const {isTest} = classify(path, cwdAndGlobs); + const stats = fileStats(path); + return {path, isTest, exists: stats !== undefined, isFile: stats?.isFile() ?? false}; + }; + + // Begin a file trace in the background. + fileTracer.update(findTests(cwdAndGlobs).then(testFiles => testFiles.map(path => ({ + path: nodePath.relative(projectDir, path), + isTest: true, + exists: true, + })))); + + // State tracked for test runs. + const filesWithExclusiveTests = new Set(); + const touchedFiles = new Set(); + const temporaryFiles = new Set(); + const failureCounts = new Map(); + + // Observe all test runs. + api.on('run', ({status}) => { + status.on('stateChange', evt => { + switch (evt.type) { + case 'accessed-snapshots': { + fileTracer.addDependency(nodePath.relative(projectDir, evt.testFile), nodePath.relative(projectDir, evt.filename)); + break; + } + + case 'touched-files': { + for (const file of evt.files.changedFiles) { + touchedFiles.add(nodePath.relative(projectDir, file)); + } + + for (const file of evt.files.temporaryFiles) { + temporaryFiles.add(nodePath.relative(projectDir, file)); + } + + break; + } + + case 'hook-failed': + case 'internal-error': + case 'process-exit': + case 'test-failed': + case 'uncaught-exception': + case 'unhandled-rejection': + case 'worker-failed': { + failureCounts.set(evt.testFile, 1 + (failureCounts.get(evt.testFile) ?? 0)); + break; + } + + case 'worker-finished': { + const fileStats = status.stats.byFile.get(evt.testFile); + if (fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests) { + filesWithExclusiveTests.add(nodePath.relative(projectDir, evt.testFile)); + } else { + filesWithExclusiveTests.delete(nodePath.relative(projectDir, evt.testFile)); + } + + break; + } + + default: { + break; + } + } + }); + }); + + // State for subsequent test runs. + let signalChanged; + let changed = Promise.resolve({}); + let firstRun = true; + let runAll = true; + let updateSnapshots = false; + + const reset = () => { + changed = new Promise(resolve => { + signalChanged = resolve; + }); + firstRun = false; + runAll = false; + updateSnapshots = false; + }; + + // Support interactive commands. + stdin.setEncoding('utf8'); + stdin.on('data', data => { + data = data.trim().toLowerCase(); + runAll ||= data === 'r'; + updateSnapshots ||= data === 'u'; + if (runAll || updateSnapshots) { + signalChanged({}); + } + }); + stdin.unref(); + + // Whether tests are currently running. Used to control when the next run + // is prepared. + let testsAreRunning = false; + + // Tracks file paths we know have changed since the previous test run. + const dirtyPaths = new Set(); + const debounce = setTimeout(() => { + // The callback is invoked for a variety of reasons, not necessarily because + // there are dirty paths. But if there are none, then there's nothing to do. + if (dirtyPaths.size === 0) { + takeCoverageForSelfTests?.(); + return; + } + + // Equally, if tests are currently running, then keep accumulating changes. + // The timer is refreshed after tests finish running. + if (testsAreRunning) { + takeCoverageForSelfTests?.(); + return; + } + + // If the file tracer is still analyzing dependencies, wait for that to + // complete. + if (fileTracer.busy !== null) { + fileTracer.busy.then(() => debounce.refresh()); + takeCoverageForSelfTests?.(); + return; + } + + // Identify the changes. + const changes = [...dirtyPaths].filter(path => { + if (temporaryFiles.has(path)) { + debug('Ignoring known temporary file %s', path); + return false; + } + + if (touchedFiles.has(path)) { + debug('Ignoring known touched file %s', path); + return false; + } + + for (const {main} of providers) { + switch (main.interpretChange(nodePath.join(projectDir, path))) { + case main.changeInterpretations.ignoreCompiled: { + debug('Ignoring compilation output %s', path); + return false; + } + + case main.changeInterpretations.waitForOutOfBandCompilation: { + if (!fileExists(path)) { + debug('Not waiting for out-of-band compilation of deleted %s', path); + return true; + } + + debug('Waiting for out-of-band compilation of %s', path); + return false; + } + + default: { + continue; + } + } + } + + if (isIgnored(path)) { + debug('%s is ignored by patterns', path); + return false; + } + + return true; + }).flatMap(path => { + const change = changeFromPath(path); + + for (const {main} of providers) { + const sources = main.resolvePossibleOutOfBandCompilationSources(nodePath.join(projectDir, path)); + if (sources === null) { + continue; + } + + if (sources.length === 1) { + const [source] = sources; + const newPath = nodePath.relative(projectDir, source); + if (change.exists) { + debug('Interpreting %s as %s', path, newPath); + return changeFromPath(newPath); + } + + debug('Interpreting deleted %s as deletion of %s', path, newPath); + return {...changeFromPath(newPath), exists: false}; + } + + const relativeSources = sources.map(source => nodePath.relative(projectDir, source)); + debug('Change of %s could be due to deletion of multiple source files %j', path, relativeSources); + return relativeSources.filter(possiblePath => fileTracer.has(possiblePath)).map(newPath => { + debug('Interpreting %s as deletion of %s', path, newPath); + return changeFromPath(newPath); + }); + } + + return change; + }).filter(change => { + // Filter out changes to directories. However, if a directory was deleted, + // we cannot tell that it used to be a directory. + if (change.exists && !change.isFile) { + debug('%s is not a file', change.path); + return false; + } + + return true; + }); + + // Stats only need to be cached while we identify changes. + statsCache.clear(); + + // Identify test files that need to be run next, and whether there are + // non-ignored file changes that mean we should run all test files. + const uniqueTestFiles = new Set(); + const deletedTestFiles = new Set(); + const nonTestFiles = []; + for (const {path, isTest, exists} of changes) { + if (!exists) { + debug('%s was deleted', path); + } + + if (isTest) { + debug('%s is a test file', path); + if (exists) { + uniqueTestFiles.add(path); + } else { + failureCounts.delete(path); // Stop tracking failures for deleted tests. + deletedTestFiles.add(path); + } + } else { + debug('%s is not a test file', path); + + const dependingTestFiles = fileTracer.traceToTestFile(path); + if (dependingTestFiles.length > 0) { + debug('%s is depended on by test files %o', path, dependingTestFiles); + for (const testFile of dependingTestFiles) { + uniqueTestFiles.add(testFile); + } + } else { + debug('%s is not known to be depended on by test files', path); + nonTestFiles.push(path); + } + } + } + + // One more pass to make sure deleted test files are not run. This is needed + // because test files are selected when files they depend on are changed. + for (const path of deletedTestFiles) { + uniqueTestFiles.delete(path); + } + + // Clear state from the previous run and detected file changes. + dirtyPaths.clear(); + temporaryFiles.clear(); + touchedFiles.clear(); + + // In the background, update the file tracer to reflect the changes. + if (changes.length > 0) { + fileTracer.update(changes); + } + + // Select the test files to run, and how to run them. + let testFiles = [...uniqueTestFiles]; + let runOnlyExclusive = false; + + if (testFiles.length > 0) { + const exclusiveFiles = testFiles.filter(path => filesWithExclusiveTests.has(path)); + runOnlyExclusive = exclusiveFiles.length !== filesWithExclusiveTests.size; + if (runOnlyExclusive) { + // The test files that previously contained exclusive tests are always + // run, together with the test files. + debug('Running exclusive tests in %o', [...filesWithExclusiveTests]); + testFiles = [...new Set([...filesWithExclusiveTests, ...testFiles])]; + } + } + + if (filter.length > 0) { + testFiles = applyTestFileFilter({ + cwd: projectDir, + expandDirectories: false, + filter: patternFilters, + testFiles, + treatFilterPatternsAsFiles: false, + }); + } + + if (nonTestFiles.length > 0) { + debug('Non-test files changed, running all tests'); + failureCounts.clear(); // All tests are run, so clear previous failures. + signalChanged({runOnlyExclusive}); + } else if (testFiles.length > 0) { + // Remove previous failures for tests that will run again. + for (const path of testFiles) { + failureCounts.delete(path); + } + + signalChanged({runOnlyExclusive, testFiles}); + } + + takeCoverageForSelfTests?.(); + }, 100).unref(); + + // Detect changed files. + fs.watch(projectDir, {recursive: true, signal: abortSignal}, (_, filename) => { + if (filename !== null) { + dirtyPaths.add(filename); + debug('Detected change in %s', filename); + debounce.refresh(); + } + }); + + abortSignal.addEventListener('abort', () => { + signalChanged?.({}); + }); + + // And finally, the watch loop. + while (!abortSignal.aborted) { + const {testFiles: files = [], runOnlyExclusive = false} = await changed; // eslint-disable-line no-await-in-loop + + if (abortSignal.aborted) { + break; + } + + let previousFailures = 0; + for (const count of failureCounts.values()) { + previousFailures += count; + } + + const instructions = { + files: files.map(file => nodePath.join(projectDir, file)), + firstRun, // Value is changed by refresh() so record now. + previousFailures, + runOnlyExclusive, + updateSnapshots, // Value is changed by refresh() so record now. + }; + reset(); // Make sure the next run can be triggered. + testsAreRunning = true; + yield instructions; // Let the tests run. + testsAreRunning = false; + debounce.refresh(); // Trigger the callback, which if there were changes will run the tests again. + } +} + +// State management for file tracer. +class Node { + #children = new Map(); + #parents = new Map(); + isTest = false; + + constructor(path) { + this.path = path; + } + + get parents() { + return this.#parents.keys(); + } + + addChild(node) { + this.#children.set(node.path, node); + node.#addParent(this); + } + + #addParent(node) { + this.#parents.set(node.path, node); + } + + prune() { + for (const child of this.#children.values()) { + child.#removeParent(this); + } + + for (const parent of this.#parents.values()) { + parent.#removeChild(this); + } + } + + #removeChild(node) { + this.#children.delete(node.path); + } + + #removeParent(node) { + this.#parents.delete(node.path); + } +} + +class Tree extends Map { + get(path) { + if (!this.has(path)) { + this.set(path, new Node(path)); + } + + return super.get(path); + } + + delete(path) { + const node = this.get(path); + node?.prune(); + super.delete(path); + } +} + +// Track file dependencies to determine which test files to run. +class FileTracer { + #base; + #cache = Object.create(null); + #pendingTrace = null; + #updateRunning; + #signalUpdateRunning; + #tree = new Tree(); + + constructor({base}) { + this.#base = base; + this.#updateRunning = new Promise(resolve => { + this.#signalUpdateRunning = resolve; + }); + } + + get busy() { + return this.#pendingTrace; + } + + traceToTestFile(startingPath) { + const todo = [startingPath]; + const testFiles = new Set(); + const visited = new Set(); + for (const path of todo) { + if (visited.has(path)) { + continue; + } + + visited.add(path); + + const node = this.#tree.get(path); + if (node === undefined) { + continue; + } + + if (node.isTest) { + testFiles.add(node.path); + } else { + todo.push(...node.parents); + } + } + + return [...testFiles]; + } + + addDependency(testFile, path) { + const testNode = this.#tree.get(testFile); + testNode.isTest = true; + + const node = this.#tree.get(path); + testNode.addChild(node); + } + + has(path) { + return this.#tree.has(path); + } + + update(changes) { + const current = this.#update(changes).finally(() => { + if (this.#pendingTrace === current) { + this.#pendingTrace = null; + this.#updateRunning = new Promise(resolve => { + this.#signalUpdateRunning = resolve; + }); + } + }); + + this.#pendingTrace = current; + } + + async #update(changes) { + await this.#pendingTrace; // Guard against race conditions. + this.#signalUpdateRunning(); + + let reuseCache = true; + const knownTestFiles = new Set(); + const deletedFiles = new Set(); + const filesToTrace = new Set(); + for (const {path, isTest, exists} of await changes) { + if (exists) { + if (isTest) { + knownTestFiles.add(path); + } + + filesToTrace.add(path); + } else { + deletedFiles.add(path); + } + + // The cache can be reused as long as the changes are just for new files. + reuseCache = reuseCache && !this.#tree.has(path); + } + + // Remove deleted files from the tree. + for (const path of deletedFiles) { + this.#tree.delete(path); + } + + // Create a new cache if the old one can't be reused. + if (!reuseCache) { + this.#cache = Object.create(null); + } + + // If all changes are deletions then there is no more work to do. + if (filesToTrace.size === 0) { + return; + } + + // Always retrace all test files, in case a file was deleted and then replaced. + for (const node of this.#tree.values()) { + if (node.isTest) { + filesToTrace.add(node.path); + } + } + + // Trace any new and changed files. + const {fileList, reasons} = await nodeFileTrace([...filesToTrace], { + analysis: { // Only trace exact imports. + emitGlobs: false, + computeFileReferences: false, + evaluatePureExpressions: true, + }, + base: this.#base, + cache: this.#cache, + conditions: ['node'], + exportsOnly: true, // Disregard "main" in package files when "exports" is present. + ignore: ['**/node_modules/**'], // Don't trace through installed dependencies. + }); + + // Update the tree. + for (const path of fileList) { + const node = this.#tree.get(path); + node.isTest = knownTestFiles.has(path); + + const {parents} = reasons.get(path); + for (const parent of parents) { + const parentNode = this.#tree.get(parent); + parentNode.addChild(node); + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 0bbc841c9..4ac9fb634 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", @@ -1018,6 +1019,25 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", + "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1050,6 +1070,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", @@ -1217,6 +1249,30 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "node_modules/@vercel/nft": { + "version": "0.22.6", + "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.22.6.tgz", + "integrity": "sha512-gTsFnnT4mGxodr4AUlW3/urY+8JKKB452LwF3m477RFUJTAaDmcz2JqFuInzvdybYIeyIv1sSONEJxsxnbQ5JQ==", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.5", + "@rollup/pluginutils": "^4.0.0", + "acorn": "^8.6.0", + "async-sema": "^3.1.1", + "bindings": "^1.4.0", + "estree-walker": "2.0.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.2", + "node-gyp-build": "^4.2.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "nft": "out/cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -1392,6 +1448,11 @@ "dev": true, "peer": true }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, "node_modules/acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", @@ -1430,6 +1491,17 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/aggregate-error": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-4.0.1.tgz", @@ -1533,12 +1605,29 @@ "node": ">=8" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1666,6 +1755,11 @@ "node": ">=10" } }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==" + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -1681,8 +1775,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -1702,6 +1795,14 @@ "node": ">=10" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/blueimp-md5": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", @@ -1711,7 +1812,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2023,6 +2123,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -2206,7 +2314,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, "bin": { "color-support": "bin.js" } @@ -2232,8 +2339,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concordance": { "version": "5.0.4", @@ -2259,6 +2365,11 @@ "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", "dev": true }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2564,6 +2675,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "node_modules/detect-libc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", + "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", @@ -3721,6 +3845,11 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3857,6 +3986,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3980,11 +4114,21 @@ "integrity": "sha512-kSxoARUDn4F2RPXX48UXnaFKwVU7Ivd/6qpzZL29MCDmr9sTvybv4gFCp+qaI4fM9m0z9fgz/yJvi56GAz+BZg==", "dev": true }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.2", @@ -4038,6 +4182,70 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4141,7 +4349,6 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -4243,8 +4450,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -4342,6 +4548,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -4397,6 +4608,18 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", @@ -4492,7 +4715,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -4501,8 +4723,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.0.5", @@ -5811,7 +6032,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 +6046,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 +6236,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 +6279,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 +6286,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 +6349,35 @@ "type-detect": "4.0.8" } }, + "node_modules/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", + "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -6147,6 +6404,20 @@ "node": ">=12.19" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-package-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", @@ -6198,6 +6469,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nyc": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", @@ -6492,6 +6774,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -6549,7 +6839,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -6907,7 +7196,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7418,6 +7706,19 @@ "node": ">=8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7586,7 +7887,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -7623,7 +7923,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -7637,8 +7936,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "peer": true + ] }, "node_modules/safe-regex": { "version": "2.1.1", @@ -7734,8 +8032,7 @@ "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -7775,8 +8072,7 @@ "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, "node_modules/sinon": { "version": "15.1.0", @@ -7912,6 +8208,14 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -10095,6 +10399,30 @@ "node": ">=0.6" } }, + "node_modules/tar": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", + "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/tcompare": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/tcompare/-/tcompare-5.0.7.tgz", @@ -10278,6 +10606,11 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -10535,6 +10868,11 @@ "url": "https://github.com/fisker/url-or-path?sponsor=1" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -10582,6 +10920,11 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, "node_modules/webpack": { "version": "5.83.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.83.1.tgz", @@ -10696,6 +11039,15 @@ "node": ">=6" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10753,6 +11105,59 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wide-align/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", @@ -10856,8 +11261,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 3cc5c5a4d..2771149ca 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "typescript" ], "dependencies": { + "@vercel/nft": "^0.22.6", "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "ansi-styles": "^6.2.1", diff --git a/test-tap/globs.js b/test-tap/globs.js index 73db32d47..486e7085d 100644 --- a/test-tap/globs.js +++ b/test-tap/globs.js @@ -208,11 +208,11 @@ test('isIgnoredByWatcher with defaults', t => { }; function isIgnoredByWatcher(file) { - t.ok(globs.classify(fixture(file), options).isIgnoredByWatcher, `${file} should be ignored`); + t.ok(globs.classifyAva5Watcher(fixture(file), options).isIgnoredByWatcher, `${file} should be ignored`); } function notIgnored(file) { - t.notOk(globs.classify(fixture(file), options).isIgnoredByWatcher, `${file} should not be ignored`); + t.notOk(globs.classifyAva5Watcher(fixture(file), options).isIgnoredByWatcher, `${file} should not be ignored`); } notIgnored('foo-bar.js'); @@ -247,9 +247,9 @@ test('isIgnoredByWatcher with patterns', t => { cwd: fixture(), }; - t.ok(globs.classify(fixture('node_modules/foo/foo.js'), options).isIgnoredByWatcher); - t.ok(globs.classify(fixture('bar.js'), options).isIgnoredByWatcher); - t.ok(globs.classify(fixture('foo/bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('node_modules/foo/foo.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('foo/bar.js'), options).isIgnoredByWatcher); t.end(); }); @@ -264,9 +264,9 @@ test('isIgnoredByWatcher (pattern starts with directory)', t => { cwd: fixture(), }; - t.ok(globs.classify(fixture('node_modules/foo/foo.js'), options).isIgnoredByWatcher); - t.notOk(globs.classify(fixture('bar.js'), options).isIgnoredByWatcher); - t.ok(globs.classify(fixture('foo/bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('node_modules/foo/foo.js'), options).isIgnoredByWatcher); + t.notOk(globs.classifyAva5Watcher(fixture('bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('foo/bar.js'), options).isIgnoredByWatcher); t.end(); }); @@ -291,8 +291,8 @@ test('isIgnoredByWatcher after provider modifications', t => { cwd: fixture(), }; - t.ok(globs.classify(fixture('foo.js'), options).isIgnoredByWatcher); - t.notOk(globs.classify(fixture('bar.js'), options).isIgnoredByWatcher); + t.ok(globs.classifyAva5Watcher(fixture('foo.js'), options).isIgnoredByWatcher); + t.notOk(globs.classifyAva5Watcher(fixture('bar.js'), options).isIgnoredByWatcher); t.end(); }); diff --git a/test/config/integration.js b/test/config/integration.js index a36676e9a..13f18ad30 100644 --- a/test/config/integration.js +++ b/test/config/integration.js @@ -67,7 +67,7 @@ test('use current working directory if `package.json` is not found', async t => const cwd = temporaryDirectory(); const testFilePath = path.join(cwd, 'test.js'); - fs.writeFileSync(testFilePath, 'const test = require(process.env.TEST_AVA_IMPORT_FROM);\ntest(\'test name\', t => { t.pass(); });'); + fs.writeFileSync(testFilePath, 'const test = require(process.env.TEST_AVA_REQUIRE_FROM);\ntest(\'test name\', t => { t.pass(); });'); const options = { cwd, diff --git a/test/helpers/exec.js b/test/helpers/exec.js index ae4777687..e6a0ce6f2 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)}; @@ -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..323ae40f2 --- /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.doneImmediately(); + + 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.discard(); + t.pass('Most likely on Windows we did not capture stdout when the process was killed'); + } else { + result.commit(); + } + }, + }); +}); + +test('can rerun tests', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1(result) { + result.process.stdin.write('r\n'); + const {selectedTestCount, passed} = result.stats; + return {selectedTestCount, passed}; + }, + + async 2(result, statsSubset) { + result.process.stdin.write('R\n'); // Case-insensitive + t.like(result.stats, statsSubset); + return statsSubset; + }, + + async 3(result, statsSubset) { + t.like(result.stats, statsSubset); + this.done(); + }, + }); +}); + +test('can update snapshots', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1({process}) { + process.stdin.write('u\n'); + const {mtimeMs} = await this.stat('test.js.snap'); + return mtimeMs; + }, + + async 2({process}, previousMtimeMs) { + process.stdin.write('U\n'); // Case-insensitive + const {mtimeMs} = await this.stat('test.js.snap'); + t.true(mtimeMs > previousMtimeMs); + return mtimeMs; + }, + + async 3(_, previousMtimeMs) { + const {mtimeMs} = await this.stat('test.js.snap'); + t.true(mtimeMs > previousMtimeMs); + this.done(); + }, + }); +}); diff --git a/test/watch-mode/fixtures/basic/ava.config.js b/test/watch-mode/fixtures/basic/ava.config.js new file mode 100644 index 000000000..618a8b186 --- /dev/null +++ b/test/watch-mode/fixtures/basic/ava.config.js @@ -0,0 +1,3 @@ +export default { + ignoredByWatcher: ['ignored-by-watcher.js'], +}; diff --git a/test/watch-mode/fixtures/basic/ignored-by-watcher.js b/test/watch-mode/fixtures/basic/ignored-by-watcher.js new file mode 100644 index 000000000..0b491bb80 --- /dev/null +++ b/test/watch-mode/fixtures/basic/ignored-by-watcher.js @@ -0,0 +1,3 @@ +import process from 'node:process'; + +process.exit(1); // eslint-disable-line unicorn/no-process-exit diff --git a/test/watch-mode/fixtures/basic/not-depended-on.js b/test/watch-mode/fixtures/basic/not-depended-on.js new file mode 100644 index 000000000..0b491bb80 --- /dev/null +++ b/test/watch-mode/fixtures/basic/not-depended-on.js @@ -0,0 +1,3 @@ +import process from 'node:process'; + +process.exit(1); // eslint-disable-line unicorn/no-process-exit diff --git a/test/watch-mode/fixtures/basic/package.json b/test/watch-mode/fixtures/basic/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/watch-mode/fixtures/basic/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/watch-mode/fixtures/basic/source.js b/test/watch-mode/fixtures/basic/source.js new file mode 100644 index 000000000..d1ba1a094 --- /dev/null +++ b/test/watch-mode/fixtures/basic/source.js @@ -0,0 +1 @@ +export default 'source'; diff --git a/test/watch-mode/fixtures/basic/source.test.js b/test/watch-mode/fixtures/basic/source.test.js new file mode 100644 index 000000000..33594d787 --- /dev/null +++ b/test/watch-mode/fixtures/basic/source.test.js @@ -0,0 +1,7 @@ +import source from './source.js'; + +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('source', t => { + t.is(source, 'source'); +}); diff --git a/test/watch-mode/fixtures/basic/test.js b/test/watch-mode/fixtures/basic/test.js new file mode 100644 index 000000000..c34610900 --- /dev/null +++ b/test/watch-mode/fixtures/basic/test.js @@ -0,0 +1,9 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('pass', t => { + t.pass(); +}); + +test('snapshot', t => { + t.snapshot('snapshot'); +}); diff --git a/test/watch-mode/fixtures/exclusive/a.test.js b/test/watch-mode/fixtures/exclusive/a.test.js new file mode 100644 index 000000000..fd313167d --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/a.test.js @@ -0,0 +1,9 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('pass', t => { + t.pass(); +}); + +test('fail', t => { + t.fail(); +}); diff --git a/test/watch-mode/fixtures/exclusive/ava.config.js b/test/watch-mode/fixtures/exclusive/ava.config.js new file mode 100644 index 000000000..618a8b186 --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/ava.config.js @@ -0,0 +1,3 @@ +export default { + ignoredByWatcher: ['ignored-by-watcher.js'], +}; diff --git a/test/watch-mode/fixtures/exclusive/b.test.js b/test/watch-mode/fixtures/exclusive/b.test.js new file mode 100644 index 000000000..e0aa2b911 --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/b.test.js @@ -0,0 +1,9 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test.only('pass', t => { + t.pass(); +}); + +test('fail', t => { + t.fail(); +}); diff --git a/test/watch-mode/fixtures/exclusive/c.test.js b/test/watch-mode/fixtures/exclusive/c.test.js new file mode 100644 index 000000000..fd313167d --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/c.test.js @@ -0,0 +1,9 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); // This fixture is copied to a temporary directory, so import AVA through its configured path. + +test('pass', t => { + t.pass(); +}); + +test('fail', t => { + t.fail(); +}); diff --git a/test/watch-mode/fixtures/exclusive/package.json b/test/watch-mode/fixtures/exclusive/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/watch-mode/fixtures/exclusive/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/watch-mode/helpers/watch.js b/test/watch-mode/helpers/watch.js new file mode 100644 index 000000000..192c3ad74 --- /dev/null +++ b/test/watch-mode/helpers/watch.js @@ -0,0 +1,149 @@ +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 parseMs from 'ms'; +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) => { + 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; + }); + const done = () => { + activeWatchCount--; + signalDone({done: true}); + }; + + let isDoneImmediately = false; + const doneImmediately = () => { + done(); + isDoneImmediately = true; + }; + + let idlePromise = new Promise(() => {}); + let assertingIdle = false; + let failedIdleAssertion = false; + const assertIdle = async duration => { + assertingIdle = true; + + const ms = parseMs(duration); + t.timeout(ms * 1.2); + + const promise = delay(parseMs(duration), null, {ref: false}).then(() => { + if (idlePromise === promise) { + idlePromise = new Promise(() => {}); + assertingIdle = false; + if (failedIdleAssertion) { + failedIdleAssertion = false; + t.fail('Watcher performed a test run while it should have been idle'); + return false; + } + } + + return true; + }); + idlePromise = promise; + await promise; + }; + + let state = {}; + let pendingState; + + const results = run(args, options); + while (true) { // eslint-disable-line no-constant-condition + const item = await Promise.race([results.next(), 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, doneImmediately, ...operations}, result, state); + } + + if (item.done || isDoneImmediately) { + item.value?.process.send('abort-watcher'); + results.return(); + await pendingState; // eslint-disable-line no-await-in-loop + break; + } + } + }, + }); + + t.is(activeWatchCount, 0, 'Handlers for all watch() calls should have invoked `this.done()` to end their tests'); + }).catch(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..72a298f8f --- /dev/null +++ b/test/watch-mode/scenarios.js @@ -0,0 +1,113 @@ +import {test, withFixture} from './helpers/watch.js'; + +test('watcher can be configured to ignore files', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.touch('ignored-by-watcher.js'); + await this.assertIdle('2s'); + this.done(); + }, + }); +}); + +test('new, empty directories are ignored', withFixture('basic'), async (t, fixture) => { + await fixture.watch({ + async 1() { + await this.mkdir('empty-directory'); + await this.assertIdle('2s'); + 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.rm('source.test.js'); + await this.touch('source.js'); + await this.assertIdle('2s'); + 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')); + this.touch('test.js'); + this.assertIdle('2s'); + this.done(); + }, + }, ['source.test.js']); +});