main
js 308 lines 11.1 KB
Raw
1 'use strict';
2
3 // This is mostly copypasta from toWarnDev.js matchers
4 // that we use in the main repo Jest configuration.
5
6 const expect = global.expect;
7
8 const {diff: jestDiff} = require('jest-diff');
9 const util = require('util');
10
11 function shouldIgnoreConsoleError(format, args) {
12 if (process.env.NODE_ENV !== 'production') {
13 if (typeof format === 'string') {
14 if (format.indexOf('Error: Uncaught [') === 0) {
15 // This looks like an uncaught error from invokeGuardedCallback() wrapper
16 // in development that is reported by jsdom. Ignore because it's noisy.
17 return true;
18 }
19 if (format.indexOf('The above error occurred') === 0) {
20 // This looks like an error addendum from ReactFiberErrorLogger.
21 // Ignore it too.
22 return true;
23 }
24 }
25 } else {
26 if (
27 format != null &&
28 typeof format.message === 'string' &&
29 typeof format.stack === 'string' &&
30 args.length === 0
31 ) {
32 // In production, ReactFiberErrorLogger logs error objects directly.
33 // They are noisy too so we'll try to ignore them.
34 return true;
35 }
36 }
37 // Looks legit
38 return false;
39 }
40
41 function normalizeCodeLocInfo(str) {
42 if (typeof str !== 'string') {
43 return str;
44 }
45 // This special case exists only for the special source location in
46 // ReactElementValidator. That will go away if we remove source locations.
47 str = str.replace(/Check your code at .+?:\d+/g, 'Check your code at **');
48 // V8 format:
49 // at Component (/path/filename.js:123:45)
50 // React format:
51 // in Component (at filename.js:123)
52 return str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
53 return '\n in ' + name + ' (at **)';
54 });
55 }
56
57 const createMatcherFor = (consoleMethod, matcherName) =>
58 function matcher(callback, expectedMessages, options = {}) {
59 if (process.env.NODE_ENV !== 'production') {
60 // Warn about incorrect usage of matcher.
61 if (typeof expectedMessages === 'string') {
62 expectedMessages = [expectedMessages];
63 } else if (!Array.isArray(expectedMessages)) {
64 throw Error(
65 `${matcherName}() requires a parameter of type string or an array of strings ` +
66 `but was given ${typeof expectedMessages}.`
67 );
68 }
69 if (
70 options != null &&
71 (typeof options !== 'object' || Array.isArray(options))
72 ) {
73 throw new Error(
74 `${matcherName}() second argument, when present, should be an object. ` +
75 'Did you forget to wrap the messages into an array?'
76 );
77 }
78 if (arguments.length > 3) {
79 // `matcher` comes from Jest, so it's more than 2 in practice
80 throw new Error(
81 `${matcherName}() received more than two arguments. ` +
82 'Did you forget to wrap the messages into an array?'
83 );
84 }
85
86 const withoutStack = options.withoutStack;
87 const logAllErrors = options.logAllErrors;
88 const warningsWithoutComponentStack = [];
89 const warningsWithComponentStack = [];
90 const unexpectedWarnings = [];
91
92 let lastWarningWithMismatchingFormat = null;
93 let lastWarningWithExtraComponentStack = null;
94
95 // Catch errors thrown by the callback,
96 // But only rethrow them if all test expectations have been satisfied.
97 // Otherwise an Error in the callback can mask a failed expectation,
98 // and result in a test that passes when it shouldn't.
99 let caughtError;
100
101 const isLikelyAComponentStack = message =>
102 typeof message === 'string' &&
103 (message.includes('\n in ') || message.includes('\n at '));
104
105 const consoleSpy = (format, ...args) => {
106 // Ignore uncaught errors reported by jsdom
107 // and React addendums because they're too noisy.
108 if (
109 !logAllErrors &&
110 consoleMethod === 'error' &&
111 shouldIgnoreConsoleError(format, args)
112 ) {
113 return;
114 }
115
116 const message = util.format(format, ...args);
117 const normalizedMessage = normalizeCodeLocInfo(message);
118
119 // Remember if the number of %s interpolations
120 // doesn't match the number of arguments.
121 // We'll fail the test if it happens.
122 let argIndex = 0;
123 format.replace(/%s/g, () => argIndex++);
124 if (argIndex !== args.length) {
125 lastWarningWithMismatchingFormat = {
126 format,
127 args,
128 expectedArgCount: argIndex,
129 };
130 }
131
132 // Protect against accidentally passing a component stack
133 // to warning() which already injects the component stack.
134 if (
135 args.length >= 2 &&
136 isLikelyAComponentStack(args[args.length - 1]) &&
137 isLikelyAComponentStack(args[args.length - 2])
138 ) {
139 lastWarningWithExtraComponentStack = {
140 format,
141 };
142 }
143
144 for (let index = 0; index < expectedMessages.length; index++) {
145 const expectedMessage = expectedMessages[index];
146 if (
147 normalizedMessage === expectedMessage ||
148 normalizedMessage.includes(expectedMessage)
149 ) {
150 if (isLikelyAComponentStack(normalizedMessage)) {
151 warningsWithComponentStack.push(normalizedMessage);
152 } else {
153 warningsWithoutComponentStack.push(normalizedMessage);
154 }
155 expectedMessages.splice(index, 1);
156 return;
157 }
158 }
159
160 let errorMessage;
161 if (expectedMessages.length === 0) {
162 errorMessage =
163 'Unexpected warning recorded: ' +
164 this.utils.printReceived(normalizedMessage);
165 } else if (expectedMessages.length === 1) {
166 errorMessage =
167 'Unexpected warning recorded: ' +
168 jestDiff(expectedMessages[0], normalizedMessage);
169 } else {
170 errorMessage =
171 'Unexpected warning recorded: ' +
172 jestDiff(expectedMessages, [normalizedMessage]);
173 }
174
175 // Record the call stack for unexpected warnings.
176 // We don't throw an Error here though,
177 // Because it might be suppressed by ReactFiberScheduler.
178 unexpectedWarnings.push(new Error(errorMessage));
179 };
180
181 // TODO Decide whether we need to support nested toWarn* expectations.
182 // If we don't need it, add a check here to see if this is already our spy,
183 // And throw an error.
184 const originalMethod = console[consoleMethod];
185
186 // Avoid using Jest's built-in spy since it can't be removed.
187 console[consoleMethod] = consoleSpy;
188
189 try {
190 callback();
191 } catch (error) {
192 caughtError = error;
193 } finally {
194 // Restore the unspied method so that unexpected errors fail tests.
195 console[consoleMethod] = originalMethod;
196
197 // Any unexpected Errors thrown by the callback should fail the test.
198 // This should take precedence since unexpected errors could block warnings.
199 if (caughtError) {
200 throw caughtError;
201 }
202
203 // Any unexpected warnings should be treated as a failure.
204 if (unexpectedWarnings.length > 0) {
205 return {
206 message: () => unexpectedWarnings[0].stack,
207 pass: false,
208 };
209 }
210
211 // Any remaining messages indicate a failed expectations.
212 if (expectedMessages.length > 0) {
213 return {
214 message: () =>
215 `Expected warning was not recorded:\n ${this.utils.printReceived(
216 expectedMessages[0]
217 )}`,
218 pass: false,
219 };
220 }
221
222 if (typeof withoutStack === 'number') {
223 // We're expecting a particular number of warnings without stacks.
224 if (withoutStack !== warningsWithoutComponentStack.length) {
225 return {
226 message: () =>
227 `Expected ${withoutStack} warnings without a component stack but received ${warningsWithoutComponentStack.length}:\n` +
228 warningsWithoutComponentStack.map(warning =>
229 this.utils.printReceived(warning)
230 ),
231 pass: false,
232 };
233 }
234 } else if (withoutStack === true) {
235 // We're expecting that all warnings won't have the stack.
236 // If some warnings have it, it's an error.
237 if (warningsWithComponentStack.length > 0) {
238 return {
239 message: () =>
240 `Received warning unexpectedly includes a component stack:\n ${this.utils.printReceived(
241 warningsWithComponentStack[0]
242 )}\nIf this warning intentionally includes the component stack, remove ` +
243 `{withoutStack: true} from the ${matcherName}() call. If you have a mix of ` +
244 `warnings with and without stack in one ${matcherName}() call, pass ` +
245 `{withoutStack: N} where N is the number of warnings without stacks.`,
246 pass: false,
247 };
248 }
249 } else if (withoutStack === false || withoutStack === undefined) {
250 // We're expecting that all warnings *do* have the stack (default).
251 // If some warnings don't have it, it's an error.
252 if (warningsWithoutComponentStack.length > 0) {
253 return {
254 message: () =>
255 `Received warning unexpectedly does not include a component stack:\n ${this.utils.printReceived(
256 warningsWithoutComponentStack[0]
257 )}\nIf this warning intentionally omits the component stack, add ` +
258 `{withoutStack: true} to the ${matcherName} call.`,
259 pass: false,
260 };
261 }
262 } else {
263 throw Error(
264 `The second argument for ${matcherName}(), when specified, must be an object. It may have a ` +
265 `property called "withoutStack" whose value may be undefined, boolean, or a number. ` +
266 `Instead received ${typeof withoutStack}.`
267 );
268 }
269
270 if (lastWarningWithMismatchingFormat !== null) {
271 return {
272 message: () =>
273 `Received ${
274 lastWarningWithMismatchingFormat.args.length
275 } arguments for a message with ${
276 lastWarningWithMismatchingFormat.expectedArgCount
277 } placeholders:\n ${this.utils.printReceived(
278 lastWarningWithMismatchingFormat.format
279 )}`,
280 pass: false,
281 };
282 }
283
284 if (lastWarningWithExtraComponentStack !== null) {
285 return {
286 message: () =>
287 `Received more than one component stack for a warning:\n ${this.utils.printReceived(
288 lastWarningWithExtraComponentStack.format
289 )}\nDid you accidentally pass a stack to warning() as the last argument? ` +
290 `Don't forget warning() already injects the component stack automatically.`,
291 pass: false,
292 };
293 }
294
295 return {pass: true};
296 }
297 } else {
298 // Any uncaught errors or warnings should fail tests in production mode.
299 callback();
300
301 return {pass: true};
302 }
303 };
304
305 expect.extend({
306 toWarnDev: createMatcherFor('warn', 'toWarnDev'),
307 toErrorDev: createMatcherFor('error', 'toErrorDev'),
308 });