UNPKG

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