UNPKG

18.8 kBJavaScriptView Raw
1'use strict';
2const concordance = require('concordance');
3const isPromise = require('is-promise');
4const plur = require('plur');
5const assert = require('./assert');
6const nowAndTimers = require('./now-and-timers');
7const parseTestArgs = require('./parse-test-args');
8const concordanceOptions = require('./concordance-options').default;
9
10function formatErrorValue(label, error) {
11 const formatted = concordance.format(error, concordanceOptions);
12 return {label, formatted};
13}
14
15const captureSavedError = () => {
16 const limitBefore = Error.stackTraceLimit;
17 Error.stackTraceLimit = 1;
18 const err = new Error();
19 Error.stackTraceLimit = limitBefore;
20 return err;
21};
22
23const testMap = new WeakMap();
24class ExecutionContext extends assert.Assertions {
25 constructor(test) {
26 super({
27 pass: () => {
28 test.countPassedAssertion();
29 },
30 pending: promise => {
31 test.addPendingAssertion(promise);
32 },
33 fail: err => {
34 test.addFailedAssertion(err);
35 },
36 skip: () => {
37 test.countPassedAssertion();
38 },
39 compareWithSnapshot: options => {
40 return test.compareWithSnapshot(options);
41 },
42 powerAssert: test.powerAssert
43 });
44 testMap.set(this, test);
45
46 this.snapshot.skip = () => {
47 test.skipSnapshot();
48 };
49
50 this.log = (...inputArgs) => {
51 const args = inputArgs.map(value => {
52 return typeof value === 'string' ?
53 value :
54 concordance.format(value, concordanceOptions);
55 });
56 if (args.length > 0) {
57 test.addLog(args.join(' '));
58 }
59 };
60
61 this.plan = count => {
62 test.plan(count, captureSavedError());
63 };
64
65 this.plan.skip = () => {};
66
67 this.timeout = ms => {
68 test.timeout(ms);
69 };
70
71 this.teardown = callback => {
72 test.addTeardown(callback);
73 };
74
75 this.try = async (...attemptArgs) => {
76 const {args, buildTitle, implementations, receivedImplementationArray} = parseTestArgs(attemptArgs);
77
78 if (implementations.length === 0) {
79 throw new TypeError('Expected an implementation.');
80 }
81
82 const attemptPromises = implementations.map((implementation, index) => {
83 let {title, isSet, isValid, isEmpty} = buildTitle(implementation);
84
85 if (!isSet || isEmpty) {
86 title = `${test.title} ─ attempt ${test.attemptCount + 1 + index}`;
87 } else if (isValid) {
88 title = `${test.title}${title}`;
89 } else {
90 throw new TypeError('`t.try()` titles must be strings'); // Throw synchronously!
91 }
92
93 if (!test.registerUniqueTitle(title)) {
94 throw new Error(`Duplicate test title: ${title}`);
95 }
96
97 return {implementation, title};
98 }).map(async ({implementation, title}) => {
99 let committed = false;
100 let discarded = false;
101
102 const {assertCount, deferredSnapshotRecordings, errors, logs, passed, snapshotCount, startingSnapshotCount} = await test.runAttempt(title, t => implementation(t, ...args));
103
104 return {
105 errors,
106 logs: [...logs], // Don't allow modification of logs.
107 passed,
108 title,
109 commit: ({retainLogs = true} = {}) => {
110 if (committed) {
111 return;
112 }
113
114 if (discarded) {
115 test.saveFirstError(new Error('Can’t commit a result that was previously discarded'));
116 return;
117 }
118
119 committed = true;
120 test.finishAttempt({
121 assertCount,
122 commit: true,
123 deferredSnapshotRecordings,
124 errors,
125 logs,
126 passed,
127 retainLogs,
128 snapshotCount,
129 startingSnapshotCount
130 });
131 },
132 discard: ({retainLogs = false} = {}) => {
133 if (committed) {
134 test.saveFirstError(new Error('Can’t discard a result that was previously committed'));
135 return;
136 }
137
138 if (discarded) {
139 return;
140 }
141
142 discarded = true;
143 test.finishAttempt({
144 assertCount: 0,
145 commit: false,
146 deferredSnapshotRecordings,
147 errors,
148 logs,
149 passed,
150 retainLogs,
151 snapshotCount,
152 startingSnapshotCount
153 });
154 }
155 };
156 });
157
158 const results = await Promise.all(attemptPromises);
159 return receivedImplementationArray ? results : results[0];
160 };
161 }
162
163 get end() {
164 const end = testMap.get(this).bindEndCallback();
165 const endFn = error => end(error, captureSavedError());
166 return endFn;
167 }
168
169 get title() {
170 return testMap.get(this).title;
171 }
172
173 get context() {
174 return testMap.get(this).contextRef.get();
175 }
176
177 set context(context) {
178 testMap.get(this).contextRef.set(context);
179 }
180
181 get passed() {
182 return testMap.get(this).testPassed;
183 }
184
185 _throwsArgStart(assertion, file, line) {
186 testMap.get(this).trackThrows({assertion, file, line});
187 }
188
189 _throwsArgEnd() {
190 testMap.get(this).trackThrows(null);
191 }
192}
193
194class Test {
195 constructor(options) {
196 this.contextRef = options.contextRef;
197 this.experiments = options.experiments || {};
198 this.failWithoutAssertions = options.failWithoutAssertions;
199 this.fn = options.fn;
200 this.isHook = options.isHook === true;
201 this.metadata = options.metadata;
202 this.powerAssert = options.powerAssert;
203 this.title = options.title;
204 this.testPassed = options.testPassed;
205 this.registerUniqueTitle = options.registerUniqueTitle;
206 this.logs = [];
207 this.teardowns = [];
208
209 const {snapshotBelongsTo = this.title, nextSnapshotIndex = 0} = options;
210 this.snapshotBelongsTo = snapshotBelongsTo;
211 this.nextSnapshotIndex = nextSnapshotIndex;
212 this.snapshotCount = 0;
213
214 const deferRecording = this.metadata.inline;
215 this.deferredSnapshotRecordings = [];
216 this.compareWithSnapshot = ({expected, id, message}) => {
217 this.snapshotCount++;
218
219 // TODO: In a breaking change, reject non-undefined, falsy IDs and messages.
220 const belongsTo = id || snapshotBelongsTo;
221 const index = id ? 0 : this.nextSnapshotIndex++;
222 const label = id ? '' : message || `Snapshot ${index + 1}`; // Human-readable labels start counting at 1.
223
224 const {record, ...result} = options.compareTestSnapshot({belongsTo, deferRecording, expected, index, label});
225 if (record) {
226 this.deferredSnapshotRecordings.push(record);
227 }
228
229 return result;
230 };
231
232 this.skipSnapshot = () => {
233 if (options.updateSnapshots) {
234 this.addFailedAssertion(new Error('Snapshot assertions cannot be skipped when updating snapshots'));
235 } else {
236 this.nextSnapshotIndex++;
237 this.snapshotCount++;
238 this.countPassedAssertion();
239 }
240 };
241
242 this.runAttempt = async (title, fn) => {
243 if (this.finishing) {
244 this.saveFirstError(new Error('Running a `t.try()`, but the test has already finished'));
245 }
246
247 this.attemptCount++;
248 this.pendingAttemptCount++;
249
250 const {contextRef, snapshotBelongsTo, nextSnapshotIndex, snapshotCount: startingSnapshotCount} = this;
251 const attempt = new Test({
252 ...options,
253 fn,
254 metadata: {...options.metadata, callback: false, failing: false, inline: true},
255 contextRef: contextRef.copy(),
256 snapshotBelongsTo,
257 nextSnapshotIndex,
258 title
259 });
260
261 const {deferredSnapshotRecordings, error, logs, passed, assertCount, snapshotCount} = await attempt.run();
262 const errors = error ? [error] : [];
263 return {assertCount, deferredSnapshotRecordings, errors, logs, passed, snapshotCount, startingSnapshotCount};
264 };
265
266 this.assertCount = 0;
267 this.assertError = undefined;
268 this.attemptCount = 0;
269 this.calledEnd = false;
270 this.duration = null;
271 this.endCallbackFinisher = null;
272 this.finishDueToAttributedError = null;
273 this.finishDueToInactivity = null;
274 this.finishDueToTimeout = null;
275 this.finishing = false;
276 this.pendingAssertionCount = 0;
277 this.pendingAttemptCount = 0;
278 this.pendingThrowsAssertion = null;
279 this.planCount = null;
280 this.startedAt = 0;
281 this.timeoutMs = 0;
282 this.timeoutTimer = null;
283 }
284
285 bindEndCallback() {
286 if (this.metadata.callback) {
287 return (error, savedError) => {
288 this.endCallback(error, savedError);
289 };
290 }
291
292 if (this.metadata.inline) {
293 throw new Error('`t.end()` is not supported inside `t.try()`');
294 } else {
295 throw new Error('`t.end()` is not supported in this context. To use `t.end()` as a callback, you must use "callback mode" via `test.cb(testName, fn)`');
296 }
297 }
298
299 endCallback(error, savedError) {
300 if (this.calledEnd) {
301 this.saveFirstError(new Error('`t.end()` called more than once'));
302 return;
303 }
304
305 this.calledEnd = true;
306
307 if (error) {
308 this.saveFirstError(new assert.AssertionError({
309 actual: error,
310 message: 'Callback called with an error',
311 savedError,
312 values: [formatErrorValue('Callback called with an error:', error)]
313 }));
314 }
315
316 if (this.endCallbackFinisher) {
317 this.endCallbackFinisher();
318 }
319 }
320
321 createExecutionContext() {
322 return new ExecutionContext(this);
323 }
324
325 countPassedAssertion() {
326 if (this.finishing) {
327 this.saveFirstError(new Error('Assertion passed, but test has already finished'));
328 }
329
330 if (this.pendingAttemptCount > 0) {
331 this.saveFirstError(new Error('Assertion passed, but an attempt is pending. Use the attempt’s assertions instead'));
332 }
333
334 this.assertCount++;
335 this.refreshTimeout();
336 }
337
338 addLog(text) {
339 this.logs.push(text);
340 }
341
342 addPendingAssertion(promise) {
343 if (this.finishing) {
344 this.saveFirstError(new Error('Assertion started, but test has already finished'));
345 }
346
347 if (this.pendingAttemptCount > 0) {
348 this.saveFirstError(new Error('Assertion started, but an attempt is pending. Use the attempt’s assertions instead'));
349 }
350
351 this.assertCount++;
352 this.pendingAssertionCount++;
353 this.refreshTimeout();
354
355 promise
356 .catch(error => this.saveFirstError(error))
357 .then(() => { // eslint-disable-line promise/prefer-await-to-then
358 this.pendingAssertionCount--;
359 this.refreshTimeout();
360 });
361 }
362
363 addFailedAssertion(error) {
364 if (this.finishing) {
365 this.saveFirstError(new Error('Assertion failed, but test has already finished'));
366 }
367
368 if (this.pendingAttemptCount > 0) {
369 this.saveFirstError(new Error('Assertion failed, but an attempt is pending. Use the attempt’s assertions instead'));
370 }
371
372 this.assertCount++;
373 this.refreshTimeout();
374 this.saveFirstError(error);
375 }
376
377 finishAttempt({commit, deferredSnapshotRecordings, errors, logs, passed, retainLogs, snapshotCount, startingSnapshotCount}) {
378 if (this.finishing) {
379 if (commit) {
380 this.saveFirstError(new Error('`t.try()` result was committed, but the test has already finished'));
381 } else {
382 this.saveFirstError(new Error('`t.try()` result was discarded, but the test has already finished'));
383 }
384 }
385
386 if (commit) {
387 this.assertCount++;
388
389 if (startingSnapshotCount === this.snapshotCount) {
390 this.snapshotCount += snapshotCount;
391 this.nextSnapshotIndex += snapshotCount;
392 for (const record of deferredSnapshotRecordings) {
393 record();
394 }
395 } else {
396 this.saveFirstError(new Error('Cannot commit `t.try()` result. Do not run concurrent snapshot assertions when using `t.try()`'));
397 }
398 }
399
400 this.pendingAttemptCount--;
401
402 if (commit && !passed) {
403 this.saveFirstError(errors[0]);
404 }
405
406 if (retainLogs) {
407 for (const log of logs) {
408 this.addLog(log);
409 }
410 }
411
412 this.refreshTimeout();
413 }
414
415 saveFirstError(error) {
416 if (!this.assertError) {
417 this.assertError = error;
418 }
419 }
420
421 plan(count, planError) {
422 if (typeof count !== 'number') {
423 throw new TypeError('Expected a number');
424 }
425
426 this.planCount = count;
427
428 // In case the `planCount` doesn't match `assertCount, we need the stack of
429 // this function to throw with a useful stack.
430 this.planError = planError;
431 }
432
433 timeout(ms) {
434 if (this.finishing) {
435 return;
436 }
437
438 this.clearTimeout();
439 this.timeoutMs = ms;
440 this.timeoutTimer = nowAndTimers.setTimeout(() => {
441 this.saveFirstError(new Error('Test timeout exceeded'));
442
443 if (this.finishDueToTimeout) {
444 this.finishDueToTimeout();
445 }
446 }, ms);
447 }
448
449 refreshTimeout() {
450 if (!this.timeoutTimer) {
451 return;
452 }
453
454 if (this.timeoutTimer.refresh) {
455 this.timeoutTimer.refresh();
456 } else {
457 this.timeout(this.timeoutMs);
458 }
459 }
460
461 clearTimeout() {
462 nowAndTimers.clearTimeout(this.timeoutTimer);
463 this.timeoutTimer = null;
464 }
465
466 addTeardown(callback) {
467 if (this.isHook) {
468 this.saveFirstError(new Error('`t.teardown()` is not allowed in hooks'));
469 return;
470 }
471
472 if (this.finishing) {
473 this.saveFirstError(new Error('`t.teardown()` cannot be used during teardown'));
474 return;
475 }
476
477 if (typeof callback !== 'function') {
478 throw new TypeError('Expected a function');
479 }
480
481 this.teardowns.push(callback);
482 }
483
484 async runTeardowns() {
485 for (const teardown of this.teardowns) {
486 try {
487 await teardown(); // eslint-disable-line no-await-in-loop
488 } catch (error) {
489 this.saveFirstError(error);
490 }
491 }
492 }
493
494 verifyPlan() {
495 if (!this.assertError && this.planCount !== null && this.planCount !== this.assertCount) {
496 this.saveFirstError(new assert.AssertionError({
497 assertion: 'plan',
498 message: `Planned for ${this.planCount} ${plur('assertion', this.planCount)}, but got ${this.assertCount}.`,
499 operator: '===',
500 savedError: this.planError
501 }));
502 }
503 }
504
505 verifyAssertions() {
506 if (this.assertError) {
507 return;
508 }
509
510 if (this.pendingAttemptCount > 0) {
511 this.saveFirstError(new Error('Test finished, but not all attempts were committed or discarded'));
512 return;
513 }
514
515 if (this.pendingAssertionCount > 0) {
516 this.saveFirstError(new Error('Test finished, but an assertion is still pending'));
517 return;
518 }
519
520 if (this.failWithoutAssertions) {
521 if (this.planCount !== null) {
522 return; // `verifyPlan()` will report an error already.
523 }
524
525 if (this.assertCount === 0 && !this.calledEnd) {
526 this.saveFirstError(new Error('Test finished without running any assertions'));
527 }
528 }
529 }
530
531 trackThrows(pending) {
532 this.pendingThrowsAssertion = pending;
533 }
534
535 detectImproperThrows(error) {
536 if (!this.pendingThrowsAssertion) {
537 return false;
538 }
539
540 const pending = this.pendingThrowsAssertion;
541 this.pendingThrowsAssertion = null;
542
543 const values = [];
544 if (error) {
545 values.push(formatErrorValue(`The following error was thrown, possibly before \`t.${pending.assertion}()\` could be called:`, error));
546 }
547
548 this.saveFirstError(new assert.AssertionError({
549 assertion: pending.assertion,
550 fixedSource: {file: pending.file, line: pending.line},
551 improperUsage: true,
552 message: `Improper usage of \`t.${pending.assertion}()\` detected`,
553 savedError: error instanceof Error && error,
554 values
555 }));
556 return true;
557 }
558
559 waitForPendingThrowsAssertion() {
560 return new Promise(resolve => {
561 this.finishDueToAttributedError = () => {
562 resolve(this.finish());
563 };
564
565 this.finishDueToInactivity = () => {
566 this.detectImproperThrows();
567 resolve(this.finish());
568 };
569
570 // Wait up to a second to see if an error can be attributed to the
571 // pending assertion.
572 nowAndTimers.setTimeout(() => this.finishDueToInactivity(), 1000).unref();
573 });
574 }
575
576 attributeLeakedError(error) {
577 if (!this.detectImproperThrows(error)) {
578 return false;
579 }
580
581 this.finishDueToAttributedError();
582 return true;
583 }
584
585 callFn() {
586 try {
587 return {
588 ok: true,
589 retval: this.fn.call(null, this.createExecutionContext())
590 };
591 } catch (error) {
592 return {
593 ok: false,
594 error
595 };
596 }
597 }
598
599 run() {
600 this.startedAt = nowAndTimers.now();
601
602 const result = this.callFn();
603 if (!result.ok) {
604 if (!this.detectImproperThrows(result.error)) {
605 this.saveFirstError(new assert.AssertionError({
606 message: 'Error thrown in test',
607 savedError: result.error instanceof Error && result.error,
608 values: [formatErrorValue('Error thrown in test:', result.error)]
609 }));
610 }
611
612 return this.finish();
613 }
614
615 const returnedObservable = result.retval !== null && typeof result.retval === 'object' && typeof result.retval.subscribe === 'function';
616 const returnedPromise = isPromise(result.retval);
617
618 let promise;
619 if (returnedObservable) {
620 promise = new Promise((resolve, reject) => {
621 result.retval.subscribe({
622 error: reject,
623 complete: () => resolve()
624 });
625 });
626 } else if (returnedPromise) {
627 // `retval` can be any thenable, so convert to a proper promise.
628 promise = Promise.resolve(result.retval);
629 }
630
631 if (this.metadata.callback) {
632 if (returnedObservable || returnedPromise) {
633 const asyncType = returnedObservable ? 'observables' : 'promises';
634 this.saveFirstError(new Error(`Do not return ${asyncType} from tests declared via \`test.cb(…)\`. Use \`test.cb(…)\` for legacy callback APIs. When using promises, observables or async functions, use \`test(…)\`.`));
635 return this.finish();
636 }
637
638 if (this.calledEnd) {
639 return this.finish();
640 }
641
642 return new Promise(resolve => {
643 this.endCallbackFinisher = () => {
644 resolve(this.finish());
645 };
646
647 this.finishDueToAttributedError = () => {
648 resolve(this.finish());
649 };
650
651 this.finishDueToTimeout = () => {
652 resolve(this.finish());
653 };
654
655 this.finishDueToInactivity = () => {
656 this.saveFirstError(new Error('`t.end()` was never called'));
657 resolve(this.finish());
658 };
659 });
660 }
661
662 if (promise) {
663 return new Promise(resolve => {
664 this.finishDueToAttributedError = () => {
665 resolve(this.finish());
666 };
667
668 this.finishDueToTimeout = () => {
669 resolve(this.finish());
670 };
671
672 this.finishDueToInactivity = () => {
673 const error = returnedObservable ?
674 new Error('Observable returned by test never completed') :
675 new Error('Promise returned by test never resolved');
676 this.saveFirstError(error);
677 resolve(this.finish());
678 };
679
680 promise
681 .catch(error => {
682 if (!this.detectImproperThrows(error)) {
683 this.saveFirstError(new assert.AssertionError({
684 message: 'Rejected promise returned by test',
685 savedError: error instanceof Error && error,
686 values: [formatErrorValue('Rejected promise returned by test. Reason:', error)]
687 }));
688 }
689 })
690 .then(() => resolve(this.finish())); // eslint-disable-line promise/prefer-await-to-then
691 });
692 }
693
694 return this.finish();
695 }
696
697 async finish() {
698 this.finishing = true;
699
700 if (!this.assertError && this.pendingThrowsAssertion) {
701 return this.waitForPendingThrowsAssertion();
702 }
703
704 this.clearTimeout();
705 this.verifyPlan();
706 this.verifyAssertions();
707 await this.runTeardowns();
708
709 this.duration = nowAndTimers.now() - this.startedAt;
710
711 let error = this.assertError;
712 let passed = !error;
713
714 if (this.metadata.failing) {
715 passed = !passed;
716
717 if (passed) {
718 error = null;
719 } else {
720 error = new Error('Test was expected to fail, but succeeded, you should stop marking the test as failing');
721 }
722 }
723
724 return {
725 deferredSnapshotRecordings: this.deferredSnapshotRecordings,
726 duration: this.duration,
727 error,
728 logs: this.logs,
729 metadata: this.metadata,
730 passed,
731 snapshotCount: this.snapshotCount,
732 assertCount: this.assertCount,
733 title: this.title
734 };
735 }
736}
737
738module.exports = Test;