Skip to content

Rewrite watcher #3123

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

Closed
wants to merge 10 commits into from
Closed
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
8 changes: 3 additions & 5 deletions docs/recipes/watch-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ $ npx ava --watch

Please note that integrated debugging and the TAP reporter are unavailable when using watch mode.

## Requirements
## Limitations

AVA uses [`chokidar`] as the file watcher. Note that even if you see warnings about optional dependencies failing during install, it will still work fine. Please refer to the *[Install Troubleshooting]* section of `chokidar` documentation for how to resolve the installation problems with chokidar.
AVA uses `fs.watch()`. On supported platforms, `recursive` mode is used. When not supported, `fs.watch()` is used with individual directories, based on the dependency tracking (see below). This is an approximation. `recursive` mode is available with Node.js 19.1 on Linux, MacOS and Windows so we will not attempt to improve our fallback implementation.

## Ignoring changes

Expand All @@ -30,7 +30,7 @@ If your tests write to disk they may trigger the watcher to rerun your tests. Co

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.

Dependency tracking works for required modules. Custom extensions and transpilers are supported, provided you [added them in your `package.json` or `ava.config.*` file][config], and not from inside your test file. Files accessed using the `fs` module are not tracked.
Dependency tracking works for `require()` and `import` syntax, as supported by [@vercel/nft](https://github.com/vercel/nft). `import()` is supported but dynamic paths such as `import(myVariable)` are not. Files accessed using the `fs` module are not tracked.

## Watch mode and the `.only` modifier

Expand Down Expand Up @@ -60,8 +60,6 @@ $ DEBUG=ava:watcher npx ava --watch

Watch mode is relatively new and there might be some rough edges. Please [report](https://github.com/avajs/ava/issues) any issues you encounter. Thanks!

[`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
[config]: ../06-configuration.md
6 changes: 3 additions & 3 deletions lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,15 +200,14 @@ export default class Api extends Emittery {

await this.emit('run', {
bailWithoutReporting: debugWithoutSpecificFile,
clearLogOnNextRun: runtimeOptions.clearLogOnNextRun === true,
debug: Boolean(this.options.debug),
failFastEnabled: failFast,
filePathPrefix: getFilePathPrefix(selectedFiles),
files: selectedFiles,
matching: apiOptions.match.length > 0,
previousFailures: runtimeOptions.previousFailures || 0,
runOnlyExclusive: runtimeOptions.runOnlyExclusive === true,
runVector: runtimeOptions.runVector || 0,
firstRun: runtimeOptions.firstRun ?? true,
status: runStatus,
});

Expand Down Expand Up @@ -306,7 +305,8 @@ export default class Api extends Emittery {

// Allow shared workers to clean up before the run ends.
await Promise.all(deregisteredSharedWorkers);
scheduler.storeFailedTestFiles(runStatus, this.options.cacheEnabled === false ? null : this._createCacheDir());
const files = scheduler.storeFailedTestFiles(runStatus, this.options.cacheEnabled === false ? null : this._createCacheDir());
runStatus.emitStateChange({type: 'touched-files', files});
} catch (error) {
if (error && error.name === 'AggregateError') {
for (const error_ of error.errors) {
Expand Down
6 changes: 3 additions & 3 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import pkg from './pkg.cjs';
import providerManager from './provider-manager.js';
import DefaultReporter from './reporters/default.js';
import TapReporter from './reporters/tap.js';
import Watcher from './watcher.js';
import {start as startWatcher} from './watcher.js';

function exit(message) {
console.error(`\n ${chalk.red(figures.cross)} ${message}`);
Expand Down Expand Up @@ -469,15 +469,15 @@ export default async function loadCli() { // eslint-disable-line complexity
});

if (combined.watch) {
const watcher = new Watcher({
startWatcher({
api,
filter,
globs,
projectDir,
providers,
reporter,
stdin: process.stdin,
});
watcher.observeStdin(process.stdin);
} else {
let debugWithoutSpecificFile = false;
api.on('run', plan => {
Expand Down
10 changes: 1 addition & 9 deletions lib/glob-helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ const defaultPicomatchIgnorePatterns = [
...defaultIgnorePatterns.map(pattern => `${pattern}/**/*`),
];

const defaultMatchNoIgnore = picomatch(defaultPicomatchIgnorePatterns);

const matchingCache = new WeakMap();
const processMatchingPatterns = input => {
let result = matchingCache.get(input);
Expand Down Expand Up @@ -45,15 +43,9 @@ const processMatchingPatterns = input => {

exports.processMatchingPatterns = processMatchingPatterns;

const matchesIgnorePatterns = (file, patterns) => {
const {matchNoIgnore} = processMatchingPatterns(patterns);
return matchNoIgnore(file) || defaultMatchNoIgnore(file);
};

function classify(file, {cwd, extensions, filePatterns, ignoredByWatcherPatterns}) {
function classify(file, {cwd, extensions, filePatterns}) {
file = normalizeFileForMatching(cwd, file);
return {
isIgnoredByWatcher: matchesIgnorePatterns(file, ignoredByWatcherPatterns),
isTest: hasExtension(extensions, file) && !isHelperish(file) && filePatterns.length > 0 && matches(file, filePatterns),
};
}
Expand Down
8 changes: 6 additions & 2 deletions lib/globs.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';

import {globby, globbySync} from 'globby';
import picomatch from 'picomatch';

import {
defaultIgnorePatterns,
Expand All @@ -26,6 +27,7 @@ const defaultIgnoredByWatcherPatterns = [
'**/*.snap.md', // No need to rerun tests when the Markdown files change.
'ava.config.js', // Config is not reloaded so avoid rerunning tests when it changes.
'ava.config.cjs', // Config is not reloaded so avoid rerunning tests when it changes.
'ava.config.mjs', // Config is not reloaded so avoid rerunning tests when it changes.
];

const buildExtensionPattern = extensions => extensions.length === 1 ? extensions[0] : `{${extensions.join(',')}}`;
Expand Down Expand Up @@ -125,11 +127,13 @@ export async function findTests({cwd, extensions, filePatterns}) {
return files.filter(file => !path.basename(file).startsWith('_'));
}

export function getChokidarIgnorePatterns({ignoredByWatcherPatterns}) {
return [
export function buildIgnoreMatcher({ignoredByWatcherPatterns}) {
const patterns = [
...defaultIgnorePatterns.map(pattern => `${pattern}/**/*`),
...ignoredByWatcherPatterns.filter(pattern => !pattern.startsWith('!')),
];

return picomatch(patterns, {dot: true});
}

export function applyTestFileFilter({ // eslint-disable-line complexity
Expand Down
13 changes: 7 additions & 6 deletions lib/provider-manager.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import * as globs from './globs.js';
import pkg from './pkg.cjs';

const levels = {
export const levels = {
// As the protocol changes, comparing levels by integer allows AVA to be
// compatible with different versions. Currently there is only one supported
// version, so this is effectively unused. The infrastructure is retained for
// future use.
levelIntegersAreCurrentlyUnused: 0,
ava3Stable: 1,
ava5Watchmode: 2,
};

const levelsByProtocol = {
'ava-3.2': levels.levelIntegersAreCurrentlyUnused,
};
const levelsByProtocol = Object.assign(Object.create(null), {
'ava-3.2': levels.ava3Stable,
'ava-5-watch-mode': levels.ava5Watchmode,
});

async function load(providerModule, projectDir) {
const ava = {version: pkg.version};
Expand Down Expand Up @@ -50,7 +52,6 @@ async function load(providerModule, projectDir) {
}

const providerManager = {
levels,
async typescript(projectDir) {
return load('@ava/typescript', projectDir);
},
Expand Down
2 changes: 1 addition & 1 deletion lib/reporters/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export default class Reporter {
this.consumeStateChange(evt);
});

if (this.watching && plan.runVector > 1) {
if (this.watching && !plan.firstRun) {
this.lineWriter.write(chalk.gray.dim('\u2500'.repeat(this.lineWriter.columns)) + os.EOL);
}

Expand Down
2 changes: 1 addition & 1 deletion lib/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export default class Runner extends Emittery {
updating: this.updateSnapshots,
});
if (snapshots.snapPath !== undefined) {
this.emit('dependency', snapshots.snapPath);
this.emit('accessed-snapshots', snapshots.snapPath);
}

this._snapshots = snapshots;
Expand Down
15 changes: 14 additions & 1 deletion lib/scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,22 @@ const scheduler = {
return;
}

const filename = path.join(cacheDir, FILENAME);
// Given that we're writing to a cache directory, consider this file
// temporary.
const temporaryFiles = [filename];
try {
writeFileAtomic.sync(path.join(cacheDir, FILENAME), JSON.stringify(runStatus.getFailedTestFiles()));
writeFileAtomic.sync(filename, JSON.stringify(runStatus.getFailedTestFiles()), {
tmpfileCreated(tmpfile) {
temporaryFiles.push(tmpfile);
},
});
} catch {}

return {
changedFiles: [],
temporaryFiles,
};
},

// Order test-files, so that files with failing tests come first
Expand Down
Loading