diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 39d7c4bee..32731c7b1 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -34,6 +34,40 @@ export default { If your tests write to disk they may trigger the watcher to rerun your tests. Configuring additional ignore patterns helps avoid this. +### Filter tests while watching + +You may also filter tests while watching by using the CLI. For example, after running + +```console +npx ava --watch +``` + +You will see a prompt like this: + +```console +Type `p` and press enter to filter by a filename regex pattern + [Current filename filter is $pattern] +Type `t` and press enter to filter by a test name regex pattern + [Current test filter is $pattern] + +[Type `a` and press enter to run *all* tests] +(Type `r` and press enter to rerun tests || + Type `r` and press enter to rerun tests that match your filters) +Type `u` and press enter to update snapshots + +command > +``` + +So, to run only tests numbered like + +- foo23434 +- foo4343 +- foo93823 + +You can type `t` and press enter, then type `foo\d+` and press enter. This will then run all tests that match that pattern. + +Afterwards you can use the `r` command to run the matched tests again, or `a` command to run **all** tests. + ## Dependency tracking AVA tracks which source files your test files depend on. If you change such a dependency only the test file that depends on it will be rerun. AVA will rerun all tests if it cannot determine which test file depends on the changed source file. @@ -42,10 +76,6 @@ Dependency tracking works for `require()` and `import` syntax, as supported by [ Files accessed using the `fs` module are not tracked. -## Watch mode and the `.only` modifier - -The [`.only` modifier] disables watch mode's dependency tracking algorithm. When a change is made, all `.only` tests will be rerun, regardless of whether the test depends on the changed file. - ## Watch mode and CI If you run AVA in your CI with watch mode, the execution will exit with an error (`Error : Watch mode is not available in CI, as it prevents AVA from terminating.`). AVA will not run with the `--watch` (`-w`) option in CI, because CI processes should terminate, and with the `--watch` option, AVA will never terminate. @@ -66,7 +96,6 @@ Sometimes watch mode does something surprising like rerunning all tests when you $ DEBUG=ava:watcher npx ava --watch ``` -[`chokidar`]: https://github.com/paulmillr/chokidar [Install Troubleshooting]: https://github.com/paulmillr/chokidar#install-troubleshooting [`ignore-by-default`]: https://github.com/novemberborn/ignore-by-default [`.only` modifier]: ../01-writing-tests.md#running-specific-tests diff --git a/lib/api.js b/lib/api.js index f43fad799..e525e9235 100644 --- a/lib/api.js +++ b/lib/api.js @@ -88,7 +88,7 @@ export default class Api extends Emittery { } } - async run({files: selectedFiles = [], filter = [], runtimeOptions = {}} = {}) { // eslint-disable-line complexity + async run({files: selectedFiles = [], filter = [], runtimeOptions = {}, testFileSelector} = {}) { // eslint-disable-line complexity let setupOrGlobError; const apiOptions = this.options; @@ -149,7 +149,9 @@ export default class Api extends Emittery { let testFiles; try { testFiles = await globs.findTests({cwd: this.options.projectDir, ...apiOptions.globs}); - if (selectedFiles.length === 0) { + if (typeof testFileSelector === 'function') { + selectedFiles = testFileSelector(testFiles, selectedFiles); + } else if (selectedFiles.length === 0) { selectedFiles = filter.length === 0 ? testFiles : globs.applyTestFileFilter({ cwd: this.options.projectDir, filter: filter.map(({pattern}) => pattern), @@ -163,7 +165,7 @@ export default class Api extends Emittery { } const selectionInsights = { - filter, + filter: selectedFiles.appliedFilters ?? filter, ignoredFilterPatternFiles: selectedFiles.ignoredFilterPatternFiles ?? [], testFileCount: testFiles.length, selectionCount: selectedFiles.length, @@ -201,9 +203,8 @@ export default class Api extends Emittery { failFastEnabled: failFast, filePathPrefix: getFilePathPrefix(selectedFiles), files: selectedFiles, - matching: apiOptions.match.length > 0, - previousFailures: runtimeOptions.previousFailures ?? 0, - runOnlyExclusive: runtimeOptions.runOnlyExclusive === true, + matching: apiOptions.match.length > 0 || runtimeOptions.interactiveMatchPattern !== undefined, + previousFailures: runtimeOptions.countPreviousFailures?.() ?? 0, firstRun: runtimeOptions.firstRun ?? true, status: runStatus, }); @@ -266,14 +267,13 @@ export default class Api extends Emittery { const lineNumbers = getApplicableLineNumbers(globs.normalizeFileForMatching(apiOptions.projectDir, file), filter); // Removing `providers` and `sortTestFiles` fields because they cannot be transferred to the worker threads. - const {providers, sortTestFiles, ...forkOptions} = apiOptions; + const {providers, sortTestFiles, match, ...forkOptions} = apiOptions; const options = { ...forkOptions, providerStates, lineNumbers, recordNewSnapshots: !isCi, - // If we're looking for matches, run every single test process in exclusive-only mode - runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true, + match: runtimeOptions.interactiveMatchPattern === undefined ? match : [...match, runtimeOptions.interactiveMatchPattern], }; if (runtimeOptions.updateSnapshots) { diff --git a/lib/reporters/default.js b/lib/reporters/default.js index a70f24e4e..7da27c797 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -26,7 +26,7 @@ class LineWriter extends stream.Writable { this.dest = dest; this.columns = dest.columns ?? 80; - this.lastLineIsEmpty = false; + this.lastLineIsEmpty = true; } _write(chunk, _, callback) { @@ -34,9 +34,9 @@ class LineWriter extends stream.Writable { callback(); } - writeLine(string) { + writeLine(string, indent = true) { if (string) { - this.write(indentString(string, 2) + os.EOL); + this.write((indent ? indentString(string, 2) : string) + os.EOL); this.lastLineIsEmpty = false; } else { this.write(os.EOL); @@ -44,6 +44,11 @@ class LineWriter extends stream.Writable { } } + write(string) { + this.lastLineIsEmpty = false; + super.write(string); + } + ensureEmptyLine() { if (!this.lastLineIsEmpty) { this.writeLine(); @@ -120,7 +125,6 @@ export default class Reporter { this.previousFailures = 0; this.failFastEnabled = false; - this.lastLineIsEmpty = false; this.matching = false; this.removePreviousListener = null; @@ -628,7 +632,7 @@ export default class Reporter { this.lineWriter.writeLine(colors.error(`${figures.cross} Couldn’t find any files to test` + firstLinePostfix)); } else { const {testFileCount: count} = this.selectionInsights; - this.lineWriter.writeLine(colors.error(`${figures.cross} Based on your configuration, ${count} test ${plur('file was', 'files were', count)} found, but did not match the CLI arguments:` + firstLinePostfix)); + this.lineWriter.writeLine(colors.error(`${figures.cross} Based on your configuration, ${count} test ${plur('file was', 'files were', count)} found, but did not match the filters:` + firstLinePostfix)); this.lineWriter.writeLine(); for (const {pattern} of this.selectionInsights.filter) { this.lineWriter.writeLine(colors.error(`* ${pattern}`)); diff --git a/lib/runner.js b/lib/runner.js index fac04e344..2a9bb3bf3 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -2,7 +2,7 @@ import process from 'node:process'; import {pathToFileURL} from 'node:url'; import Emittery from 'emittery'; -import {matcher} from 'matcher'; +import * as matcher from 'matcher'; import ContextRef from './context-ref.js'; import createChain from './create-chain.js'; @@ -13,6 +13,15 @@ import Runnable from './test.js'; import {waitForReady} from './worker/state.cjs'; const makeFileURL = file => file.startsWith('file://') ? file : pathToFileURL(file).toString(); + +const isTitleMatch = (title, patterns) => { + if (patterns.length === 0) { + return true; + } + + return matcher.isMatch(title, patterns); +}; + export default class Runner extends Emittery { constructor(options = {}) { super(); @@ -22,10 +31,9 @@ export default class Runner extends Emittery { this.failWithoutAssertions = options.failWithoutAssertions !== false; this.file = options.file; this.checkSelectedByLineNumbers = options.checkSelectedByLineNumbers; - this.match = options.match ?? []; + this.matchPatterns = options.match ?? []; this.projectDir = options.projectDir; this.recordNewSnapshots = options.recordNewSnapshots === true; - this.runOnlyExclusive = options.runOnlyExclusive === true; this.serial = options.serial === true; this.snapshotDir = options.snapshotDir; this.updateSnapshots = options.updateSnapshots; @@ -34,6 +42,7 @@ export default class Runner extends Emittery { this.boundCompareTestSnapshot = this.compareTestSnapshot.bind(this); this.boundSkipSnapshot = this.skipSnapshot.bind(this); this.interrupted = false; + this.runOnlyExclusive = false; this.nextTaskIndex = 0; this.tasks = { @@ -92,9 +101,7 @@ export default class Runner extends Emittery { const {args, implementation, title} = parseTestArgs(testArgs); - if (this.checkSelectedByLineNumbers) { - metadata.selected = this.checkSelectedByLineNumbers(); - } + metadata.selected &&= this.checkSelectedByLineNumbers?.() ?? true; if (metadata.todo) { if (implementation) { @@ -110,10 +117,7 @@ export default class Runner extends Emittery { } // --match selects TODO tests. - if (this.match.length > 0 && matcher(title.value, this.match).length === 1) { - metadata.exclusive = true; - this.runOnlyExclusive = true; - } + metadata.selected &&= isTitleMatch(title.value, this.matchPatterns); this.tasks.todo.push({title: title.value, metadata}); this.emit('stateChange', { @@ -154,14 +158,10 @@ export default class Runner extends Emittery { }; if (metadata.type === 'test') { - if (this.match.length > 0) { - // --match overrides .only() - task.metadata.exclusive = matcher(title.value, this.match).length === 1; - } - - if (task.metadata.exclusive) { - this.runOnlyExclusive = true; - } + task.metadata.selected &&= isTitleMatch(title.value, this.matchPatterns); + // Unmatched .only() are not selected and won't run. However, runOnlyExclusive can only be true if no titles + // are being matched. + this.runOnlyExclusive ||= this.matchPatterns.length === 0 && task.metadata.exclusive && task.metadata.selected; this.tasks[metadata.serial ? 'serial' : 'concurrent'].push(task); @@ -181,6 +181,7 @@ export default class Runner extends Emittery { serial: false, exclusive: false, skipped: false, + selected: true, todo: false, failing: false, callback: false, @@ -402,16 +403,11 @@ export default class Runner extends Emittery { return alwaysOk && hooksOk && testOk; } - async start() { // eslint-disable-line complexity + async start() { const concurrentTests = []; const serialTests = []; for (const task of this.tasks.serial) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { - this.snapshots.skipBlock(task.title, task.metadata.taskIndex); - continue; - } - - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected || (this.runOnlyExclusive && !task.metadata.exclusive)) { this.snapshots.skipBlock(task.title, task.metadata.taskIndex); continue; } @@ -432,12 +428,7 @@ export default class Runner extends Emittery { } for (const task of this.tasks.concurrent) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { - this.snapshots.skipBlock(task.title, task.metadata.taskIndex); - continue; - } - - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected || (this.runOnlyExclusive && !task.metadata.exclusive)) { this.snapshots.skipBlock(task.title, task.metadata.taskIndex); continue; } @@ -460,11 +451,7 @@ export default class Runner extends Emittery { } for (const task of this.tasks.todo) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { - continue; - } - - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected || (this.runOnlyExclusive && !task.metadata.exclusive)) { continue; } diff --git a/lib/watcher.js b/lib/watcher.js index 2ee7d23b2..b28db8052 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -5,10 +5,12 @@ import v8 from 'node:v8'; import {nodeFileTrace} from '@vercel/nft'; import createDebug from 'debug'; +import split2 from 'split2'; import {chalk} from './chalk.js'; import { applyTestFileFilter, classify, buildIgnoreMatcher, findTests, + normalizePattern, } from './globs.js'; import {levels as providerLevels} from './provider-manager.js'; @@ -18,8 +20,6 @@ const debug = createDebug('ava:watcher'); // 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, {persistent: false, recursive: true, signal: AbortSignal.abort()}); @@ -34,18 +34,116 @@ export function available(projectDir) { return true; } +async function * readLines(stream) { + for await (const line of stream.pipe(split2())) { + yield line.trim(); + } + + return ''; +} + +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.write('> '); + + const {value: pattern} = await lineReader.next(); + if (pattern === '') { + return undefined; + } + + return normalizePattern(nodePath.relative(projectDir, nodePath.resolve(process.cwd(), pattern))); +}; + +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.write('> '); + + const {value: pattern} = await lineReader.next(); + return pattern === '' ? undefined : pattern; +}; + 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, + for await (const {files, testFileSelector, ...runtimeOptions} of plan({ + api, + filter, + globs, + projectDir, + providers, + stdin, + abortSignal: signal, + reporter, })) { - await api.run({files, filter, runtimeOptions}); + await api.run({files, testFileSelector, runtimeOptions}); reporter.endRun(); - reporter.lineWriter.writeLine(END_MESSAGE); } } -async function * plan({api, filter, globs, projectDir, providers, stdin, abortSignal}) { +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); @@ -79,11 +177,19 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi })))); // State tracked for test runs. - const filesWithExclusiveTests = new Set(); 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 => { @@ -117,17 +223,6 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi 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; } @@ -151,21 +246,108 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi 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({}); + // 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(); - // Whether tests are currently running. Used to control when the next run - // is prepared. - let testsAreRunning = false; + // Handle commands. + eachLine(lineReader, async line => { + 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; + interactiveGlobPattern = await promptForGlobPattern(reporter, lineReader, interactiveGlobPattern, projectDir); + respondToChanges = true; + reporter.lineWriter.writeLine(); + if (oldGlobPattern === interactiveGlobPattern) { + debounce.refresh(); + } else { + signalChanged(); + } + + break; + } + + case 'm': { + respondToChanges = false; + const oldMatchPattern = interactiveMatchPattern; + interactiveMatchPattern = await promptForMatchPattern(reporter, lineReader, interactiveMatchPattern); + respondToChanges = true; + reporter.lineWriter.writeLine(); + if (oldMatchPattern === interactiveMatchPattern) { + debounce.refresh(); + } else { + 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(); @@ -177,9 +359,9 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi return; } - // Equally, if tests are currently running, then keep accumulating changes. - // The timer is refreshed after tests finish running. - if (testsAreRunning) { + // 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; } @@ -327,42 +509,12 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi 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}); + signalChanged(); + } else if (uniqueTestFiles.size > 0) { + signalChanged({testFiles: [...uniqueTestFiles]}); } takeCoverageForSelfTests?.(); @@ -378,34 +530,61 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi }); abortSignal?.addEventListener('abort', () => { - signalChanged?.({}); + signalChanged?.(); }); // And finally, the watch loop. while (abortSignal?.aborted !== true) { - const {testFiles: files = [], runOnlyExclusive = false} = await changed; // eslint-disable-line no-await-in-loop + const {testFiles = []} = (await changed) ?? {}; // eslint-disable-line no-await-in-loop if (abortSignal?.aborted) { break; } - let previousFailures = 0; - for (const count of failureCounts.values()) { - previousFailures += count; + // 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(); } - 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. + // Let the tests run. + respondToChanges = false; + yield { + countPreviousFailures, + files, + firstRun: instructFirstRun, + testFileSelector: instructTestFileSelector, + updateSnapshots: instructUpdateSnapshots, + interactiveMatchPattern: skipInteractive ? undefined : interactiveMatchPattern, }; - 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. + 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(); } } diff --git a/lib/worker/base.js b/lib/worker/base.js index 28f174bb6..520107dd3 100644 --- a/lib/worker/base.js +++ b/lib/worker/base.js @@ -81,7 +81,6 @@ const run = async options => { match: options.match, projectDir: options.projectDir, recordNewSnapshots: options.recordNewSnapshots, - runOnlyExclusive: options.runOnlyExclusive, serial: options.serial, snapshotDir: options.snapshotDir, updateSnapshots: options.updateSnapshots, diff --git a/package-lock.json b/package-lock.json index 2aecb7256..8311b60bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "plur": "^5.1.0", "pretty-ms": "^9.2.0", "resolve-cwd": "^3.0.0", + "split2": "^4.2.0", "stack-utils": "^2.0.6", "strip-ansi": "^7.1.0", "supertap": "^3.0.1", @@ -11518,6 +11519,15 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/package.json b/package.json index e5b532f34..37248802b 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "plur": "^5.1.0", "pretty-ms": "^9.2.0", "resolve-cwd": "^3.0.0", + "split2": "^4.2.0", "stack-utils": "^2.0.6", "strip-ansi": "^7.1.0", "supertap": "^3.0.1", diff --git a/test-tap/helper/report.js b/test-tap/helper/report.js index e616e52a4..71cc54cf4 100644 --- a/test-tap/helper/report.js +++ b/test-tap/helper/report.js @@ -109,12 +109,12 @@ const run = async (type, reporter, {match = [], filter} = {}) => { } // Mimick watch mode - return api.run({files, filter, runtimeOptions: {previousFailures: 0, firstRun: true}}).then(() => { + return api.run({files, filter, runtimeOptions: {countPreviousFailures: () => 0, firstRun: true}}).then(() => { reporter.endRun(); - return api.run({files, filter, runtimeOptions: {previousFailures: 2, firstRun: false}}); + return api.run({files, filter, runtimeOptions: {countPreviousFailures: () => 2, firstRun: false}}); }).then(() => { reporter.endRun(); - return api.run({files, filter, runtimeOptions: {previousFailures: 0, firstRun: false}}); + return api.run({files, filter, runtimeOptions: {countPreviousFailures: () => 0, firstRun: false}}); }).then(() => { reporter.endRun(); }); diff --git a/test-tap/runner.js b/test-tap/runner.js index d36137bdf..e8c1975e1 100644 --- a/test-tap/runner.js +++ b/test-tap/runner.js @@ -345,20 +345,6 @@ test('only test', t => { }); }); -test('options.runOnlyExclusive means only exclusive tests are run', t => { - t.plan(1); - - return promiseEnd(new Runner({file: import.meta.url, runOnlyExclusive: true}), runner => { - runner.chain('test', () => { - t.fail(); - }); - - runner.chain.only('test 2', () => { - t.pass(); - }); - }); -}); - test('options.serial forces all tests to be serial', t => { t.plan(1); diff --git a/test/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index 33b28a6a7..e8eea0868 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -8,8 +8,10 @@ test('prints results and instructions', withFixture('basic'), async (t, fixture) 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/); + t.regex(stdout, /Type `g` followed by enter to filter test files by a glob pattern/); + t.regex(stdout, /Type `m` followed by enter to filter tests by their title/); + t.regex(stdout, /Type `r` followed by enter to rerun tests/); + t.regex(stdout, /Type `u` followed by enter to update snapshots/); this.done(); }, }); diff --git a/test/watch-mode/fixtures/filter-files/ava.config.js b/test/watch-mode/fixtures/filter-files/ava.config.js new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/test/watch-mode/fixtures/filter-files/ava.config.js @@ -0,0 +1 @@ +export default {}; diff --git a/test/watch-mode/fixtures/filter-files/package.json b/test/watch-mode/fixtures/filter-files/package.json new file mode 100644 index 000000000..bedb411a9 --- /dev/null +++ b/test/watch-mode/fixtures/filter-files/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/watch-mode/fixtures/filter-files/test1.test.js b/test/watch-mode/fixtures/filter-files/test1.test.js new file mode 100644 index 000000000..a5c70aedc --- /dev/null +++ b/test/watch-mode/fixtures/filter-files/test1.test.js @@ -0,0 +1,17 @@ +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("alice", (t) => { + t.pass(); +}); + +test("bob", async (t) => { + t.pass(); +}); + +test("catherine", async (t) => { + t.pass(); +}); + +test("david", async (t) => { + t.fail(); +}); diff --git a/test/watch-mode/fixtures/filter-files/test2.test.js b/test/watch-mode/fixtures/filter-files/test2.test.js new file mode 100644 index 000000000..52927aeb5 --- /dev/null +++ b/test/watch-mode/fixtures/filter-files/test2.test.js @@ -0,0 +1,17 @@ +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("emma", (t) => { + t.pass(); +}); + +test("frank", async (t) => { + t.pass(); +}); + +test("gina", async (t) => { + t.pass(); +}); + +test("harry", async (t) => { + t.fail(); +}); diff --git a/test/watch-mode/helpers/watch.js b/test/watch-mode/helpers/watch.js index 32c00ced1..b42ce3d4e 100644 --- a/test/watch-mode/helpers/watch.js +++ b/test/watch-mode/helpers/watch.js @@ -12,6 +12,17 @@ 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; +/** + * Races between `promises` and returns the result, unless `possiblyErroring` rejects first. + * + * `possiblyErroring` may be any value and is ignored, unless it rejects. + */ +const raceUnlessError = async (possiblyErroring, ...promises) => { + const race = Promise.race(promises); + const intermediate = await Promise.race([Promise.resolve(possiblyErroring).then(() => raceUnlessError), race]); + return intermediate === raceUnlessError ? race : intermediate; +}; + export const withFixture = fixture => async (t, task) => { let completedTask = false; await temporaryDirectoryTask(async dir => { @@ -124,7 +135,7 @@ export const withFixture = fixture => async (t, task) => { try { let nextResult = results.next(); while (!isDone) { // eslint-disable-line no-unmodified-loop-condition - const item = await Promise.race([nextResult, idlePromise, donePromise]); // eslint-disable-line no-await-in-loop + const item = await raceUnlessError(pendingState, nextResult, idlePromise, donePromise); // eslint-disable-line no-await-in-loop process ??= item.value?.process; if (item.value) { diff --git a/test/watch-mode/interactive-filters.js b/test/watch-mode/interactive-filters.js new file mode 100644 index 000000000..1ac675c99 --- /dev/null +++ b/test/watch-mode/interactive-filters.js @@ -0,0 +1,155 @@ +import {test, withFixture} from './helpers/watch.js'; + +test('can filter test files by glob pattern', withFixture('filter-files'), async (t, fixture) => { + await fixture.watch({ + async 1({process, stats}) { + // First run should run all tests + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + // Set a file filter to only run test1.test.js + process.stdin.write('g\n'); + process.stdin.write('**/test1.*\n'); + }, + + async 2({stats}) { + // Only tests from test1 should run + t.is(stats.selectedTestCount, 4); + + t.is(stats.passed.length, 3); + for (const skipped of stats.passed) { + t.regex(skipped.file, /test1\.test\.js/); + } + + t.is(stats.failed.length, 1); + for (const skipped of stats.failed) { + t.regex(skipped.file, /test1\.test\.js/); + } + + this.done(); + }, + }); +}); + +test('can filter test files by glob pattern and have no tests run', withFixture('filter-files'), async (t, fixture) => { + await fixture.watch({ + async 1({process, stats}) { + // First run should run all tests + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + // Set a file filter that doesn't match any files + process.stdin.write('g\n'); + process.stdin.write('kangarookanbankentuckykendoll\n'); + + process.send('abort-watcher'); + const {stdout} = await process; + t.regex(stdout, /2 test files were found, but did not match the filters/); + t.regex(stdout, /\* kangarookanbankentuckykendoll/); + + this.done(); + }, + }); +}); + +test('when filtering by glob pattern, run all tests with \'a', withFixture('filter-files'), async (t, fixture) => { + await fixture.watch({ + async 1({process, stats}) { + // First run should run all tests + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + // Set a file filter to only run test1.test.js + process.stdin.write('g\n'); + process.stdin.write('**/test1.*\n'); + }, + + async 2({process, stats}) { + t.is(stats.selectedTestCount, 4); + + process.stdin.write('a\n'); + }, + async 3({stats}) { + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + this.done(); + }, + }); +}); + +test('can filter tests by title', withFixture('filter-files'), async (t, fixture) => { + await fixture.watch({ + async 1({process, stats}) { + // First run should run all tests + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + // Set a title filter to only run bob from test1.test.js + process.stdin.write('m\n'); + process.stdin.write('bob\n'); + }, + + async 2({stats}) { + // Only tests that match the test title should run + t.is(stats.selectedTestCount, 1); + t.is(stats.passed.length, 1); + for (const ran of stats.passed) { + t.regex(ran.title, /bob/); + } + + this.done(); + }, + }); +}); + +test('can filter tests title and have no tests run', withFixture('filter-files'), async (t, fixture) => { + await fixture.watch({ + async 1({process, stats}) { + // First run should run all tests + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + // Set a title filter that doesn't match any tests + process.stdin.write('m\n'); + process.stdin.write('sirnotappearinginthisfilm\n'); + }, + + async 2({process, stats}) { + // No tests should run + t.is(stats.selectedTestCount, 0); + + process.send('abort-watcher'); + const {stdout} = await process; + t.regex(stdout, /Couldn’t find any matching tests/); + + this.done(); + }, + }); +}); + +test('when filtering by title, run all tests with \'a', withFixture('filter-files'), async (t, fixture) => { + await fixture.watch({ + async 1({process, stats}) { + // First run should run all tests + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + // Set a file filter to only run bob from test1.test.js + process.stdin.write('m\n'); + process.stdin.write('bob\n'); + }, + + async 2({process, stats}) { + t.is(stats.selectedTestCount, 1); + + process.stdin.write('a\n'); + }, + async 3({stats}) { + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + this.done(); + }, + }); +}); diff --git a/test/watch-mode/scenarios.js b/test/watch-mode/scenarios.js index b9daa8113..59ca14117 100644 --- a/test/watch-mode/scenarios.js +++ b/test/watch-mode/scenarios.js @@ -95,24 +95,6 @@ test('runs test file when source it depends on is deleted', withFixture('basic') }); }); -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}) {