| 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 | } |