diff --git a/docs/recipes/watch-mode.md b/docs/recipes/watch-mode.md index 39d7c4bee..bab9220fc 100644 --- a/docs/recipes/watch-mode.md +++ b/docs/recipes/watch-mode.md @@ -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. diff --git a/lib/api.js b/lib/api.js index f43fad799..77d8cef40 100644 --- a/lib/api.js +++ b/lib/api.js @@ -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, }); @@ -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) { diff --git a/lib/runner.js b/lib/runner.js index fac04e344..2a9bb3bf3 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -2,7 +2,7 @@ import process from 'node:process'; import {pathToFileURL} from 'node:url'; import Emittery from 'emittery'; -import {matcher} from 'matcher'; +import * as matcher from 'matcher'; import ContextRef from './context-ref.js'; import createChain from './create-chain.js'; @@ -13,6 +13,15 @@ import Runnable from './test.js'; import {waitForReady} from './worker/state.cjs'; const makeFileURL = file => file.startsWith('file://') ? file : pathToFileURL(file).toString(); + +const isTitleMatch = (title, patterns) => { + if (patterns.length === 0) { + return true; + } + + return matcher.isMatch(title, patterns); +}; + export default class Runner extends Emittery { constructor(options = {}) { super(); @@ -22,10 +31,9 @@ export default class Runner extends Emittery { this.failWithoutAssertions = options.failWithoutAssertions !== false; this.file = options.file; this.checkSelectedByLineNumbers = options.checkSelectedByLineNumbers; - this.match = options.match ?? []; + this.matchPatterns = options.match ?? []; this.projectDir = options.projectDir; this.recordNewSnapshots = options.recordNewSnapshots === true; - this.runOnlyExclusive = options.runOnlyExclusive === true; this.serial = options.serial === true; this.snapshotDir = options.snapshotDir; this.updateSnapshots = options.updateSnapshots; @@ -34,6 +42,7 @@ export default class Runner extends Emittery { this.boundCompareTestSnapshot = this.compareTestSnapshot.bind(this); this.boundSkipSnapshot = this.skipSnapshot.bind(this); this.interrupted = false; + this.runOnlyExclusive = false; this.nextTaskIndex = 0; this.tasks = { @@ -92,9 +101,7 @@ export default class Runner extends Emittery { const {args, implementation, title} = parseTestArgs(testArgs); - if (this.checkSelectedByLineNumbers) { - metadata.selected = this.checkSelectedByLineNumbers(); - } + metadata.selected &&= this.checkSelectedByLineNumbers?.() ?? true; if (metadata.todo) { if (implementation) { @@ -110,10 +117,7 @@ export default class Runner extends Emittery { } // --match selects TODO tests. - if (this.match.length > 0 && matcher(title.value, this.match).length === 1) { - metadata.exclusive = true; - this.runOnlyExclusive = true; - } + metadata.selected &&= isTitleMatch(title.value, this.matchPatterns); this.tasks.todo.push({title: title.value, metadata}); this.emit('stateChange', { @@ -154,14 +158,10 @@ export default class Runner extends Emittery { }; if (metadata.type === 'test') { - if (this.match.length > 0) { - // --match overrides .only() - task.metadata.exclusive = matcher(title.value, this.match).length === 1; - } - - if (task.metadata.exclusive) { - this.runOnlyExclusive = true; - } + task.metadata.selected &&= isTitleMatch(title.value, this.matchPatterns); + // Unmatched .only() are not selected and won't run. However, runOnlyExclusive can only be true if no titles + // are being matched. + this.runOnlyExclusive ||= this.matchPatterns.length === 0 && task.metadata.exclusive && task.metadata.selected; this.tasks[metadata.serial ? 'serial' : 'concurrent'].push(task); @@ -181,6 +181,7 @@ export default class Runner extends Emittery { serial: false, exclusive: false, skipped: false, + selected: true, todo: false, failing: false, callback: false, @@ -402,16 +403,11 @@ export default class Runner extends Emittery { return alwaysOk && hooksOk && testOk; } - async start() { // eslint-disable-line complexity + async start() { const concurrentTests = []; const serialTests = []; for (const task of this.tasks.serial) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { - this.snapshots.skipBlock(task.title, task.metadata.taskIndex); - continue; - } - - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected || (this.runOnlyExclusive && !task.metadata.exclusive)) { this.snapshots.skipBlock(task.title, task.metadata.taskIndex); continue; } @@ -432,12 +428,7 @@ export default class Runner extends Emittery { } for (const task of this.tasks.concurrent) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { - this.snapshots.skipBlock(task.title, task.metadata.taskIndex); - continue; - } - - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected || (this.runOnlyExclusive && !task.metadata.exclusive)) { this.snapshots.skipBlock(task.title, task.metadata.taskIndex); continue; } @@ -460,11 +451,7 @@ export default class Runner extends Emittery { } for (const task of this.tasks.todo) { - if (this.runOnlyExclusive && !task.metadata.exclusive) { - continue; - } - - if (this.checkSelectedByLineNumbers && !task.metadata.selected) { + if (!task.metadata.selected || (this.runOnlyExclusive && !task.metadata.exclusive)) { continue; } diff --git a/lib/watcher.js b/lib/watcher.js index 2ee7d23b2..bae6c9e6c 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -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(); @@ -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; } @@ -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({ @@ -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?.(); @@ -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; @@ -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. 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}) {