UNPKG

14.7 kBJavaScriptView Raw
1'use strict';
2const Emittery = require('emittery');
3const matcher = require('matcher');
4const ContextRef = require('./context-ref');
5const createChain = require('./create-chain');
6const parseTestArgs = require('./parse-test-args');
7const snapshotManager = require('./snapshot-manager');
8const serializeError = require('./serialize-error');
9const Runnable = require('./test');
10
11class Runner extends Emittery {
12 constructor(options = {}) {
13 super();
14
15 this.experiments = options.experiments || {};
16 this.failFast = options.failFast === true;
17 this.failWithoutAssertions = options.failWithoutAssertions !== false;
18 this.file = options.file;
19 this.checkSelectedByLineNumbers = options.checkSelectedByLineNumbers;
20 this.match = options.match || [];
21 this.powerAssert = undefined; // Assigned later.
22 this.projectDir = options.projectDir;
23 this.recordNewSnapshots = options.recordNewSnapshots === true;
24 this.runOnlyExclusive = options.runOnlyExclusive === true;
25 this.serial = options.serial === true;
26 this.skippingTests = false;
27 this.snapshotDir = options.snapshotDir;
28 this.updateSnapshots = options.updateSnapshots;
29
30 this.activeRunnables = new Set();
31 this.boundCompareTestSnapshot = this.compareTestSnapshot.bind(this);
32 this.interrupted = false;
33 this.snapshots = null;
34 this.tasks = {
35 after: [],
36 afterAlways: [],
37 afterEach: [],
38 afterEachAlways: [],
39 before: [],
40 beforeEach: [],
41 concurrent: [],
42 serial: [],
43 todo: []
44 };
45
46 const uniqueTestTitles = new Set();
47 this.registerUniqueTitle = title => {
48 if (uniqueTestTitles.has(title)) {
49 return false;
50 }
51
52 uniqueTestTitles.add(title);
53 return true;
54 };
55
56 let hasStarted = false;
57 let scheduledStart = false;
58 const meta = Object.freeze({
59 file: options.file,
60 get snapshotDirectory() {
61 const {file, snapshotDir: fixedLocation, projectDir} = options;
62 return snapshotManager.determineSnapshotDir({file, fixedLocation, projectDir});
63 }
64 });
65 this.chain = createChain((metadata, testArgs) => { // eslint-disable-line complexity
66 if (hasStarted) {
67 throw new Error('All tests and hooks must be declared synchronously in your test file, and cannot be nested within other tests or hooks.');
68 }
69
70 if (!scheduledStart) {
71 scheduledStart = true;
72 process.nextTick(() => {
73 hasStarted = true;
74 this.start();
75 });
76 }
77
78 const {args, buildTitle, implementations, rawTitle} = parseTestArgs(testArgs);
79
80 if (this.checkSelectedByLineNumbers) {
81 metadata.selected = this.checkSelectedByLineNumbers();
82 }
83
84 if (metadata.todo) {
85 if (implementations.length > 0) {
86 throw new TypeError('`todo` tests are not allowed to have an implementation. Use `test.skip()` for tests with an implementation.');
87 }
88
89 if (!rawTitle) { // Either undefined or a string.
90 throw new TypeError('`todo` tests require a title');
91 }
92
93 if (!this.registerUniqueTitle(rawTitle)) {
94 throw new Error(`Duplicate test title: ${rawTitle}`);
95 }
96
97 if (this.match.length > 0) {
98 // --match selects TODO tests.
99 if (matcher([rawTitle], this.match).length === 1) {
100 metadata.exclusive = true;
101 this.runOnlyExclusive = true;
102 }
103 }
104
105 this.tasks.todo.push({title: rawTitle, metadata});
106 this.emit('stateChange', {
107 type: 'declared-test',
108 title: rawTitle,
109 knownFailing: false,
110 todo: true
111 });
112 } else {
113 if (implementations.length === 0) {
114 throw new TypeError('Expected an implementation. Use `test.todo()` for tests without an implementation.');
115 }
116
117 for (const implementation of implementations) {
118 let {title, isSet, isValid, isEmpty} = buildTitle(implementation);
119
120 if (isSet && !isValid) {
121 throw new TypeError('Test & hook titles must be strings');
122 }
123
124 if (isEmpty) {
125 if (metadata.type === 'test') {
126 throw new TypeError('Tests must have a title');
127 } else if (metadata.always) {
128 title = `${metadata.type}.always hook`;
129 } else {
130 title = `${metadata.type} hook`;
131 }
132 }
133
134 if (metadata.type === 'test' && !this.registerUniqueTitle(title)) {
135 throw new Error(`Duplicate test title: ${title}`);
136 }
137
138 const task = {
139 title,
140 implementation,
141 args,
142 metadata: {...metadata}
143 };
144
145 if (metadata.type === 'test') {
146 if (this.match.length > 0) {
147 // --match overrides .only()
148 task.metadata.exclusive = matcher([title], this.match).length === 1;
149 }
150
151 if (task.metadata.skipped) {
152 this.skippingTests = true;
153 }
154
155 if (task.metadata.exclusive) {
156 this.runOnlyExclusive = true;
157 }
158
159 this.tasks[metadata.serial ? 'serial' : 'concurrent'].push(task);
160 this.emit('stateChange', {
161 type: 'declared-test',
162 title,
163 knownFailing: metadata.failing,
164 todo: false
165 });
166 } else if (!metadata.skipped) {
167 this.tasks[metadata.type + (metadata.always ? 'Always' : '')].push(task);
168 }
169 }
170 }
171 }, {
172 serial: false,
173 exclusive: false,
174 skipped: false,
175 todo: false,
176 failing: false,
177 callback: false,
178 inline: false, // Set for attempt metadata created by `t.try()`
179 always: false
180 }, meta);
181 }
182
183 compareTestSnapshot(options) {
184 if (!this.snapshots) {
185 this.snapshots = snapshotManager.load({
186 file: this.file,
187 fixedLocation: this.snapshotDir,
188 projectDir: this.projectDir,
189 recordNewSnapshots: this.recordNewSnapshots,
190 updating: this.updateSnapshots && !this.runOnlyExclusive && !this.skippingTests
191 });
192 this.emit('dependency', this.snapshots.snapPath);
193 }
194
195 return this.snapshots.compare(options);
196 }
197
198 saveSnapshotState() {
199 if (this.updateSnapshots && (this.runOnlyExclusive || this.skippingTests)) {
200 return {cannotSave: true};
201 }
202
203 if (this.snapshots) {
204 return {touchedFiles: this.snapshots.save()};
205 }
206
207 if (this.updateSnapshots) {
208 // TODO: There may be unused snapshot files if no test caused the
209 // snapshots to be loaded. Prune them. But not if tests (including hooks!)
210 // were skipped. Perhaps emit a warning if this occurs?
211 }
212
213 return {};
214 }
215
216 onRun(runnable) {
217 this.activeRunnables.add(runnable);
218 }
219
220 onRunComplete(runnable) {
221 this.activeRunnables.delete(runnable);
222 }
223
224 attributeLeakedError(err) {
225 for (const runnable of this.activeRunnables) {
226 if (runnable.attributeLeakedError(err)) {
227 return true;
228 }
229 }
230
231 return false;
232 }
233
234 beforeExitHandler() {
235 for (const runnable of this.activeRunnables) {
236 runnable.finishDueToInactivity();
237 }
238 }
239
240 async runMultiple(runnables) {
241 let allPassed = true;
242 const storedResults = [];
243 const runAndStoreResult = async runnable => {
244 const result = await this.runSingle(runnable);
245 if (!result.passed) {
246 allPassed = false;
247 }
248
249 storedResults.push(result);
250 };
251
252 let waitForSerial = Promise.resolve();
253 await runnables.reduce((previous, runnable) => { // eslint-disable-line unicorn/no-reduce
254 if (runnable.metadata.serial || this.serial) {
255 waitForSerial = previous.then(() => {
256 // Serial runnables run as long as there was no previous failure, unless
257 // the runnable should always be run.
258 return (allPassed || runnable.metadata.always) && runAndStoreResult(runnable);
259 });
260 return waitForSerial;
261 }
262
263 return Promise.all([
264 previous,
265 waitForSerial.then(() => {
266 // Concurrent runnables are kicked off after the previous serial
267 // runnables have completed, as long as there was no previous failure
268 // (or if the runnable should always be run). One concurrent runnable's
269 // failure does not prevent the next runnable from running.
270 return (allPassed || runnable.metadata.always) && runAndStoreResult(runnable);
271 })
272 ]);
273 }, waitForSerial);
274
275 return {allPassed, storedResults};
276 }
277
278 async runSingle(runnable) {
279 this.onRun(runnable);
280 const result = await runnable.run();
281 // If run() throws or rejects then the entire test run crashes, so
282 // onRunComplete() doesn't *have* to be inside a finally.
283 this.onRunComplete(runnable);
284 return result;
285 }
286
287 async runHooks(tasks, contextRef, titleSuffix, testPassed) {
288 const hooks = tasks.map(task => new Runnable({
289 contextRef,
290 experiments: this.experiments,
291 failWithoutAssertions: false,
292 fn: task.args.length === 0 ?
293 task.implementation :
294 t => task.implementation.apply(null, [t].concat(task.args)),
295 compareTestSnapshot: this.boundCompareTestSnapshot,
296 updateSnapshots: this.updateSnapshots,
297 metadata: task.metadata,
298 powerAssert: this.powerAssert,
299 title: `${task.title}${titleSuffix || ''}`,
300 isHook: true,
301 testPassed
302 }));
303 const outcome = await this.runMultiple(hooks, this.serial);
304 for (const result of outcome.storedResults) {
305 if (result.passed) {
306 this.emit('stateChange', {
307 type: 'hook-finished',
308 title: result.title,
309 duration: result.duration,
310 logs: result.logs
311 });
312 } else {
313 this.emit('stateChange', {
314 type: 'hook-failed',
315 title: result.title,
316 err: serializeError('Hook failure', true, result.error),
317 duration: result.duration,
318 logs: result.logs
319 });
320 }
321 }
322
323 return outcome.allPassed;
324 }
325
326 async runTest(task, contextRef) {
327 const hookSuffix = ` for ${task.title}`;
328 let hooksOk = await this.runHooks(this.tasks.beforeEach, contextRef, hookSuffix);
329
330 let testOk = false;
331 if (hooksOk) {
332 // Only run the test if all `beforeEach` hooks passed.
333 const test = new Runnable({
334 contextRef,
335 experiments: this.experiments,
336 failWithoutAssertions: this.failWithoutAssertions,
337 fn: task.args.length === 0 ?
338 task.implementation :
339 t => task.implementation.apply(null, [t].concat(task.args)),
340 compareTestSnapshot: this.boundCompareTestSnapshot,
341 updateSnapshots: this.updateSnapshots,
342 metadata: task.metadata,
343 powerAssert: this.powerAssert,
344 title: task.title,
345 registerUniqueTitle: this.registerUniqueTitle
346 });
347
348 const result = await this.runSingle(test);
349 testOk = result.passed;
350
351 if (testOk) {
352 this.emit('stateChange', {
353 type: 'test-passed',
354 title: result.title,
355 duration: result.duration,
356 knownFailing: result.metadata.failing,
357 logs: result.logs
358 });
359
360 hooksOk = await this.runHooks(this.tasks.afterEach, contextRef, hookSuffix, testOk);
361 } else {
362 this.emit('stateChange', {
363 type: 'test-failed',
364 title: result.title,
365 err: serializeError('Test failure', true, result.error, this.file),
366 duration: result.duration,
367 knownFailing: result.metadata.failing,
368 logs: result.logs
369 });
370 // Don't run `afterEach` hooks if the test failed.
371 }
372 }
373
374 const alwaysOk = await this.runHooks(this.tasks.afterEachAlways, contextRef, hookSuffix, testOk);
375 return alwaysOk && hooksOk && testOk;
376 }
377
378 async start() {
379 const concurrentTests = [];
380 const serialTests = [];
381 for (const task of this.tasks.serial) {
382 if (this.runOnlyExclusive && !task.metadata.exclusive) {
383 continue;
384 }
385
386 if (this.checkSelectedByLineNumbers && !task.metadata.selected) {
387 continue;
388 }
389
390 this.emit('stateChange', {
391 type: 'selected-test',
392 title: task.title,
393 knownFailing: task.metadata.failing,
394 skip: task.metadata.skipped,
395 todo: false
396 });
397
398 if (!task.metadata.skipped) {
399 serialTests.push(task);
400 }
401 }
402
403 for (const task of this.tasks.concurrent) {
404 if (this.runOnlyExclusive && !task.metadata.exclusive) {
405 continue;
406 }
407
408 if (this.checkSelectedByLineNumbers && !task.metadata.selected) {
409 continue;
410 }
411
412 this.emit('stateChange', {
413 type: 'selected-test',
414 title: task.title,
415 knownFailing: task.metadata.failing,
416 skip: task.metadata.skipped,
417 todo: false
418 });
419
420 if (!task.metadata.skipped) {
421 if (this.serial) {
422 serialTests.push(task);
423 } else {
424 concurrentTests.push(task);
425 }
426 }
427 }
428
429 for (const task of this.tasks.todo) {
430 if (this.runOnlyExclusive && !task.metadata.exclusive) {
431 continue;
432 }
433
434 if (this.checkSelectedByLineNumbers && !task.metadata.selected) {
435 continue;
436 }
437
438 this.emit('stateChange', {
439 type: 'selected-test',
440 title: task.title,
441 knownFailing: false,
442 skip: false,
443 todo: true
444 });
445 }
446
447 if (concurrentTests.length === 0 && serialTests.length === 0) {
448 this.emit('finish');
449 // Don't run any hooks if there are no tests to run.
450 return;
451 }
452
453 const contextRef = new ContextRef();
454
455 // Note that the hooks and tests always begin running asynchronously.
456 const beforePromise = this.runHooks(this.tasks.before, contextRef);
457 const serialPromise = beforePromise.then(beforeHooksOk => { // eslint-disable-line promise/prefer-await-to-then
458 // Don't run tests if a `before` hook failed.
459 if (!beforeHooksOk) {
460 return false;
461 }
462
463 return serialTests.reduce(async (previous, task) => { // eslint-disable-line unicorn/no-reduce
464 const previousOk = await previous;
465 // Don't start tests after an interrupt.
466 if (this.interrupted) {
467 return previousOk;
468 }
469
470 // Prevent subsequent tests from running if `failFast` is enabled and
471 // the previous test failed.
472 if (!previousOk && this.failFast) {
473 return false;
474 }
475
476 return this.runTest(task, contextRef.copy());
477 }, true);
478 });
479 const concurrentPromise = Promise.all([beforePromise, serialPromise]).then(async ([beforeHooksOk, serialOk]) => { // eslint-disable-line promise/prefer-await-to-then
480 // Don't run tests if a `before` hook failed, or if `failFast` is enabled
481 // and a previous serial test failed.
482 if (!beforeHooksOk || (!serialOk && this.failFast)) {
483 return false;
484 }
485
486 // Don't start tests after an interrupt.
487 if (this.interrupted) {
488 return true;
489 }
490
491 // If a concurrent test fails, even if `failFast` is enabled it won't
492 // stop other concurrent tests from running.
493 const allOkays = await Promise.all(concurrentTests.map(task => {
494 return this.runTest(task, contextRef.copy());
495 }));
496 return allOkays.every(ok => ok);
497 });
498
499 const beforeExitHandler = this.beforeExitHandler.bind(this);
500 process.on('beforeExit', beforeExitHandler);
501
502 try {
503 const ok = await concurrentPromise;
504 // Only run `after` hooks if all hooks and tests passed.
505 if (ok) {
506 await this.runHooks(this.tasks.after, contextRef);
507 }
508
509 // Always run `after.always` hooks.
510 await this.runHooks(this.tasks.afterAlways, contextRef);
511 process.removeListener('beforeExit', beforeExitHandler);
512 await this.emit('finish');
513 } catch (error) {
514 await this.emit('error', error);
515 }
516 }
517
518 interrupt() {
519 this.interrupted = true;
520 }
521}
522
523module.exports = Runner;