ava 5.3.1 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/entrypoints/internal.d.mts +7 -0
  2. package/lib/api-event-iterator.js +12 -0
  3. package/lib/api.js +14 -23
  4. package/lib/assert.js +289 -444
  5. package/lib/cli.js +95 -61
  6. package/lib/code-excerpt.js +2 -2
  7. package/lib/eslint-plugin-helper-worker.js +3 -3
  8. package/lib/fork.js +3 -13
  9. package/lib/glob-helpers.cjs +1 -9
  10. package/lib/globs.js +7 -3
  11. package/lib/line-numbers.js +1 -1
  12. package/lib/load-config.js +3 -3
  13. package/lib/parse-test-args.js +3 -3
  14. package/lib/plugin-support/shared-workers.js +4 -4
  15. package/lib/provider-manager.js +11 -13
  16. package/lib/reporters/beautify-stack.js +0 -1
  17. package/lib/reporters/default.js +92 -45
  18. package/lib/reporters/format-serialized-error.js +6 -6
  19. package/lib/reporters/improper-usage-messages.js +5 -5
  20. package/lib/reporters/tap.js +30 -30
  21. package/lib/run-status.js +9 -0
  22. package/lib/runner.js +7 -7
  23. package/lib/scheduler.js +14 -1
  24. package/lib/serialize-error.js +44 -116
  25. package/lib/slash.cjs +1 -1
  26. package/lib/snapshot-manager.js +14 -8
  27. package/lib/test.js +90 -81
  28. package/lib/watcher.js +494 -365
  29. package/lib/worker/base.js +90 -51
  30. package/lib/worker/channel.cjs +9 -53
  31. package/license +1 -1
  32. package/package.json +36 -42
  33. package/readme.md +6 -12
  34. package/types/assertions.d.cts +107 -49
  35. package/types/shared-worker.d.cts +0 -2
  36. package/types/state-change-events.d.cts +143 -0
  37. package/types/test-fn.d.cts +10 -5
  38. package/lib/worker/dependency-tracker.js +0 -48
  39. /package/entrypoints/{main.d.ts → main.d.mts} +0 -0
  40. /package/entrypoints/{plugin.d.ts → plugin.d.mts} +0 -0
package/lib/watcher.js CHANGED
@@ -1,476 +1,605 @@
1
+ import fs from 'node:fs';
1
2
  import nodePath from 'node:path';
3
+ import process from 'node:process';
4
+ import v8 from 'node:v8';
2
5
 
3
- import chokidar_ from 'chokidar';
6
+ import {nodeFileTrace} from '@vercel/nft';
4
7
  import createDebug from 'debug';
5
8
 
6
9
  import {chalk} from './chalk.js';
7
- import {applyTestFileFilter, classify, getChokidarIgnorePatterns} from './globs.js';
10
+ import {applyTestFileFilter, classify, buildIgnoreMatcher, findTests} from './globs.js';
11
+ import {levels as providerLevels} from './provider-manager.js';
8
12
 
9
- let chokidar = chokidar_;
10
- export function _testOnlyReplaceChokidar(replacement) {
11
- chokidar = replacement;
12
- }
13
-
14
- let debug = createDebug('ava:watcher');
15
- export function _testOnlyReplaceDebug(replacement) {
16
- debug = replacement('ava:watcher');
17
- }
13
+ const debug = createDebug('ava:watcher');
18
14
 
19
- function rethrowAsync(error) {
20
- // Don't swallow exceptions. Note that any
21
- // expected error should already have been logged
22
- setImmediate(() => {
23
- throw error;
24
- });
25
- }
15
+ // In order to get reliable code coverage for the tests of the watcher, we need
16
+ // to make Node.js write out interim reports in various places.
17
+ const takeCoverageForSelfTests = process.env.TEST_AVA ? v8.takeCoverage : undefined;
26
18
 
27
- const MIN_DEBOUNCE_DELAY = 10;
28
- const INITIAL_DEBOUNCE_DELAY = 100;
29
19
  const END_MESSAGE = chalk.gray('Type `r` and press enter to rerun tests\nType `u` and press enter to update snapshots\n');
30
20
 
