From 78a3768fbe3360930351374f854f9cc7a46fda51 Mon Sep 17 00:00:00 2001 From: Michael Mulet Date: Wed, 26 Mar 2025 17:54:14 -0500 Subject: [PATCH 01/25] Implemented Watch mode filter by filename and by test name #1530 --- docs/recipes/watch-mode.md | 29 ++ lib/api.js | 2 + lib/reporters/default.js | 15 +- lib/runner.js | 32 +- lib/watch-mode-skip-tests.js | 56 ++++ lib/watcher.js | 224 +++++++++++++- lib/worker/base.js | 1 + test/watch-mode/basic-functionality.js | 291 ++++++++++++++++++ .../fixtures/filter-files/ava.config.js | 1 + .../fixtures/filter-files/package.json | 3 + .../fixtures/filter-files/test1.test.js | 20 ++ .../fixtures/filter-files/test2.test.js | 20 ++ 12 files changed, 674 insertions(+), 20 deletions(-) create mode 100644 lib/watch-mode-skip-tests.js create mode 100644 test/watch-mode/fixtures/filter-files/ava.config.js create mode 100644 test/watch-mode/fixtures/filter-files/package.json create mode 100644 test/watch-mode/fixtures/filter-files/test1.test.js create mode 100644 test/watch-mode/fixtures/filter-files/test2.test.js diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 39d7c4bee..8bab4de52 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -34,6 +34,35 @@ 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. diff --git a/lib/api.js b/lib/api.js index ff0f0f74c..e32ad724c 100644 --- a/lib/api.js +++ b/lib/api.js @@ -206,6 +206,7 @@ export default class Api extends Emittery { runOnlyExclusive: runtimeOptions.runOnlyExclusive === true, firstRun: runtimeOptions.firstRun ?? true, status: runStatus, + watchModeSkipTestsOrUndefined: runtimeOptions.watchModeSkipTestsOrUndefined, }); if (setupOrGlobError) { @@ -273,6 +274,7 @@ export default class Api extends Emittery { providerStates, lineNumbers, recordNewSnapshots: !isCi, + watchModeSkipTestsData: runtimeOptions.watchModeSkipTestsOrUndefined, // If we're looking for matches, run every single test process in exclusive-only mode runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true, }; diff --git a/lib/reporters/default.js b/lib/reporters/default.js index a70f24e4e..3081ac1a0 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -139,6 +139,7 @@ export default class Reporter { this.previousFailures = plan.previousFailures; this.emptyParallelRun = plan.status.emptyParallelRun; this.selectionInsights = plan.status.selectionInsights; + this.watchModeSkipTestsOrUndefined = plan.watchModeSkipTestsOrUndefined; if (this.watching || plan.files.length > 1) { this.prefixTitle = (testFile, title) => prefixTitle(this.extensions, plan.filePathPrefix, testFile, title); @@ -251,9 +252,19 @@ export default class Reporter { case 'selected-test': { if (event.skip) { - this.lineWriter.writeLine(colors.skip(`- [skip] ${this.prefixTitle(event.testFile, event.title)}`)); + if (!this.watchModeSkipTestsOrUndefined?.shouldSkipEvent(event)) { + this.lineWriter.writeLine( + colors.skip( + `- [skip] ${this.prefixTitle(event.testFile, event.title)}`, + ), + ); + } } else if (event.todo) { - this.lineWriter.writeLine(colors.todo(`- [todo] ${this.prefixTitle(event.testFile, event.title)}`)); + this.lineWriter.writeLine( + colors.todo( + `- [todo] ${this.prefixTitle(event.testFile, event.title)}`, + ), + ); } break; diff --git a/lib/runner.js b/lib/runner.js index fac04e344..e5f3011f1 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -10,6 +10,7 @@ import parseTestArgs from './parse-test-args.js'; import serializeError from './serialize-error.js'; import {load as loadSnapshots, determineSnapshotDir} from './snapshot-manager.js'; import Runnable from './test.js'; +import {WatchModeSkipTests} from './watch-mode-skip-tests.js'; import {waitForReady} from './worker/state.cjs'; const makeFileURL = file => file.startsWith('file://') ? file : pathToFileURL(file).toString(); @@ -29,6 +30,10 @@ export default class Runner extends Emittery { this.serial = options.serial === true; this.snapshotDir = options.snapshotDir; this.updateSnapshots = options.updateSnapshots; + this.watchModeSkipTests + = new WatchModeSkipTests(options.watchModeSkipTestsData); + this.skipAllTestsInThisFile = this.watchModeSkipTests + .shouldSkipFile(this.file); this.activeRunnables = new Set(); this.boundCompareTestSnapshot = this.compareTestSnapshot.bind(this); @@ -91,6 +96,11 @@ export default class Runner extends Emittery { metadata.taskIndex = this.nextTaskIndex++; const {args, implementation, title} = parseTestArgs(testArgs); + if ( + this.shouldSkipThisTestBecauseItDoesNotMatchARegexSetAtTheWatchCLI(metadata, title.value) + ) { + metadata.skipped = true; + } if (this.checkSelectedByLineNumbers) { metadata.selected = this.checkSelectedByLineNumbers(); @@ -180,7 +190,7 @@ export default class Runner extends Emittery { }, { serial: false, exclusive: false, - skipped: false, + skipped: this.skipAllTestsInThisFile, todo: false, failing: false, callback: false, @@ -254,8 +264,8 @@ export default class Runner extends Emittery { await runnables.reduce((previous, runnable) => { // eslint-disable-line unicorn/no-array-reduce if (runnable.metadata.serial || this.serial) { waitForSerial = previous.then(() => - // Serial runnables run as long as there was no previous failure, unless - // the runnable should always be run. + // Serial runnables run as long as there was no previous failure, unless + // the runnable should always be run. (allPassed || runnable.metadata.always) && runAndStoreResult(runnable), ); return waitForSerial; @@ -264,10 +274,10 @@ export default class Runner extends Emittery { return Promise.all([ previous, waitForSerial.then(() => - // Concurrent runnables are kicked off after the previous serial - // runnables have completed, as long as there was no previous failure - // (or if the runnable should always be run). One concurrent runnable's - // failure does not prevent the next runnable from running. + // Concurrent runnables are kicked off after the previous serial + // runnables have completed, as long as there was no previous failure + // (or if the runnable should always be run). One concurrent runnable's + // failure does not prevent the next runnable from running. (allPassed || runnable.metadata.always) && runAndStoreResult(runnable), ), ]); @@ -551,4 +561,12 @@ export default class Runner extends Emittery { interrupt() { this.interrupted = true; } + + shouldSkipThisTestBecauseItDoesNotMatchARegexSetAtTheWatchCLI(metadata, testTitle) { + if (metadata.skipped || this.skipAllTestsInThisFile) { + return true; + } + + return this.watchModeSkipTests.shouldSkipTest(testTitle); + } } diff --git a/lib/watch-mode-skip-tests.js b/lib/watch-mode-skip-tests.js new file mode 100644 index 000000000..f8ef8b5b5 --- /dev/null +++ b/lib/watch-mode-skip-tests.js @@ -0,0 +1,56 @@ +/** + * The WatchModeSkipTests class is used to determine + * if a test should be skipped using filters provided + * by the user in watch mode. + */ +export class WatchModeSkipTests { + /** + * Properties are public to allow + * for easy sending to the worker. + */ + fileRegexOrNull = null; + testRegexOrNull = null; + + constructor(watchModeSkipTestsData = undefined) { + if (!watchModeSkipTestsData) { + return; + } + + this.fileRegexOrNull = watchModeSkipTestsData.fileRegexOrNull; + this.testRegexOrNull = watchModeSkipTestsData.testRegexOrNull; + } + + shouldSkipFile(file) { + if (this.fileRegexOrNull === null) { + return false; + } + + return !this.fileRegexOrNull.test(file); + } + + shouldSkipTest(testTitle) { + if (this.testRegexOrNull === null) { + return false; + } + + return !this.testRegexOrNull.test(testTitle); + } + + hasAnyFilters() { + return this.fileRegexOrNull !== null || this.testRegexOrNull !== null; + } + + shouldSkipEvent(event) { + return ( + this.shouldSkipFile(event.testFile) || this.shouldSkipTest(event.title) + ); + } + + replaceFileRegex(fileRegexOrNull) { + this.fileRegexOrNull = fileRegexOrNull; + } + + replaceTestRegex(testRegexOrNull) { + this.testRegexOrNull = testRegexOrNull; + } +} diff --git a/lib/watcher.js b/lib/watcher.js index 2ee7d23b2..c91d296c8 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import nodePath from 'node:path'; import process from 'node:process'; import v8 from 'node:v8'; @@ -11,6 +12,7 @@ import { applyTestFileFilter, classify, buildIgnoreMatcher, findTests, } from './globs.js'; import {levels as providerLevels} from './provider-manager.js'; +import {WatchModeSkipTests} from './watch-mode-skip-tests.js'; const debug = createDebug('ava:watcher'); @@ -18,7 +20,41 @@ 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'); +const endMessage = skipTests => + chalk.gray( + `Type \`p\` and press enter to filter by a filename regex pattern${os.EOL}` + + (skipTests.fileRegexOrNull === null + ? '' + : ` Current filename filter is ${skipTests.fileRegexOrNull}${os.EOL}`) + + `Type \`t\` and press enter to filter by a test name regex pattern${os.EOL}` + + (skipTests.testRegexOrNull === null + ? '' + : ` Current test filter is ${skipTests.testRegexOrNull}${os.EOL}`) + + os.EOL + + (skipTests.hasAnyFilters() + ? `Type \`a\` and press enter to rerun *all* tests${os.EOL}${os.EOL}` + + `Type \`r\` and press enter to rerun tests that match your filters${os.EOL}` + : `Type \`r\` and press enter to rerun tests${os.EOL}`) + + `Type \`u\` and press enter to update snapshots${os.EOL}` + + os.EOL, + ); + +/** + * Once mainstream adoption + * of Promise.withResolvers + * is achieved, simplify this. + * + */ +const promiseWithResolvers + = () => { + let resolve = null; + let reject = null; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return {promise, resolve, reject}; + }; export function available(projectDir) { try { @@ -37,15 +73,36 @@ export function available(projectDir) { 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, + api, + filter, + globs, + projectDir, + providers, + stdin, + abortSignal: signal, + reporter, })) { await api.run({files, filter, runtimeOptions}); reporter.endRun(); - reporter.lineWriter.writeLine(END_MESSAGE); + reporter.lineWriter.writeLine( + endMessage(runtimeOptions.watchModeSkipTestsOrUndefined), + ); + reporter.lineWriter.write(chalk.white('command > ')); } } -async function * plan({api, filter, globs, projectDir, providers, stdin, abortSignal}) { +async function * plan({ + api, + filter, + globs, + projectDir, + providers, + stdin, + abortSignal, + reporter, +}) { + const watchModeSkipTests = new WatchModeSkipTests(); + const fileTracer = new FileTracer({base: projectDir}); const isIgnored = buildIgnoreMatcher(globs); const patternFilters = filter.map(({pattern}) => pattern); @@ -141,6 +198,7 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi let firstRun = true; let runAll = true; let updateSnapshots = false; + let runSelected = false; const reset = () => { changed = new Promise(resolve => { @@ -149,18 +207,159 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi firstRun = false; runAll = false; updateSnapshots = false; + runSelected = 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({}); + + /** + * Here is how it works: + * The onDataGenerator function + * is an async generator that listens + * for lines of data from stdin. + * + * We store this generator in the field + * #data which we listen to in both + * the #listenForCommand function, + * and the promptForFilter. By keeping + * one generator instance for both + * we can have nested listeners. + */ + const _class = new (class { + #data; + + constructor() { + this.#data = this.#onDataGenerator(); + this.#listenForCommand(); } - }); + + #onDataGenerator = async function * () { + let p = promiseWithResolvers(); + /** + * Pending data allows the data + * callback to be called multiple + * times before the promise + * wakes up from its await. + */ + const pendingData = []; + stdin.on('data', async data => { + pendingData.push(data); + p.resolve(); + }); + while (true) { + /** + * Loops depend on each other, it makes + * sense to disable the eslint rule + */ + await p.promise; // eslint-disable-line no-await-in-loop + p = promiseWithResolvers(); + while (pendingData.length > 0) { + const lines = pendingData.shift().trim().split('\n'); + for (const line of lines) { + yield line; + } + } + } + }; + #listenForCommand = async () => { + for await (const data of this.#data) { + await this.#onCommand(data); + } + }; + #onCommand = async data => { + data = data.trim().toLowerCase(); + + switch (data) { + case 'r': { + runSelected = true; + break; + } + + case 'u': { + updateSnapshots = true; + break; + } + + case 'a': { + runAll = true; + break; + } + + case 'p': { + watchModeSkipTests.replaceFileRegex( + await this.#promptForFilter( + watchModeSkipTests.fileRegexOrNull, + 'filename', + ), + ); + reporter.lineWriter.write(`${os.EOL}${os.EOL}`); + reporter.lineWriter.writeLine(endMessage(watchModeSkipTests)); + reporter.lineWriter.write(chalk.white('command > ')); + runSelected = true; + break; + } + + case 't': { + watchModeSkipTests.replaceTestRegex( + await this.#promptForFilter( + watchModeSkipTests.testRegexOrNull, + 'test', + ), + ); + reporter.lineWriter.write(`${os.EOL}`); + reporter.lineWriter.writeLine(endMessage(watchModeSkipTests)); + reporter.lineWriter.write(chalk.white('command > ')); + runSelected = true; + break; + } + + default: { + return; + } + } + + if (runAll || runSelected || updateSnapshots) { + signalChanged({}); + } + }; + #promptForFilter = async (currentFilterRegexOrNull, filterName) => { + reporter.lineWriter.writeLine( + chalk.gray( + `${os.EOL}${os.EOL}Current ${filterName} filter is ${ + currentFilterRegexOrNull === null + ? 'empty' + : currentFilterRegexOrNull + }${os.EOL}`, + ), + ); + reporter.lineWriter.writeLine( + chalk.gray( + `Type the ${filterName} regex pattern then press enter:${os.EOL}` + + ` - Leave it blank and press enter to clear the filter${os.EOL}`, + ), + ); + reporter.lineWriter.write('pattern > '); + const generatorResult = (await this.#data.next()); + const patternInput = generatorResult.value.trim(); + let outFilter = null; + if (patternInput !== '') { + try { + const pattern = new RegExp(patternInput); + outFilter = pattern; + reporter.lineWriter.writeLine( + chalk.gray(`Added ${filterName} regex filter: ${pattern}${os.EOL}`), + ); + } catch (error) { + reporter.lineWriter.writeLine( + chalk.gray(`Invalid regex: ${error.message}${os.EOL}`), + ); + } + } + + return outFilter; + }; + })(); stdin.unref(); // Whether tests are currently running. Used to control when the next run @@ -400,6 +599,9 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi previousFailures, runOnlyExclusive, updateSnapshots, // Value is changed by refresh() so record now. + watchModeSkipTestsOrUndefined: runAll + ? new WatchModeSkipTests() + : watchModeSkipTests, }; reset(); // Make sure the next run can be triggered. testsAreRunning = true; diff --git a/lib/worker/base.js b/lib/worker/base.js index 7e4ff4aa4..6da792c98 100644 --- a/lib/worker/base.js +++ b/lib/worker/base.js @@ -85,6 +85,7 @@ const run = async options => { serial: options.serial, snapshotDir: options.snapshotDir, updateSnapshots: options.updateSnapshots, + watchModeSkipTestsData: options.watchModeSkipTestsData, }); refs.runnerChain = runner.chain; diff --git a/test/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index 33b28a6a7..98b83ce94 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -8,6 +8,14 @@ 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 `p` and press enter to filter by a filename regex pattern/, + ); + t.regex( + stdout, + /Type `t` and press enter to filter by a test name regex pattern/, + ); t.regex(stdout, /Type `r` and press enter to rerun tests/); t.regex(stdout, /Type `u` and press enter to update snapshots/); this.done(); @@ -78,3 +86,286 @@ test('can update snapshots', withFixture('basic'), async (t, fixture) => { }, }); }); + +test( + 'can filter tests by filename pattern', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'test1'; + const test1Regex = new RegExp(test1RegexString); + await fixture.watch({ + async 1({process, stats}) { + try { + // 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + } catch { + this.done(); + } + }, + + async 2({stats}) { + try { + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 4); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); + } + + t.is(stats.passed.length, 3); + for (const skipped of stats.passed) { + t.regex(skipped.file, test1Regex); + } + + t.is(stats.failed.length, 1); + for (const skipped of stats.failed) { + t.regex(skipped.file, test1Regex); + } + } catch {} + + this.done(); + }, + }); + }, +); + +test( + 'can filter tests by filename pattern and have no tests run', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'kangarookanbankentuckykendoll'; + const test1Regex = new RegExp(test1RegexString); + await fixture.watch({ + async 1({process, stats}) { + try { + // 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + } catch { + this.done(); + } + }, + + async 2({stats}) { + try { + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 8); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); + } + } catch {} + + this.done(); + }, + }); + }, +); + +test( + 'can filter tests by filename pattern, and run all tests with \'a', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'kangarookanbankentuckykendoll'; + const test1Regex = new RegExp(test1RegexString); + await fixture.watch({ + async 1({process, stats}) { + try { + // 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + } catch { + this.done(); + } + }, + + async 2({process, stats}) { + try { + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 8); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); + } + + process.stdin.write('a\n'); + } catch { + this.done(); + } + }, + async 3({stats}) { + try { + /** + * All tests should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + } catch {} + + this.done(); + }, + }); + }, +); + +test( + 'can filter tests by test pattern', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'bob'; + const test1Regex = new RegExp(test1RegexString); + await fixture.watch({ + async 1({process, stats}) { + try { + // 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + } catch (error) { + console.log('e'); + console.log(error); + this.done(); + } + }, + + async 2({stats}) { + try { + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 7); + for (const skipped of stats.skipped) { + t.notRegex(skipped.title, test1Regex); + } + + t.is(stats.passed.length, 1); + for (const skipped of stats.passed) { + t.regex(skipped.title, test1Regex); + } + } catch {} + + this.done(); + }, + }); + }, +); + +test( + 'can filter tests by test pattern and have no tests run', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'sirnotappearinginthisfilm'; + const test1Regex = new RegExp(test1RegexString); + await fixture.watch({ + async 1({process, stats}) { + try { + // 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + } catch { + this.done(); + } + }, + + async 2({stats}) { + try { + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 8); + for (const skipped of stats.skipped) { + t.notRegex(skipped.title, test1Regex); + } + } catch {} + + this.done(); + }, + }); + }, +); + +test( + 'can filter tests by test pattern, and run all tests with \'a', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'sirnotappearinginthisfilm'; + const test1Regex = new RegExp(test1RegexString); + await fixture.watch({ + async 1({process, stats}) { + try { + // 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + } catch { + this.done(); + } + }, + + async 2({process, stats}) { + try { + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 8); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); + } + + process.stdin.write('a\n'); + } catch { + this.done(); + } + }, + async 3({stats}) { + try { + /** + * All tests should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + } catch {} + + 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..47dc78d39 --- /dev/null +++ b/test/watch-mode/fixtures/filter-files/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} \ No newline at end of file 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..72df83822 --- /dev/null +++ b/test/watch-mode/fixtures/filter-files/test1.test.js @@ -0,0 +1,20 @@ +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) => { + const bar = Promise.resolve("bar"); + t.is(await bar, "bar"); +}); + +test("catherine", async (t) => { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, 50); + return promise.then(() => t.pass()); +}); + +test("david", async (t) => { + t.is(1, 2); +}); \ No newline at end of file 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..168981bb7 --- /dev/null +++ b/test/watch-mode/fixtures/filter-files/test2.test.js @@ -0,0 +1,20 @@ +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) => { + const bar = Promise.resolve("bar"); + t.is(await bar, "bar"); +}); + +test("gina", async (t) => { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, 50); + return promise.then(() => t.pass()); +}); + +test("harry", async (t) => { + t.is(1, 2); +}); \ No newline at end of file From 8ecd51ded2f581314e5952e6fa9998cf79c58c30 Mon Sep 17 00:00:00 2001 From: Michael Mulet Date: Wed, 26 Mar 2025 20:47:16 -0500 Subject: [PATCH 02/25] fix tests on onlder node versions --- test/watch-mode/fixtures/filter-files/test1.test.js | 4 +--- test/watch-mode/fixtures/filter-files/test2.test.js | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/test/watch-mode/fixtures/filter-files/test1.test.js b/test/watch-mode/fixtures/filter-files/test1.test.js index 72df83822..527e19615 100644 --- a/test/watch-mode/fixtures/filter-files/test1.test.js +++ b/test/watch-mode/fixtures/filter-files/test1.test.js @@ -10,9 +10,7 @@ test("bob", async (t) => { }); test("catherine", async (t) => { - const { promise, resolve } = Promise.withResolvers(); - setTimeout(resolve, 50); - return promise.then(() => t.pass()); + t.is(1, 1); }); test("david", async (t) => { diff --git a/test/watch-mode/fixtures/filter-files/test2.test.js b/test/watch-mode/fixtures/filter-files/test2.test.js index 168981bb7..e82f004d7 100644 --- a/test/watch-mode/fixtures/filter-files/test2.test.js +++ b/test/watch-mode/fixtures/filter-files/test2.test.js @@ -10,9 +10,7 @@ test("frank", async (t) => { }); test("gina", async (t) => { - const { promise, resolve } = Promise.withResolvers(); - setTimeout(resolve, 50); - return promise.then(() => t.pass()); + t.is(1, 1); }); test("harry", async (t) => { From 0d78fd3c3541a1fca30dfba3b8e2d740884be4a5 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 4 Apr 2025 14:04:55 +0200 Subject: [PATCH 03/25] Format documentation --- docs/recipes/watch-mode.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 8bab4de52..d861ef31e 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -35,11 +35,15 @@ 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 + +You may also filter tests while watching by using the CLI. For example, after running + ```console -$ npx ava --watch +npx ava --watch ``` -You will see a prompt like this : + +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] @@ -48,20 +52,21 @@ Type `t` and press enter to filter by a test name regex 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 `r` and press enter to rerun tests that match your filters) Type `u` and press enter to update snapshots -command > +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. +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 From b72a9dd94a8630d6ac52c837c798b4d7513ec5b9 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 4 Apr 2025 14:20:02 +0200 Subject: [PATCH 04/25] Add newline at EOF --- test/watch-mode/fixtures/filter-files/package.json | 2 +- test/watch-mode/fixtures/filter-files/test1.test.js | 2 +- test/watch-mode/fixtures/filter-files/test2.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/watch-mode/fixtures/filter-files/package.json b/test/watch-mode/fixtures/filter-files/package.json index 47dc78d39..bedb411a9 100644 --- a/test/watch-mode/fixtures/filter-files/package.json +++ b/test/watch-mode/fixtures/filter-files/package.json @@ -1,3 +1,3 @@ { "type": "module" -} \ No newline at end of file +} diff --git a/test/watch-mode/fixtures/filter-files/test1.test.js b/test/watch-mode/fixtures/filter-files/test1.test.js index 527e19615..6e28c015f 100644 --- a/test/watch-mode/fixtures/filter-files/test1.test.js +++ b/test/watch-mode/fixtures/filter-files/test1.test.js @@ -15,4 +15,4 @@ test("catherine", async (t) => { test("david", async (t) => { t.is(1, 2); -}); \ No newline at end of file +}); diff --git a/test/watch-mode/fixtures/filter-files/test2.test.js b/test/watch-mode/fixtures/filter-files/test2.test.js index e82f004d7..0b3304753 100644 --- a/test/watch-mode/fixtures/filter-files/test2.test.js +++ b/test/watch-mode/fixtures/filter-files/test2.test.js @@ -15,4 +15,4 @@ test("gina", async (t) => { test("harry", async (t) => { t.is(1, 2); -}); \ No newline at end of file +}); From a3cce757c160b59e73d144a337b96bc462c1695e Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 4 Apr 2025 14:39:38 +0200 Subject: [PATCH 05/25] Fix error handling in watcher tests If the previous handler failed, perhaps due to an assertion, it wouldn't trigger the next run and the tests would hang. Fix by including a failure of the previous handler in the await condition for the next run. --- test/watch-mode/helpers/watch.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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) { From b0762b0fc2f453a5d8bc092b71f9c9387da91b85 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 4 Apr 2025 14:39:54 +0200 Subject: [PATCH 06/25] Remove unnecessary this.done() calls in catch blocks --- test/watch-mode/basic-functionality.js | 292 +++++++++++-------------- 1 file changed, 123 insertions(+), 169 deletions(-) diff --git a/test/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index 98b83ce94..41010b051 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -95,41 +95,35 @@ test( const test1Regex = new RegExp(test1RegexString); await fixture.watch({ async 1({process, stats}) { - try { - // 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - } catch { - this.done(); - } + // 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; }, async 2({stats}) { - try { - /** - * Only tests from test1 should run - */ - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 4); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } - - t.is(stats.passed.length, 3); - for (const skipped of stats.passed) { - t.regex(skipped.file, test1Regex); - } - - t.is(stats.failed.length, 1); - for (const skipped of stats.failed) { - t.regex(skipped.file, test1Regex); - } - } catch {} + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 4); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); + } + + t.is(stats.passed.length, 3); + for (const skipped of stats.passed) { + t.regex(skipped.file, test1Regex); + } + + t.is(stats.failed.length, 1); + for (const skipped of stats.failed) { + t.regex(skipped.file, test1Regex); + } this.done(); }, @@ -145,31 +139,25 @@ test( const test1Regex = new RegExp(test1RegexString); await fixture.watch({ async 1({process, stats}) { - try { - // 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - } catch { - this.done(); - } + // 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; }, async 2({stats}) { - try { - /** - * Only tests from test1 should run - */ - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 8); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } - } catch {} + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 8); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); + } this.done(); }, @@ -185,44 +173,34 @@ test( const test1Regex = new RegExp(test1RegexString); await fixture.watch({ async 1({process, stats}) { - try { - // 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - } catch { - this.done(); - } + // 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; }, async 2({process, stats}) { - try { - /** - * Only tests from test1 should run - */ - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 8); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } - - process.stdin.write('a\n'); - } catch { - this.done(); + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 8); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); } + + process.stdin.write('a\n'); }, async 3({stats}) { - try { - /** - * All tests should run - */ - t.is(stats.selectedTestCount, 8); - t.is(stats.passed.length, 6); - } catch {} + /** + * All tests should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); this.done(); }, @@ -238,38 +216,30 @@ test( const test1Regex = new RegExp(test1RegexString); await fixture.watch({ async 1({process, stats}) { - try { - // 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.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - } catch (error) { - console.log('e'); - console.log(error); - this.done(); - } + // 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; }, async 2({stats}) { - try { - /** - * Only tests from test1 should run - */ - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 7); - for (const skipped of stats.skipped) { - t.notRegex(skipped.title, test1Regex); - } - - t.is(stats.passed.length, 1); - for (const skipped of stats.passed) { - t.regex(skipped.title, test1Regex); - } - } catch {} + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 7); + for (const skipped of stats.skipped) { + t.notRegex(skipped.title, test1Regex); + } + + t.is(stats.passed.length, 1); + for (const skipped of stats.passed) { + t.regex(skipped.title, test1Regex); + } this.done(); }, @@ -285,31 +255,25 @@ test( const test1Regex = new RegExp(test1RegexString); await fixture.watch({ async 1({process, stats}) { - try { - // 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.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - } catch { - this.done(); - } + // 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; }, async 2({stats}) { - try { - /** - * Only tests from test1 should run - */ - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 8); - for (const skipped of stats.skipped) { - t.notRegex(skipped.title, test1Regex); - } - } catch {} + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 8); + for (const skipped of stats.skipped) { + t.notRegex(skipped.title, test1Regex); + } this.done(); }, @@ -325,44 +289,34 @@ test( const test1Regex = new RegExp(test1RegexString); await fixture.watch({ async 1({process, stats}) { - try { - // 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.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - } catch { - this.done(); - } + // 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; }, async 2({process, stats}) { - try { - /** - * Only tests from test1 should run - */ - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 8); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } - - process.stdin.write('a\n'); - } catch { - this.done(); + /** + * Only tests from test1 should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.skipped.length, 8); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); } + + process.stdin.write('a\n'); }, async 3({stats}) { - try { - /** - * All tests should run - */ - t.is(stats.selectedTestCount, 8); - t.is(stats.passed.length, 6); - } catch {} + /** + * All tests should run + */ + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); this.done(); }, From 5195cbe4ce23e222c18850f2ab25e3fc851519ed Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 4 Apr 2025 14:41:01 +0200 Subject: [PATCH 07/25] Remove unnecessary multiline comments --- test/watch-mode/basic-functionality.js | 32 +++++++------------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/test/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index 41010b051..a22fc42e9 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -106,9 +106,7 @@ test( }, async 2({stats}) { - /** - * Only tests from test1 should run - */ + // Only tests from test1 should run t.is(stats.selectedTestCount, 8); t.is(stats.skipped.length, 4); for (const skipped of stats.skipped) { @@ -150,9 +148,7 @@ test( }, async 2({stats}) { - /** - * Only tests from test1 should run - */ + // Only tests from test1 should run t.is(stats.selectedTestCount, 8); t.is(stats.skipped.length, 8); for (const skipped of stats.skipped) { @@ -184,9 +180,7 @@ test( }, async 2({process, stats}) { - /** - * Only tests from test1 should run - */ + // Only tests from test1 should run t.is(stats.selectedTestCount, 8); t.is(stats.skipped.length, 8); for (const skipped of stats.skipped) { @@ -196,9 +190,7 @@ test( process.stdin.write('a\n'); }, async 3({stats}) { - /** - * All tests should run - */ + // All tests should run t.is(stats.selectedTestCount, 8); t.is(stats.passed.length, 6); @@ -227,9 +219,7 @@ test( }, async 2({stats}) { - /** - * Only tests from test1 should run - */ + // Only tests from test1 should run t.is(stats.selectedTestCount, 8); t.is(stats.skipped.length, 7); for (const skipped of stats.skipped) { @@ -266,9 +256,7 @@ test( }, async 2({stats}) { - /** - * Only tests from test1 should run - */ + // Only tests from test1 should run t.is(stats.selectedTestCount, 8); t.is(stats.skipped.length, 8); for (const skipped of stats.skipped) { @@ -300,9 +288,7 @@ test( }, async 2({process, stats}) { - /** - * Only tests from test1 should run - */ + // Only tests from test1 should run t.is(stats.selectedTestCount, 8); t.is(stats.skipped.length, 8); for (const skipped of stats.skipped) { @@ -312,9 +298,7 @@ test( process.stdin.write('a\n'); }, async 3({stats}) { - /** - * All tests should run - */ + // All tests should run t.is(stats.selectedTestCount, 8); t.is(stats.passed.length, 6); From 16630d4438515c92263c06cc51466631251dda24 Mon Sep 17 00:00:00 2001 From: Michael Mulet Date: Mon, 7 Apr 2025 16:09:30 -0500 Subject: [PATCH 08/25] implemented fixes --- lib/api.js | 5 +- lib/interactive-filter.js | 78 +++++ lib/reporters/default.js | 13 +- lib/runner.js | 35 +- lib/watch-mode-skip-tests.js | 56 ---- lib/watcher.js | 304 ++++++++---------- lib/worker/base.js | 2 +- package-lock.json | 10 + package.json | 1 + test/watch-mode/basic-functionality.js | 69 ++-- .../fixtures/filter-files/test1.test.js | 7 +- .../fixtures/filter-files/test2.test.js | 7 +- 12 files changed, 264 insertions(+), 323 deletions(-) create mode 100644 lib/interactive-filter.js delete mode 100644 lib/watch-mode-skip-tests.js diff --git a/lib/api.js b/lib/api.js index e32ad724c..7184132f6 100644 --- a/lib/api.js +++ b/lib/api.js @@ -162,6 +162,8 @@ export default class Api extends Emittery { setupOrGlobError = error; } + selectedFiles = selectedFiles.filter(file => runtimeOptions.interactiveFilter?.canSelectTestsInThisFile(file) ?? true); + const selectionInsights = { filter, ignoredFilterPatternFiles: selectedFiles.ignoredFilterPatternFiles ?? [], @@ -206,7 +208,6 @@ export default class Api extends Emittery { runOnlyExclusive: runtimeOptions.runOnlyExclusive === true, firstRun: runtimeOptions.firstRun ?? true, status: runStatus, - watchModeSkipTestsOrUndefined: runtimeOptions.watchModeSkipTestsOrUndefined, }); if (setupOrGlobError) { @@ -274,7 +275,7 @@ export default class Api extends Emittery { providerStates, lineNumbers, recordNewSnapshots: !isCi, - watchModeSkipTestsData: runtimeOptions.watchModeSkipTestsOrUndefined, + interactiveFilterData: runtimeOptions.interactiveFilter?.getData(), // If we're looking for matches, run every single test process in exclusive-only mode runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true, }; diff --git a/lib/interactive-filter.js b/lib/interactive-filter.js new file mode 100644 index 000000000..5759f5329 --- /dev/null +++ b/lib/interactive-filter.js @@ -0,0 +1,78 @@ +/** + * The InteractiveFilter class is used to determine + * if a test should be skipped using filters provided + * by the user in watch mode. + */ +export class InteractiveFilter { + #filepathRegex = null; + + replaceFilepathRegex(filepathRegex) { + const filterHasChanged = !this.#regexesAreEqual(this.#filepathRegex, filepathRegex); + this.#filepathRegex = filepathRegex; + return filterHasChanged; + } + + #testTitleRegex = null; + + replaceTestTitleRegex(testTitleRegex) { + const filterHasChanged = !this.#regexesAreEqual(this.#testTitleRegex, testTitleRegex); + this.#testTitleRegex = testTitleRegex; + return filterHasChanged; + } + + #regexesAreEqual(a, b) { + return a?.source === b?.source && a?.flags === b?.flags; + } + + constructor(interactiveFilterData = undefined) { + if (!interactiveFilterData) { + return; + } + + this.#filepathRegex = interactiveFilterData.filepathRegex; + this.#testTitleRegex = interactiveFilterData.testTitleRegex; + } + + getData() { + return { + filepathRegex: this.#filepathRegex, + testTitleRegex: this.#testTitleRegex, + }; + } + + printFilePathRegex() { + if (!this.#filepathRegex) { + return ''; + } + + return `Current filename filter is ${this.#filepathRegex}`; + } + + printTestTitleRegex() { + if (!this.#testTitleRegex) { + return ''; + } + + return `Current test title filter is ${this.#testTitleRegex}`; + } + + shouldSkipThisFile(file) { + if (this.#filepathRegex === null) { + return false; + } + + return !this.#filepathRegex.test(file); + } + + canSelectTestsInThisFile(file) { + return this.#filepathRegex?.test(file) ?? true; + } + + shouldSelectTest(testTitle) { + return this.#testTitleRegex?.test(testTitle) ?? true; + } + + hasAnyFilters() { + return this.#filepathRegex !== null || this.#testTitleRegex !== null; + } +} diff --git a/lib/reporters/default.js b/lib/reporters/default.js index 3081ac1a0..5402cbd71 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -139,7 +139,6 @@ export default class Reporter { this.previousFailures = plan.previousFailures; this.emptyParallelRun = plan.status.emptyParallelRun; this.selectionInsights = plan.status.selectionInsights; - this.watchModeSkipTestsOrUndefined = plan.watchModeSkipTestsOrUndefined; if (this.watching || plan.files.length > 1) { this.prefixTitle = (testFile, title) => prefixTitle(this.extensions, plan.filePathPrefix, testFile, title); @@ -252,13 +251,11 @@ export default class Reporter { case 'selected-test': { if (event.skip) { - if (!this.watchModeSkipTestsOrUndefined?.shouldSkipEvent(event)) { - this.lineWriter.writeLine( - colors.skip( - `- [skip] ${this.prefixTitle(event.testFile, event.title)}`, - ), - ); - } + this.lineWriter.writeLine( + colors.skip( + `- [skip] ${this.prefixTitle(event.testFile, event.title)}`, + ), + ); } else if (event.todo) { this.lineWriter.writeLine( colors.todo( diff --git a/lib/runner.js b/lib/runner.js index e5f3011f1..0215e8d34 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -6,11 +6,11 @@ import {matcher} from 'matcher'; import ContextRef from './context-ref.js'; import createChain from './create-chain.js'; +import {InteractiveFilter} from './interactive-filter.js'; import parseTestArgs from './parse-test-args.js'; import serializeError from './serialize-error.js'; import {load as loadSnapshots, determineSnapshotDir} from './snapshot-manager.js'; import Runnable from './test.js'; -import {WatchModeSkipTests} from './watch-mode-skip-tests.js'; import {waitForReady} from './worker/state.cjs'; const makeFileURL = file => file.startsWith('file://') ? file : pathToFileURL(file).toString(); @@ -30,10 +30,8 @@ export default class Runner extends Emittery { this.serial = options.serial === true; this.snapshotDir = options.snapshotDir; this.updateSnapshots = options.updateSnapshots; - this.watchModeSkipTests - = new WatchModeSkipTests(options.watchModeSkipTestsData); - this.skipAllTestsInThisFile = this.watchModeSkipTests - .shouldSkipFile(this.file); + this.interactiveFilter + = new InteractiveFilter(options.interactiveFilterData); this.activeRunnables = new Set(); this.boundCompareTestSnapshot = this.compareTestSnapshot.bind(this); @@ -97,13 +95,11 @@ export default class Runner extends Emittery { const {args, implementation, title} = parseTestArgs(testArgs); if ( - this.shouldSkipThisTestBecauseItDoesNotMatchARegexSetAtTheWatchCLI(metadata, title.value) + this.interactiveFilter.shouldSelectTest(title.value) ) { - metadata.skipped = true; - } - - if (this.checkSelectedByLineNumbers) { - metadata.selected = this.checkSelectedByLineNumbers(); + metadata.selected = this.checkSelectedByLineNumbers ? this.checkSelectedByLineNumbers() : true; + } else { + metadata.selected = false; } if (metadata.todo) { @@ -190,7 +186,8 @@ export default class Runner extends Emittery { }, { serial: false, exclusive: false, - skipped: this.skipAllTestsInThisFile, + skipped: false, + selected: false, todo: false, failing: false, callback: false, @@ -421,7 +418,7 @@ export default class Runner extends Emittery { continue; } - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected) { this.snapshots.skipBlock(task.title, task.metadata.taskIndex); continue; } @@ -447,7 +444,7 @@ export default class Runner extends Emittery { continue; } - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected) { this.snapshots.skipBlock(task.title, task.metadata.taskIndex); continue; } @@ -474,7 +471,7 @@ export default class Runner extends Emittery { continue; } - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected) { continue; } @@ -561,12 +558,4 @@ export default class Runner extends Emittery { interrupt() { this.interrupted = true; } - - shouldSkipThisTestBecauseItDoesNotMatchARegexSetAtTheWatchCLI(metadata, testTitle) { - if (metadata.skipped || this.skipAllTestsInThisFile) { - return true; - } - - return this.watchModeSkipTests.shouldSkipTest(testTitle); - } } diff --git a/lib/watch-mode-skip-tests.js b/lib/watch-mode-skip-tests.js deleted file mode 100644 index f8ef8b5b5..000000000 --- a/lib/watch-mode-skip-tests.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * The WatchModeSkipTests class is used to determine - * if a test should be skipped using filters provided - * by the user in watch mode. - */ -export class WatchModeSkipTests { - /** - * Properties are public to allow - * for easy sending to the worker. - */ - fileRegexOrNull = null; - testRegexOrNull = null; - - constructor(watchModeSkipTestsData = undefined) { - if (!watchModeSkipTestsData) { - return; - } - - this.fileRegexOrNull = watchModeSkipTestsData.fileRegexOrNull; - this.testRegexOrNull = watchModeSkipTestsData.testRegexOrNull; - } - - shouldSkipFile(file) { - if (this.fileRegexOrNull === null) { - return false; - } - - return !this.fileRegexOrNull.test(file); - } - - shouldSkipTest(testTitle) { - if (this.testRegexOrNull === null) { - return false; - } - - return !this.testRegexOrNull.test(testTitle); - } - - hasAnyFilters() { - return this.fileRegexOrNull !== null || this.testRegexOrNull !== null; - } - - shouldSkipEvent(event) { - return ( - this.shouldSkipFile(event.testFile) || this.shouldSkipTest(event.title) - ); - } - - replaceFileRegex(fileRegexOrNull) { - this.fileRegexOrNull = fileRegexOrNull; - } - - replaceTestRegex(testRegexOrNull) { - this.testRegexOrNull = testRegexOrNull; - } -} diff --git a/lib/watcher.js b/lib/watcher.js index c91d296c8..aad804745 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -6,13 +6,14 @@ 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, } from './globs.js'; +import {InteractiveFilter} from './interactive-filter.js'; import {levels as providerLevels} from './provider-manager.js'; -import {WatchModeSkipTests} from './watch-mode-skip-tests.js'; const debug = createDebug('ava:watcher'); @@ -20,42 +21,38 @@ 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 endMessage = skipTests => - chalk.gray( - `Type \`p\` and press enter to filter by a filename regex pattern${os.EOL}` - + (skipTests.fileRegexOrNull === null - ? '' - : ` Current filename filter is ${skipTests.fileRegexOrNull}${os.EOL}`) - + `Type \`t\` and press enter to filter by a test name regex pattern${os.EOL}` - + (skipTests.testRegexOrNull === null - ? '' - : ` Current test filter is ${skipTests.testRegexOrNull}${os.EOL}`) - + os.EOL - + (skipTests.hasAnyFilters() - ? `Type \`a\` and press enter to rerun *all* tests${os.EOL}${os.EOL}` - + `Type \`r\` and press enter to rerun tests that match your filters${os.EOL}` - : `Type \`r\` and press enter to rerun tests${os.EOL}`) - + `Type \`u\` and press enter to update snapshots${os.EOL}` - + os.EOL, - ); - -/** - * Once mainstream adoption - * of Promise.withResolvers - * is achieved, simplify this. - * - */ -const promiseWithResolvers - = () => { - let resolve = null; - let reject = null; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - return {promise, resolve, reject}; +const reportEndMessage = (reporter, interactiveFilter) => { + const writeLine = message => { + reporter.lineWriter.writeLine(chalk.gray(message)); }; + writeLine('Type `p` and press enter to filter by a filepath regex pattern'); + + const filePathRegex = interactiveFilter.printFilePathRegex(); + if (filePathRegex) { + writeLine(` ${filePathRegex}`); + } + + writeLine('Type `t` and press enter to filter by a test title regex pattern'); + + const testTitleRegex = interactiveFilter.printTestTitleRegex(); + if (testTitleRegex) { + writeLine(` ${testTitleRegex}`); + } + + writeLine(os.EOL); + if (interactiveFilter.hasAnyFilters()) { + writeLine('Type `a` and press enter to rerun *all* tests'); + writeLine('Type `r` and press enter to rerun tests that match your filters'); + } else { + writeLine('Type `r` and press enter to rerun tests'); + } + + writeLine('Type `u` and press enter to update snapshots'); + writeLine(os.EOL); + reporter.lineWriter.write(chalk.white('command > ')); +}; + export function available(projectDir) { try { fs.watch(projectDir, {persistent: false, recursive: true, signal: AbortSignal.abort()}); @@ -84,10 +81,6 @@ export async function start({api, filter, globs, projectDir, providers, reporter })) { await api.run({files, filter, runtimeOptions}); reporter.endRun(); - reporter.lineWriter.writeLine( - endMessage(runtimeOptions.watchModeSkipTestsOrUndefined), - ); - reporter.lineWriter.write(chalk.white('command > ')); } } @@ -101,7 +94,7 @@ async function * plan({ abortSignal, reporter, }) { - const watchModeSkipTests = new WatchModeSkipTests(); + const interactiveFilter = new InteractiveFilter(); const fileTracer = new FileTracer({base: projectDir}); const isIgnored = buildIgnoreMatcher(globs); @@ -213,153 +206,111 @@ async function * plan({ // Support interactive commands. stdin.setEncoding('utf8'); - /** - * Here is how it works: - * The onDataGenerator function - * is an async generator that listens - * for lines of data from stdin. - * - * We store this generator in the field - * #data which we listen to in both - * the #listenForCommand function, - * and the promptForFilter. By keeping - * one generator instance for both - * we can have nested listeners. - */ - const _class = new (class { - #data; - - constructor() { - this.#data = this.#onDataGenerator(); - this.#listenForCommand(); + const data = (async function * () { + for await (const line of stdin.pipe(split2())) { + yield line.trim(); } + })(); - #onDataGenerator = async function * () { - let p = promiseWithResolvers(); - /** - * Pending data allows the data - * callback to be called multiple - * times before the promise - * wakes up from its await. - */ - const pendingData = []; - stdin.on('data', async data => { - pendingData.push(data); - p.resolve(); - }); - while (true) { - /** - * Loops depend on each other, it makes - * sense to disable the eslint rule - */ - await p.promise; // eslint-disable-line no-await-in-loop - p = promiseWithResolvers(); - while (pendingData.length > 0) { - const lines = pendingData.shift().trim().split('\n'); - for (const line of lines) { - yield line; - } - } - } - }; - #listenForCommand = async () => { - for await (const data of this.#data) { - await this.#onCommand(data); + const promptForFilter = async (currentFilterRegexOrNull, filterName) => { + reporter.lineWriter.writeLine( + chalk.gray( + `${os.EOL}${os.EOL}Current ${filterName} filter is ${ + currentFilterRegexOrNull === null + ? 'empty' + : currentFilterRegexOrNull + }${os.EOL}`, + ), + ); + reporter.lineWriter.writeLine( + chalk.gray( + `Type the ${filterName} regex pattern then press enter:${os.EOL}` + + ` - Leave it blank and press enter to clear the filter${os.EOL}`, + ), + ); + reporter.lineWriter.write('pattern > '); + const generatorResult = (await data.next()); + const patternInput = generatorResult.value.trim(); + let outFilter = null; + if (patternInput !== '') { + try { + const pattern = new RegExp(patternInput); + outFilter = pattern; + reporter.lineWriter.writeLine( + chalk.gray(`Added ${filterName} regex filter: ${pattern}${os.EOL}`), + ); + } catch (error) { + reporter.lineWriter.writeLine( + chalk.gray(`Invalid regex: ${error.message}${os.EOL}`), + ); } - }; - #onCommand = async data => { - data = data.trim().toLowerCase(); + } - switch (data) { - case 'r': { - runSelected = true; - break; - } + return outFilter; + }; - case 'u': { - updateSnapshots = true; - break; - } + const onCommand = async data => { + data = data.trim().toLowerCase(); - case 'a': { - runAll = true; - break; - } + switch (data) { + case 'r': { + runSelected = true; + break; + } - case 'p': { - watchModeSkipTests.replaceFileRegex( - await this.#promptForFilter( - watchModeSkipTests.fileRegexOrNull, - 'filename', - ), - ); - reporter.lineWriter.write(`${os.EOL}${os.EOL}`); - reporter.lineWriter.writeLine(endMessage(watchModeSkipTests)); - reporter.lineWriter.write(chalk.white('command > ')); - runSelected = true; - break; - } + case 'u': { + updateSnapshots = true; + break; + } - case 't': { - watchModeSkipTests.replaceTestRegex( - await this.#promptForFilter( - watchModeSkipTests.testRegexOrNull, - 'test', - ), - ); - reporter.lineWriter.write(`${os.EOL}`); - reporter.lineWriter.writeLine(endMessage(watchModeSkipTests)); - reporter.lineWriter.write(chalk.white('command > ')); - runSelected = true; - break; - } + case 'a': { + runAll = true; + break; + } - default: { - return; - } + case 'p': { + const filterHasChanged = interactiveFilter.replaceFilepathRegex( + await promptForFilter( + interactiveFilter.fileRegexOrNull, + 'filepath', + ), + ); + reporter.lineWriter.write(`${os.EOL}${os.EOL}`); + reportEndMessage(reporter, interactiveFilter); + runSelected = filterHasChanged; + break; } - if (runAll || runSelected || updateSnapshots) { - signalChanged({}); + case 't': { + const filterHasChanged = interactiveFilter.replaceTestTitleRegex( + await promptForFilter( + interactiveFilter.testTitleRegexOrNull, + 'test title', + ), + ); + reporter.lineWriter.write(`${os.EOL}`); + reportEndMessage(reporter, interactiveFilter); + runSelected = filterHasChanged; + break; } - }; - #promptForFilter = async (currentFilterRegexOrNull, filterName) => { - reporter.lineWriter.writeLine( - chalk.gray( - `${os.EOL}${os.EOL}Current ${filterName} filter is ${ - currentFilterRegexOrNull === null - ? 'empty' - : currentFilterRegexOrNull - }${os.EOL}`, - ), - ); - reporter.lineWriter.writeLine( - chalk.gray( - `Type the ${filterName} regex pattern then press enter:${os.EOL}` - + ` - Leave it blank and press enter to clear the filter${os.EOL}`, - ), - ); - reporter.lineWriter.write('pattern > '); - const generatorResult = (await this.#data.next()); - const patternInput = generatorResult.value.trim(); - let outFilter = null; - if (patternInput !== '') { - try { - const pattern = new RegExp(patternInput); - outFilter = pattern; - reporter.lineWriter.writeLine( - chalk.gray(`Added ${filterName} regex filter: ${pattern}${os.EOL}`), - ); - } catch (error) { - reporter.lineWriter.writeLine( - chalk.gray(`Invalid regex: ${error.message}${os.EOL}`), - ); - } + + default: { + return; } + } + + if (runAll || runSelected || updateSnapshots) { + signalChanged({}); + } + }; + + (async () => { + for await (const d of data) { + await onCommand(d); + } + } + )(); - return outFilter; - }; - })(); stdin.unref(); // Whether tests are currently running. Used to control when the next run @@ -599,13 +550,14 @@ async function * plan({ previousFailures, runOnlyExclusive, updateSnapshots, // Value is changed by refresh() so record now. - watchModeSkipTestsOrUndefined: runAll - ? new WatchModeSkipTests() - : watchModeSkipTests, + interactiveFilter: runAll + ? new InteractiveFilter() + : interactiveFilter, }; reset(); // Make sure the next run can be triggered. testsAreRunning = true; yield instructions; // Let the tests run. + reportEndMessage(reporter, interactiveFilter); testsAreRunning = false; debounce.refresh(); // Trigger the callback, which if there were changes will run the tests again. } diff --git a/lib/worker/base.js b/lib/worker/base.js index 6da792c98..71090b1be 100644 --- a/lib/worker/base.js +++ b/lib/worker/base.js @@ -85,7 +85,7 @@ const run = async options => { serial: options.serial, snapshotDir: options.snapshotDir, updateSnapshots: options.updateSnapshots, - watchModeSkipTestsData: options.watchModeSkipTestsData, + interactiveFilterData: options.interactiveFilterData, }); refs.runnerChain = runner.chain; diff --git a/package-lock.json b/package-lock.json index 7fb86d068..14bbc91be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "plur": "^5.1.0", "pretty-ms": "^9.1.0", "resolve-cwd": "^3.0.0", + "split2": "^4.2.0", "stack-utils": "^2.0.6", "strip-ansi": "^7.1.0", "supertap": "^3.0.1", @@ -10943,6 +10944,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 426edad69..c236e7cf6 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "plur": "^5.1.0", "pretty-ms": "^9.1.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/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index a22fc42e9..c6601b667 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -10,11 +10,11 @@ test('prints results and instructions', withFixture('basic'), async (t, fixture) t.regex(stdout, /\d+ tests? passed/); t.regex( stdout, - /Type `p` and press enter to filter by a filename regex pattern/, + /Type `p` and press enter to filter by a filepath regex pattern/, ); t.regex( stdout, - /Type `t` and press enter to filter by a test name regex pattern/, + /Type `t` and press enter to filter by a test title regex pattern/, ); t.regex(stdout, /Type `r` and press enter to rerun tests/); t.regex(stdout, /Type `u` and press enter to update snapshots/); @@ -88,7 +88,7 @@ test('can update snapshots', withFixture('basic'), async (t, fixture) => { }); test( - 'can filter tests by filename pattern', + 'can filter tests by filepath pattern', withFixture('filter-files'), async (t, fixture) => { const test1RegexString = 'test1'; @@ -102,16 +102,13 @@ test( // Set a file filter to only run test1.js process.stdin.write('p\n'); process.stdin.write(`${test1RegexString}\n`); + return stats; }, async 2({stats}) { // Only tests from test1 should run - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 4); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } + t.is(stats.selectedTestCount, 4); t.is(stats.passed.length, 3); for (const skipped of stats.passed) { @@ -130,11 +127,10 @@ test( ); test( - 'can filter tests by filename pattern and have no tests run', + 'can filter tests by filepath pattern and have no tests run', withFixture('filter-files'), async (t, fixture) => { const test1RegexString = 'kangarookanbankentuckykendoll'; - const test1Regex = new RegExp(test1RegexString); await fixture.watch({ async 1({process, stats}) { // First run should run all tests @@ -144,29 +140,23 @@ test( // Set a file filter to only run test1.js process.stdin.write('p\n'); process.stdin.write(`${test1RegexString}\n`); - return stats; - }, - - async 2({stats}) { - // Only tests from test1 should run - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 8); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } + process.send('abort-watcher'); + const {stdout} = await process; + t.regex(stdout, /2 test files were found, but did not match the CLI arguments/); this.done(); + + return stats; }, }); }, ); test( - 'can filter tests by filename pattern, and run all tests with \'a', + 'can filter tests by filepath pattern, and run all tests with \'a', withFixture('filter-files'), async (t, fixture) => { - const test1RegexString = 'kangarookanbankentuckykendoll'; - const test1Regex = new RegExp(test1RegexString); + const test1RegexString = 'test1'; await fixture.watch({ async 1({process, stats}) { // First run should run all tests @@ -180,17 +170,11 @@ test( }, async 2({process, stats}) { - // Only tests from test1 should run - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 8); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } + t.is(stats.selectedTestCount, 4); process.stdin.write('a\n'); }, async 3({stats}) { - // All tests should run t.is(stats.selectedTestCount, 8); t.is(stats.passed.length, 6); @@ -201,7 +185,7 @@ test( ); test( - 'can filter tests by test pattern', + 'can filter tests by test title pattern', withFixture('filter-files'), async (t, fixture) => { const test1RegexString = 'bob'; @@ -219,13 +203,8 @@ test( }, async 2({stats}) { - // Only tests from test1 should run - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 7); - for (const skipped of stats.skipped) { - t.notRegex(skipped.title, test1Regex); - } - + // Only tests that match the test title should run + t.is(stats.selectedTestCount, 1); t.is(stats.passed.length, 1); for (const skipped of stats.passed) { t.regex(skipped.title, test1Regex); @@ -242,7 +221,6 @@ test( withFixture('filter-files'), async (t, fixture) => { const test1RegexString = 'sirnotappearinginthisfilm'; - const test1Regex = new RegExp(test1RegexString); await fixture.watch({ async 1({process, stats}) { // First run should run all tests @@ -256,13 +234,8 @@ test( }, async 2({stats}) { - // Only tests from test1 should run - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 8); - for (const skipped of stats.skipped) { - t.notRegex(skipped.title, test1Regex); - } - + // No tests should run + t.is(stats.selectedTestCount, 0); this.done(); }, }); @@ -288,9 +261,7 @@ test( }, async 2({process, stats}) { - // Only tests from test1 should run - t.is(stats.selectedTestCount, 8); - t.is(stats.skipped.length, 8); + t.is(stats.selectedTestCount, 0); for (const skipped of stats.skipped) { t.notRegex(skipped.file, test1Regex); } diff --git a/test/watch-mode/fixtures/filter-files/test1.test.js b/test/watch-mode/fixtures/filter-files/test1.test.js index 6e28c015f..a5c70aedc 100644 --- a/test/watch-mode/fixtures/filter-files/test1.test.js +++ b/test/watch-mode/fixtures/filter-files/test1.test.js @@ -5,14 +5,13 @@ test("alice", (t) => { }); test("bob", async (t) => { - const bar = Promise.resolve("bar"); - t.is(await bar, "bar"); + t.pass(); }); test("catherine", async (t) => { - t.is(1, 1); + t.pass(); }); test("david", async (t) => { - t.is(1, 2); + t.fail(); }); diff --git a/test/watch-mode/fixtures/filter-files/test2.test.js b/test/watch-mode/fixtures/filter-files/test2.test.js index 0b3304753..52927aeb5 100644 --- a/test/watch-mode/fixtures/filter-files/test2.test.js +++ b/test/watch-mode/fixtures/filter-files/test2.test.js @@ -5,14 +5,13 @@ test("emma", (t) => { }); test("frank", async (t) => { - const bar = Promise.resolve("bar"); - t.is(await bar, "bar"); + t.pass(); }); test("gina", async (t) => { - t.is(1, 1); + t.pass(); }); test("harry", async (t) => { - t.is(1, 2); + t.fail(); }); From 0b88520559c35b9e8852c77c090bd8a9b5dc8f6f Mon Sep 17 00:00:00 2001 From: Michael Mulet Date: Mon, 7 Apr 2025 16:12:04 -0500 Subject: [PATCH 09/25] lint fix --- lib/runner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/runner.js b/lib/runner.js index 0215e8d34..365a50e59 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -409,7 +409,7 @@ 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) { From 63ff4fc303ccb16a207807b1b20db49f3cda6515 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 17 Apr 2025 12:05:45 +0200 Subject: [PATCH 10/25] Revert whitespace changes in reporter --- lib/reporters/default.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/reporters/default.js b/lib/reporters/default.js index 5402cbd71..a70f24e4e 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -251,17 +251,9 @@ export default class Reporter { case 'selected-test': { if (event.skip) { - this.lineWriter.writeLine( - colors.skip( - `- [skip] ${this.prefixTitle(event.testFile, event.title)}`, - ), - ); + this.lineWriter.writeLine(colors.skip(`- [skip] ${this.prefixTitle(event.testFile, event.title)}`)); } else if (event.todo) { - this.lineWriter.writeLine( - colors.todo( - `- [todo] ${this.prefixTitle(event.testFile, event.title)}`, - ), - ); + this.lineWriter.writeLine(colors.todo(`- [todo] ${this.prefixTitle(event.testFile, event.title)}`)); } break; From 0948247f3e1ed99f50e1afc3797ec7f5fabe2892 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 25 Apr 2025 14:42:21 +0200 Subject: [PATCH 11/25] Revert test worker changes We'll adjust the test title matching implementation instead. --- lib/runner.js | 19 ++++++------------- lib/worker/base.js | 1 - 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/lib/runner.js b/lib/runner.js index 365a50e59..140c49c81 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -6,7 +6,6 @@ import {matcher} from 'matcher'; import ContextRef from './context-ref.js'; import createChain from './create-chain.js'; -import {InteractiveFilter} from './interactive-filter.js'; import parseTestArgs from './parse-test-args.js'; import serializeError from './serialize-error.js'; import {load as loadSnapshots, determineSnapshotDir} from './snapshot-manager.js'; @@ -30,8 +29,6 @@ export default class Runner extends Emittery { this.serial = options.serial === true; this.snapshotDir = options.snapshotDir; this.updateSnapshots = options.updateSnapshots; - this.interactiveFilter - = new InteractiveFilter(options.interactiveFilterData); this.activeRunnables = new Set(); this.boundCompareTestSnapshot = this.compareTestSnapshot.bind(this); @@ -94,12 +91,9 @@ export default class Runner extends Emittery { metadata.taskIndex = this.nextTaskIndex++; const {args, implementation, title} = parseTestArgs(testArgs); - if ( - this.interactiveFilter.shouldSelectTest(title.value) - ) { - metadata.selected = this.checkSelectedByLineNumbers ? this.checkSelectedByLineNumbers() : true; - } else { - metadata.selected = false; + + if (this.checkSelectedByLineNumbers) { + metadata.selected = this.checkSelectedByLineNumbers(); } if (metadata.todo) { @@ -187,7 +181,6 @@ export default class Runner extends Emittery { serial: false, exclusive: false, skipped: false, - selected: false, todo: false, failing: false, callback: false, @@ -418,7 +411,7 @@ export default class Runner extends Emittery { continue; } - if (!task.metadata.selected) { + if (this.checkSelectedByLineNumbers && !task.metadata.selected) { this.snapshots.skipBlock(task.title, task.metadata.taskIndex); continue; } @@ -444,7 +437,7 @@ export default class Runner extends Emittery { continue; } - if (!task.metadata.selected) { + if (this.checkSelectedByLineNumbers && !task.metadata.selected) { this.snapshots.skipBlock(task.title, task.metadata.taskIndex); continue; } @@ -471,7 +464,7 @@ export default class Runner extends Emittery { continue; } - if (!task.metadata.selected) { + if (this.checkSelectedByLineNumbers && !task.metadata.selected) { continue; } diff --git a/lib/worker/base.js b/lib/worker/base.js index 7cb58fe47..28f174bb6 100644 --- a/lib/worker/base.js +++ b/lib/worker/base.js @@ -85,7 +85,6 @@ const run = async options => { serial: options.serial, snapshotDir: options.snapshotDir, updateSnapshots: options.updateSnapshots, - interactiveFilterData: options.interactiveFilterData, }); refs.runnerChain = runner.chain; From 747ab06e6a2ca9c6fa4bff2787ea7e2bf5416faf Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Wed, 30 Apr 2025 21:40:46 +0200 Subject: [PATCH 12/25] Move interactive filters tests to their own file --- test/watch-mode/basic-functionality.js | 192 ------------------------ test/watch-mode/interactive-filters.js | 193 +++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 192 deletions(-) create mode 100644 test/watch-mode/interactive-filters.js diff --git a/test/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index c6601b667..60245043e 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -86,195 +86,3 @@ test('can update snapshots', withFixture('basic'), async (t, fixture) => { }, }); }); - -test( - 'can filter tests by filepath pattern', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'test1'; - const test1Regex = new RegExp(test1RegexString); - 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - - return stats; - }, - - 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, test1Regex); - } - - t.is(stats.failed.length, 1); - for (const skipped of stats.failed) { - t.regex(skipped.file, test1Regex); - } - - this.done(); - }, - }); - }, -); - -test( - 'can filter tests by filepath pattern and have no tests run', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'kangarookanbankentuckykendoll'; - 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - - process.send('abort-watcher'); - const {stdout} = await process; - t.regex(stdout, /2 test files were found, but did not match the CLI arguments/); - this.done(); - - return stats; - }, - }); - }, -); - -test( - 'can filter tests by filepath pattern, and run all tests with \'a', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'test1'; - 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - }, - - 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 test title pattern', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'bob'; - const test1Regex = new RegExp(test1RegexString); - 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.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - }, - - 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 skipped of stats.passed) { - t.regex(skipped.title, test1Regex); - } - - this.done(); - }, - }); - }, -); - -test( - 'can filter tests by test pattern and have no tests run', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'sirnotappearinginthisfilm'; - 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.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - }, - - async 2({stats}) { - // No tests should run - t.is(stats.selectedTestCount, 0); - this.done(); - }, - }); - }, -); - -test( - 'can filter tests by test pattern, and run all tests with \'a', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'sirnotappearinginthisfilm'; - const test1Regex = new RegExp(test1RegexString); - 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.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - }, - - async 2({process, stats}) { - t.is(stats.selectedTestCount, 0); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } - - process.stdin.write('a\n'); - }, - async 3({stats}) { - // All tests should run - t.is(stats.selectedTestCount, 8); - t.is(stats.passed.length, 6); - - this.done(); - }, - }); - }, -); diff --git a/test/watch-mode/interactive-filters.js b/test/watch-mode/interactive-filters.js new file mode 100644 index 000000000..16a065bab --- /dev/null +++ b/test/watch-mode/interactive-filters.js @@ -0,0 +1,193 @@ +import {test, withFixture} from './helpers/watch.js'; + +test( + 'can filter tests by filepath pattern', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'test1'; + const test1Regex = new RegExp(test1RegexString); + 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + + return stats; + }, + + 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, test1Regex); + } + + t.is(stats.failed.length, 1); + for (const skipped of stats.failed) { + t.regex(skipped.file, test1Regex); + } + + this.done(); + }, + }); + }, +); + +test( + 'can filter tests by filepath pattern and have no tests run', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'kangarookanbankentuckykendoll'; + 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + + process.send('abort-watcher'); + const {stdout} = await process; + t.regex(stdout, /2 test files were found, but did not match the CLI arguments/); + this.done(); + + return stats; + }, + }); + }, +); + +test( + 'can filter tests by filepath pattern, and run all tests with \'a', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'test1'; + 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.js + process.stdin.write('p\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + }, + + 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 test title pattern', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'bob'; + const test1Regex = new RegExp(test1RegexString); + 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + }, + + 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 skipped of stats.passed) { + t.regex(skipped.title, test1Regex); + } + + this.done(); + }, + }); + }, +); + +test( + 'can filter tests by test pattern and have no tests run', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'sirnotappearinginthisfilm'; + 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + }, + + async 2({stats}) { + // No tests should run + t.is(stats.selectedTestCount, 0); + this.done(); + }, + }); + }, +); + +test( + 'can filter tests by test pattern, and run all tests with \'a', + withFixture('filter-files'), + async (t, fixture) => { + const test1RegexString = 'sirnotappearinginthisfilm'; + const test1Regex = new RegExp(test1RegexString); + 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.js + process.stdin.write('t\n'); + process.stdin.write(`${test1RegexString}\n`); + return stats; + }, + + async 2({process, stats}) { + t.is(stats.selectedTestCount, 0); + for (const skipped of stats.skipped) { + t.notRegex(skipped.file, test1Regex); + } + + process.stdin.write('a\n'); + }, + async 3({stats}) { + // All tests should run + t.is(stats.selectedTestCount, 8); + t.is(stats.passed.length, 6); + + this.done(); + }, + }); + }, +); From 8c14e1a5a2084eeeeae6eaab61ed0bf2ae9a53a6 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 20:54:53 +0200 Subject: [PATCH 13/25] Improve empty line tracking in reporter * Last line is initially empty * Override write() to record that the last line is not empty * Remove unused property declaration --- lib/reporters/default.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/reporters/default.js b/lib/reporters/default.js index a70f24e4e..44856ba6c 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) { @@ -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; From 1ade579e6367a2e4550b953b4236fe53318689b6 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 21:00:34 +0200 Subject: [PATCH 14/25] Add option in reporter line writer to not indent the line Interactive prompts in watch mode look better without indentation. --- lib/reporters/default.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/reporters/default.js b/lib/reporters/default.js index 44856ba6c..235db673f 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -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); From 68dd12f1a3e463a2a0f34961b2653676c11da64a Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 21:02:55 +0200 Subject: [PATCH 15/25] Remove dead chokidar link reference definition --- docs/recipes/watch-mode.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index d861ef31e..6974f5445 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -100,7 +100,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 From 9c1e8c14e8fb4294c7a011b9683c3b5e4b870668 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 21:13:40 +0200 Subject: [PATCH 16/25] Remove special .only() behavior in watch mode This is a historical feature back from when AVA tried to persist "runExclusive" during regular runs. This behavior conflicts with interactive mode, and in any case interactive mode makes it easier to focus on a specific test file or title. --- docs/recipes/watch-mode.md | 4 ---- lib/api.js | 3 --- lib/runner.js | 2 +- lib/watcher.js | 33 ++++----------------------------- lib/worker/base.js | 1 - test-tap/runner.js | 14 -------------- test/watch-mode/scenarios.js | 18 ------------------ 7 files changed, 5 insertions(+), 70 deletions(-) diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 6974f5445..32731c7b1 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -76,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. diff --git a/lib/api.js b/lib/api.js index 3b8523d4b..f3b2bbb88 100644 --- a/lib/api.js +++ b/lib/api.js @@ -205,7 +205,6 @@ export default class Api extends Emittery { files: selectedFiles, matching: apiOptions.match.length > 0, previousFailures: runtimeOptions.previousFailures ?? 0, - runOnlyExclusive: runtimeOptions.runOnlyExclusive === true, firstRun: runtimeOptions.firstRun ?? true, status: runStatus, }); @@ -275,8 +274,6 @@ export default class Api extends Emittery { lineNumbers, recordNewSnapshots: !isCi, interactiveFilterData: runtimeOptions.interactiveFilter?.getData(), - // If we're looking for matches, run every single test process in exclusive-only mode - runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true, }; if (runtimeOptions.updateSnapshots) { diff --git a/lib/runner.js b/lib/runner.js index 140c49c81..604b672b0 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -25,7 +25,6 @@ export default class Runner extends Emittery { this.match = 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 +33,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 = { diff --git a/lib/watcher.js b/lib/watcher.js index aad804745..5903abf41 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -48,7 +48,7 @@ const reportEndMessage = (reporter, interactiveFilter) => { writeLine('Type `r` and press enter to rerun tests'); } - writeLine('Type `u` and press enter to update snapshots'); + writeLine('Type `u` followed by enter to update snapshots in selected tests'); writeLine(os.EOL); reporter.lineWriter.write(chalk.white('command > ')); }; @@ -129,7 +129,6 @@ async function * plan({ })))); // State tracked for test runs. - const filesWithExclusiveTests = new Set(); const touchedFiles = new Set(); const temporaryFiles = new Set(); const failureCounts = new Map(); @@ -167,17 +166,6 @@ async function * plan({ 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; } @@ -479,18 +467,6 @@ async function * plan({ // 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({ @@ -505,14 +481,14 @@ async function * plan({ if (nonTestFiles.length > 0) { debug('Non-test files changed, running all tests'); failureCounts.clear(); // All tests are run, so clear previous failures. - signalChanged({runOnlyExclusive}); + signalChanged({}); } 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({testFiles}); } takeCoverageForSelfTests?.(); @@ -533,7 +509,7 @@ async function * plan({ // 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: files = []} = await changed; // eslint-disable-line no-await-in-loop if (abortSignal?.aborted) { break; @@ -548,7 +524,6 @@ async function * plan({ 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. interactiveFilter: runAll ? new InteractiveFilter() 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/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/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}) { From 65acae4cf4293795f3d72e08b9ad8691ca2deeb6 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 21:16:03 +0200 Subject: [PATCH 17/25] Implement --match using the selected flag This is a newer flag, previously used for line number selection. It's a better choice than overloading the `exclusive` flag used with `.only()`. --- lib/runner.js | 67 +++++++++++++++++++++------------------------------ 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/lib/runner.js b/lib/runner.js index 604b672b0..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,7 +31,7 @@ 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.serial = options.serial === true; @@ -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, @@ -254,8 +255,8 @@ export default class Runner extends Emittery { await runnables.reduce((previous, runnable) => { // eslint-disable-line unicorn/no-array-reduce if (runnable.metadata.serial || this.serial) { waitForSerial = previous.then(() => - // Serial runnables run as long as there was no previous failure, unless - // the runnable should always be run. + // Serial runnables run as long as there was no previous failure, unless + // the runnable should always be run. (allPassed || runnable.metadata.always) && runAndStoreResult(runnable), ); return waitForSerial; @@ -264,10 +265,10 @@ export default class Runner extends Emittery { return Promise.all([ previous, waitForSerial.then(() => - // Concurrent runnables are kicked off after the previous serial - // runnables have completed, as long as there was no previous failure - // (or if the runnable should always be run). One concurrent runnable's - // failure does not prevent the next runnable from running. + // Concurrent runnables are kicked off after the previous serial + // runnables have completed, as long as there was no previous failure + // (or if the runnable should always be run). One concurrent runnable's + // failure does not prevent the next runnable from running. (allPassed || runnable.metadata.always) && runAndStoreResult(runnable), ), ]); @@ -406,12 +407,7 @@ export default class Runner extends Emittery { 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; } From 6f2adfff62909958f08495bce561e2651b322167 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 21:36:21 +0200 Subject: [PATCH 18/25] Place available() function before reportEndMessage It doesn't rely on any utility methods so it's clearer to list it first rather than having it pop up further down the file. --- lib/watcher.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index 5903abf41..780aea3fd 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -21,6 +21,20 @@ 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; +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 reportEndMessage = (reporter, interactiveFilter) => { const writeLine = message => { reporter.lineWriter.writeLine(chalk.gray(message)); @@ -53,20 +67,6 @@ const reportEndMessage = (reporter, interactiveFilter) => { reporter.lineWriter.write(chalk.white('command > ')); }; -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; -} - 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({ From 74d40771f4962a5a956910e15c696ff6fb4b6b00 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 21:45:12 +0200 Subject: [PATCH 19/25] Use helpers to read lines and process commands IIFEs are a little awkward, plus we can trim values in the helper. --- lib/watcher.js | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index 780aea3fd..549f9a7e8 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -35,6 +35,20 @@ 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 reportEndMessage = (reporter, interactiveFilter) => { const writeLine = message => { reporter.lineWriter.writeLine(chalk.gray(message)); @@ -191,14 +205,10 @@ async function * plan({ runSelected = false; }; - // Support interactive commands. - stdin.setEncoding('utf8'); + const lineReader = readLines(stdin); - const data = (async function * () { - for await (const line of stdin.pipe(split2())) { - yield line.trim(); - } - })(); + // Don't let the reader keep the process alive. + stdin.unref(); const promptForFilter = async (currentFilterRegexOrNull, filterName) => { reporter.lineWriter.writeLine( @@ -217,8 +227,7 @@ async function * plan({ ), ); reporter.lineWriter.write('pattern > '); - const generatorResult = (await data.next()); - const patternInput = generatorResult.value.trim(); + const {value: patternInput = ''} = await lineReader.next(); let outFilter = null; if (patternInput !== '') { try { @@ -237,10 +246,14 @@ async function * plan({ return outFilter; }; - const onCommand = async data => { - data = data.trim().toLowerCase(); + const lineReader = readLines(stdin); - switch (data) { + // Don't let the reader keep the process alive. + stdin.unref(); + + // Handle commands. + eachLine(lineReader, async line => { + switch (line.toLowerCase()) { case 'r': { runSelected = true; break; @@ -290,16 +303,7 @@ async function * plan({ if (runAll || runSelected || updateSnapshots) { signalChanged({}); } - }; - - (async () => { - for await (const d of data) { - await onCommand(d); - } - } - )(); - - stdin.unref(); + }); // Whether tests are currently running. Used to control when the next run // is prepared. From 8561644686d2afbdc4d69b26dba0bd863ae2de02 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 22:03:35 +0200 Subject: [PATCH 20/25] Refactor command instructions and filter prompts for improved clarity and usability Change "run all" to reset filters. Clear prompts before running. --- lib/watcher.js | 152 ++++++++++++++----------- test/watch-mode/basic-functionality.js | 14 +-- 2 files changed, 89 insertions(+), 77 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index 549f9a7e8..7fbf91367 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -49,36 +49,82 @@ const eachLine = async (lineReader, callback) => { } }; -const reportEndMessage = (reporter, interactiveFilter) => { - const writeLine = message => { - reporter.lineWriter.writeLine(chalk.gray(message)); - }; +const writeCommandInstructions = (reporter, interactiveFilter) => { + reporter.lineWriter.writeLine(chalk.gray('Type `p` followed by enter to filter test files by a regex pattern')); + reporter.lineWriter.writeLine(chalk.gray('Type `t` followed by enter to filter tests by their title (using a regex pattern)')); + if (interactiveFilter.hasAnyFilters()) { + reporter.lineWriter.writeLine(chalk.gray('Type `a` followed by enter to reset filters and rerun all tests')); + 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')); - writeLine('Type `p` and press enter to filter by a filepath regex pattern'); + if (interactiveFilter.hasAnyFilters()) { + reporter.lineWriter.writeLine(); + + const filePathRegex = interactiveFilter.printFilePathRegex(); + if (filePathRegex) { + reporter.lineWriter.writeLine(chalk.gray(`Current test file pattern: ${chalk.italic(filePathRegex)}`)); + } - const filePathRegex = interactiveFilter.printFilePathRegex(); - if (filePathRegex) { - writeLine(` ${filePathRegex}`); + const testTitleRegex = interactiveFilter.printTestTitleRegex(); + if (testTitleRegex) { + reporter.lineWriter.writeLine(chalk.gray(`Current test title pattern: ${chalk.italic(testTitleRegex)}`)); + } } - writeLine('Type `t` and press enter to filter by a test title regex pattern'); + reporter.lineWriter.writeLine(); + reporter.lineWriter.write('> '); +}; - const testTitleRegex = interactiveFilter.printTestTitleRegex(); - if (testTitleRegex) { - writeLine(` ${testTitleRegex}`); +const promptForFileFilter = async (reporter, lineReader, currentFilterRegexOrNull) => { + reporter.lineWriter.ensureEmptyLine(); + reporter.lineWriter.writeLine('Type the file regex pattern then press enter. Leave blank to clear.', false); + if (currentFilterRegexOrNull !== null) { + reporter.lineWriter.writeLine(); + reporter.lineWriter.writeLine(chalk.gray(`Current file regex pattern is: ${chalk.italic(currentFilterRegexOrNull)}`)); } - writeLine(os.EOL); - if (interactiveFilter.hasAnyFilters()) { - writeLine('Type `a` and press enter to rerun *all* tests'); - writeLine('Type `r` and press enter to rerun tests that match your filters'); - } else { - writeLine('Type `r` and press enter to rerun tests'); + reporter.lineWriter.write('> '); + + const {value: patternInput = ''} = await lineReader.next(); + if (patternInput !== '') { + try { + return new RegExp(patternInput); + } catch (error) { + reporter.lineWriter.writeLine( + chalk.gray(`Invalid regex: ${error.message}${os.EOL}`), + ); + } } - writeLine('Type `u` followed by enter to update snapshots in selected tests'); - writeLine(os.EOL); - reporter.lineWriter.write(chalk.white('command > ')); + return null; +}; + +const promptForTitleFilter = async (reporter, lineReader, currentFilterRegexOrNull) => { + reporter.lineWriter.ensureEmptyLine(); + reporter.lineWriter.writeLine('Type the title regex pattern then press enter. Leave blank to clear.', false); + if (currentFilterRegexOrNull !== null) { + reporter.lineWriter.writeLine(); + reporter.lineWriter.writeLine(chalk.gray(`Current title regex pattern is: ${chalk.italic(currentFilterRegexOrNull)}`)); + } + + reporter.lineWriter.write('> '); + + const {value: patternInput = ''} = await lineReader.next(); + if (patternInput !== '') { + try { + return new RegExp(patternInput); + } catch (error) { + reporter.lineWriter.writeLine( + chalk.gray(`Invalid regex: ${error.message}${os.EOL}`), + ); + } + } + + return null; }; export async function start({api, filter, globs, projectDir, providers, reporter, stdin, signal}) { @@ -210,47 +256,6 @@ async function * plan({ // Don't let the reader keep the process alive. stdin.unref(); - const promptForFilter = async (currentFilterRegexOrNull, filterName) => { - reporter.lineWriter.writeLine( - chalk.gray( - `${os.EOL}${os.EOL}Current ${filterName} filter is ${ - currentFilterRegexOrNull === null - ? 'empty' - : currentFilterRegexOrNull - }${os.EOL}`, - ), - ); - reporter.lineWriter.writeLine( - chalk.gray( - `Type the ${filterName} regex pattern then press enter:${os.EOL}` - + ` - Leave it blank and press enter to clear the filter${os.EOL}`, - ), - ); - reporter.lineWriter.write('pattern > '); - const {value: patternInput = ''} = await lineReader.next(); - let outFilter = null; - if (patternInput !== '') { - try { - const pattern = new RegExp(patternInput); - outFilter = pattern; - reporter.lineWriter.writeLine( - chalk.gray(`Added ${filterName} regex filter: ${pattern}${os.EOL}`), - ); - } catch (error) { - reporter.lineWriter.writeLine( - chalk.gray(`Invalid regex: ${error.message}${os.EOL}`), - ); - } - } - - return outFilter; - }; - - const lineReader = readLines(stdin); - - // Don't let the reader keep the process alive. - stdin.unref(); - // Handle commands. eachLine(lineReader, async line => { switch (line.toLowerCase()) { @@ -266,31 +271,37 @@ async function * plan({ case 'a': { runAll = true; + interactiveFilter.replaceFilepathRegex(null); + interactiveFilter.replaceTestTitleRegex(null); break; } case 'p': { const filterHasChanged = interactiveFilter.replaceFilepathRegex( - await promptForFilter( + await promptForFileFilter( + reporter, + lineReader, interactiveFilter.fileRegexOrNull, 'filepath', ), ); reporter.lineWriter.write(`${os.EOL}${os.EOL}`); - reportEndMessage(reporter, interactiveFilter); + writeCommandInstructions(reporter, interactiveFilter); runSelected = filterHasChanged; break; } case 't': { const filterHasChanged = interactiveFilter.replaceTestTitleRegex( - await promptForFilter( + await promptForTitleFilter( + reporter, + lineReader, interactiveFilter.testTitleRegexOrNull, 'test title', ), ); reporter.lineWriter.write(`${os.EOL}`); - reportEndMessage(reporter, interactiveFilter); + writeCommandInstructions(reporter, interactiveFilter); runSelected = filterHasChanged; break; } @@ -534,9 +545,16 @@ async function * plan({ : interactiveFilter, }; reset(); // Make sure the next run can be triggered. + + // Clear any prompt. + if (!reporter.lineWriter.lastLineIsEmpty && reporter.reportStream.isTTY) { + reporter.reportStream.clearLine(0); + reporter.lineWriter.writeLine(); + } + testsAreRunning = true; yield instructions; // Let the tests run. - reportEndMessage(reporter, interactiveFilter); + writeCommandInstructions(reporter, interactiveFilter); testsAreRunning = false; debounce.refresh(); // Trigger the callback, which if there were changes will run the tests again. } diff --git a/test/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index 60245043e..fd00bc12c 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -8,16 +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 `p` and press enter to filter by a filepath regex pattern/, - ); - t.regex( - stdout, - /Type `t` and press enter to filter by a test title regex pattern/, - ); - 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 `p` followed by enter to filter test files by a regex pattern/); + t.regex(stdout, /Type `t` 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(); }, }); From 7b98b2d2e9a961b3d35ccd8b265a7c1814809a4b Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 22:13:33 +0200 Subject: [PATCH 21/25] Simplify change signaling, don't require an argument --- lib/watcher.js | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index 7fbf91367..5601fbd90 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -239,7 +239,6 @@ async function * plan({ let firstRun = true; let runAll = true; let updateSnapshots = false; - let runSelected = false; const reset = () => { changed = new Promise(resolve => { @@ -248,7 +247,6 @@ async function * plan({ firstRun = false; runAll = false; updateSnapshots = false; - runSelected = false; }; const lineReader = readLines(stdin); @@ -260,12 +258,13 @@ async function * plan({ eachLine(lineReader, async line => { switch (line.toLowerCase()) { case 'r': { - runSelected = true; + signalChanged(); break; } case 'u': { updateSnapshots = true; + signalChanged(); break; } @@ -273,6 +272,7 @@ async function * plan({ runAll = true; interactiveFilter.replaceFilepathRegex(null); interactiveFilter.replaceTestTitleRegex(null); + signalChanged(); break; } @@ -287,7 +287,10 @@ async function * plan({ ); reporter.lineWriter.write(`${os.EOL}${os.EOL}`); writeCommandInstructions(reporter, interactiveFilter); - runSelected = filterHasChanged; + if (filterHasChanged) { + signalChanged(); + } + break; } @@ -302,18 +305,17 @@ async function * plan({ ); reporter.lineWriter.write(`${os.EOL}`); writeCommandInstructions(reporter, interactiveFilter); - runSelected = filterHasChanged; + if (filterHasChanged) { + signalChanged(); + } + break; } default: { - return; + break; } } - - if (runAll || runSelected || updateSnapshots) { - signalChanged({}); - } }); // Whether tests are currently running. Used to control when the next run @@ -496,7 +498,7 @@ async function * plan({ if (nonTestFiles.length > 0) { debug('Non-test files changed, running all tests'); failureCounts.clear(); // All tests are run, so clear previous failures. - signalChanged({}); + signalChanged(); } else if (testFiles.length > 0) { // Remove previous failures for tests that will run again. for (const path of testFiles) { @@ -519,12 +521,12 @@ async function * plan({ }); abortSignal?.addEventListener('abort', () => { - signalChanged?.({}); + signalChanged?.(); }); // And finally, the watch loop. while (abortSignal?.aborted !== true) { - const {testFiles: files = []} = await changed; // eslint-disable-line no-await-in-loop + const {testFiles: files = []} = (await changed) ?? {}; // eslint-disable-line no-await-in-loop if (abortSignal?.aborted) { break; From fe34e34224fccc333059f8e1eb2a9f3baa8d629a Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 22:17:11 +0200 Subject: [PATCH 22/25] Don't respond to changes when prompting for filters --- lib/watcher.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index 5601fbd90..5df57b568 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -277,6 +277,7 @@ async function * plan({ } case 'p': { + respondToChanges = false; const filterHasChanged = interactiveFilter.replaceFilepathRegex( await promptForFileFilter( reporter, @@ -285,16 +286,20 @@ async function * plan({ 'filepath', ), ); + respondToChanges = true; reporter.lineWriter.write(`${os.EOL}${os.EOL}`); writeCommandInstructions(reporter, interactiveFilter); if (filterHasChanged) { signalChanged(); + } else { + debounce.refresh(); } break; } case 't': { + respondToChanges = false; const filterHasChanged = interactiveFilter.replaceTestTitleRegex( await promptForTitleFilter( reporter, @@ -303,10 +308,13 @@ async function * plan({ 'test title', ), ); + respondToChanges = true; reporter.lineWriter.write(`${os.EOL}`); writeCommandInstructions(reporter, interactiveFilter); if (filterHasChanged) { signalChanged(); + } else { + debounce.refresh(); } break; @@ -318,9 +326,8 @@ async function * plan({ } }); - // Whether tests are currently running. Used to control when the next run - // is prepared. - let testsAreRunning = false; + // 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(); @@ -332,9 +339,9 @@ async function * plan({ 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; } @@ -554,10 +561,10 @@ async function * plan({ reporter.lineWriter.writeLine(); } - testsAreRunning = true; + respondToChanges = false; yield instructions; // Let the tests run. writeCommandInstructions(reporter, interactiveFilter); - testsAreRunning = false; + respondToChanges = true; debounce.refresh(); // Trigger the callback, which if there were changes will run the tests again. } } From 95759708cf96b876d0267b052755a0d17176f7c8 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 21:20:13 +0200 Subject: [PATCH 23/25] Implement interactive file filters using globs * Allow watcher logic to override default file selection * Remove from interactive filter class * Count previous failures after watcher has modified file selection. During this modification it may discard previous failures, which means it can't provide an accurate count when starting the run. --- lib/api.js | 12 +- lib/interactive-filter.js | 32 +---- lib/reporters/default.js | 2 +- lib/watcher.js | 184 +++++++++++++++---------- test-tap/helper/report.js | 6 +- test/watch-mode/basic-functionality.js | 2 +- test/watch-mode/interactive-filters.js | 115 +++++++--------- 7 files changed, 173 insertions(+), 180 deletions(-) diff --git a/lib/api.js b/lib/api.js index f3b2bbb88..0b0ddb8da 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), @@ -162,10 +164,8 @@ export default class Api extends Emittery { setupOrGlobError = error; } - selectedFiles = selectedFiles.filter(file => runtimeOptions.interactiveFilter?.canSelectTestsInThisFile(file) ?? true); - const selectionInsights = { - filter, + filter: selectedFiles.appliedFilters ?? filter, ignoredFilterPatternFiles: selectedFiles.ignoredFilterPatternFiles ?? [], testFileCount: testFiles.length, selectionCount: selectedFiles.length, @@ -204,7 +204,7 @@ export default class Api extends Emittery { filePathPrefix: getFilePathPrefix(selectedFiles), files: selectedFiles, matching: apiOptions.match.length > 0, - previousFailures: runtimeOptions.previousFailures ?? 0, + previousFailures: runtimeOptions.countPreviousFailures?.() ?? 0, firstRun: runtimeOptions.firstRun ?? true, status: runStatus, }); diff --git a/lib/interactive-filter.js b/lib/interactive-filter.js index 5759f5329..db484c014 100644 --- a/lib/interactive-filter.js +++ b/lib/interactive-filter.js @@ -4,14 +4,6 @@ * by the user in watch mode. */ export class InteractiveFilter { - #filepathRegex = null; - - replaceFilepathRegex(filepathRegex) { - const filterHasChanged = !this.#regexesAreEqual(this.#filepathRegex, filepathRegex); - this.#filepathRegex = filepathRegex; - return filterHasChanged; - } - #testTitleRegex = null; replaceTestTitleRegex(testTitleRegex) { @@ -29,25 +21,15 @@ export class InteractiveFilter { return; } - this.#filepathRegex = interactiveFilterData.filepathRegex; this.#testTitleRegex = interactiveFilterData.testTitleRegex; } getData() { return { - filepathRegex: this.#filepathRegex, testTitleRegex: this.#testTitleRegex, }; } - printFilePathRegex() { - if (!this.#filepathRegex) { - return ''; - } - - return `Current filename filter is ${this.#filepathRegex}`; - } - printTestTitleRegex() { if (!this.#testTitleRegex) { return ''; @@ -56,23 +38,11 @@ export class InteractiveFilter { return `Current test title filter is ${this.#testTitleRegex}`; } - shouldSkipThisFile(file) { - if (this.#filepathRegex === null) { - return false; - } - - return !this.#filepathRegex.test(file); - } - - canSelectTestsInThisFile(file) { - return this.#filepathRegex?.test(file) ?? true; - } - shouldSelectTest(testTitle) { return this.#testTitleRegex?.test(testTitle) ?? true; } hasAnyFilters() { - return this.#filepathRegex !== null || this.#testTitleRegex !== null; + return this.#testTitleRegex !== null; } } diff --git a/lib/reporters/default.js b/lib/reporters/default.js index 235db673f..7da27c797 100644 --- a/lib/reporters/default.js +++ b/lib/reporters/default.js @@ -632,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/watcher.js b/lib/watcher.js index 5df57b568..821b9c860 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -11,6 +11,7 @@ import split2 from 'split2'; import {chalk} from './chalk.js'; import { applyTestFileFilter, classify, buildIgnoreMatcher, findTests, + normalizePattern, } from './globs.js'; import {InteractiveFilter} from './interactive-filter.js'; import {levels as providerLevels} from './provider-manager.js'; @@ -49,10 +50,10 @@ const eachLine = async (lineReader, callback) => { } }; -const writeCommandInstructions = (reporter, interactiveFilter) => { - reporter.lineWriter.writeLine(chalk.gray('Type `p` followed by enter to filter test files by a regex pattern')); +const writeCommandInstructions = (reporter, interactiveGlobPattern, interactiveFilter) => { + reporter.lineWriter.writeLine(chalk.gray('Type `g` followed by enter to filter test files by a glob pattern')); reporter.lineWriter.writeLine(chalk.gray('Type `t` followed by enter to filter tests by their title (using a regex pattern)')); - if (interactiveFilter.hasAnyFilters()) { + if (interactiveGlobPattern || interactiveFilter.hasAnyFilters()) { reporter.lineWriter.writeLine(chalk.gray('Type `a` followed by enter to reset filters and rerun all tests')); reporter.lineWriter.writeLine(chalk.gray('Type `r` followed by enter to rerun tests that match your filters')); } else { @@ -61,12 +62,11 @@ const writeCommandInstructions = (reporter, interactiveFilter) => { reporter.lineWriter.writeLine(chalk.gray('Type `u` followed by enter to update snapshots in selected tests')); - if (interactiveFilter.hasAnyFilters()) { + if (interactiveGlobPattern || interactiveFilter.hasAnyFilters()) { reporter.lineWriter.writeLine(); - const filePathRegex = interactiveFilter.printFilePathRegex(); - if (filePathRegex) { - reporter.lineWriter.writeLine(chalk.gray(`Current test file pattern: ${chalk.italic(filePathRegex)}`)); + if (interactiveGlobPattern) { + reporter.lineWriter.writeLine(chalk.gray(`Current test file glob pattern: ${chalk.italic(interactiveGlobPattern)}`)); } const testTitleRegex = interactiveFilter.printTestTitleRegex(); @@ -79,28 +79,26 @@ const writeCommandInstructions = (reporter, interactiveFilter) => { reporter.lineWriter.write('> '); }; -const promptForFileFilter = async (reporter, lineReader, currentFilterRegexOrNull) => { +const promptForGlobPattern = async (reporter, lineReader, currentPattern, projectDir) => { reporter.lineWriter.ensureEmptyLine(); - reporter.lineWriter.writeLine('Type the file regex pattern then press enter. Leave blank to clear.', false); - if (currentFilterRegexOrNull !== null) { + 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(chalk.gray(`Current file regex pattern is: ${chalk.italic(currentFilterRegexOrNull)}`)); + reporter.lineWriter.writeLine(`Current glob pattern is: ${chalk.italic(currentPattern)}`, false); } reporter.lineWriter.write('> '); - const {value: patternInput = ''} = await lineReader.next(); - if (patternInput !== '') { - try { - return new RegExp(patternInput); - } catch (error) { - reporter.lineWriter.writeLine( - chalk.gray(`Invalid regex: ${error.message}${os.EOL}`), - ); - } + const {value: pattern} = await lineReader.next(); + if (pattern === '') { + return undefined; } - return null; + return normalizePattern(nodePath.relative(projectDir, nodePath.resolve(process.cwd(), pattern))); }; const promptForTitleFilter = async (reporter, lineReader, currentFilterRegexOrNull) => { @@ -129,7 +127,7 @@ const promptForTitleFilter = async (reporter, lineReader, currentFilterRegexOrNu 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({ + for await (const {files, testFileSelector, ...runtimeOptions} of plan({ api, filter, globs, @@ -139,7 +137,7 @@ export async function start({api, filter, globs, projectDir, providers, reporter abortSignal: signal, reporter, })) { - await api.run({files, filter, runtimeOptions}); + await api.run({files, testFileSelector, runtimeOptions}); reporter.endRun(); } } @@ -193,6 +191,15 @@ async function * plan({ 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 => { @@ -249,6 +256,44 @@ async function * plan({ updateSnapshots = false; }; + // Interactive filters. + let interactiveGlobPattern; + const testFileSelector = (allTestFiles, selectedFiles = []) => { + 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 (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. @@ -276,23 +321,16 @@ async function * plan({ break; } - case 'p': { + case 'g': { respondToChanges = false; - const filterHasChanged = interactiveFilter.replaceFilepathRegex( - await promptForFileFilter( - reporter, - lineReader, - interactiveFilter.fileRegexOrNull, - 'filepath', - ), - ); + const oldGlobPattern = interactiveGlobPattern; + interactiveGlobPattern = await promptForGlobPattern(reporter, lineReader, interactiveGlobPattern, projectDir); respondToChanges = true; - reporter.lineWriter.write(`${os.EOL}${os.EOL}`); - writeCommandInstructions(reporter, interactiveFilter); - if (filterHasChanged) { - signalChanged(); - } else { + reporter.lineWriter.writeLine(); + if (oldGlobPattern === interactiveGlobPattern) { debounce.refresh(); + } else { + signalChanged(); } break; @@ -489,30 +527,12 @@ async function * plan({ fileTracer.update(changes); } - // Select the test files to run, and how to run them. - let testFiles = [...uniqueTestFiles]; - - 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(); - } else if (testFiles.length > 0) { - // Remove previous failures for tests that will run again. - for (const path of testFiles) { - failureCounts.delete(path); - } - - signalChanged({testFiles}); + } else if (uniqueTestFiles.size > 0) { + signalChanged({testFiles: [...uniqueTestFiles]}); } takeCoverageForSelfTests?.(); @@ -533,39 +553,55 @@ async function * plan({ // And finally, the watch loop. while (abortSignal?.aborted !== true) { - const {testFiles: files = []} = (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 instructUpdateSnapshots = updateSnapshots; + const instructInteractiveFilter = runAll ? new InteractiveFilter() : interactiveFilter; - const instructions = { - files: files.map(file => nodePath.join(projectDir, file)), - firstRun, // Value is changed by refresh() so record now. - previousFailures, - updateSnapshots, // Value is changed by refresh() so record now. - interactiveFilter: runAll - ? new InteractiveFilter() - : interactiveFilter, - }; 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); + 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; + } + // Clear any prompt. if (!reporter.lineWriter.lastLineIsEmpty && reporter.reportStream.isTTY) { reporter.reportStream.clearLine(0); reporter.lineWriter.writeLine(); } + // Let the tests run. respondToChanges = false; - yield instructions; // Let the tests run. - writeCommandInstructions(reporter, interactiveFilter); + yield { + countPreviousFailures, + files, + firstRun: instructFirstRun, + testFileSelector: instructTestFileSelector, + updateSnapshots: instructUpdateSnapshots, + interactiveFilter: instructInteractiveFilter, + }; respondToChanges = true; - debounce.refresh(); // Trigger the callback, which if there were changes will run the tests again. + + // Write command instructions after the tests have run and been reported. + writeCommandInstructions(reporter, interactiveGlobPattern, interactiveFilter); + + // Trigger the callback, which if there were changes will run the tests again. + debounce.refresh(); } } 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/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index fd00bc12c..bbcf8c48e 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -8,7 +8,7 @@ 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 `p` followed by enter to filter test files by a regex pattern/); + t.regex(stdout, /Type `g` followed by enter to filter test files by a glob pattern/); t.regex(stdout, /Type `t` 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/); diff --git a/test/watch-mode/interactive-filters.js b/test/watch-mode/interactive-filters.js index 16a065bab..86d1d4f1c 100644 --- a/test/watch-mode/interactive-filters.js +++ b/test/watch-mode/interactive-filters.js @@ -1,69 +1,56 @@ import {test, withFixture} from './helpers/watch.js'; -test( - 'can filter tests by filepath pattern', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'test1'; - const test1Regex = new RegExp(test1RegexString); - 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - - return stats; - }, - - 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, test1Regex); - } - - t.is(stats.failed.length, 1); - for (const skipped of stats.failed) { - t.regex(skipped.file, test1Regex); - } - - this.done(); - }, - }); - }, -); - -test( - 'can filter tests by filepath pattern and have no tests run', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'kangarookanbankentuckykendoll'; - 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - - process.send('abort-watcher'); - const {stdout} = await process; - t.regex(stdout, /2 test files were found, but did not match the CLI arguments/); - this.done(); - - return stats; - }, - }); - }, -); +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( 'can filter tests by filepath pattern, and run all tests with \'a', From a05369ac95a7e25a7cdf995c6d1a516fbf74d9bd Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 29 Apr 2025 21:25:42 +0200 Subject: [PATCH 24/25] Implement interactive test file filter akin to --match Remove interactive filter class, the API can concatenate the interactive match pattern with the --match arguments. --- lib/api.js | 6 +- lib/interactive-filter.js | 48 ------------- lib/watcher.js | 78 ++++++++------------- test/watch-mode/basic-functionality.js | 2 +- test/watch-mode/interactive-filters.js | 94 ++++++++++++-------------- 5 files changed, 77 insertions(+), 151 deletions(-) delete mode 100644 lib/interactive-filter.js diff --git a/lib/api.js b/lib/api.js index 0b0ddb8da..e525e9235 100644 --- a/lib/api.js +++ b/lib/api.js @@ -203,7 +203,7 @@ export default class Api extends Emittery { failFastEnabled: failFast, filePathPrefix: getFilePathPrefix(selectedFiles), files: selectedFiles, - matching: apiOptions.match.length > 0, + matching: apiOptions.match.length > 0 || runtimeOptions.interactiveMatchPattern !== undefined, previousFailures: runtimeOptions.countPreviousFailures?.() ?? 0, firstRun: runtimeOptions.firstRun ?? true, status: runStatus, @@ -267,13 +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, - interactiveFilterData: runtimeOptions.interactiveFilter?.getData(), + match: runtimeOptions.interactiveMatchPattern === undefined ? match : [...match, runtimeOptions.interactiveMatchPattern], }; if (runtimeOptions.updateSnapshots) { diff --git a/lib/interactive-filter.js b/lib/interactive-filter.js deleted file mode 100644 index db484c014..000000000 --- a/lib/interactive-filter.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * The InteractiveFilter class is used to determine - * if a test should be skipped using filters provided - * by the user in watch mode. - */ -export class InteractiveFilter { - #testTitleRegex = null; - - replaceTestTitleRegex(testTitleRegex) { - const filterHasChanged = !this.#regexesAreEqual(this.#testTitleRegex, testTitleRegex); - this.#testTitleRegex = testTitleRegex; - return filterHasChanged; - } - - #regexesAreEqual(a, b) { - return a?.source === b?.source && a?.flags === b?.flags; - } - - constructor(interactiveFilterData = undefined) { - if (!interactiveFilterData) { - return; - } - - this.#testTitleRegex = interactiveFilterData.testTitleRegex; - } - - getData() { - return { - testTitleRegex: this.#testTitleRegex, - }; - } - - printTestTitleRegex() { - if (!this.#testTitleRegex) { - return ''; - } - - return `Current test title filter is ${this.#testTitleRegex}`; - } - - shouldSelectTest(testTitle) { - return this.#testTitleRegex?.test(testTitle) ?? true; - } - - hasAnyFilters() { - return this.#testTitleRegex !== null; - } -} diff --git a/lib/watcher.js b/lib/watcher.js index 821b9c860..9cab93847 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -1,5 +1,4 @@ import fs from 'node:fs'; -import os from 'node:os'; import nodePath from 'node:path'; import process from 'node:process'; import v8 from 'node:v8'; @@ -13,7 +12,6 @@ import { applyTestFileFilter, classify, buildIgnoreMatcher, findTests, normalizePattern, } from './globs.js'; -import {InteractiveFilter} from './interactive-filter.js'; import {levels as providerLevels} from './provider-manager.js'; const debug = createDebug('ava:watcher'); @@ -50,10 +48,10 @@ const eachLine = async (lineReader, callback) => { } }; -const writeCommandInstructions = (reporter, interactiveGlobPattern, interactiveFilter) => { +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 `t` followed by enter to filter tests by their title (using a regex pattern)')); - if (interactiveGlobPattern || interactiveFilter.hasAnyFilters()) { + 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 reset filters and rerun all tests')); reporter.lineWriter.writeLine(chalk.gray('Type `r` followed by enter to rerun tests that match your filters')); } else { @@ -62,16 +60,15 @@ const writeCommandInstructions = (reporter, interactiveGlobPattern, interactiveF reporter.lineWriter.writeLine(chalk.gray('Type `u` followed by enter to update snapshots in selected tests')); - if (interactiveGlobPattern || interactiveFilter.hasAnyFilters()) { + if (interactiveGlobPattern || interactiveMatchPattern) { reporter.lineWriter.writeLine(); if (interactiveGlobPattern) { reporter.lineWriter.writeLine(chalk.gray(`Current test file glob pattern: ${chalk.italic(interactiveGlobPattern)}`)); } - const testTitleRegex = interactiveFilter.printTestTitleRegex(); - if (testTitleRegex) { - reporter.lineWriter.writeLine(chalk.gray(`Current test title pattern: ${chalk.italic(testTitleRegex)}`)); + if (interactiveMatchPattern) { + reporter.lineWriter.writeLine(chalk.gray(`Current test title match pattern: ${chalk.italic(interactiveMatchPattern)}`)); } } @@ -101,28 +98,23 @@ const promptForGlobPattern = async (reporter, lineReader, currentPattern, projec return normalizePattern(nodePath.relative(projectDir, nodePath.resolve(process.cwd(), pattern))); }; -const promptForTitleFilter = async (reporter, lineReader, currentFilterRegexOrNull) => { - reporter.lineWriter.ensureEmptyLine(); - reporter.lineWriter.writeLine('Type the title regex pattern then press enter. Leave blank to clear.', false); - if (currentFilterRegexOrNull !== null) { +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(chalk.gray(`Current title regex pattern is: ${chalk.italic(currentFilterRegexOrNull)}`)); + reporter.lineWriter.writeLine(`Current match pattern is: ${chalk.italic(currentPattern)}`, false); } reporter.lineWriter.write('> '); - const {value: patternInput = ''} = await lineReader.next(); - if (patternInput !== '') { - try { - return new RegExp(patternInput); - } catch (error) { - reporter.lineWriter.writeLine( - chalk.gray(`Invalid regex: ${error.message}${os.EOL}`), - ); - } - } - - return null; + const {value: pattern} = await lineReader.next(); + return pattern === '' ? undefined : pattern; }; export async function start({api, filter, globs, projectDir, providers, reporter, stdin, signal}) { @@ -152,8 +144,6 @@ async function * plan({ abortSignal, reporter, }) { - const interactiveFilter = new InteractiveFilter(); - const fileTracer = new FileTracer({base: projectDir}); const isIgnored = buildIgnoreMatcher(globs); const patternFilters = filter.map(({pattern}) => pattern); @@ -258,6 +248,7 @@ async function * plan({ // Interactive filters. let interactiveGlobPattern; + let interactiveMatchPattern; const testFileSelector = (allTestFiles, selectedFiles = []) => { if (selectedFiles.length === 0) { selectedFiles = allTestFiles; @@ -315,8 +306,8 @@ async function * plan({ case 'a': { runAll = true; - interactiveFilter.replaceFilepathRegex(null); - interactiveFilter.replaceTestTitleRegex(null); + interactiveGlobPattern = undefined; + interactiveMatchPattern = undefined; signalChanged(); break; } @@ -336,23 +327,16 @@ async function * plan({ break; } - case 't': { + case 'm': { respondToChanges = false; - const filterHasChanged = interactiveFilter.replaceTestTitleRegex( - await promptForTitleFilter( - reporter, - lineReader, - interactiveFilter.testTitleRegexOrNull, - 'test title', - ), - ); + const oldMatchPattern = interactiveMatchPattern; + interactiveMatchPattern = await promptForMatchPattern(reporter, lineReader, interactiveMatchPattern); respondToChanges = true; - reporter.lineWriter.write(`${os.EOL}`); - writeCommandInstructions(reporter, interactiveFilter); - if (filterHasChanged) { - signalChanged(); - } else { + reporter.lineWriter.writeLine(); + if (oldMatchPattern === interactiveMatchPattern) { debounce.refresh(); + } else { + signalChanged(); } break; @@ -562,8 +546,6 @@ async function * plan({ // Values are changed by refresh() so copy them now. const instructFirstRun = firstRun; const instructUpdateSnapshots = updateSnapshots; - const instructInteractiveFilter = runAll ? new InteractiveFilter() : interactiveFilter; - reset(); // Make sure the next run can be triggered. let files = testFiles.map(file => nodePath.join(projectDir, file)); @@ -593,12 +575,12 @@ async function * plan({ firstRun: instructFirstRun, testFileSelector: instructTestFileSelector, updateSnapshots: instructUpdateSnapshots, - interactiveFilter: instructInteractiveFilter, + interactiveMatchPattern, }; respondToChanges = true; // Write command instructions after the tests have run and been reported. - writeCommandInstructions(reporter, interactiveGlobPattern, interactiveFilter); + writeCommandInstructions(reporter, interactiveGlobPattern, interactiveMatchPattern); // Trigger the callback, which if there were changes will run the tests again. debounce.refresh(); diff --git a/test/watch-mode/basic-functionality.js b/test/watch-mode/basic-functionality.js index bbcf8c48e..e8eea0868 100644 --- a/test/watch-mode/basic-functionality.js +++ b/test/watch-mode/basic-functionality.js @@ -9,7 +9,7 @@ test('prints results and instructions', withFixture('basic'), async (t, fixture) const {stdout} = await process; t.regex(stdout, /\d+ tests? passed/); t.regex(stdout, /Type `g` followed by enter to filter test files by a glob pattern/); - t.regex(stdout, /Type `t` followed by enter to filter tests by their title/); + 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/interactive-filters.js b/test/watch-mode/interactive-filters.js index 86d1d4f1c..1bcdeb7e9 100644 --- a/test/watch-mode/interactive-filters.js +++ b/test/watch-mode/interactive-filters.js @@ -84,63 +84,55 @@ test( }, ); -test( - 'can filter tests by test title pattern', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'bob'; - const test1Regex = new RegExp(test1RegexString); - await fixture.watch({ - async 1({process, stats}) { - // First run should run all tests - t.is(stats.selectedTestCount, 8); - t.is(stats.passed.length, 6); +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 file filter to only run test1.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - }, + // 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 skipped of stats.passed) { - t.regex(skipped.title, test1Regex); - } + 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(); - }, - }); - }, -); + this.done(); + }, + }); +}); -test( - 'can filter tests by test pattern and have no tests run', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'sirnotappearinginthisfilm'; - await fixture.watch({ - async 1({process, stats}) { - // First run should run all tests - t.is(stats.selectedTestCount, 8); - t.is(stats.passed.length, 6); +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 file filter to only run test1.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - }, + // Set a title filter that doesn't match any tests + process.stdin.write('m\n'); + process.stdin.write('sirnotappearinginthisfilm\n'); + }, - async 2({stats}) { - // No tests should run - t.is(stats.selectedTestCount, 0); - this.done(); - }, - }); - }, -); + 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( 'can filter tests by test pattern, and run all tests with \'a', From 795e9a5b940c1e52de78037fca9515440eb5a60f Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 1 May 2025 22:24:17 +0200 Subject: [PATCH 25/25] Change run-all to not reset filters It's more likely you want to quickly see if anything else has broken, than that you mean to reset the filters. --- lib/watcher.js | 15 ++-- test/watch-mode/interactive-filters.js | 117 +++++++++++-------------- 2 files changed, 58 insertions(+), 74 deletions(-) diff --git a/lib/watcher.js b/lib/watcher.js index 9cab93847..b28db8052 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -52,7 +52,7 @@ const writeCommandInstructions = (reporter, interactiveGlobPattern, interactiveM 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 reset filters and rerun all tests')); + 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')); @@ -249,7 +249,7 @@ async function * plan({ // Interactive filters. let interactiveGlobPattern; let interactiveMatchPattern; - const testFileSelector = (allTestFiles, selectedFiles = []) => { + const testFileSelector = (allTestFiles, selectedFiles = [], skipInteractive = runAll) => { if (selectedFiles.length === 0) { selectedFiles = allTestFiles; } @@ -264,7 +264,7 @@ async function * plan({ selectedFiles.appliedFilters = filter; // `filter` is the original input. } - if (interactiveGlobPattern !== undefined) { + if (!skipInteractive && interactiveGlobPattern !== undefined) { const {appliedFilters = [], ignoredFilterPatternFiles} = selectedFiles; selectedFiles = applyTestFileFilter({ cwd: projectDir, @@ -306,8 +306,6 @@ async function * plan({ case 'a': { runAll = true; - interactiveGlobPattern = undefined; - interactiveMatchPattern = undefined; signalChanged(); break; } @@ -545,13 +543,14 @@ async function * plan({ // 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); + files = testFileSelector(files, [], skipInteractive); if (files.length === 0) { debug('Filters rejected all test files'); continue; @@ -559,6 +558,8 @@ async function * plan({ // 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. @@ -575,7 +576,7 @@ async function * plan({ firstRun: instructFirstRun, testFileSelector: instructTestFileSelector, updateSnapshots: instructUpdateSnapshots, - interactiveMatchPattern, + interactiveMatchPattern: skipInteractive ? undefined : interactiveMatchPattern, }; respondToChanges = true; diff --git a/test/watch-mode/interactive-filters.js b/test/watch-mode/interactive-filters.js index 1bcdeb7e9..1ac675c99 100644 --- a/test/watch-mode/interactive-filters.js +++ b/test/watch-mode/interactive-filters.js @@ -52,37 +52,31 @@ test('can filter test files by glob pattern and have no tests run', withFixture( }); }); -test( - 'can filter tests by filepath pattern, and run all tests with \'a', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'test1'; - 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.js - process.stdin.write('p\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - }, - - 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('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({ @@ -134,39 +128,28 @@ test('can filter tests title and have no tests run', withFixture('filter-files') }); }); -test( - 'can filter tests by test pattern, and run all tests with \'a', - withFixture('filter-files'), - async (t, fixture) => { - const test1RegexString = 'sirnotappearinginthisfilm'; - const test1Regex = new RegExp(test1RegexString); - 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.js - process.stdin.write('t\n'); - process.stdin.write(`${test1RegexString}\n`); - return stats; - }, - - async 2({process, stats}) { - t.is(stats.selectedTestCount, 0); - for (const skipped of stats.skipped) { - t.notRegex(skipped.file, test1Regex); - } - - process.stdin.write('a\n'); - }, - async 3({stats}) { - // All tests should run - t.is(stats.selectedTestCount, 8); - t.is(stats.passed.length, 6); - - 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(); + }, + }); +});