UNPKG

8.19 kBJavaScriptView Raw
1'use strict';
2const fs = require('fs');
3const path = require('path');
4const os = require('os');
5const commonPathPrefix = require('common-path-prefix');
6const resolveCwd = require('resolve-cwd');
7const debounce = require('lodash/debounce');
8const arrify = require('arrify');
9const ms = require('ms');
10const chunkd = require('chunkd');
11const Emittery = require('emittery');
12const pMap = require('p-map');
13const tempDir = require('temp-dir');
14const globs = require('./globs');
15const isCi = require('./is-ci');
16const RunStatus = require('./run-status');
17const fork = require('./fork');
18const serializeError = require('./serialize-error');
19const {getApplicableLineNumbers} = require('./line-numbers');
20
21function resolveModules(modules) {
22 return arrify(modules).map(name => {
23 const modulePath = resolveCwd.silent(name);
24
25 if (modulePath === undefined) {
26 throw new Error(`Could not resolve required module ’${name}’`);
27 }
28
29 return modulePath;
30 });
31}
32
33function getFilePathPrefix(files) {
34 if (files.length === 1) {
35 // Get the correct prefix up to the basename.
36 return commonPathPrefix([files[0], path.dirname(files[0])]);
37 }
38
39 return commonPathPrefix(files);
40}
41
42class Api extends Emittery {
43 constructor(options) {
44 super();
45
46 this.options = {match: [], moduleTypes: {}, ...options};
47 this.options.require = resolveModules(this.options.require);
48
49 this._cacheDir = null;
50 this._interruptHandler = () => {};
51
52 if (options.ranFromCli) {
53 process.on('SIGINT', () => this._interruptHandler());
54 }
55 }
56
57 async run({files: selectedFiles = [], filter = [], runtimeOptions = {}} = {}) {
58 let setupOrGlobError;
59
60 const apiOptions = this.options;
61
62 // Each run will have its own status. It can only be created when test files
63 // have been found.
64 let runStatus;
65 // Irrespectively, perform some setup now, before finding test files.
66
67 // Track active forks and manage timeouts.
68 const failFast = apiOptions.failFast === true;
69 let bailed = false;
70 const pendingWorkers = new Set();
71 const timedOutWorkerFiles = new Set();
72 let restartTimer;
73 if (apiOptions.timeout && !apiOptions.debug) {
74 const timeout = ms(apiOptions.timeout);
75
76 restartTimer = debounce(() => {
77 // If failFast is active, prevent new test files from running after
78 // the current ones are exited.
79 if (failFast) {
80 bailed = true;
81 }
82
83 runStatus.emitStateChange({type: 'timeout', period: timeout});
84
85 for (const worker of pendingWorkers) {
86 timedOutWorkerFiles.add(worker.file);
87 worker.exit();
88 }
89 }, timeout);
90 } else {
91 restartTimer = Object.assign(() => {}, {cancel() {}});
92 }
93
94 this._interruptHandler = () => {
95 if (bailed) {
96 // Exiting already
97 return;
98 }
99
100 // Prevent new test files from running
101 bailed = true;
102
103 // Make sure we don't run the timeout handler
104 restartTimer.cancel();
105
106 runStatus.emitStateChange({type: 'interrupt'});
107
108 for (const worker of pendingWorkers) {
109 worker.exit();
110 }
111 };
112
113 let cacheDir;
114 let testFiles;
115 try {
116 cacheDir = this._createCacheDir();
117 testFiles = await globs.findTests({cwd: this.options.projectDir, ...apiOptions.globs});
118 if (selectedFiles.length === 0) {
119 if (filter.length === 0) {
120 selectedFiles = testFiles;
121 } else {
122 selectedFiles = globs.applyTestFileFilter({
123 cwd: this.options.projectDir,
124 filter: filter.map(({pattern}) => pattern),
125 testFiles
126 });
127 }
128 }
129 } catch (error) {
130 selectedFiles = [];
131 setupOrGlobError = error;
132 }
133
134 try {
135 if (this.options.parallelRuns) {
136 const {currentIndex, totalRuns} = this.options.parallelRuns;
137 const fileCount = selectedFiles.length;
138
139 // The files must be in the same order across all runs, so sort them.
140 selectedFiles = selectedFiles.sort((a, b) => a.localeCompare(b, [], {numeric: true}));
141 selectedFiles = chunkd(selectedFiles, currentIndex, totalRuns);
142
143 const currentFileCount = selectedFiles.length;
144
145 runStatus = new RunStatus(fileCount, {currentFileCount, currentIndex, totalRuns});
146 } else {
147 runStatus = new RunStatus(selectedFiles.length, null);
148 }
149
150 const debugWithoutSpecificFile = Boolean(this.options.debug) && !this.options.debug.active && selectedFiles.length !== 1;
151
152 await this.emit('run', {
153 bailWithoutReporting: debugWithoutSpecificFile,
154 clearLogOnNextRun: runtimeOptions.clearLogOnNextRun === true,
155 debug: Boolean(this.options.debug),
156 failFastEnabled: failFast,
157 filePathPrefix: getFilePathPrefix(selectedFiles),
158 files: selectedFiles,
159 matching: apiOptions.match.length > 0,
160 previousFailures: runtimeOptions.previousFailures || 0,
161 runOnlyExclusive: runtimeOptions.runOnlyExclusive === true,
162 runVector: runtimeOptions.runVector || 0,
163 status: runStatus
164 });
165
166 if (setupOrGlobError) {
167 throw setupOrGlobError;
168 }
169
170 // Bail out early if no files were found, or when debugging and there is not a single specific test file to debug.
171 if (selectedFiles.length === 0 || debugWithoutSpecificFile) {
172 return runStatus;
173 }
174
175 runStatus.on('stateChange', record => {
176 if (record.testFile && !timedOutWorkerFiles.has(record.testFile)) {
177 // Restart the timer whenever there is activity from workers that
178 // haven't already timed out.
179 restartTimer();
180 }
181
182 if (failFast && (record.type === 'hook-failed' || record.type === 'test-failed' || record.type === 'worker-failed')) {
183 // Prevent new test files from running once a test has failed.
184 bailed = true;
185
186 // Try to stop currently scheduled tests.
187 for (const worker of pendingWorkers) {
188 worker.notifyOfPeerFailure();
189 }
190 }
191 });
192
193 const {providers = []} = this.options;
194 const providerStates = (await Promise.all(providers.map(async ({type, main}) => {
195 const state = await main.compile({cacheDir, files: testFiles});
196 return state === null ? null : {type, state};
197 }))).filter(state => state !== null);
198
199 // Resolve the correct concurrency value.
200 let concurrency = Math.min(os.cpus().length, isCi ? 2 : Infinity);
201 if (apiOptions.concurrency > 0) {
202 concurrency = apiOptions.concurrency;
203 }
204
205 if (apiOptions.serial) {
206 concurrency = 1;
207 }
208
209 // Try and run each file, limited by `concurrency`.
210 await pMap(selectedFiles, async file => {
211 // No new files should be run once a test has timed out or failed,
212 // and failFast is enabled.
213 if (bailed) {
214 return;
215 }
216
217 const lineNumbers = getApplicableLineNumbers(globs.normalizeFileForMatching(apiOptions.projectDir, file), filter);
218 const options = {
219 ...apiOptions,
220 providerStates,
221 lineNumbers,
222 recordNewSnapshots: !isCi,
223 // If we're looking for matches, run every single test process in exclusive-only mode
224 runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true
225 };
226
227 if (runtimeOptions.updateSnapshots) {
228 // Don't use in Object.assign() since it'll override options.updateSnapshots even when false.
229 options.updateSnapshots = true;
230 }
231
232 const worker = fork(file, options, apiOptions.nodeArguments);
233 runStatus.observeWorker(worker, file, {selectingLines: lineNumbers.length > 0});
234
235 pendingWorkers.add(worker);
236 worker.promise.then(() => {
237 pendingWorkers.delete(worker);
238 });
239 restartTimer();
240
241 return worker.promise;
242 }, {concurrency, stopOnError: false});
243 } catch (error) {
244 if (error && error.name === 'AggregateError') {
245 for (const err of error) {
246 runStatus.emitStateChange({type: 'internal-error', err: serializeError('Internal error', false, err)});
247 }
248 } else {
249 runStatus.emitStateChange({type: 'internal-error', err: serializeError('Internal error', false, error)});
250 }
251 }
252
253 restartTimer.cancel();
254 return runStatus;
255 }
256
257 _createCacheDir() {
258 if (this._cacheDir) {
259 return this._cacheDir;
260 }
261
262 const cacheDir = this.options.cacheEnabled === false ?
263 fs.mkdtempSync(`${tempDir}${path.sep}`) :
264 path.join(this.options.projectDir, 'node_modules', '.cache', 'ava');
265
266 // Ensure cacheDir exists
267 fs.mkdirSync(cacheDir, {recursive: true});
268
269 this._cacheDir = cacheDir;
270
271 return cacheDir;
272 }
273}
274
275module.exports = Api;