UNPKG

22.9 kBJavaScriptView Raw
1'use strict';
2const concordance = require('concordance');
3const empower = require('empower-core');
4const isError = require('is-error');
5const isPromise = require('is-promise');
6const concordanceOptions = require('./concordance-options').default;
7const concordanceDiffOptions = require('./concordance-options').diff;
8const enhanceAssert = require('./enhance-assert');
9const snapshotManager = require('./snapshot-manager');
10
11function formatDescriptorDiff(actualDescriptor, expectedDescriptor, options) {
12 options = {...options, ...concordanceDiffOptions};
13 return {
14 label: 'Difference:',
15 formatted: concordance.diffDescriptors(actualDescriptor, expectedDescriptor, options)
16 };
17}
18
19function formatDescriptorWithLabel(label, descriptor) {
20 return {
21 label,
22 formatted: concordance.formatDescriptor(descriptor, concordanceOptions)
23 };
24}
25
26function formatWithLabel(label, value) {
27 return formatDescriptorWithLabel(label, concordance.describe(value, concordanceOptions));
28}
29
30const hasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
31const noop = () => {};
32const notImplemented = () => {
33 throw new Error('not implemented');
34};
35
36class AssertionError extends Error {
37 constructor(opts) {
38 super(opts.message || '');
39 this.name = 'AssertionError';
40
41 this.assertion = opts.assertion;
42 this.fixedSource = opts.fixedSource;
43 this.improperUsage = opts.improperUsage || false;
44 this.actualStack = opts.actualStack;
45 this.operator = opts.operator;
46 this.values = opts.values || [];
47
48 // Raw expected and actual objects are stored for custom reporters
49 // (such as wallaby.js), that manage worker processes directly and
50 // use the values for custom diff views
51 this.raw = opts.raw;
52
53 // Reserved for power-assert statements
54 this.statements = [];
55
56 if (opts.savedError) {
57 this.savedError = opts.savedError;
58 } else {
59 this.savedError = getErrorWithLongStackTrace();
60 }
61 }
62}
63exports.AssertionError = AssertionError;
64
65function getErrorWithLongStackTrace() {
66 const limitBefore = Error.stackTraceLimit;
67 Error.stackTraceLimit = Infinity;
68 const err = new Error();
69 Error.stackTraceLimit = limitBefore;
70 return err;
71}
72
73function validateExpectations(assertion, expectations, numArgs) { // eslint-disable-line complexity
74 if (typeof expectations === 'function') {
75 expectations = {instanceOf: expectations};
76 } else if (typeof expectations === 'string' || expectations instanceof RegExp) {
77 expectations = {message: expectations};
78 } else if (numArgs === 1 || expectations === null) {
79 expectations = {};
80 } else if (typeof expectations !== 'object' || Array.isArray(expectations) || Object.keys(expectations).length === 0) {
81 throw new AssertionError({
82 assertion,
83 message: `The second argument to \`t.${assertion}()\` must be a function, string, regular expression, expectation object or \`null\``,
84 values: [formatWithLabel('Called with:', expectations)]
85 });
86 } else {
87 if (hasOwnProperty(expectations, 'instanceOf') && typeof expectations.instanceOf !== 'function') {
88 throw new AssertionError({
89 assertion,
90 message: `The \`instanceOf\` property of the second argument to \`t.${assertion}()\` must be a function`,
91 values: [formatWithLabel('Called with:', expectations)]
92 });
93 }
94
95 if (hasOwnProperty(expectations, 'message') && typeof expectations.message !== 'string' && !(expectations.message instanceof RegExp)) {
96 throw new AssertionError({
97 assertion,
98 message: `The \`message\` property of the second argument to \`t.${assertion}()\` must be a string or regular expression`,
99 values: [formatWithLabel('Called with:', expectations)]
100 });
101 }
102
103 if (hasOwnProperty(expectations, 'name') && typeof expectations.name !== 'string') {
104 throw new AssertionError({
105 assertion,
106 message: `The \`name\` property of the second argument to \`t.${assertion}()\` must be a string`,
107 values: [formatWithLabel('Called with:', expectations)]
108 });
109 }
110
111 if (hasOwnProperty(expectations, 'code') && typeof expectations.code !== 'string' && typeof expectations.code !== 'number') {
112 throw new AssertionError({
113 assertion,
114 message: `The \`code\` property of the second argument to \`t.${assertion}()\` must be a string or number`,
115 values: [formatWithLabel('Called with:', expectations)]
116 });
117 }
118
119 for (const key of Object.keys(expectations)) {
120 switch (key) {
121 case 'instanceOf':
122 case 'is':
123 case 'message':
124 case 'name':
125 case 'code':
126 continue;
127 default:
128 throw new AssertionError({
129 assertion,
130 message: `The second argument to \`t.${assertion}()\` contains unexpected properties`,
131 values: [formatWithLabel('Called with:', expectations)]
132 });
133 }
134 }
135 }
136
137 return expectations;
138}
139
140// Note: this function *must* throw exceptions, since it can be used
141// as part of a pending assertion for promises.
142function assertExpectations({assertion, actual, expectations, message, prefix, savedError}) {
143 if (!isError(actual)) {
144 throw new AssertionError({
145 assertion,
146 message,
147 savedError,
148 values: [formatWithLabel(`${prefix} exception that is not an error:`, actual)]
149 });
150 }
151
152 const actualStack = actual.stack;
153
154 if (hasOwnProperty(expectations, 'is') && actual !== expectations.is) {
155 throw new AssertionError({
156 assertion,
157 message,
158 savedError,
159 actualStack,
160 values: [
161 formatWithLabel(`${prefix} unexpected exception:`, actual),
162 formatWithLabel('Expected to be strictly equal to:', expectations.is)
163 ]
164 });
165 }
166
167 if (expectations.instanceOf && !(actual instanceof expectations.instanceOf)) {
168 throw new AssertionError({
169 assertion,
170 message,
171 savedError,
172 actualStack,
173 values: [
174 formatWithLabel(`${prefix} unexpected exception:`, actual),
175 formatWithLabel('Expected instance of:', expectations.instanceOf)
176 ]
177 });
178 }
179
180 if (typeof expectations.name === 'string' && actual.name !== expectations.name) {
181 throw new AssertionError({
182 assertion,
183 message,
184 savedError,
185 actualStack,
186 values: [
187 formatWithLabel(`${prefix} unexpected exception:`, actual),
188 formatWithLabel('Expected name to equal:', expectations.name)
189 ]
190 });
191 }
192
193 if (typeof expectations.message === 'string' && actual.message !== expectations.message) {
194 throw new AssertionError({
195 assertion,
196 message,
197 savedError,
198 actualStack,
199 values: [
200 formatWithLabel(`${prefix} unexpected exception:`, actual),
201 formatWithLabel('Expected message to equal:', expectations.message)
202 ]
203 });
204 }
205
206 if (expectations.message instanceof RegExp && !expectations.message.test(actual.message)) {
207 throw new AssertionError({
208 assertion,
209 message,
210 savedError,
211 actualStack,
212 values: [
213 formatWithLabel(`${prefix} unexpected exception:`, actual),
214 formatWithLabel('Expected message to match:', expectations.message)
215 ]
216 });
217 }
218
219 if (typeof expectations.code !== 'undefined' && actual.code !== expectations.code) {
220 throw new AssertionError({
221 assertion,
222 message,
223 savedError,
224 actualStack,
225 values: [
226 formatWithLabel(`${prefix} unexpected exception:`, actual),
227 formatWithLabel('Expected code to equal:', expectations.code)
228 ]
229 });
230 }
231}
232
233class Assertions {
234 constructor({
235 pass = notImplemented,
236 pending = notImplemented,
237 fail = notImplemented,
238 skip = notImplemented,
239 compareWithSnapshot = notImplemented
240 } = {}) {
241 const withSkip = assertionFn => {
242 assertionFn.skip = skip;
243 return assertionFn;
244 };
245
246 // When adding new enhanced functions with new patterns, don't forget to add to
247 // https://github.com/avajs/babel-preset-transform-test-files/blob/master/espower-patterns.json
248 // Then release a new version of that preset and bump the SemVer range here.
249 const withPowerAssert = (pattern, assertionFn) => empower(assertionFn, {
250 onError: event => {
251 if (event.powerAssertContext) {
252 event.error.statements = enhanceAssert.formatter(event.powerAssertContext);
253 }
254
255 fail(event.error);
256 },
257 onSuccess: () => {
258 pass();
259 },
260 bindReceiver: false,
261 patterns: [pattern]
262 });
263
264 const checkMessage = (assertion, message, powerAssert = false) => {
265 if (typeof message === 'undefined' || typeof message === 'string') {
266 return true;
267 }
268
269 const error = new AssertionError({
270 assertion,
271 improperUsage: true,
272 message: 'The assertion message must be a string',
273 values: [formatWithLabel('Called with:', message)]
274 });
275
276 if (powerAssert) {
277 throw error;
278 }
279
280 fail(error);
281 return false;
282 };
283
284 this.pass = withSkip(() => {
285 pass();
286 });
287
288 this.fail = withSkip(message => {
289 if (!checkMessage('fail', message)) {
290 return;
291 }
292
293 fail(new AssertionError({
294 assertion: 'fail',
295 message: message || 'Test failed via `t.fail()`'
296 }));
297 });
298
299 this.is = withSkip((actual, expected, message) => {
300 if (!checkMessage('is', message)) {
301 return;
302 }
303
304 if (Object.is(actual, expected)) {
305 pass();
306 } else {
307 const result = concordance.compare(actual, expected, concordanceOptions);
308 const actualDescriptor = result.actual || concordance.describe(actual, concordanceOptions);
309 const expectedDescriptor = result.expected || concordance.describe(expected, concordanceOptions);
310
311 if (result.pass) {
312 fail(new AssertionError({
313 assertion: 'is',
314 message,
315 raw: {actual, expected},
316 values: [formatDescriptorWithLabel('Values are deeply equal to each other, but they are not the same:', actualDescriptor)]
317 }));
318 } else {
319 fail(new AssertionError({
320 assertion: 'is',
321 message,
322 raw: {actual, expected},
323 values: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)]
324 }));
325 }
326 }
327 });
328
329 this.not = withSkip((actual, expected, message) => {
330 if (!checkMessage('not', message)) {
331 return;
332 }
333
334 if (Object.is(actual, expected)) {
335 fail(new AssertionError({
336 assertion: 'not',
337 message,
338 raw: {actual, expected},
339 values: [formatWithLabel('Value is the same as:', actual)]
340 }));
341 } else {
342 pass();
343 }
344 });
345
346 this.deepEqual = withSkip((actual, expected, message) => {
347 if (!checkMessage('deepEqual', message)) {
348 return;
349 }
350
351 const result = concordance.compare(actual, expected, concordanceOptions);
352 if (result.pass) {
353 pass();
354 } else {
355 const actualDescriptor = result.actual || concordance.describe(actual, concordanceOptions);
356 const expectedDescriptor = result.expected || concordance.describe(expected, concordanceOptions);
357 fail(new AssertionError({
358 assertion: 'deepEqual',
359 message,
360 raw: {actual, expected},
361 values: [formatDescriptorDiff(actualDescriptor, expectedDescriptor)]
362 }));
363 }
364 });
365
366 this.notDeepEqual = withSkip((actual, expected, message) => {
367 if (!checkMessage('notDeepEqual', message)) {
368 return;
369 }
370
371 const result = concordance.compare(actual, expected, concordanceOptions);
372 if (result.pass) {
373 const actualDescriptor = result.actual || concordance.describe(actual, concordanceOptions);
374 fail(new AssertionError({
375 assertion: 'notDeepEqual',
376 message,
377 raw: {actual, expected},
378 values: [formatDescriptorWithLabel('Value is deeply equal:', actualDescriptor)]
379 }));
380 } else {
381 pass();
382 }
383 });
384
385 this.throws = withSkip((...args) => {
386 // Since arrow functions do not support 'arguments', we are using rest
387 // operator, so we can determine the total number of arguments passed
388 // to the function.
389 let [fn, expectations, message] = args;
390
391 if (!checkMessage('throws', message)) {
392 return;
393 }
394
395 if (typeof fn !== 'function') {
396 fail(new AssertionError({
397 assertion: 'throws',
398 improperUsage: true,
399 message: '`t.throws()` must be called with a function',
400 values: [formatWithLabel('Called with:', fn)]
401 }));
402 return;
403 }
404
405 try {
406 expectations = validateExpectations('throws', expectations, args.length);
407 } catch (error) {
408 fail(error);
409 return;
410 }
411
412 let retval;
413 let actual = null;
414 try {
415 retval = fn();
416 if (isPromise(retval)) {
417 // Here isPromise() checks if something is "promise like". Cast to an actual promise.
418 Promise.resolve(retval).catch(noop);
419 fail(new AssertionError({
420 assertion: 'throws',
421 message,
422 values: [formatWithLabel('Function returned a promise. Use `t.throwsAsync()` instead:', retval)]
423 }));
424 return;
425 }
426 } catch (error) {
427 actual = error;
428 }
429
430 if (!actual) {
431 fail(new AssertionError({
432 assertion: 'throws',
433 message,
434 values: [formatWithLabel('Function returned:', retval)]
435 }));
436 return;
437 }
438
439 try {
440 assertExpectations({
441 assertion: 'throws',
442 actual,
443 expectations,
444 message,
445 prefix: 'Function threw'
446 });
447 pass();
448 return actual;
449 } catch (error) {
450 fail(error);
451 }
452 });
453
454 this.throwsAsync = withSkip((...args) => {
455 let [thrower, expectations, message] = args;
456
457 if (!checkMessage('throwsAsync', message)) {
458 return Promise.resolve();
459 }
460
461 if (typeof thrower !== 'function' && !isPromise(thrower)) {
462 fail(new AssertionError({
463 assertion: 'throwsAsync',
464 improperUsage: true,
465 message: '`t.throwsAsync()` must be called with a function or promise',
466 values: [formatWithLabel('Called with:', thrower)]
467 }));
468 return Promise.resolve();
469 }
470
471 try {
472 expectations = validateExpectations('throwsAsync', expectations, args.length);
473 } catch (error) {
474 fail(error);
475 return Promise.resolve();
476 }
477
478 const handlePromise = (promise, wasReturned) => {
479 // Create an error object to record the stack before it gets lost in the promise chain.
480 const savedError = getErrorWithLongStackTrace();
481 // Handle "promise like" objects by casting to a real Promise.
482 const intermediate = Promise.resolve(promise).then(value => { // eslint-disable-line promise/prefer-await-to-then
483 throw new AssertionError({
484 assertion: 'throwsAsync',
485 message,
486 savedError,
487 values: [formatWithLabel(`${wasReturned ? 'Returned promise' : 'Promise'} resolved with:`, value)]
488 });
489 }, reason => {
490 assertExpectations({
491 assertion: 'throwsAsync',
492 actual: reason,
493 expectations,
494 message,
495 prefix: `${wasReturned ? 'Returned promise' : 'Promise'} rejected with`,
496 savedError
497 });
498 return reason;
499 });
500
501 pending(intermediate);
502 // Don't reject the returned promise, even if the assertion fails.
503 return intermediate.catch(noop);
504 };
505
506 if (isPromise(thrower)) {
507 return handlePromise(thrower, false);
508 }
509
510 let retval;
511 let actual = null;
512 try {
513 retval = thrower();
514 } catch (error) {
515 actual = error;
516 }
517
518 if (actual) {
519 fail(new AssertionError({
520 assertion: 'throwsAsync',
521 message,
522 actualStack: actual.stack,
523 values: [formatWithLabel('Function threw synchronously. Use `t.throws()` instead:', actual)]
524 }));
525 return Promise.resolve();
526 }
527
528 if (isPromise(retval)) {
529 return handlePromise(retval, true);
530 }
531
532 fail(new AssertionError({
533 assertion: 'throwsAsync',
534 message,
535 values: [formatWithLabel('Function returned:', retval)]
536 }));
537 return Promise.resolve();
538 });
539
540 this.notThrows = withSkip((fn, message) => {
541 if (!checkMessage('notThrows', message)) {
542 return;
543 }
544
545 if (typeof fn !== 'function') {
546 fail(new AssertionError({
547 assertion: 'notThrows',
548 improperUsage: true,
549 message: '`t.notThrows()` must be called with a function',
550 values: [formatWithLabel('Called with:', fn)]
551 }));
552 return;
553 }
554
555 try {
556 fn();
557 } catch (error) {
558 fail(new AssertionError({
559 assertion: 'notThrows',
560 message,
561 actualStack: error.stack,
562 values: [formatWithLabel('Function threw:', error)]
563 }));
564 return;
565 }
566
567 pass();
568 });
569
570 this.notThrowsAsync = withSkip((nonThrower, message) => {
571 if (!checkMessage('notThrowsAsync', message)) {
572 return Promise.resolve();
573 }
574
575 if (typeof nonThrower !== 'function' && !isPromise(nonThrower)) {
576 fail(new AssertionError({
577 assertion: 'notThrowsAsync',
578 improperUsage: true,
579 message: '`t.notThrowsAsync()` must be called with a function or promise',
580 values: [formatWithLabel('Called with:', nonThrower)]
581 }));
582 return Promise.resolve();
583 }
584
585 const handlePromise = (promise, wasReturned) => {
586 // Create an error object to record the stack before it gets lost in the promise chain.
587 const savedError = getErrorWithLongStackTrace();
588 // Handle "promise like" objects by casting to a real Promise.
589 const intermediate = Promise.resolve(promise).then(noop, error => { // eslint-disable-line promise/prefer-await-to-then
590 throw new AssertionError({
591 assertion: 'notThrowsAsync',
592 message,
593 savedError,
594 values: [formatWithLabel(`${wasReturned ? 'Returned promise' : 'Promise'} rejected with:`, error)]
595 });
596 });
597 pending(intermediate);
598 // Don't reject the returned promise, even if the assertion fails.
599 return intermediate.catch(noop);
600 };
601
602 if (isPromise(nonThrower)) {
603 return handlePromise(nonThrower, false);
604 }
605
606 let retval;
607 try {
608 retval = nonThrower();
609 } catch (error) {
610 fail(new AssertionError({
611 assertion: 'notThrowsAsync',
612 message,
613 actualStack: error.stack,
614 values: [formatWithLabel('Function threw:', error)]
615 }));
616 return Promise.resolve();
617 }
618
619 if (!isPromise(retval)) {
620 fail(new AssertionError({
621 assertion: 'notThrowsAsync',
622 message,
623 values: [formatWithLabel('Function did not return a promise. Use `t.notThrows()` instead:', retval)]
624 }));
625 return Promise.resolve();
626 }
627
628 return handlePromise(retval, true);
629 });
630
631 this.snapshot = withSkip((expected, ...rest) => {
632 let message;
633 let snapshotOptions;
634 if (rest.length > 1) {
635 [snapshotOptions, message] = rest;
636 } else {
637 const [optionsOrMessage] = rest;
638 if (typeof optionsOrMessage === 'object') {
639 snapshotOptions = optionsOrMessage;
640 } else {
641 message = optionsOrMessage;
642 }
643 }
644
645 if (!checkMessage('snapshot', message)) {
646 return;
647 }
648
649 let result;
650 try {
651 result = compareWithSnapshot({
652 expected,
653 id: snapshotOptions ? snapshotOptions.id : undefined,
654 message
655 });
656 } catch (error) {
657 if (!(error instanceof snapshotManager.SnapshotError)) {
658 throw error;
659 }
660
661 const improperUsage = {name: error.name, snapPath: error.snapPath};
662 if (error instanceof snapshotManager.VersionMismatchError) {
663 improperUsage.snapVersion = error.snapVersion;
664 improperUsage.expectedVersion = error.expectedVersion;
665 }
666
667 fail(new AssertionError({
668 assertion: 'snapshot',
669 message: message || 'Could not compare snapshot',
670 improperUsage
671 }));
672 return;
673 }
674
675 if (result.pass) {
676 pass();
677 } else if (result.actual) {
678 fail(new AssertionError({
679 assertion: 'snapshot',
680 message: message || 'Did not match snapshot',
681 values: [formatDescriptorDiff(result.actual, result.expected, {invert: true})]
682 }));
683 } else {
684 // This can only occur in CI environments.
685 fail(new AssertionError({
686 assertion: 'snapshot',
687 message: message || 'No snapshot available — new snapshots are not created in CI environments'
688 }));
689 }
690 });
691
692 this.truthy = withSkip((actual, message) => {
693 if (!checkMessage('truthy', message)) {
694 return;
695 }
696
697 if (actual) {
698 pass();
699 } else {
700 fail(new AssertionError({
701 assertion: 'truthy',
702 message,
703 operator: '!!',
704 values: [formatWithLabel('Value is not truthy:', actual)]
705 }));
706 }
707 });
708
709 this.falsy = withSkip((actual, message) => {
710 if (!checkMessage('falsy', message)) {
711 return;
712 }
713
714 if (actual) {
715 fail(new AssertionError({
716 assertion: 'falsy',
717 message,
718 operator: '!',
719 values: [formatWithLabel('Value is not falsy:', actual)]
720 }));
721 } else {
722 pass();
723 }
724 });
725
726 this.true = withSkip((actual, message) => {
727 if (!checkMessage('true', message)) {
728 return;
729 }
730
731 if (actual === true) {
732 pass();
733 } else {
734 fail(new AssertionError({
735 assertion: 'true',
736 message,
737 values: [formatWithLabel('Value is not `true`:', actual)]
738 }));
739 }
740 });
741
742 this.false = withSkip((actual, message) => {
743 if (!checkMessage('false', message)) {
744 return;
745 }
746
747 if (actual === false) {
748 pass();
749 } else {
750 fail(new AssertionError({
751 assertion: 'false',
752 message,
753 values: [formatWithLabel('Value is not `false`:', actual)]
754 }));
755 }
756 });
757
758 this.regex = withSkip((string, regex, message) => {
759 if (!checkMessage('regex', message)) {
760 return;
761 }
762
763 if (typeof string !== 'string') {
764 fail(new AssertionError({
765 assertion: 'regex',
766 improperUsage: true,
767 message: '`t.regex()` must be called with a string',
768 values: [formatWithLabel('Called with:', string)]
769 }));
770 return;
771 }
772
773 if (!(regex instanceof RegExp)) {
774 fail(new AssertionError({
775 assertion: 'regex',
776 improperUsage: true,
777 message: '`t.regex()` must be called with a regular expression',
778 values: [formatWithLabel('Called with:', regex)]
779 }));
780 return;
781 }
782
783 if (!regex.test(string)) {
784 fail(new AssertionError({
785 assertion: 'regex',
786 message,
787 values: [
788 formatWithLabel('Value must match expression:', string),
789 formatWithLabel('Regular expression:', regex)
790 ]
791 }));
792 return;
793 }
794
795 pass();
796 });
797
798 this.notRegex = withSkip((string, regex, message) => {
799 if (!checkMessage('notRegex', message)) {
800 return;
801 }
802
803 if (typeof string !== 'string') {
804 fail(new AssertionError({
805 assertion: 'notRegex',
806 improperUsage: true,
807 message: '`t.notRegex()` must be called with a string',
808 values: [formatWithLabel('Called with:', string)]
809 }));
810 return;
811 }
812
813 if (!(regex instanceof RegExp)) {
814 fail(new AssertionError({
815 assertion: 'notRegex',
816 improperUsage: true,
817 message: '`t.notRegex()` must be called with a regular expression',
818 values: [formatWithLabel('Called with:', regex)]
819 }));
820 return;
821 }
822
823 if (regex.test(string)) {
824 fail(new AssertionError({
825 assertion: 'notRegex',
826 message,
827 values: [
828 formatWithLabel('Value must not match expression:', string),
829 formatWithLabel('Regular expression:', regex)
830 ]
831 }));
832 return;
833 }
834
835 pass();
836 });
837
838 this.assert = withSkip(withPowerAssert(
839 'assert(value, [message])',
840 (actual, message) => {
841 checkMessage('assert', message, true);
842
843 if (!actual) {
844 throw new AssertionError({
845 assertion: 'assert',
846 message,
847 operator: '!!',
848 values: [formatWithLabel('Value is not truthy:', actual)]
849 });
850 }
851 })
852 );
853 }
854}
855exports.Assertions = Assertions;