UNPKG

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