ava 5.3.0 → 6.0.0
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.
- package/entrypoints/internal.d.mts +7 -0
- package/lib/api-event-iterator.js +12 -0
- package/lib/api.js +14 -23
- package/lib/assert.js +289 -444
- package/lib/cli.js +95 -61
- package/lib/code-excerpt.js +2 -2
- package/lib/eslint-plugin-helper-worker.js +3 -3
- package/lib/fork.js +3 -13
- package/lib/glob-helpers.cjs +1 -9
- package/lib/globs.js +7 -3
- package/lib/like-selector.js +26 -17
- package/lib/line-numbers.js +1 -1
- package/lib/load-config.js +3 -3
- package/lib/parse-test-args.js +3 -3
- package/lib/plugin-support/shared-workers.js +4 -4
- package/lib/provider-manager.js +11 -13
- package/lib/reporters/beautify-stack.js +0 -1
- package/lib/reporters/default.js +92 -45
- package/lib/reporters/format-serialized-error.js +6 -6
- package/lib/reporters/improper-usage-messages.js +5 -5
- package/lib/reporters/tap.js +30 -30
- package/lib/run-status.js +9 -0
- package/lib/runner.js +7 -7
- package/lib/scheduler.js +14 -1
- package/lib/serialize-error.js +44 -116
- package/lib/slash.cjs +1 -1
- package/lib/snapshot-manager.js +14 -8
- package/lib/test.js +90 -81
- package/lib/watcher.js +494 -365
- package/lib/worker/base.js +90 -51
- package/lib/worker/channel.cjs +9 -53
- package/license +1 -1
- package/package.json +36 -42
- package/readme.md +6 -12
- package/types/assertions.d.cts +107 -49
- package/types/shared-worker.d.cts +0 -2
- package/types/state-change-events.d.cts +143 -0
- package/types/test-fn.d.cts +10 -5
- package/lib/worker/dependency-tracker.js +0 -48
- /package/entrypoints/{main.d.ts → main.d.mts} +0 -0
- /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
|
|
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,
|
|
10
|
+
import {applyTestFileFilter, classify, buildIgnoreMatcher, findTests} from './globs.js';
|
|
11
|
+
import {levels as providerLevels} from './provider-manager.js';
|
|
8
12
|
|
|
9
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
95
|
+
for (const file of evt.files.temporaryFiles) {
|
|
96
|
+
temporaryFiles.add(nodePath.relative(projectDir, file));
|
|
97
|
+
}
|
|
166
98
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
this.trackTouchedFiles(api);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
170
101
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
this.rerunAll();
|
|
180
|
-
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
181
123
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
195
|
+
if (touchedFiles.has(path)) {
|
|
196
|
+
debug('Ignoring known touched file %s', path);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
233
199
|
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
this.pruneFailures(plan.files);
|
|
361
|
+
takeCoverageForSelfTests?.();
|
|
362
|
+
}, 100).unref();
|
|
287
363
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
377
|
+
// And finally, the watch loop.
|
|
378
|
+
while (!abortSignal.aborted) {
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
this.filesWithFailures = this.filesWithFailures.filter(state => !toPrune.has(state.file));
|
|
411
|
+
constructor(path) {
|
|
412
|
+
this.path = path;
|
|
317
413
|
}
|
|
318
414
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return false;
|
|
323
|
-
}
|
|
415
|
+
get parents() {
|
|
416
|
+
return this.#parents.keys();
|
|
417
|
+
}
|
|
324
418
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
419
|
+
addChild(node) {
|
|
420
|
+
this.#children.set(node.path, node);
|
|
421
|
+
node.#addParent(this);
|
|
422
|
+
}
|
|
328
423
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
file,
|
|
332
|
-
vector,
|
|
333
|
-
count: 1,
|
|
334
|
-
});
|
|
335
|
-
}
|
|
424
|
+
#addParent(node) {
|
|
425
|
+
this.#parents.set(node.path, node);
|
|
336
426
|
}
|
|
337
427
|
|
|
338
|
-
|
|
339
|
-
|
|
428
|
+
prune() {
|
|
429
|
+
for (const child of this.#children.values()) {
|
|
430
|
+
child.#removeParent(this);
|
|
431
|
+
}
|
|
340
432
|
|
|
341
|
-
for (const
|
|
342
|
-
|
|
343
|
-
total += state.count;
|
|
344
|
-
}
|
|
433
|
+
for (const parent of this.#parents.values()) {
|
|
434
|
+
parent.#removeChild(this);
|
|
345
435
|
}
|
|
436
|
+
}
|
|
346
437
|
|
|
347
|
-
|
|
438
|
+
#removeChild(node) {
|
|
439
|
+
this.#children.delete(node.path);
|
|
348
440
|
}
|
|
349
441
|
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
447
|
+
class Tree extends Map {
|
|
448
|
+
get(path) {
|
|
449
|
+
if (!this.has(path)) {
|
|
450
|
+
this.set(path, new Node(path));
|
|
451
|
+
}
|
|
361
452
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (data !== 'r' && data !== 'rs' && data !== 'u') {
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
453
|
+
return super.get(path);
|
|
454
|
+
}
|
|
367
455
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
this
|
|
391
|
-
this.run(this.previousFiles, true);
|
|
479
|
+
get busy() {
|
|
480
|
+
return this.#pendingTrace;
|
|
392
481
|
}
|
|
393
482
|
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
492
|
+
visited.add(path);
|
|
493
|
+
|
|
494
|
+
const node = this.#tree.get(path);
|
|
495
|
+
if (node === undefined) {
|
|
496
|
+
continue;
|
|
403
497
|
}
|
|
404
498
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
if (main.ignoreChange(path)) {
|
|
419
|
-
debug('Ignoring changed file %s', path);
|
|
420
|
-
return false;
|
|
421
|
-
}
|
|
531
|
+
this.#pendingTrace = current;
|
|
532
|
+
}
|
|
422
533
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
534
|
+
async #update(changes) {
|
|
535
|
+
await this.#pendingTrace; // Guard against race conditions.
|
|
536
|
+
this.#signalUpdateRunning();
|
|
426
537
|
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
557
|
+
// Remove deleted files from the tree.
|
|
558
|
+
for (const path of deletedFiles) {
|
|
559
|
+
this.#tree.delete(path);
|
|
560
|
+
}
|
|
446
561
|
|
|
447
|
-
//
|
|
448
|
-
if (
|
|
449
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
//
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
//
|
|
474
|
-
|
|
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
|
}
|