UNPKG

9.7 kBJavaScriptView Raw
1'use strict';
2const path = require('path');
3const fs = require('fs');
4const os = require('os');
5const commonPathPrefix = require('common-path-prefix');
6const escapeStringRegexp = require('escape-string-regexp');
7const uniqueTempDir = require('unique-temp-dir');
8const isCi = require('is-ci');
9const resolveCwd = require('resolve-cwd');
10const debounce = require('lodash/debounce');
11const Bluebird = require('bluebird');
12const getPort = require('get-port');
13const arrify = require('arrify');
14const makeDir = require('make-dir');
15const ms = require('ms');
16const chunkd = require('chunkd');
17const Emittery = require('emittery');
18const babelPipeline = require('./babel-pipeline');
19const globs = require('./globs');
20const RunStatus = require('./run-status');
21const fork = require('./fork');
22const serializeError = require('./serialize-error');
23
24function resolveModules(modules) {
25 return arrify(modules).map(name => {
26 const modulePath = resolveCwd.silent(name);
27
28 if (modulePath === undefined) {
29 throw new Error(`Could not resolve required module '${name}'`);
30 }
31
32 return modulePath;
33 });
34}
35
36class Api extends Emittery {
37 constructor(options) {
38 super();
39
40 this.options = {match: [], ...options};
41 this.options.require = resolveModules(this.options.require);
42
43 this._allExtensions = this.options.extensions.all;
44 this._regexpFullExtensions = new RegExp(`\\.(${this.options.extensions.full.map(ext => escapeStringRegexp(ext)).join('|')})$`);
45 this._precompiler = null;
46 this._interruptHandler = () => {};
47
48 if (options.ranFromCli) {
49 process.on('SIGINT', () => this._interruptHandler());
50 }
51 }
52
53 async run(files = [], runtimeOptions = {}) {
54 files = files.map(file => path.resolve(this.options.resolveTestsFrom, file));
55
56 const apiOptions = this.options;
57
58 // Each run will have its own status. It can only be created when test files
59 // have been found.
60 let runStatus;
61 // Irrespectively, perform some setup now, before finding test files.
62
63 // Track active forks and manage timeouts.
64 const failFast = apiOptions.failFast === true;
65 let bailed = false;
66 const pendingWorkers = new Set();
67 const timedOutWorkerFiles = new Set();
68 let restartTimer;
69 if (apiOptions.timeout) {
70 const timeout = ms(apiOptions.timeout);
71
72 restartTimer = debounce(() => {
73 // If failFast is active, prevent new test files from running after
74 // the current ones are exited.
75 if (failFast) {
76 bailed = true;
77 }
78
79 runStatus.emitStateChange({type: 'timeout', period: timeout});
80
81 for (const worker of pendingWorkers) {
82 timedOutWorkerFiles.add(worker.file);
83 worker.exit();
84 }
85 }, timeout);
86 } else {
87 restartTimer = Object.assign(() => {}, {cancel() {}});
88 }
89
90 this._interruptHandler = () => {
91 if (bailed) {
92 // Exiting already
93 return;
94 }
95
96 // Prevent new test files from running
97 bailed = true;
98
99 // Make sure we don't run the timeout handler
100 restartTimer.cancel();
101
102 runStatus.emitStateChange({type: 'interrupt'});
103
104 for (const worker of pendingWorkers) {
105 worker.exit();
106 }
107 };
108
109 try {
110 const precompiler = await this._setupPrecompiler();
111 let helpers = [];
112 if (files.length === 0 || precompiler.enabled) {
113 let found;
114 if (precompiler.enabled) {
115 found = await globs.findHelpersAndTests({cwd: this.options.resolveTestsFrom, ...apiOptions.globs});
116 helpers = found.helpers;
117 } else {
118 found = await globs.findTests({cwd: this.options.resolveTestsFrom, ...apiOptions.globs});
119 }
120
121 if (files.length === 0) {
122 ({tests: files} = found);
123 }
124 }
125
126 if (this.options.parallelRuns) {
127 const {currentIndex, totalRuns} = this.options.parallelRuns;
128 const fileCount = files.length;
129
130 // The files must be in the same order across all runs, so sort them.
131 files = files.sort((a, b) => a.localeCompare(b, [], {numeric: true}));
132 files = chunkd(files, currentIndex, totalRuns);
133
134 const currentFileCount = files.length;
135
136 runStatus = new RunStatus(fileCount, {currentFileCount, currentIndex, totalRuns});
137 } else {
138 runStatus = new RunStatus(files.length, null);
139 }
140
141 await this.emit('run', {
142 clearLogOnNextRun: runtimeOptions.clearLogOnNextRun === true,
143 failFastEnabled: failFast,
144 filePathPrefix: commonPathPrefix(files),
145 files,
146 matching: apiOptions.match.length > 0,
147 previousFailures: runtimeOptions.previousFailures || 0,
148 runOnlyExclusive: runtimeOptions.runOnlyExclusive === true,
149 runVector: runtimeOptions.runVector || 0,
150 status: runStatus
151 });
152
153 // Bail out early if no files were found.
154 if (files.length === 0) {
155 return runStatus;
156 }
157
158 runStatus.on('stateChange', record => {
159 if (record.testFile && !timedOutWorkerFiles.has(record.testFile)) {
160 // Restart the timer whenever there is activity from workers that
161 // haven't already timed out.
162 restartTimer();
163 }
164
165 if (failFast && (record.type === 'hook-failed' || record.type === 'test-failed' || record.type === 'worker-failed')) {
166 // Prevent new test files from running once a test has failed.
167 bailed = true;
168
169 // Try to stop currently scheduled tests.
170 for (const worker of pendingWorkers) {
171 worker.notifyOfPeerFailure();
172 }
173 }
174 });
175
176 let precompilation = null;
177 if (precompiler.enabled) {
178 // Compile all test and helper files. Assumes the tests only load
179 // helpers from within the `resolveTestsFrom` directory. Without
180 // arguments this is the `projectDir`, else it's `process.cwd()`
181 // which may be nested too deeply.
182 precompilation = {
183 cacheDir: precompiler.cacheDir,
184 map: [...files, ...helpers].reduce((acc, file) => {
185 try {
186 const realpath = fs.realpathSync(file);
187 const filename = path.basename(realpath);
188 const cachePath = this._regexpFullExtensions.test(filename) ?
189 precompiler.precompileFull(realpath) :
190 precompiler.precompileEnhancementsOnly(realpath);
191 if (cachePath) {
192 acc[realpath] = cachePath;
193 }
194 } catch (error) {
195 throw Object.assign(error, {file});
196 }
197
198 return acc;
199 }, {})
200 };
201 }
202
203 // Resolve the correct concurrency value.
204 let concurrency = Math.min(os.cpus().length, isCi ? 2 : Infinity);
205 if (apiOptions.concurrency > 0) {
206 concurrency = apiOptions.concurrency;
207 }
208
209 if (apiOptions.serial) {
210 concurrency = 1;
211 }
212
213 // Try and run each file, limited by `concurrency`.
214 await Bluebird.map(files, async file => {
215 // No new files should be run once a test has timed out or failed,
216 // and failFast is enabled.
217 if (bailed) {
218 return;
219 }
220
221 const execArgv = await this._computeForkExecArgv();
222 const options = {
223 ...apiOptions,
224 recordNewSnapshots: !isCi,
225 // If we're looking for matches, run every single test process in exclusive-only mode
226 runOnlyExclusive: apiOptions.match.length > 0 || runtimeOptions.runOnlyExclusive === true
227 };
228 if (precompilation) {
229 options.cacheDir = precompilation.cacheDir;
230 options.precompiled = precompilation.map;
231 } else {
232 options.precompiled = {};
233 }
234
235 if (runtimeOptions.updateSnapshots) {
236 // Don't use in Object.assign() since it'll override options.updateSnapshots even when false.
237 options.updateSnapshots = true;
238 }
239
240 const worker = fork(file, options, execArgv);
241 runStatus.observeWorker(worker, file);
242
243 pendingWorkers.add(worker);
244 worker.promise.then(() => {
245 pendingWorkers.delete(worker);
246 });
247 restartTimer();
248
249 return worker.promise;
250 }, {concurrency});
251 } catch (error) {
252 runStatus.emitStateChange({type: 'internal-error', err: serializeError('Internal error', false, error)});
253 }
254
255 restartTimer.cancel();
256 return runStatus;
257 }
258
259 _setupPrecompiler() {
260 if (this._precompiler) {
261 return this._precompiler;
262 }
263
264 const cacheDir = this.options.cacheEnabled === false ?
265 uniqueTempDir() :
266 path.join(this.options.projectDir, 'node_modules', '.cache', 'ava');
267
268 // Ensure cacheDir exists
269 makeDir.sync(cacheDir);
270
271 const {projectDir, babelConfig} = this.options;
272 const compileEnhancements = this.options.compileEnhancements !== false;
273 const precompileFull = babelConfig ?
274 babelPipeline.build(projectDir, cacheDir, babelConfig, compileEnhancements) :
275 filename => {
276 throw new Error(`Cannot apply full precompilation, possible bad usage: ${filename}`);
277 };
278
279 let precompileEnhancementsOnly = () => null;
280 if (compileEnhancements) {
281 precompileEnhancementsOnly = this.options.extensions.enhancementsOnly.length > 0 ?
282 babelPipeline.build(projectDir, cacheDir, null, compileEnhancements) :
283 filename => {
284 throw new Error(`Cannot apply enhancement-only precompilation, possible bad usage: ${filename}`);
285 };
286 }
287
288 this._precompiler = {
289 cacheDir,
290 enabled: babelConfig || compileEnhancements,
291 precompileEnhancementsOnly,
292 precompileFull
293 };
294 return this._precompiler;
295 }
296
297 async _computeForkExecArgv() {
298 const execArgv = this.options.testOnlyExecArgv || process.execArgv;
299 if (execArgv.length === 0) {
300 return Promise.resolve(execArgv);
301 }
302
303 // --inspect-brk is used in addition to --inspect to break on first line and wait
304 const inspectArgIndex = execArgv.findIndex(arg => /^--inspect(-brk)?($|=)/.test(arg));
305 if (inspectArgIndex === -1) {
306 return Promise.resolve(execArgv);
307 }
308
309 const port = await getPort();
310 const forkExecArgv = execArgv.slice();
311 let flagName = '--inspect';
312 const oldValue = forkExecArgv[inspectArgIndex];
313 if (oldValue.indexOf('brk') > 0) {
314 flagName += '-brk';
315 }
316
317 forkExecArgv[inspectArgIndex] = `${flagName}=${port}`;
318
319 return forkExecArgv;
320 }
321}
322
323module.exports = Api;