UNPKG

ava

Version:

Node.js test runner that lets you develop with confidence.

848 lines (710 loc) 23.4 kB
import fs from 'node:fs'; import nodePath from 'node:path'; import process from 'node:process'; import * as readline from 'node:readline/promises'; import v8 from 'node:v8'; import {nodeFileTrace} from '@vercel/nft'; import createDebug from 'debug'; import {chalk} from './chalk.js'; import { applyTestFileFilter, classify, buildIgnoreMatcher, findTests, normalizePattern, } 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; export function available(projectDir) { try { fs.watch(projectDir, {persistent: false, recursive: true, signal: AbortSignal.abort()}); } catch (error) { if (error.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') { return false; } throw error; } return true; } const cancel = Symbol('cancel'); const close = Symbol('close'); const promiseWithResolvers = Promise.withResolvers?.bind(Promise) ?? (() => { let resolve; let reject; const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); return {promise, resolve, reject}; }); async function * readLines(stream) { const rl = readline.createInterface({input: stream, output: process.stdout}); let promise; let resolve; let values = []; rl.addListener('close', () => { values.push(close); resolve?.(); }); rl.addListener('SIGINT', () => { values.push(cancel); resolve?.(); }); rl.addListener('line', line => { values.push(line.trim()); resolve?.(); }); while (true) { yield * values; values = []; await promise; // eslint-disable-line no-await-in-loop // Immediately create a new promise to wait for the next line. ({promise, resolve} = promiseWithResolvers()); } } const eachLine = async (lineReader, callback) => { for await (const line of lineReader) { await callback(line); } }; const writeCommandInstructions = (reporter, interactiveGlobPattern, interactiveMatchPattern) => { reporter.lineWriter.writeLine(chalk.gray('Type `g` followed by enter to filter test files by a glob pattern')); reporter.lineWriter.writeLine(chalk.gray('Type `m` followed by enter to filter tests by their title (similar to --match)')); if (interactiveGlobPattern || interactiveMatchPattern) { reporter.lineWriter.writeLine(chalk.gray('Type `a` followed by enter to rerun all tests (while preserving filters)')); reporter.lineWriter.writeLine(chalk.gray('Type `r` followed by enter to rerun tests that match your filters')); } else { reporter.lineWriter.writeLine(chalk.gray('Type `r` followed by enter to rerun tests')); } reporter.lineWriter.writeLine(chalk.gray('Type `u` followed by enter to update snapshots in selected tests')); if (interactiveGlobPattern || interactiveMatchPattern) { reporter.lineWriter.writeLine(); if (interactiveGlobPattern) { reporter.lineWriter.writeLine(chalk.gray(`Current test file glob pattern: ${chalk.italic(interactiveGlobPattern)}`)); } if (interactiveMatchPattern) { reporter.lineWriter.writeLine(chalk.gray(`Current test title match pattern: ${chalk.italic(interactiveMatchPattern)}`)); } } reporter.lineWriter.writeLine(); reporter.lineWriter.write('> '); }; const promptForGlobPattern = async (reporter, lineReader, currentPattern, projectDir) => { reporter.lineWriter.ensureEmptyLine(); reporter.lineWriter.writeLine('Type the glob pattern then press enter. Leave blank to clear.', false); if (currentPattern === undefined) { reporter.lineWriter.writeLine(); reporter.lineWriter.writeLine(chalk.italic('Tip: Start with `**/` to select files in any directory.'), false); reporter.lineWriter.writeLine(chalk.italic('Tip: Start with `!` to exclude files.'), false); } else { reporter.lineWriter.writeLine(); reporter.lineWriter.writeLine(`Current glob pattern is: ${chalk.italic(currentPattern)}`, false); reporter.lineWriter.writeLine(); reporter.lineWriter.writeLine(chalk.italic('Tip: Ctrl+C to exit without any changes.'), false); } reporter.lineWriter.write('> '); const {value} = await lineReader.next(); if (value === close || value === cancel) { return value; } if (value === '') { return undefined; } return normalizePattern(nodePath.relative(projectDir, nodePath.resolve(process.cwd(), value))); }; const promptForMatchPattern = async (reporter, lineReader, currentPattern) => { reporter.lineWriter.writeLine(); reporter.lineWriter.writeLine('Type the match pattern then press enter. Leave blank to clear.', false); if (currentPattern === undefined) { reporter.lineWriter.writeLine(); reporter.lineWriter.writeLine(chalk.italic('Tip: Start with `*` to match suffixes'), false); reporter.lineWriter.writeLine(chalk.italic('Tip: End with `*` to match prefixes.'), false); reporter.lineWriter.writeLine(chalk.italic('Tip: Start with `!` to exclude titles.'), false); } else { reporter.lineWriter.writeLine(); reporter.lineWriter.writeLine(`Current match pattern is: ${chalk.italic(currentPattern)}`, false); reporter.lineWriter.writeLine(); reporter.lineWriter.writeLine(chalk.italic('Tip: Ctrl+C to exit without any changes.'), false); } reporter.lineWriter.write('> '); const {value} = await lineReader.next(); return value === '' ? undefined : value; }; export async function start({api, filter, globs, projectDir, providers, reporter, stdin, signal}) { providers = providers.filter(({level}) => level >= providerLevels.ava6); for await (const {files, testFileSelector, ...runtimeOptions} of plan({ api, filter, globs, projectDir, providers, stdin, abortSignal: signal, reporter, })) { await api.run({files, testFileSelector, runtimeOptions}); reporter.endRun(); } } async function * plan({ api, filter, globs, projectDir, providers, stdin, abortSignal, reporter, }) { 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 => ({ // eslint-disable-line promise/prefer-await-to-then path: nodePath.relative(projectDir, path), isTest: true, exists: true, })))); // State tracked for test runs. const touchedFiles = new Set(); const temporaryFiles = new Set(); const failureCounts = new Map(); const countPreviousFailures = () => { let previousFailures = 0; for (const count of failureCounts.values()) { previousFailures += count; } return previousFailures; }; // 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': { const path = nodePath.relative(projectDir, evt.testFile); failureCounts.set(path, 1 + (failureCounts.get(path) ?? 0)); 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; }; // Interactive filters. let interactiveGlobPattern; let interactiveMatchPattern; const testFileSelector = (allTestFiles, selectedFiles = [], skipInteractive = runAll) => { if (selectedFiles.length === 0) { selectedFiles = allTestFiles; } if (patternFilters.length > 0) { selectedFiles = applyTestFileFilter({ cwd: projectDir, filter: patternFilters, testFiles: selectedFiles, treatFilterPatternsAsFiles: runAll, // This option is additive, so only select individual files on full runs. }); selectedFiles.appliedFilters = filter; // `filter` is the original input. } if (!skipInteractive && interactiveGlobPattern !== undefined) { const {appliedFilters = [], ignoredFilterPatternFiles} = selectedFiles; selectedFiles = applyTestFileFilter({ cwd: projectDir, filter: [interactiveGlobPattern], testFiles: selectedFiles, treatFilterPatternsAsFiles: false, }); selectedFiles.appliedFilters = [...appliedFilters, {pattern: interactiveGlobPattern}]; selectedFiles.ignoredFilterPatternFiles = ignoredFilterPatternFiles; } // Remove previous failures for tests that will run again. for (const file of selectedFiles) { const path = nodePath.relative(projectDir, file); failureCounts.delete(path); } return selectedFiles; }; const lineReader = readLines(stdin); // Don't let the reader keep the process alive. stdin.unref(); // Handle commands. eachLine(lineReader, async line => { if (line === cancel || line === close) { process.exit(); // eslint-disable-line unicorn/no-process-exit } switch (line.toLowerCase()) { case 'r': { signalChanged(); break; } case 'u': { updateSnapshots = true; signalChanged(); break; } case 'a': { runAll = true; signalChanged(); break; } case 'g': { respondToChanges = false; const oldGlobPattern = interactiveGlobPattern; const promptValue = await promptForGlobPattern(reporter, lineReader, interactiveGlobPattern, projectDir); respondToChanges = true; reporter.lineWriter.writeLine(); if (promptValue === close) { process.exit(); // eslint-disable-line unicorn/no-process-exit } if (promptValue === cancel || (promptValue === oldGlobPattern)) { signalChanged(); break; } interactiveGlobPattern = promptValue; signalChanged(); break; } case 'm': { respondToChanges = false; const oldMatchPattern = interactiveMatchPattern; const promptValue = await promptForMatchPattern(reporter, lineReader, interactiveMatchPattern); respondToChanges = true; reporter.lineWriter.writeLine(); if (promptValue === close) { process.exit(); // eslint-disable-line unicorn/no-process-exit } if (promptValue === cancel || (promptValue === oldMatchPattern)) { signalChanged(); break; } interactiveMatchPattern = promptValue; signalChanged(); break; } default: { break; } } }); // Whether to respond to file system changes. Used to control when the next run is prepared. let respondToChanges = true; // 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, or the user is being prompted, then keep accumulating changes. // The timer is refreshed when we're ready to resume. if (!respondToChanges) { takeCoverageForSelfTests?.(); return; } // If the file tracer is still analyzing dependencies, wait for that to // complete. if (fileTracer.busy !== null) { fileTracer.busy.then(() => debounce.refresh()); // eslint-disable-line promise/prefer-await-to-then 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); } if (nonTestFiles.length > 0) { debug('Non-test files changed, running all tests'); failureCounts.clear(); // All tests are run, so clear previous failures. signalChanged(); } else if (uniqueTestFiles.size > 0) { signalChanged({testFiles: [...uniqueTestFiles]}); } 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 !== true) { const {testFiles = []} = (await changed) ?? {}; // eslint-disable-line no-await-in-loop if (abortSignal?.aborted) { break; } // Values are changed by refresh() so copy them now. const instructFirstRun = firstRun; const skipInteractive = runAll; const instructUpdateSnapshots = updateSnapshots; reset(); // Make sure the next run can be triggered. let files = testFiles.map(file => nodePath.join(projectDir, file)); let instructTestFileSelector = testFileSelector; if (files.length > 0) { files = testFileSelector(files, [], skipInteractive); if (files.length === 0) { debug('Filters rejected all test files'); continue; } // Make a no-op for the API to avoid filtering `files` again. instructTestFileSelector = () => files; } else if (skipInteractive) { instructTestFileSelector = (allTestFiles, selectedFiles = []) => testFileSelector(allTestFiles, selectedFiles, true); } // Clear any prompt. if (!reporter.lineWriter.lastLineIsEmpty && reporter.reportStream.isTTY) { reporter.reportStream.clearLine(0); reporter.lineWriter.writeLine(); } // Let the tests run. respondToChanges = false; yield { countPreviousFailures, files, firstRun: instructFirstRun, testFileSelector: instructTestFileSelector, updateSnapshots: instructUpdateSnapshots, interactiveMatchPattern: skipInteractive ? undefined : interactiveMatchPattern, }; respondToChanges = true; // Write command instructions after the tests have run and been reported. writeCommandInstructions(reporter, interactiveGlobPattern, interactiveMatchPattern); // Trigger the callback, which if there were changes will run the tests again. debounce.refresh(); } } // 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(() => { // eslint-disable-line promise/prefer-await-to-then 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 &&= !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); } } } }