Skip to content

Implemented Watch mode filter by filename and by test name #1530 #3372

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
78a3768
Implemented Watch mode filter by filename and by test name #1530
Mar 26, 2025
8ecd51d
fix tests on onlder node versions
Mar 27, 2025
0d78fd3
Format documentation
novemberborn Apr 4, 2025
b72a9dd
Add newline at EOF
novemberborn Apr 4, 2025
a3cce75
Fix error handling in watcher tests
novemberborn Apr 4, 2025
b0762b0
Remove unnecessary this.done() calls in catch blocks
novemberborn Apr 4, 2025
5195cbe
Remove unnecessary multiline comments
novemberborn Apr 4, 2025
16630d4
implemented fixes
Apr 7, 2025
0b88520
lint fix
Apr 7, 2025
e958e4b
Merge branch 'main' into Watch_mode_filter
novemberborn Apr 16, 2025
63ff4fc
Revert whitespace changes in reporter
novemberborn Apr 17, 2025
0948247
Revert test worker changes
novemberborn Apr 25, 2025
747ab06
Move interactive filters tests to their own file
novemberborn Apr 30, 2025
8c14e1a
Improve empty line tracking in reporter
novemberborn Apr 29, 2025
1ade579
Add option in reporter line writer to not indent the line
novemberborn Apr 29, 2025
68dd12f
Remove dead chokidar link reference definition
novemberborn Apr 29, 2025
9c1e8c1
Remove special .only() behavior in watch mode
novemberborn Apr 29, 2025
65acae4
Implement --match using the selected flag
novemberborn Apr 29, 2025
6f2adff
Place available() function before reportEndMessage
novemberborn Apr 29, 2025
74d4077
Use helpers to read lines and process commands
novemberborn Apr 29, 2025
8561644
Refactor command instructions and filter prompts for improved clarity…
novemberborn Apr 29, 2025
7b98b2d
Simplify change signaling, don't require an argument
novemberborn Apr 29, 2025
fe34e34
Don't respond to changes when prompting for filters
novemberborn Apr 29, 2025
9575970
Implement interactive file filters using globs
novemberborn Apr 29, 2025
a05369a
Implement interactive test file filter akin to --match
novemberborn Apr 29, 2025
795e9a5
Change run-all to not reset filters
novemberborn May 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions docs/recipes/watch-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,40 @@ export default {

If your tests write to disk they may trigger the watcher to rerun your tests. Configuring additional ignore patterns helps avoid this.

### Filter tests while watching

You may also filter tests while watching by using the CLI. For example, after running

```console
npx ava --watch
```

You will see a prompt like this:

```console
Type `p` and press enter to filter by a filename regex pattern
[Current filename filter is $pattern]
Type `t` and press enter to filter by a test name regex pattern
[Current test filter is $pattern]

[Type `a` and press enter to run *all* tests]
(Type `r` and press enter to rerun tests ||
Type `r` and press enter to rerun tests that match your filters)
Type `u` and press enter to update snapshots

command >
```

So, to run only tests numbered like

- foo23434
- foo4343
- foo93823

You can type `t` and press enter, then type `foo\d+` and press enter. This will then run all tests that match that pattern.

Afterwards you can use the `r` command to run the matched tests again, or `a` command to run **all** tests.

## Dependency tracking

AVA tracks which source files your test files depend on. If you change such a dependency only the test file that depends on it will be rerun. AVA will rerun all tests if it cannot determine which test file depends on the changed source file.
Expand All @@ -42,10 +76,6 @@ Dependency tracking works for `require()` and `import` syntax, as supported by [

Files accessed using the `fs` module are not tracked.

## Watch mode and the `.only` modifier

The [`.only` modifier] disables watch mode's dependency tracking algorithm. When a change is made, all `.only` tests will be rerun, regardless of whether the test depends on the changed file.

## Watch mode and CI

If you run AVA in your CI with watch mode, the execution will exit with an error (`Error : Watch mode is not available in CI, as it prevents AVA from terminating.`). AVA will not run with the `--watch` (`-w`) option in CI, because CI processes should terminate, and with the `--watch` option, AVA will never terminate.
Expand All @@ -66,7 +96,6 @@ Sometimes watch mode does something surprising like rerunning all tests when you
$ DEBUG=ava:watcher npx ava --watch
```

[`chokidar`]: https://github.com/paulmillr/chokidar
[Install Troubleshooting]: https://github.com/paulmillr/chokidar#install-troubleshooting
[`ignore-by-default`]: https://github.com/novemberborn/ignore-by-default
[`.only` modifier]: ../01-writing-tests.md#running-specific-tests
Expand Down
18 changes: 9 additions & 9 deletions lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -163,7 +165,7 @@ export default class Api extends Emittery {
}

const selectionInsights = {
filter,
filter: selectedFiles.appliedFilters ?? filter,
ignoredFilterPatternFiles: selectedFiles.ignoredFilterPatternFiles ?? [],
testFileCount: testFiles.length,
selectionCount: selectedFiles.length,
Expand Down Expand Up @@ -201,9 +203,8 @@ export default class Api extends Emittery {
failFastEnabled: failFast,
filePathPrefix: getFilePathPrefix(selectedFiles),
files: selectedFiles,
matching: apiOptions.match.length > 0,
previousFailures: runtimeOptions.previousFailures ?? 0,
runOnlyExclusive: runtimeOptions.runOnlyExclusive === true,
matching: apiOptions.match.length > 0 || runtimeOptions.interactiveMatchPattern !== undefined,
previousFailures: runtimeOptions.countPreviousFailures?.() ?? 0,
firstRun: runtimeOptions.firstRun ?? true,
status: runStatus,
});
Expand Down Expand Up @@ -266,14 +267,13 @@ export default class Api extends Emittery {

const lineNumbers = getApplicableLineNumbers(globs.normalizeFileForMatching(apiOptions.projectDir, file), filter);
// Removing `providers` and `sortTestFiles` fields because they cannot be transferred to the worker threads.
const {providers, sortTestFiles, ...forkOptions} = apiOptions;
const {providers, sortTestFiles, match, ...forkOptions} = apiOptions;
const options = {
...forkOptions,
providerStates,
lineNumbers,
recordNewSnapshots: !isCi,
// If we're looking for matches, run every single test process in exclusive-only mode
runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true,
match: runtimeOptions.interactiveMatchPattern === undefined ? match : [...match, runtimeOptions.interactiveMatchPattern],
};

if (runtimeOptions.updateSnapshots) {
Expand Down
14 changes: 9 additions & 5 deletions lib/reporters/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,29 @@ class LineWriter extends stream.Writable {

this.dest = dest;
this.columns = dest.columns ?? 80;
this.lastLineIsEmpty = false;
this.lastLineIsEmpty = true;
}

_write(chunk, _, callback) {
this.dest.write(chunk);
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);
this.lastLineIsEmpty = true;
}
}

write(string) {
this.lastLineIsEmpty = false;
super.write(string);
}

ensureEmptyLine() {
if (!this.lastLineIsEmpty) {
this.writeLine();
Expand Down Expand Up @@ -120,7 +125,6 @@ export default class Reporter {
this.previousFailures = 0;

this.failFastEnabled = false;
this.lastLineIsEmpty = false;
this.matching = false;

this.removePreviousListener = null;
Expand Down Expand Up @@ -628,7 +632,7 @@ export default class Reporter {
this.lineWriter.writeLine(colors.error(`${figures.cross} Couldn’t find any files to test` + firstLinePostfix));
} else {
const {testFileCount: count} = this.selectionInsights;
this.lineWriter.writeLine(colors.error(`${figures.cross} Based on your configuration, ${count} test ${plur('file was', 'files were', count)} found, but did not match the CLI arguments:` + firstLinePostfix));
this.lineWriter.writeLine(colors.error(`${figures.cross} Based on your configuration, ${count} test ${plur('file was', 'files were', count)} found, but did not match the filters:` + firstLinePostfix));
this.lineWriter.writeLine();
for (const {pattern} of this.selectionInsights.filter) {
this.lineWriter.writeLine(colors.error(`* ${pattern}`));
Expand Down
59 changes: 23 additions & 36 deletions lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -22,10 +31,9 @@ export default class Runner extends Emittery {
this.failWithoutAssertions = options.failWithoutAssertions !== false;
this.file = options.file;
this.checkSelectedByLineNumbers = options.checkSelectedByLineNumbers;
this.match = options.match ?? [];
this.matchPatterns = options.match ?? [];
this.projectDir = options.projectDir;
this.recordNewSnapshots = options.recordNewSnapshots === true;
this.runOnlyExclusive = options.runOnlyExclusive === true;
this.serial = options.serial === true;
this.snapshotDir = options.snapshotDir;
this.updateSnapshots = options.updateSnapshots;
Expand All @@ -34,6 +42,7 @@ export default class Runner extends Emittery {
this.boundCompareTestSnapshot = this.compareTestSnapshot.bind(this);
this.boundSkipSnapshot = this.skipSnapshot.bind(this);
this.interrupted = false;
this.runOnlyExclusive = false;

this.nextTaskIndex = 0;
this.tasks = {
Expand Down Expand Up @@ -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) {
Expand All @@ -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', {
Expand Down Expand Up @@ -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);

Expand All @@ -181,6 +181,7 @@ export default class Runner extends Emittery {
serial: false,
exclusive: false,
skipped: false,
selected: true,
todo: false,
failing: false,
callback: false,
Expand Down Expand Up @@ -402,16 +403,11 @@ export default class Runner extends Emittery {
return alwaysOk && hooksOk && testOk;
}

async start() { // eslint-disable-line complexity
async start() {
const concurrentTests = [];
const serialTests = [];
for (const task of this.tasks.serial) {
if (this.runOnlyExclusive && !task.metadata.exclusive) {
this.snapshots.skipBlock(task.title, task.metadata.taskIndex);
continue;
}

if (this.checkSelectedByLineNumbers && !task.metadata.selected) {
if (!task.metadata.selected || (this.runOnlyExclusive && !task.metadata.exclusive)) {
this.snapshots.skipBlock(task.title, task.metadata.taskIndex);
continue;
}
Expand All @@ -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;
}
Expand All @@ -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;
}

Expand Down
Loading