UNPKG

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