31
- class Debouncer {
32
- constructor(watcher) {
33
- this.watcher = watcher;
34
- this.timer = null;
35
- this.repeat = false;
36
- }
37
-
38
- debounce(delay) {
39
- if (this.timer) {
40
- this.again = true;
41
- return;
21
+ export function available(projectDir) {
22
+ try {
23
+ fs.watch(projectDir, {recursive: true, signal: AbortSignal.abort()});
24
+ } catch (error) {
25
+ if (error.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') {
26
+ return false;
42
27
  }
43
28
 
44
- delay = delay ? Math.max(delay, MIN_DEBOUNCE_DELAY) : INITIAL_DEBOUNCE_DELAY;
45
-
46
- const timer = setTimeout(async () => {
47
- await this.watcher.busy;
48
- // Do nothing if debouncing was canceled while waiting for the busy
49
- // promise to fulfil
50
- if (this.timer !== timer) {
51
- return;
52
- }
53
-
54
- if (this.again) {
55
- this.timer = null;
56
- this.again = false;
57
- this.debounce(delay / 2);
58
- } else {
59
- this.watcher.runAfterChanges();
60
- this.timer = null;
61
- this.again = false;
62
- }
63
- }, delay);
64
-
65
- this.timer = timer;
29
+ throw error;
66
30
  }
67
31
 
68
- cancel() {
69
- if (this.timer) {
70
- clearTimeout(this.timer);
71
- this.timer = null;
72
- this.again = false;
73
- }
74
- }
32
+ return true;
75
33
  }
76
34
 
77
- class TestDependency {
78
- constructor(file, dependencies) {
79
- this.file = file;
80
- this.dependencies = dependencies;
81
- }
82
-
83
- contains(dependency) {
84
- return this.dependencies.includes(dependency);
35
+ export async function start({api, filter, globs, projectDir, providers, reporter, stdin, signal}) {
36
+ providers = providers.filter(({level}) => level >= providerLevels.ava6);
37
+ for await (const {files, ...runtimeOptions} of plan({api, filter, globs, projectDir, providers, stdin, abortSignal: signal})) {
38
+ await api.run({files, filter, runtimeOptions});
39
+ reporter.endRun();
40
+ reporter.lineWriter.writeLine(END_MESSAGE);
85
41
  }
86
42
  }
87
43
 
88
- export default class Watcher {
89
- constructor({api, filter = [], globs, projectDir, providers, reporter}) {
90
- this.debouncer = new Debouncer(this);
91
-
92
- this.clearLogOnNextRun = true;
93
- this.runVector = 0;
94
- this.previousFiles = [];
95
- this.globs = {cwd: projectDir, ...globs};
44
+ async function * plan({api, filter, globs, projectDir, providers, stdin, abortSignal}) {
45
+ const fileTracer = new FileTracer({base: projectDir});
46
+ const isIgnored = buildIgnoreMatcher(globs);
47
+ const patternFilters = filter.map(({pattern}) => pattern);
96
48
 
97
- const patternFilters = filter.map(({pattern}) => pattern);
98
-
99
- this.providers = providers;
100
- this.run = (specificFiles = [], updateSnapshots = false) => {
101
- const clearLogOnNextRun = this.clearLogOnNextRun && this.runVector > 0;
102
- if (this.runVector > 0) {
103
- this.clearLogOnNextRun = true;
104
- }
105
-
106
- this.runVector++;
107
-
108
- let runOnlyExclusive = false;
109
- if (specificFiles.length > 0) {
110
- const exclusiveFiles = specificFiles.filter(file => this.filesWithExclusiveTests.includes(file));
111
- runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length;
112
- if (runOnlyExclusive) {
113
- // The test files that previously contained exclusive tests are always
114
- // run, together with the remaining specific files.
115
- const remainingFiles = specificFiles.filter(file => !exclusiveFiles.includes(file));
116
- specificFiles = [...this.filesWithExclusiveTests, ...remainingFiles];
117
- }
49
+ const statsCache = new Map();
50
+ const fileStats = path => {
51
+ if (statsCache.has(path)) {
52
+ return statsCache.get(path); // N.B. `undefined` is a valid value!
53
+ }
118
54
 
119
- if (filter.length > 0) {
120
- specificFiles = applyTestFileFilter({
121
- cwd: projectDir,
122
- expandDirectories: false,
123
- filter: patternFilters,
124
- testFiles: specificFiles,
125
- treatFilterPatternsAsFiles: false,
126
- });
55
+ const stats = fs.statSync(nodePath.join(projectDir, path), {throwIfNoEntry: false});
56
+ statsCache.set(path, stats);
57
+ return stats;
58
+ };
59
+
60
+ const fileExists = path => fileStats(path) !== undefined;
61
+ const cwdAndGlobs = {cwd: projectDir, ...globs};
62
+ const changeFromPath = path => {
63
+ const {isTest} = classify(path, cwdAndGlobs);
64
+ const stats = fileStats(path);
65
+ return {path, isTest, exists: stats !== undefined, isFile: stats?.isFile() ?? false};
66
+ };
67
+
68
+ // Begin a file trace in the background.
69
+ fileTracer.update(findTests(cwdAndGlobs).then(testFiles => testFiles.map(path => ({
70
+ path: nodePath.relative(projectDir, path),
71
+ isTest: true,
72
+ exists: true,
73
+ }))));
74
+
75
+ // State tracked for test runs.
76
+ const filesWithExclusiveTests = new Set();
77
+ const touchedFiles = new Set();
78
+ const temporaryFiles = new Set();
79
+ const failureCounts = new Map();
80
+
81
+ // Observe all test runs.
82
+ api.on('run', ({status}) => {
83
+ status.on('stateChange', evt => {
84
+ switch (evt.type) {
85
+ case 'accessed-snapshots': {
86
+ fileTracer.addDependency(nodePath.relative(projectDir, evt.testFile), nodePath.relative(projectDir, evt.filename));
87
+ break;
127
88
  }
128
89
 
129
- this.pruneFailures(specificFiles);
130
- }
131
-
132
- this.touchedFiles.clear();
133
- this.previousFiles = specificFiles;
134
- this.busy = api.run({
135
- files: specificFiles,
136
- filter,
137
- runtimeOptions: {
138
- clearLogOnNextRun,
139
- previousFailures: this.sumPreviousFailures(this.runVector),
140
- runOnlyExclusive,
141
- runVector: this.runVector,
142
- updateSnapshots: updateSnapshots === true,
143
- },
144
- })
145
- .then(runStatus => {
146
- reporter.endRun();
147
- reporter.lineWriter.writeLine(END_MESSAGE);
148
-
149
- if (this.clearLogOnNextRun && (
150
- runStatus.stats.failedHooks > 0
151
- || runStatus.stats.failedTests > 0
152
- || runStatus.stats.failedWorkers > 0
153
- || runStatus.stats.internalErrors > 0
154
- || runStatus.stats.timeouts > 0
155
- || runStatus.stats.uncaughtExceptions > 0
156
- || runStatus.stats.unhandledRejections > 0
157
- )) {
158
- this.clearLogOnNextRun = false;
90
+ case 'touched-files': {
91
+ for (const file of evt.files.changedFiles) {
92
+ touchedFiles.add(nodePath.relative(projectDir, file));
159
93
  }
160
- })
161
- .catch(rethrowAsync);
162
- };
163
94
 
164
- this.testDependencies = [];
165
- this.trackTestDependencies(api);
95
+ for (const file of evt.files.temporaryFiles) {
96
+ temporaryFiles.add(nodePath.relative(projectDir, file));
97
+ }
166
98
 
167
- this.temporaryFiles = new Set();
168
- this.touchedFiles = new Set();
169
- this.trackTouchedFiles(api);
99
+ break;
100
+ }
170
101
 
171
- this.filesWithExclusiveTests = [];
172
- this.trackExclusivity(api);
102
+ case 'hook-failed':
103
+ case 'internal-error':
104
+ case 'process-exit':
105
+ case 'test-failed':
106
+ case 'uncaught-exception':
107
+ case 'unhandled-rejection':
108
+ case 'worker-failed': {
109
+ failureCounts.set(evt.testFile, 1 + (failureCounts.get(evt.testFile) ?? 0));
110
+ break;
111
+ }
173
112
 
174
- this.filesWithFailures = [];
175
- this.trackFailures(api);
113
+ case 'worker-finished': {
114
+ const fileStats = status.stats.byFile.get(evt.testFile);
115
+ if (fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests) {
116
+ filesWithExclusiveTests.add(nodePath.relative(projectDir, evt.testFile));
117
+ } else {
118
+ filesWithExclusiveTests.delete(nodePath.relative(projectDir, evt.testFile));
119
+ }
176
120
 
177
- this.dirtyStates = {};
178
- this.watchFiles();
179
- this.rerunAll();
180
- }
121
+ break;
122
+ }
181
123
 
182
- watchFiles() {
183
- chokidar.watch(['**/*'], {
184
- cwd: this.globs.cwd,
185
- ignored: getChokidarIgnorePatterns(this.globs),
186
- ignoreInitial: true,
187
- }).on('all', (event, path) => {
188
- if (event === 'add' || event === 'change' || event === 'unlink') {
189
- debug('Detected %s of %s', event, path);
190
- this.dirtyStates[nodePath.join(this.globs.cwd, path)] = event;
191
- this.debouncer.debounce();
124
+ default: {
125
+ break;
126
+ }
192
127
  }
193
128
  });
194
- }
129
+ });
195
130
 
196
- trackTestDependencies(api) {
197
- api.on('run', plan => {
198
- plan.status.on('stateChange', evt => {
199
- if (evt.type !== 'dependencies') {
200
- return;
201
- }
131
+ // State for subsequent test runs.
132
+ let signalChanged;
133
+ let changed = Promise.resolve({});
134
+ let firstRun = true;
135
+ let runAll = true;
136
+ let updateSnapshots = false;
202
137
 
203
- const dependencies = evt.dependencies.filter(filePath => {
204
- const {isIgnoredByWatcher} = classify(filePath, this.globs);
205
- return !isIgnoredByWatcher;
206
- });
207
- this.updateTestDependencies(evt.testFile, dependencies);
208
- });
138
+ const reset = () => {
139
+ changed = new Promise(resolve => {
140
+ signalChanged = resolve;
209
141
  });
210
- }
142
+ firstRun = false;
143
+ runAll = false;
144
+ updateSnapshots = false;
145
+ };
146
+
147
+ // Support interactive commands.
148
+ stdin.setEncoding('utf8');
149
+ stdin.on('data', data => {
150
+ data = data.trim().toLowerCase();
151
+ runAll ||= data === 'r';
152
+ updateSnapshots ||= data === 'u';
153
+ if (runAll || updateSnapshots) {
154
+ signalChanged({});
155
+ }
156
+ });
157
+ stdin.unref();
158
+
159
+ // Whether tests are currently running. Used to control when the next run
160
+ // is prepared.
161
+ let testsAreRunning = false;
162
+
163
+ // Tracks file paths we know have changed since the previous test run.
164
+ const dirtyPaths = new Set();
165
+ const debounce = setTimeout(() => {
166
+ // The callback is invoked for a variety of reasons, not necessarily because
167
+ // there are dirty paths. But if there are none, then there's nothing to do.
168
+ if (dirtyPaths.size === 0) {
169
+ takeCoverageForSelfTests?.();
170
+ return;
171
+ }
211
172
 
212
- updateTestDependencies(file, dependencies) {
213
- // Ensure the rewritten test file path is included in the dependencies,
214
- // since changes to non-rewritten paths are ignored.
215
- for (const {main} of this.providers) {
216
- const rewritten = main.resolveTestFile(file);
217
- if (!dependencies.includes(rewritten)) {
218
- dependencies = [rewritten, ...dependencies];
219
- }
173
+ // Equally, if tests are currently running, then keep accumulating changes.
174
+ // The timer is refreshed after tests finish running.
175
+ if (testsAreRunning) {
176
+ takeCoverageForSelfTests?.();
177
+ return;
220
178
  }
221
179
 
222
- if (dependencies.length === 0) {
223
- this.testDependencies = this.testDependencies.filter(dep => dep.file !== file);
180
+ // If the file tracer is still analyzing dependencies, wait for that to
181
+ // complete.
182
+ if (fileTracer.busy !== null) {
183
+ fileTracer.busy.then(() => debounce.refresh());
184
+ takeCoverageForSelfTests?.();
224
185
  return;
225
186
  }
226
187
 
227
- const isUpdate = this.testDependencies.some(dep => {
228
- if (dep.file !== file) {
188
+ // Identify the changes.
189
+ const changes = [...dirtyPaths].filter(path => {
190
+ if (temporaryFiles.has(path)) {
191
+ debug('Ignoring known temporary file %s', path);
229
192
  return false;
230
193
  }
231
194
 
232
- dep.dependencies = dependencies;
195
+ if (touchedFiles.has(path)) {
196
+ debug('Ignoring known touched file %s', path);
197
+ return false;
198
+ }
233
199
 
234
- return true;
235
- });
200
+ for (const {main} of providers) {
201
+ switch (main.interpretChange(nodePath.join(projectDir, path))) {
202
+ case main.changeInterpretations.ignoreCompiled: {
203
+ debug('Ignoring compilation output %s', path);
204
+ return false;
205
+ }
236
206
 
237
- if (!isUpdate) {
238
- this.testDependencies.push(new TestDependency(file, dependencies));
239
- }
240
- }
207
+ case main.changeInterpretations.waitForOutOfBandCompilation: {
208
+ if (!fileExists(path)) {
209
+ debug('Not waiting for out-of-band compilation of deleted %s', path);
210
+ return true;
211
+ }
212
+
213
+ debug('Waiting for out-of-band compilation of %s', path);
214
+ return false;
215
+ }
241
216
 
242
- trackTouchedFiles(api) {
243
- api.on('run', plan => {
244
- plan.status.on('stateChange', evt => {
245
- if (evt.type !== 'touched-files') {
246
- return;
217
+ default: {
218
+ continue;
219
+ }
247
220
  }
221
+ }
222
+
223
+ if (isIgnored(path)) {
224
+ debug('%s is ignored by patterns', path);
225
+ return false;
226
+ }
227
+
228
+ return true;
229
+ }).flatMap(path => {
230
+ const change = changeFromPath(path);
248
231
 
249
- for (const file of evt.files.changedFiles) {
250
- this.touchedFiles.add(file);
232
+ for (const {main} of providers) {
233
+ const sources = main.resolvePossibleOutOfBandCompilationSources(nodePath.join(projectDir, path));
234
+ if (sources === null) {
235
+ continue;
251
236
  }
252
237
 
253
- for (const file of evt.files.temporaryFiles) {
254
- this.temporaryFiles.add(file);
238
+ if (sources.length === 1) {
239
+ const [source] = sources;
240
+ const newPath = nodePath.relative(projectDir, source);
241
+ if (change.exists) {
242
+ debug('Interpreting %s as %s', path, newPath);
243
+ return changeFromPath(newPath);
244
+ }
245
+
246
+ debug('Interpreting deleted %s as deletion of %s', path, newPath);
247
+ return {...changeFromPath(newPath), exists: false};
255
248
  }
256
- });
249
+
250
+ const relativeSources = sources.map(source => nodePath.relative(projectDir, source));
251
+ debug('Change of %s could be due to deletion of multiple source files %j', path, relativeSources);
252
+ return relativeSources.filter(possiblePath => fileTracer.has(possiblePath)).map(newPath => {
253
+ debug('Interpreting %s as deletion of %s', path, newPath);
254
+ return changeFromPath(newPath);
255
+ });
256
+ }
257
+
258
+ return change;
259
+ }).filter(change => {
260
+ // Filter out changes to directories. However, if a directory was deleted,
261
+ // we cannot tell that it used to be a directory.
262
+ if (change.exists && !change.isFile) {
263
+ debug('%s is not a file', change.path);
264
+ return false;
265
+ }
266
+
267
+ return true;
257
268
  });
258
- }
259
269
 
260
- trackExclusivity(api) {
261
- api.on('run', plan => {
262
- plan.status.on('stateChange', evt => {
263
- if (evt.type !== 'worker-finished') {
264
- return;
270
+ // Stats only need to be cached while we identify changes.
271
+ statsCache.clear();
272
+
273
+ // Identify test files that need to be run next, and whether there are
274
+ // non-ignored file changes that mean we should run all test files.
275
+ const uniqueTestFiles = new Set();
276
+ const deletedTestFiles = new Set();
277
+ const nonTestFiles = [];
278
+ for (const {path, isTest, exists} of changes) {
279
+ if (!exists) {
280
+ debug('%s was deleted', path);
281
+ }
282
+
283
+ if (isTest) {
284
+ debug('%s is a test file', path);
285
+ if (exists) {
286
+ uniqueTestFiles.add(path);
287
+ } else {
288
+ failureCounts.delete(path); // Stop tracking failures for deleted tests.
289
+ deletedTestFiles.add(path);
265
290
  }
291
+ } else {
292
+ debug('%s is not a test file', path);
293
+
294
+ const dependingTestFiles = fileTracer.traceToTestFile(path);
295
+ if (dependingTestFiles.length > 0) {
296
+ debug('%s is depended on by test files %o', path, dependingTestFiles);
297
+ for (const testFile of dependingTestFiles) {
298
+ uniqueTestFiles.add(testFile);
299
+ }
300
+ } else {
301
+ debug('%s is not known to be depended on by test files', path);
302
+ nonTestFiles.push(path);
303
+ }
304
+ }
305
+ }
266
306
 
267
- const fileStats = plan.status.stats.byFile.get(evt.testFile);
268
- const ranExclusiveTests = fileStats.selectedTests > 0 && fileStats.declaredTests > fileStats.selectedTests;
269
- this.updateExclusivity(evt.testFile, ranExclusiveTests);
307
+ // One more pass to make sure deleted test files are not run. This is needed
308
+ // because test files are selected when files they depend on are changed.
309
+ for (const path of deletedTestFiles) {
310
+ uniqueTestFiles.delete(path);
311
+ }
312
+
313
+ // Clear state from the previous run and detected file changes.
314
+ dirtyPaths.clear();
315
+ temporaryFiles.clear();
316
+ touchedFiles.clear();
317
+
318
+ // In the background, update the file tracer to reflect the changes.
319
+ if (changes.length > 0) {
320
+ fileTracer.update(changes);
321
+ }
322
+
323
+ // Select the test files to run, and how to run them.
324
+ let testFiles = [...uniqueTestFiles];
325
+ let runOnlyExclusive = false;
326
+
327
+ if (testFiles.length > 0) {
328
+ const exclusiveFiles = testFiles.filter(path => filesWithExclusiveTests.has(path));
329
+ runOnlyExclusive = exclusiveFiles.length !== filesWithExclusiveTests.size;
330
+ if (runOnlyExclusive) {
331
+ // The test files that previously contained exclusive tests are always
332
+ // run, together with the test files.
333
+ debug('Running exclusive tests in %o', [...filesWithExclusiveTests]);
334
+ testFiles = [...new Set([...filesWithExclusiveTests, ...testFiles])];
335
+ }
336
+ }
337
+
338
+ if (filter.length > 0) {
339
+ testFiles = applyTestFileFilter({
340
+ cwd: projectDir,
341
+ expandDirectories: false,
342
+ filter: patternFilters,
343
+ testFiles,
344
+ treatFilterPatternsAsFiles: false,
270
345
  });
271
- });
272
- }
346
+ }
273
347
 
274
- updateExclusivity(file, hasExclusiveTests) {
275
- const index = this.filesWithExclusiveTests.indexOf(file);
348
+ if (nonTestFiles.length > 0) {
349
+ debug('Non-test files changed, running all tests');
350
+ failureCounts.clear(); // All tests are run, so clear previous failures.
351
+ signalChanged({runOnlyExclusive});
352
+ } else if (testFiles.length > 0) {
353
+ // Remove previous failures for tests that will run again.
354
+ for (const path of testFiles) {
355
+ failureCounts.delete(path);
356
+ }
276
357
 
277
- if (hasExclusiveTests && index === -1) {
278
- this.filesWithExclusiveTests.push(file);
279
- } else if (!hasExclusiveTests && index !== -1) {
280
- this.filesWithExclusiveTests.splice(index, 1);
358
+ signalChanged({runOnlyExclusive, testFiles});
281
359
  }
282
- }
283
360
 
284
- trackFailures(api) {
285
- api.on('run', plan => {
286
- this.pruneFailures(plan.files);
361
+ takeCoverageForSelfTests?.();
362
+ }, 100).unref();
287
363
 
288
- const currentVector = this.runVector;
289
- plan.status.on('stateChange', evt => {
290
- if (!evt.testFile) {
291
- return;
292
- }
364
+ // Detect changed files.
365
+ fs.watch(projectDir, {recursive: true, signal: abortSignal}, (_, filename) => {
366
+ if (filename !== null) {
367
+ dirtyPaths.add(filename);
368
+ debug('Detected change in %s', filename);
369
+ debounce.refresh();
370
+ }
371
+ });
293
372
 
294
- switch (evt.type) {
295
- case 'hook-failed':
296
- case 'internal-error':
297
- case 'process-exit':
298
- case 'test-failed':
299
- case 'uncaught-exception':
300
- case 'unhandled-rejection':
301
- case 'worker-failed': {
302
- this.countFailure(evt.testFile, currentVector);
303
- break;
304
- }
373
+ abortSignal?.addEventListener('abort', () => {
374
+ signalChanged?.({});
375
+ });
305
376
 
306
- default: {
307
- break;
308
- }
309
- }
310
- });
311
- });
377
+ // And finally, the watch loop.
378
+ while (abortSignal?.aborted !== true) {
379
+ const {testFiles: files = [], runOnlyExclusive = false} = await changed; // eslint-disable-line no-await-in-loop
380
+
381
+ if (abortSignal?.aborted) {
382
+ break;
383
+ }
384
+
385
+ let previousFailures = 0;
386
+ for (const count of failureCounts.values()) {
387
+ previousFailures += count;
388
+ }
389
+
390
+ const instructions = {
391
+ files: files.map(file => nodePath.join(projectDir, file)),
392
+ firstRun, // Value is changed by refresh() so record now.
393
+ previousFailures,
394
+ runOnlyExclusive,
395
+ updateSnapshots, // Value is changed by refresh() so record now.
396
+ };
397
+ reset(); // Make sure the next run can be triggered.
398
+ testsAreRunning = true;
399
+ yield instructions; // Let the tests run.
400
+ testsAreRunning = false;
401
+ debounce.refresh(); // Trigger the callback, which if there were changes will run the tests again.
312
402
  }
403
+ }
404
+
405
+ // State management for file tracer.
406
+ class Node {
407
+ #children = new Map();
408
+ #parents = new Map();
409
+ isTest = false;
313
410
 
314
- pruneFailures(files) {
315
- const toPrune = new Set(files);
316
- this.filesWithFailures = this.filesWithFailures.filter(state => !toPrune.has(state.file));
411
+ constructor(path) {
412
+ this.path = path;
317
413
  }
318
414
 
319
- countFailure(file, vector) {
320
- const isUpdate = this.filesWithFailures.some(state => {
321
- if (state.file !== file) {
322
- return false;
323
- }
415
+ get parents() {
416
+ return this.#parents.keys();
417
+ }
324
418
 
325
- state.count++;
326
- return true;
327
- });
419
+ addChild(node) {
420
+ this.#children.set(node.path, node);
421
+ node.#addParent(this);
422
+ }
328
423
 
329
- if (!isUpdate) {
330
- this.filesWithFailures.push({
331
- file,
332
- vector,
333
- count: 1,
334
- });
335
- }
424
+ #addParent(node) {
425
+ this.#parents.set(node.path, node);
336
426
  }
337
427
 
338
- sumPreviousFailures(beforeVector) {
339
- let total = 0;
428
+ prune() {
429
+ for (const child of this.#children.values()) {
430
+ child.#removeParent(this);
431
+ }
340
432
 
341
- for (const state of this.filesWithFailures) {
342
- if (state.vector < beforeVector) {
343
- total += state.count;
344
- }
433
+ for (const parent of this.#parents.values()) {
434
+ parent.#removeChild(this);
345
435
  }
436
+ }
346
437
 
347
- return total;
438
+ #removeChild(node) {
439
+ this.#children.delete(node.path);
348
440
  }
349
441
 
350
- cleanUnlinkedTests(unlinkedTests) {
351
- for (const testFile of unlinkedTests) {
352
- this.updateTestDependencies(testFile, []);
353
- this.updateExclusivity(testFile, false);
354
- this.pruneFailures([testFile]);
355
- }
442
+ #removeParent(node) {
443
+ this.#parents.delete(node.path);
356
444
  }
445
+ }
357
446
 
358
- observeStdin(stdin) {
359
- stdin.resume();
360
- stdin.setEncoding('utf8');
447
+ class Tree extends Map {
448
+ get(path) {
449
+ if (!this.has(path)) {
450
+ this.set(path, new Node(path));
451
+ }
361
452
 
362
- stdin.on('data', async data => {
363
- data = data.trim().toLowerCase();
364
- if (data !== 'r' && data !== 'rs' && data !== 'u') {
365
- return;
366
- }
453
+ return super.get(path);
454
+ }
367
455
 
368
- // Cancel the debouncer, it might rerun specific tests whereas *all* tests
369
- // need to be rerun
370
- this.debouncer.cancel();
371
- await this.busy;
372
- // Cancel the debouncer again, it might have restarted while waiting for
373
- // the busy promise to fulfil
374
- this.debouncer.cancel();
375
- this.clearLogOnNextRun = false;
376
- if (data === 'u') {
377
- this.updatePreviousSnapshots();
378
- } else {
379
- this.rerunAll();
380
- }
381
- });
456
+ delete(path) {
457
+ const node = this.get(path);
458
+ node?.prune();
459
+ super.delete(path);
382
460
  }
461
+ }
383
462
 
384
- rerunAll() {
385
- this.dirtyStates = {};
386
- this.run();
463
+ // Track file dependencies to determine which test files to run.
464
+ class FileTracer {
465
+ #base;
466
+ #cache = Object.create(null);
467
+ #pendingTrace = null;
468
+ #updateRunning;
469
+ #signalUpdateRunning;
470
+ #tree = new Tree();
471
+
472
+ constructor({base}) {
473
+ this.#base = base;
474
+ this.#updateRunning = new Promise(resolve => {
475
+ this.#signalUpdateRunning = resolve;
476
+ });
387
477
  }
388
478
 
389
- updatePreviousSnapshots() {
390
- this.dirtyStates = {};
391
- this.run(this.previousFiles, true);
479
+ get busy() {
480
+ return this.#pendingTrace;
392
481
  }
393
482
 
394
- runAfterChanges() {
395
- const {dirtyStates} = this;
396
- this.dirtyStates = {};
483
+ traceToTestFile(startingPath) {
484
+ const todo = [startingPath];
485
+ const testFiles = new Set();
486
+ const visited = new Set();
487
+ for (const path of todo) {
488
+ if (visited.has(path)) {
489
+ continue;
490
+ }
397
491
 
398
- let dirtyPaths = Object.keys(dirtyStates).filter(path => {
399
- if (this.touchedFiles.has(path)) {
400
- debug('Ignoring known touched file %s', path);
401
- this.touchedFiles.delete(path);
402
- return false;
492
+ visited.add(path);
493
+
494
+ const node = this.#tree.get(path);
495
+ if (node === undefined) {
496
+ continue;
403
497
  }
404
498
 
405
- // Unlike touched files, temporary files are never cleared. We may see
406
- // adds and unlinks detected separately, so we track the temporary files
407
- // as long as AVA is running.
408
- if (this.temporaryFiles.has(path)) {
409
- debug('Ignoring known temporary file %s', path);
410
- return false;
499
+ if (node.isTest) {
500
+ testFiles.add(node.path);
501
+ } else {
502
+ todo.push(...node.parents);
411
503
  }
504
+ }
412
505
 
413
- return true;
506
+ return [...testFiles];
507
+ }
508
+
509
+ addDependency(testFile, path) {
510
+ const testNode = this.#tree.get(testFile);
511
+ testNode.isTest = true;
512
+
513
+ const node = this.#tree.get(path);
514
+ testNode.addChild(node);
515
+ }
516
+
517
+ has(path) {
518
+ return this.#tree.has(path);
519
+ }
520
+
521
+ update(changes) {
522
+ const current = this.#update(changes).finally(() => {
523
+ if (this.#pendingTrace === current) {
524
+ this.#pendingTrace = null;
525
+ this.#updateRunning = new Promise(resolve => {
526
+ this.#signalUpdateRunning = resolve;
527
+ });
528
+ }
414
529
  });
415
530
 
416
- for (const {main} of this.providers) {
417
- dirtyPaths = dirtyPaths.filter(path => {
418
- if (main.ignoreChange(path)) {
419
- debug('Ignoring changed file %s', path);
420
- return false;
421
- }
531
+ this.#pendingTrace = current;
532
+ }
422
533
 
423
- return true;
424
- });
425
- }
534
+ async #update(changes) {
535
+ await this.#pendingTrace; // Guard against race conditions.
536
+ this.#signalUpdateRunning();
426
537
 
427
- const dirtyHelpersAndSources = [];
428
- const addedOrChangedTests = [];
429
- const unlinkedTests = [];
430
- for (const filePath of dirtyPaths) {
431
- const {isIgnoredByWatcher, isTest} = classify(filePath, this.globs);
432
- if (!isIgnoredByWatcher) {
538
+ let reuseCache = true;
539
+ const knownTestFiles = new Set();
540
+ const deletedFiles = new Set();
541
+ const filesToTrace = new Set();
542
+ for (const {path, isTest, exists} of await changes) {
543
+ if (exists) {
433
544
  if (isTest) {
434
- if (dirtyStates[filePath] === 'unlink') {
435
- unlinkedTests.push(filePath);
436
- } else {
437
- addedOrChangedTests.push(filePath);
438
- }
439
- } else {
440
- dirtyHelpersAndSources.push(filePath);
545
+ knownTestFiles.add(path);
441
546
  }
547
+
548
+ filesToTrace.add(path);
549
+ } else {
550
+ deletedFiles.add(path);
442
551
  }
552
+
553
+ // The cache can be reused as long as the changes are just for new files.
554
+ reuseCache &&= !this.#tree.has(path);
443
555
  }
444
556
 
445
- this.cleanUnlinkedTests(unlinkedTests);
557
+ // Remove deleted files from the tree.
558
+ for (const path of deletedFiles) {
559
+ this.#tree.delete(path);
560
+ }
446
561
 
447
- // No need to rerun tests if the only change is that tests were deleted
448
- if (unlinkedTests.length === dirtyPaths.length) {
449
- return;
562
+ // Create a new cache if the old one can't be reused.
563
+ if (!reuseCache) {
564
+ this.#cache = Object.create(null);
450
565
  }
451
566
 
452
- if (dirtyHelpersAndSources.length === 0) {
453
- // Run any new or changed tests
454
- this.run(addedOrChangedTests);
567
+ // If all changes are deletions then there is no more work to do.
568
+ if (filesToTrace.size === 0) {
455
569
  return;
456
570
  }
457
571
 
458
- // Try to find tests that depend on the changed source files
459
- const testsByHelpersOrSource = dirtyHelpersAndSources.map(path => this.testDependencies.filter(dep => dep.contains(path)).map(dep => {
460
- debug('%s is a dependency of %s', path, dep.file);
461
- return dep.file;
462
- })).filter(tests => tests.length > 0);
463
-
464
- // Rerun all tests if source files were changed that could not be traced to
465
- // specific tests
466
- if (testsByHelpersOrSource.length !== dirtyHelpersAndSources.length) {
467
- debug('Files remain that cannot be traced to specific tests: %O', dirtyHelpersAndSources);
468
- debug('Rerunning all tests');
469
- this.run();
470
- return;
572
+ // Always retrace all test files, in case a file was deleted and then replaced.
573
+ for (const node of this.#tree.values()) {
574
+ if (node.isTest) {
575
+ filesToTrace.add(node.path);
576
+ }
471
577
  }
472
578
 
473
- // Run all affected tests
474
- this.run([...new Set([addedOrChangedTests, testsByHelpersOrSource].flat(2))]);
579
+ // Trace any new and changed files.
580
+ const {fileList, reasons} = await nodeFileTrace([...filesToTrace], {
581
+ analysis: { // Only trace exact imports.
582
+ emitGlobs: false,
583
+ computeFileReferences: false,
584
+ evaluatePureExpressions: true,
585
+ },
586
+ base: this.#base,
587
+ cache: this.#cache,
588
+ conditions: ['node'],
589
+ exportsOnly: true, // Disregard "main" in package files when "exports" is present.
590
+ ignore: ['**/node_modules/**'], // Don't trace through installed dependencies.
591
+ });
592
+
593
+ // Update the tree.
594
+ for (const path of fileList) {
595
+ const node = this.#tree.get(path);
596
+ node.isTest = knownTestFiles.has(path);
597
+
598
+ const {parents} = reasons.get(path);
599
+ for (const parent of parents) {
600
+ const parentNode = this.#tree.get(parent);
601
+ parentNode.addChild(node);
602
+ }
603
+ }
475
604
  }
476
605
  }