UNPKG

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