main
js 559 lines 19.5 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 /* eslint-disable react-internal/no-production-logging */
9
10 const chalk = require('chalk');
11 const util = require('util');
12 const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError');
13 const shouldIgnoreConsoleWarn = require('./shouldIgnoreConsoleWarn');
14 import {diff} from 'jest-diff';
15 import {printReceived} from 'jest-matcher-utils';
16
17 // Annoying: need to store the log array on the global or it would
18 // change reference whenever you call jest.resetModules after patch.
19 const loggedErrors = (global.__loggedErrors = global.__loggedErrors || []);
20 const loggedWarns = (global.__loggedWarns = global.__loggedWarns || []);
21 const loggedLogs = (global.__loggedLogs = global.__loggedLogs || []);
22
23 const patchConsoleMethod = (methodName, logged) => {
24 const newMethod = function (format, ...args) {
25 // Ignore uncaught errors reported by jsdom
26 // and React addendums because they're too noisy.
27 if (shouldIgnoreConsoleError(format, args)) {
28 return;
29 }
30
31 // Ignore certain React warnings causing test failures
32 if (methodName === 'warn' && shouldIgnoreConsoleWarn(format)) {
33 return;
34 }
35
36 // Append Component Stacks. Simulates a framework or DevTools appending them.
37 if (
38 typeof format === 'string' &&
39 (methodName === 'error' || methodName === 'warn')
40 ) {
41 const React = require('react');
42
43 // Ideally we could remove this check, but we have some tests like
44 // useSyncExternalStoreShared-test that tests against React 17,
45 // which doesn't have the captureOwnerStack method.
46 if (React.captureOwnerStack) {
47 const stack = React.captureOwnerStack();
48 if (stack) {
49 format += '%s';
50 args.push(stack);
51 }
52 }
53 }
54
55 logged.push([format, ...args]);
56 };
57
58 console[methodName] = newMethod;
59
60 return newMethod;
61 };
62
63 let logMethod;
64 export function patchConsoleMethods({includeLog} = {includeLog: false}) {
65 patchConsoleMethod('error', loggedErrors);
66 patchConsoleMethod('warn', loggedWarns);
67
68 // Only assert console.log isn't called in CI so you can debug tests in DEV.
69 // The matchers will still work in DEV, so you can assert locally.
70 if (includeLog) {
71 logMethod = patchConsoleMethod('log', loggedLogs);
72 }
73 }
74
75 export function resetAllUnexpectedConsoleCalls() {
76 loggedErrors.length = 0;
77 loggedWarns.length = 0;
78 if (logMethod) {
79 loggedLogs.length = 0;
80 }
81 }
82
83 export function clearLogs() {
84 const logs = Array.from(loggedLogs);
85 loggedLogs.length = 0;
86 return logs;
87 }
88
89 export function clearWarnings() {
90 const warnings = Array.from(loggedWarns);
91 loggedWarns.length = 0;
92 return warnings;
93 }
94
95 export function clearErrors() {
96 const errors = Array.from(loggedErrors);
97 loggedErrors.length = 0;
98 return errors;
99 }
100
101 export function assertConsoleLogsCleared() {
102 const logs = clearLogs();
103 const warnings = clearWarnings();
104 const errors = clearErrors();
105
106 if (logs.length > 0 || errors.length > 0 || warnings.length > 0) {
107 let message = `${chalk.dim('asserConsoleLogsCleared')}(${chalk.red(
108 'expected',
109 )})\n`;
110
111 if (logs.length > 0) {
112 message += `\nconsole.log was called without assertConsoleLogDev:\n${diff(
113 '',
114 logs.join('\n'),
115 {
116 omitAnnotationLines: true,
117 },
118 )}\n`;
119 }
120
121 if (warnings.length > 0) {
122 message += `\nconsole.warn was called without assertConsoleWarnDev:\n${diff(
123 '',
124 warnings.map(normalizeComponentStack).join('\n'),
125 {
126 omitAnnotationLines: true,
127 },
128 )}\n`;
129 }
130 if (errors.length > 0) {
131 message += `\nconsole.error was called without assertConsoleErrorDev:\n${diff(
132 '',
133 errors.map(normalizeComponentStack).join('\n'),
134 {
135 omitAnnotationLines: true,
136 },
137 )}\n`;
138 }
139
140 message += `\nYou must call one of the assertConsoleDev helpers between each act call.`;
141
142 const error = Error(message);
143 Error.captureStackTrace(error, assertConsoleLogsCleared);
144 throw error;
145 }
146 }
147
148 function normalizeCodeLocInfo(str) {
149 if (typeof str !== 'string') {
150 return str;
151 }
152 // This special case exists only for the special source location in
153 // ReactElementValidator. That will go away if we remove source locations.
154 str = str.replace(/Check your code at .+?:\d+/g, 'Check your code at **');
155 // V8 format:
156 // at Component (/path/filename.js:123:45)
157 // React format:
158 // in Component (at filename.js:123)
159 return str.replace(/\n +(?:at|in) ([^(\[\n]+)[^\n]*/g, function (m, name) {
160 name = name.trim();
161 if (name.endsWith('.render')) {
162 // Class components will have the `render` method as part of their stack trace.
163 // We strip that out in our normalization to make it look more like component stacks.
164 name = name.slice(0, name.length - 7);
165 }
166 name = name.replace(/.*\/([^\/]+):\d+:\d+/, '**/$1:**:**');
167 return '\n in ' + name + ' (at **)';
168 });
169 }
170
171 // Expands environment placeholders like [Server] into ANSI escape sequences.
172 // This allows test assertions to use a cleaner syntax like "[Server] Error:"
173 // instead of the full escape sequence "\u001b[0m\u001b[7m Server \u001b[0mError:"
174 function expandEnvironmentPlaceholders(str) {
175 if (typeof str !== 'string') {
176 return str;
177 }
178 // [Environment] -> ANSI escape sequence for environment badge
179 // The format is: reset + inverse + " Environment " + reset
180 return str.replace(
181 /^\[(\w+)] /g,
182 (match, env) => '\u001b[0m\u001b[7m ' + env + ' \u001b[0m',
183 );
184 }
185
186 // The error stack placeholder that can be used in expected messages
187 const ERROR_STACK_PLACEHOLDER = '\n in <stack>';
188 // A marker used to protect the placeholder during normalization
189 const ERROR_STACK_PLACEHOLDER_MARKER = '\n in <__STACK_PLACEHOLDER__>';
190
191 // Normalizes expected messages, handling special placeholders
192 function normalizeExpectedMessage(str) {
193 if (typeof str !== 'string') {
194 return str;
195 }
196 // Protect the error stack placeholder from normalization
197 // (normalizeCodeLocInfo would add "(at **)" to it)
198 const hasStackPlaceholder = str.includes(ERROR_STACK_PLACEHOLDER);
199 let result = str;
200 if (hasStackPlaceholder) {
201 result = result.replace(
202 ERROR_STACK_PLACEHOLDER,
203 ERROR_STACK_PLACEHOLDER_MARKER,
204 );
205 }
206 result = normalizeCodeLocInfo(result);
207 result = expandEnvironmentPlaceholders(result);
208 if (hasStackPlaceholder) {
209 // Restore the placeholder (remove the "(at **)" that was added)
210 result = result.replace(
211 ERROR_STACK_PLACEHOLDER_MARKER + ' (at **)',
212 ERROR_STACK_PLACEHOLDER,
213 );
214 }
215 return result;
216 }
217
218 function normalizeComponentStack(entry) {
219 if (
220 typeof entry[0] === 'string' &&
221 entry[0].endsWith('%s') &&
222 isLikelyAComponentStack(entry[entry.length - 1])
223 ) {
224 const clone = entry.slice(0);
225 clone[clone.length - 1] = normalizeCodeLocInfo(entry[entry.length - 1]);
226 return clone;
227 }
228 return entry;
229 }
230
231 const isLikelyAComponentStack = message =>
232 typeof message === 'string' &&
233 (message.indexOf('<component stack>') > -1 ||
234 message.includes('\n in ') ||
235 message.includes('\n at '));
236
237 // Error stack traces start with "*Error:" and contain "at" frames with file paths
238 // Component stacks contain "in ComponentName" patterns
239 // This helps validate that \n in <stack> is used correctly
240 const isLikelyAnErrorStackTrace = message =>
241 typeof message === 'string' &&
242 message.includes('Error:') &&
243 // Has "at" frames typical of error stacks (with file:line:col)
244 /\n\s+at .+\(.*:\d+:\d+\)/.test(message);
245
246 export function createLogAssertion(
247 consoleMethod,
248 matcherName,
249 clearObservedErrors,
250 ) {
251 function logName() {
252 switch (consoleMethod) {
253 case 'log':
254 return 'log';
255 case 'error':
256 return 'error';
257 case 'warn':
258 return 'warning';
259 }
260 }
261
262 return function assertConsoleLog(expectedMessages, options = {}) {
263 if (__DEV__) {
264 // eslint-disable-next-line no-inner-declarations
265 function throwFormattedError(message) {
266 const error = new Error(
267 `${chalk.dim(matcherName)}(${chalk.red(
268 'expected',
269 )})\n\n${message.trim()}`,
270 );
271 Error.captureStackTrace(error, assertConsoleLog);
272 throw error;
273 }
274
275 // Warn about incorrect usage first arg.
276 if (!Array.isArray(expectedMessages)) {
277 throwFormattedError(
278 `Expected messages should be an array of strings ` +
279 `but was given type "${typeof expectedMessages}".`,
280 );
281 }
282
283 // Warn about incorrect usage second arg.
284 if (options != null) {
285 if (typeof options !== 'object' || Array.isArray(options)) {
286 throwFormattedError(
287 `The second argument should be an object. ` +
288 'Did you forget to wrap the messages into an array?',
289 );
290 }
291 }
292
293 const observedLogs = clearObservedErrors();
294 const receivedLogs = [];
295 const missingExpectedLogs = Array.from(expectedMessages);
296
297 const unexpectedLogs = [];
298 const unexpectedMissingErrorStack = [];
299 const unexpectedIncludingErrorStack = [];
300 const logsMismatchingFormat = [];
301 const logsWithExtraComponentStack = [];
302 const stackTracePlaceholderMisuses = [];
303
304 // Loop over all the observed logs to determine:
305 // - Which expected logs are missing
306 // - Which received logs are unexpected
307 // - Which logs have a component stack
308 // - Which logs have the wrong format
309 // - Which logs have extra stacks
310 for (let index = 0; index < observedLogs.length; index++) {
311 const log = observedLogs[index];
312 const [format, ...args] = log;
313 const message = util.format(format, ...args);
314
315 // Ignore uncaught errors reported by jsdom
316 // and React addendums because they're too noisy.
317 if (shouldIgnoreConsoleError(format, args)) {
318 return;
319 }
320
321 let expectedMessage;
322 const expectedMessageOrArray = expectedMessages[index];
323 if (typeof expectedMessageOrArray === 'string') {
324 expectedMessage = normalizeExpectedMessage(expectedMessageOrArray);
325 } else if (expectedMessageOrArray != null) {
326 throwFormattedError(
327 `The expected message for ${matcherName}() must be a string. ` +
328 `Instead received ${JSON.stringify(expectedMessageOrArray)}.`,
329 );
330 }
331
332 const normalizedMessage = normalizeCodeLocInfo(message);
333 receivedLogs.push(normalizedMessage);
334
335 // Check the number of %s interpolations.
336 // We'll fail the test if they mismatch.
337 let argIndex = 0;
338 // console.* could have been called with a non-string e.g. `console.error(new Error())`
339 // eslint-disable-next-line react-internal/safe-string-coercion
340 String(format).replace(/%s|%c|%o/g, () => argIndex++);
341 if (argIndex !== args.length) {
342 if (format.includes('%c%s')) {
343 // We intentionally use mismatching formatting when printing badging because we don't know
344 // the best default to use for different types because the default varies by platform.
345 } else {
346 logsMismatchingFormat.push({
347 format,
348 args,
349 expectedArgCount: argIndex,
350 });
351 }
352 }
353
354 // Check for extra component stacks
355 if (
356 args.length >= 2 &&
357 isLikelyAComponentStack(args[args.length - 1]) &&
358 isLikelyAComponentStack(args[args.length - 2])
359 ) {
360 logsWithExtraComponentStack.push({
361 format,
362 });
363 }
364
365 // Main logic to check if log is expected, with the component stack.
366 // Check for exact match OR if the message matches with a component stack appended
367 let matchesExpectedMessage = false;
368 let expectsErrorStack = false;
369 const hasErrorStack = isLikelyAnErrorStackTrace(message);
370
371 if (typeof expectedMessage === 'string') {
372 if (normalizedMessage === expectedMessage) {
373 matchesExpectedMessage = true;
374 } else if (expectedMessage.includes('\n in <stack>')) {
375 expectsErrorStack = true;
376 // \n in <stack> is ONLY for JavaScript Error stack traces (e.g., "Error: message\n at fn (file.js:1:2)")
377 // NOT for React component stacks (e.g., "\n in ComponentName (at **)").
378 // Validate that the actual message looks like an error stack trace.
379 if (!hasErrorStack) {
380 // The actual message doesn't look like an error stack trace.
381 // This is likely a misuse - someone used \n in <stack> for a component stack.
382 stackTracePlaceholderMisuses.push({
383 expected: expectedMessage,
384 received: normalizedMessage,
385 });
386 }
387
388 const expectedMessageWithoutStack = expectedMessage.replace(
389 '\n in <stack>',
390 '',
391 );
392 if (normalizedMessage.startsWith(expectedMessageWithoutStack)) {
393 // Remove the stack trace
394 const remainder = normalizedMessage.slice(
395 expectedMessageWithoutStack.length,
396 );
397
398 // After normalization, both error stacks and component stacks look like
399 // component stacks (at frames are converted to "in ... (at **)" format).
400 // So we check isLikelyAComponentStack for matching purposes.
401 if (isLikelyAComponentStack(remainder)) {
402 const messageWithoutStack = normalizedMessage.replace(
403 remainder,
404 '',
405 );
406 if (messageWithoutStack === expectedMessageWithoutStack) {
407 matchesExpectedMessage = true;
408 }
409 } else if (remainder === '') {
410 // \n in <stack> was expected but there's no stack at all
411 matchesExpectedMessage = true;
412 }
413 } else if (normalizedMessage === expectedMessageWithoutStack) {
414 // \n in <stack> was expected but actual has no stack at all (exact match without stack)
415 matchesExpectedMessage = true;
416 }
417 } else if (
418 hasErrorStack &&
419 !expectedMessage.includes('\n in <stack>') &&
420 normalizedMessage.startsWith(expectedMessage)
421 ) {
422 matchesExpectedMessage = true;
423 }
424 }
425
426 if (matchesExpectedMessage) {
427 // Check for unexpected/missing error stacks
428 if (hasErrorStack && !expectsErrorStack) {
429 // Error stack is present but \n in <stack> was not in the expected message
430 unexpectedIncludingErrorStack.push(normalizedMessage);
431 } else if (
432 expectsErrorStack &&
433 !hasErrorStack &&
434 !isLikelyAComponentStack(normalizedMessage)
435 ) {
436 // \n in <stack> was expected but the actual message doesn't have any stack at all
437 // (if it has a component stack, stackTracePlaceholderMisuses already handles it)
438 unexpectedMissingErrorStack.push(normalizedMessage);
439 }
440
441 // Found expected log, remove it from missing.
442 missingExpectedLogs.splice(0, 1);
443 } else {
444 unexpectedLogs.push(normalizedMessage);
445 }
446 }
447
448 // Helper for pretty printing diffs consistently.
449 // We inline multi-line logs for better diff printing.
450 // eslint-disable-next-line no-inner-declarations
451 function printDiff() {
452 return `${diff(
453 expectedMessages
454 .map(message => message.replace('\n', ' '))
455 .join('\n'),
456 receivedLogs.map(message => message.replace('\n', ' ')).join('\n'),
457 {
458 aAnnotation: `Expected ${logName()}s`,
459 bAnnotation: `Received ${logName()}s`,
460 },
461 )}`;
462 }
463
464 // Wrong %s formatting is a failure.
465 // This is a common mistake when creating new warnings.
466 if (logsMismatchingFormat.length > 0) {
467 throwFormattedError(
468 logsMismatchingFormat
469 .map(
470 item =>
471 `Received ${item.args.length} arguments for a message with ${
472 item.expectedArgCount
473 } placeholders:\n ${printReceived(item.format)}`,
474 )
475 .join('\n\n'),
476 );
477 }
478
479 // Any unexpected warnings should be treated as a failure.
480 if (unexpectedLogs.length > 0) {
481 throwFormattedError(
482 `Unexpected ${logName()}(s) recorded.\n\n${printDiff()}`,
483 );
484 }
485
486 // Any remaining messages indicate a failed expectations.
487 if (missingExpectedLogs.length > 0) {
488 throwFormattedError(
489 `Expected ${logName()} was not recorded.\n\n${printDiff()}`,
490 );
491 }
492
493 // Any logs that include an error stack trace but \n in <stack> wasn't expected.
494 if (unexpectedIncludingErrorStack.length > 0) {
495 throwFormattedError(
496 `${unexpectedIncludingErrorStack
497 .map(
498 stack =>
499 `Unexpected error stack trace for:\n ${printReceived(stack)}`,
500 )
501 .join(
502 '\n\n',
503 )}\n\nIf this ${logName()} should include an error stack trace, add \\n in <stack> to your expected message ` +
504 `(e.g., "Error: message\\n in <stack>").`,
505 );
506 }
507
508 // Any logs that are missing an error stack trace when \n in <stack> was expected.
509 if (unexpectedMissingErrorStack.length > 0) {
510 throwFormattedError(
511 `${unexpectedMissingErrorStack
512 .map(
513 stack =>
514 `Missing error stack trace for:\n ${printReceived(stack)}`,
515 )
516 .join(
517 '\n\n',
518 )}\n\nThe expected message uses \\n in <stack> but the actual ${logName()} doesn't include an error stack trace.` +
519 `\nIf this ${logName()} should not have an error stack trace, remove \\n in <stack> from your expected message.`,
520 );
521 }
522
523 // Duplicate component stacks is a failure.
524 // This used to be a common mistake when creating new warnings,
525 // but might not be an issue anymore.
526 if (logsWithExtraComponentStack.length > 0) {
527 throwFormattedError(
528 logsWithExtraComponentStack
529 .map(
530 item =>
531 `Received more than one component stack for a warning:\n ${printReceived(
532 item.format,
533 )}`,
534 )
535 .join('\n\n'),
536 );
537 }
538
539 // Using \n in <stack> for component stacks is a misuse.
540 // \n in <stack> should only be used for JavaScript Error stack traces,
541 // not for React component stacks.
542 if (stackTracePlaceholderMisuses.length > 0) {
543 throwFormattedError(
544 `${stackTracePlaceholderMisuses
545 .map(
546 item =>
547 `Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error ` +
548 `stack traces (messages starting with "Error:"), not for React component stacks.\n\n` +
549 `Expected: ${printReceived(item.expected)}\n` +
550 `Received: ${printReceived(item.received)}\n\n` +
551 `If this ${logName()} has a component stack, include the full component stack in your expected message ` +
552 `(e.g., "Warning message\\n in ComponentName (at **)").`,
553 )
554 .join('\n\n')}`,
555 );
556 }
557 }
558 };
559 }