1 | 'use strict';
|
2 | const Emittery = require('emittery');
|
3 | const matcher = require('matcher');
|
4 | const ContextRef = require('./context-ref');
|
5 | const createChain = require('./create-chain');
|
6 | const parseTestArgs = require('./parse-test-args');
|
7 | const snapshotManager = require('./snapshot-manager');
|
8 | const serializeError = require('./serialize-error');
|
9 | const Runnable = require('./test');
|
10 |
|
11 | class 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;
|
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) => {
|
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) {
|
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 |
|
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 |
|
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,
|
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 |
|
209 |
|
210 |
|
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) => {
|
254 | if (runnable.metadata.serial || this.serial) {
|
255 | waitForSerial = previous.then(() => {
|
256 |
|
257 |
|
258 | return (allPassed || runnable.metadata.always) && runAndStoreResult(runnable);
|
259 | });
|
260 | return waitForSerial;
|
261 | }
|
262 |
|
263 | return Promise.all([
|
264 | previous,
|
265 | waitForSerial.then(() => {
|
266 |
|
267 |
|
268 |
|
269 |
|
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 |
|
282 |
|
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 |
|
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 |
|
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 |
|
450 | return;
|
451 | }
|
452 |
|
453 | const contextRef = new ContextRef();
|
454 |
|
455 |
|
456 | const beforePromise = this.runHooks(this.tasks.before, contextRef);
|
457 | const serialPromise = beforePromise.then(beforeHooksOk => {
|
458 |
|
459 | if (!beforeHooksOk) {
|
460 | return false;
|
461 | }
|
462 |
|
463 | return serialTests.reduce(async (previous, task) => {
|
464 | const previousOk = await previous;
|
465 |
|
466 | if (this.interrupted) {
|
467 | return previousOk;
|
468 | }
|
469 |
|
470 |
|
471 |
|
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]) => {
|
480 |
|
481 |
|
482 | if (!beforeHooksOk || (!serialOk && this.failFast)) {
|
483 | return false;
|
484 | }
|
485 |
|
486 |
|
487 | if (this.interrupted) {
|
488 | return true;
|
489 | }
|
490 |
|
491 |
|
492 |
|
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 |
|
505 | if (ok) {
|
506 | await this.runHooks(this.tasks.after, contextRef);
|
507 | }
|
508 |
|
509 |
|
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 |
|
523 | module.exports = Runner;
|