Skip to content

Remove special .only() behavior in watch mode #3381

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

Merged
merged 2 commits into from
Jun 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 0 additions & 4 deletions docs/recipes/watch-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,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 Down
3 changes: 0 additions & 3 deletions lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,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,
});
Expand Down Expand Up @@ -272,8 +271,6 @@ export default class Api extends Emittery {
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,
};

if (runtimeOptions.updateSnapshots) {
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
31 changes: 3 additions & 28 deletions lib/watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi
}))));

// State tracked for test runs.
const filesWithExclusiveTests = new Set();
const touchedFiles = new Set();
const temporaryFiles = new Set();
const failureCounts = new Map();
Expand Down Expand Up @@ -117,17 +116,6 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi
break;
}

case 'worker-finished': {
const fileStats = status.stats.byFile.get(evt.testFile);
if (fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests) {
filesWithExclusiveTests.add(nodePath.relative(projectDir, evt.testFile));
} else {
filesWithExclusiveTests.delete(nodePath.relative(projectDir, evt.testFile));
}

break;
}

default: {
break;
}
Expand Down Expand Up @@ -329,18 +317,6 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi

// 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({
Expand All @@ -355,14 +331,14 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi
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?.();
Expand All @@ -383,7 +359,7 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi

// 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;
Expand All @@ -398,7 +374,6 @@ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSi
files: files.map(file => nodePath.join(projectDir, file)),
firstRun, // Value is changed by refresh() so record now.
previousFailures,
runOnlyExclusive,
updateSnapshots, // Value is changed by refresh() so record now.
};
reset(); // Make sure the next run can be triggered.
Expand Down
1 change: 0 additions & 1 deletion lib/worker/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 0 additions & 14 deletions test-tap/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
18 changes: 0 additions & 18 deletions test/watch-mode/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}) {
Expand Down