1 | 'use strict';
|
2 | const path = require('path');
|
3 | const fs = require('fs');
|
4 | const os = require('os');
|
5 | const commonPathPrefix = require('common-path-prefix');
|
6 | const escapeStringRegexp = require('escape-string-regexp');
|
7 | const uniqueTempDir = require('unique-temp-dir');
|
8 | const isCi = require('is-ci');
|
9 | const resolveCwd = require('resolve-cwd');
|
10 | const debounce = require('lodash/debounce');
|
11 | const Bluebird = require('bluebird');
|
12 | const getPort = require('get-port');
|
13 | const arrify = require('arrify');
|
14 | const makeDir = require('make-dir');
|
15 | const ms = require('ms');
|
16 | const chunkd = require('chunkd');
|
17 | const Emittery = require('emittery');
|
18 | const babelPipeline = require('./babel-pipeline');
|
19 | const globs = require('./globs');
|
20 | const RunStatus = require('./run-status');
|
21 | const fork = require('./fork');
|
22 | const serializeError = require('./serialize-error');
|
23 |
|
24 | function 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 |
|
36 | class 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 |
|
59 |
|
60 | let runStatus;
|
61 |
|
62 |
|
63 |
|
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 |
|
74 |
|
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 |
|
93 | return;
|
94 | }
|
95 |
|
96 |
|
97 | bailed = true;
|
98 |
|
99 |
|
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 |
|
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 |
|
154 | if (files.length === 0) {
|
155 | return runStatus;
|
156 | }
|
157 |
|
158 | runStatus.on('stateChange', record => {
|
159 | if (record.testFile && !timedOutWorkerFiles.has(record.testFile)) {
|
160 |
|
161 |
|
162 | restartTimer();
|
163 | }
|
164 |
|
165 | if (failFast && (record.type === 'hook-failed' || record.type === 'test-failed' || record.type === 'worker-failed')) {
|
166 |
|
167 | bailed = true;
|
168 |
|
169 |
|
170 | for (const worker of pendingWorkers) {
|
171 | worker.notifyOfPeerFailure();
|
172 | }
|
173 | }
|
174 | });
|
175 |
|
176 | let precompilation = null;
|
177 | if (precompiler.enabled) {
|
178 |
|
179 |
|
180 |
|
181 |
|
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 |
|
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 |
|
214 | await Bluebird.map(files, async file => {
|
215 |
|
216 |
|
217 | if (bailed) {
|
218 | return;
|
219 | }
|
220 |
|
221 | const execArgv = await this._computeForkExecArgv();
|
222 | const options = {
|
223 | ...apiOptions,
|
224 | recordNewSnapshots: !isCi,
|
225 |
|
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 |
|
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 |
|
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 |
|
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 |
|
323 | module.exports = Api;
|