packages/internal-test-utils/consoleMock.js JAVASCRIPT 560 lines View on github.com → Search inside
1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 */78/* eslint-disable react-internal/no-production-logging */910const chalk = require('chalk');11const util = require('util');12const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError');13const shouldIgnoreConsoleWarn = require('./shouldIgnoreConsoleWarn');14import {diff} from 'jest-diff';15import {printReceived} from 'jest-matcher-utils';1617// Annoying: need to store the log array on the global or it would18// change reference whenever you call jest.resetModules after patch.19const loggedErrors = (global.__loggedErrors = global.__loggedErrors || []);20const loggedWarns = (global.__loggedWarns = global.__loggedWarns || []);21const loggedLogs = (global.__loggedLogs = global.__loggedLogs || []);2223const patchConsoleMethod = (methodName, logged) => {24  const newMethod = function (format, ...args) {25    // Ignore uncaught errors reported by jsdom26    // and React addendums because they're too noisy.27    if (shouldIgnoreConsoleError(format, args)) {28      return;29    }3031    // Ignore certain React warnings causing test failures32    if (methodName === 'warn' && shouldIgnoreConsoleWarn(format)) {33      return;34    }3536    // 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');4243      // Ideally we could remove this check, but we have some tests like44      // 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    }5455    logged.push([format, ...args]);56  };5758  console[methodName] = newMethod;5960  return newMethod;61};6263let logMethod;64export function patchConsoleMethods({includeLog} = {includeLog: false}) {65  patchConsoleMethod('error', loggedErrors);66  patchConsoleMethod('warn', loggedWarns);6768  // 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}7475export function resetAllUnexpectedConsoleCalls() {76  loggedErrors.length = 0;77  loggedWarns.length = 0;78  if (logMethod) {79    loggedLogs.length = 0;80  }81}8283export function clearLogs() {84  const logs = Array.from(loggedLogs);85  loggedLogs.length = 0;86  return logs;87}8889export function clearWarnings() {90  const warnings = Array.from(loggedWarns);91  loggedWarns.length = 0;92  return warnings;93}9495export function clearErrors() {96  const errors = Array.from(loggedErrors);97  loggedErrors.length = 0;98  return errors;99}100101export function assertConsoleLogsCleared() {102  const logs = clearLogs();103  const warnings = clearWarnings();104  const errors = clearErrors();105106  if (logs.length > 0 || errors.length > 0 || warnings.length > 0) {107    let message = `${chalk.dim('asserConsoleLogsCleared')}(${chalk.red(108      'expected',109    )})\n`;110111    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    }120121    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    }139140    message += `\nYou must call one of the assertConsoleDev helpers between each act call.`;141142    const error = Error(message);143    Error.captureStackTrace(error, assertConsoleLogsCleared);144    throw error;145  }146}147148function normalizeCodeLocInfo(str) {149  if (typeof str !== 'string') {150    return str;151  }152  // This special case exists only for the special source location in153  // 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}170171// 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:"174function expandEnvironmentPlaceholders(str) {175  if (typeof str !== 'string') {176    return str;177  }178  // [Environment] -> ANSI escape sequence for environment badge179  // The format is: reset + inverse + " Environment " + reset180  return str.replace(181    /^\[(\w+)] /g,182    (match, env) => '\u001b[0m\u001b[7m ' + env + ' \u001b[0m',183  );184}185186// The error stack placeholder that can be used in expected messages187const ERROR_STACK_PLACEHOLDER = '\n    in <stack>';188// A marker used to protect the placeholder during normalization189const ERROR_STACK_PLACEHOLDER_MARKER = '\n    in <__STACK_PLACEHOLDER__>';190191// Normalizes expected messages, handling special placeholders192function normalizeExpectedMessage(str) {193  if (typeof str !== 'string') {194    return str;195  }196  // Protect the error stack placeholder from normalization197  // (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}217218function 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}230231const isLikelyAComponentStack = message =>232  typeof message === 'string' &&233  (message.indexOf('<component stack>') > -1 ||234    message.includes('\n    in ') ||235    message.includes('\n    at '));236237// Error stack traces start with "*Error:" and contain "at" frames with file paths238// Component stacks contain "in ComponentName" patterns239// This helps validate that \n    in <stack> is used correctly240const 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);245246export 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  }261262  return function assertConsoleLog(expectedMessages, options = {}) {263    if (__DEV__) {264      // eslint-disable-next-line no-inner-declarations265      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      }274275      // 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      }282283      // 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      }292293      const observedLogs = clearObservedErrors();294      const receivedLogs = [];295      const missingExpectedLogs = Array.from(expectedMessages);296297      const unexpectedLogs = [];298      const unexpectedMissingErrorStack = [];299      const unexpectedIncludingErrorStack = [];300      const logsMismatchingFormat = [];301      const logsWithExtraComponentStack = [];302      const stackTracePlaceholderMisuses = [];303304      // Loop over all the observed logs to determine:305      //   - Which expected logs are missing306      //   - Which received logs are unexpected307      //   - Which logs have a component stack308      //   - Which logs have the wrong format309      //   - Which logs have extra stacks310      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);314315        // Ignore uncaught errors reported by jsdom316        // and React addendums because they're too noisy.317        if (shouldIgnoreConsoleError(format, args)) {318          return;319        }320321        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        }331332        const normalizedMessage = normalizeCodeLocInfo(message);333        receivedLogs.push(normalizedMessage);334335        // 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-coercion340        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 know344            // 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        }353354        // Check for extra component stacks355        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        }364365        // 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 appended367        let matchesExpectedMessage = false;368        let expectsErrorStack = false;369        const hasErrorStack = isLikelyAnErrorStackTrace(message);370371        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            }387388            const expectedMessageWithoutStack = expectedMessage.replace(389              '\n    in <stack>',390              '',391            );392            if (normalizedMessage.startsWith(expectedMessageWithoutStack)) {393              // Remove the stack trace394              const remainder = normalizedMessage.slice(395                expectedMessageWithoutStack.length,396              );397398              // After normalization, both error stacks and component stacks look like399              // 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 all411                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        }425426        if (matchesExpectedMessage) {427          // Check for unexpected/missing error stacks428          if (hasErrorStack && !expectsErrorStack) {429            // Error stack is present but \n    in <stack> was not in the expected message430            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 all437            // (if it has a component stack, stackTracePlaceholderMisuses already handles it)438            unexpectedMissingErrorStack.push(normalizedMessage);439          }440441          // Found expected log, remove it from missing.442          missingExpectedLogs.splice(0, 1);443        } else {444          unexpectedLogs.push(normalizedMessage);445        }446      }447448      // Helper for pretty printing diffs consistently.449      // We inline multi-line logs for better diff printing.450      // eslint-disable-next-line no-inner-declarations451      function printDiff() {452        return `${diff(453          expectedMessages454            .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      }463464      // Wrong %s formatting is a failure.465      // This is a common mistake when creating new warnings.466      if (logsMismatchingFormat.length > 0) {467        throwFormattedError(468          logsMismatchingFormat469            .map(470              item =>471                `Received ${item.args.length} arguments for a message with ${472                  item.expectedArgCount473                } placeholders:\n  ${printReceived(item.format)}`,474            )475            .join('\n\n'),476        );477      }478479      // 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      }485486      // 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      }492493      // Any logs that include an error stack trace but \n    in <stack> wasn't expected.494      if (unexpectedIncludingErrorStack.length > 0) {495        throwFormattedError(496          `${unexpectedIncludingErrorStack497            .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      }507508      // Any logs that are missing an error stack trace when \n    in <stack> was expected.509      if (unexpectedMissingErrorStack.length > 0) {510        throwFormattedError(511          `${unexpectedMissingErrorStack512            .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      }522523      // 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          logsWithExtraComponentStack529            .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      }538539      // 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          `${stackTracePlaceholderMisuses545            .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}

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.