diff --git a/packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js b/packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js index 125cc261de90b..a721dde24acc4 100644 --- a/packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js +++ b/packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js @@ -12,6 +12,7 @@ const React = require('react'); const {startTransition, useDeferredValue} = React; +const chalk = require('chalk'); const ReactNoop = require('react-noop-renderer'); const { waitFor, @@ -22,6 +23,11 @@ const { } = require('internal-test-utils'); const act = require('internal-test-utils').act; const Scheduler = require('scheduler/unstable_mock'); +const { + flushAllUnexpectedConsoleCalls, + resetAllUnexpectedConsoleCalls, + patchConsoleMethods, +} = require('../consoleMock'); describe('ReactInternalTestUtils', () => { test('waitFor', async () => { @@ -154,3 +160,144 @@ describe('ReactInternalTestUtils', () => { assertLog(['A', 'B', 'C']); }); }); + +describe('ReactInternalTestUtils console mocks', () => { + beforeEach(() => { + jest.resetAllMocks(); + patchConsoleMethods({includeLog: true}); + }); + + afterEach(() => { + resetAllUnexpectedConsoleCalls(); + jest.resetAllMocks(); + }); + + describe('console.log', () => { + it('should fail if not asserted', () => { + expect(() => { + console.log('hit'); + flushAllUnexpectedConsoleCalls(); + }).toThrow(`Expected test not to call ${chalk.bold('console.log()')}.`); + }); + + // @gate __DEV__ + it('should not fail if mocked with spyOnDev', () => { + spyOnDev(console, 'log').mockImplementation(() => {}); + expect(() => { + console.log('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + // @gate !__DEV__ + it('should not fail if mocked with spyOnProd', () => { + spyOnProd(console, 'log').mockImplementation(() => {}); + expect(() => { + console.log('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + it('should not fail if mocked with spyOnDevAndProd', () => { + spyOnDevAndProd(console, 'log').mockImplementation(() => {}); + expect(() => { + console.log('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + // @gate __DEV__ + it('should not fail with toLogDev', () => { + expect(() => { + console.log('hit'); + flushAllUnexpectedConsoleCalls(); + }).toLogDev(['hit']); + }); + }); + + describe('console.warn', () => { + it('should fail if not asserted', () => { + expect(() => { + console.warn('hit'); + flushAllUnexpectedConsoleCalls(); + }).toThrow(`Expected test not to call ${chalk.bold('console.warn()')}.`); + }); + + // @gate __DEV__ + it('should not fail if mocked with spyOnDev', () => { + spyOnDev(console, 'warn').mockImplementation(() => {}); + expect(() => { + console.warn('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + // @gate !__DEV__ + it('should not fail if mocked with spyOnProd', () => { + spyOnProd(console, 'warn').mockImplementation(() => {}); + expect(() => { + console.warn('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + it('should not fail if mocked with spyOnDevAndProd', () => { + spyOnDevAndProd(console, 'warn').mockImplementation(() => {}); + expect(() => { + console.warn('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + // @gate __DEV__ + it('should not fail with toWarnDev', () => { + expect(() => { + console.warn('hit'); + flushAllUnexpectedConsoleCalls(); + }).toWarnDev(['hit'], {withoutStack: true}); + }); + }); + + describe('console.error', () => { + it('should fail if console.error is not asserted', () => { + expect(() => { + console.error('hit'); + flushAllUnexpectedConsoleCalls(); + }).toThrow(`Expected test not to call ${chalk.bold('console.error()')}.`); + }); + + // @gate __DEV__ + it('should not fail if mocked with spyOnDev', () => { + spyOnDev(console, 'error').mockImplementation(() => {}); + expect(() => { + console.error('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + // @gate !__DEV__ + it('should not fail if mocked with spyOnProd', () => { + spyOnProd(console, 'error').mockImplementation(() => {}); + expect(() => { + console.error('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + it('should not fail if mocked with spyOnDevAndProd', () => { + spyOnDevAndProd(console, 'error').mockImplementation(() => {}); + expect(() => { + console.error('hit'); + flushAllUnexpectedConsoleCalls(); + }).not.toThrow(); + }); + + // @gate __DEV__ + it('should not fail with toErrorDev', () => { + expect(() => { + console.error('hit'); + flushAllUnexpectedConsoleCalls(); + }).toErrorDev(['hit'], {withoutStack: true}); + }); + }); +}); diff --git a/packages/internal-test-utils/consoleMock.js b/packages/internal-test-utils/consoleMock.js new file mode 100644 index 0000000000000..01e923a43b8c2 --- /dev/null +++ b/packages/internal-test-utils/consoleMock.js @@ -0,0 +1,136 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* eslint-disable react-internal/no-production-logging */ +const chalk = require('chalk'); +const util = require('util'); +const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError'); +const shouldIgnoreConsoleWarn = require('./shouldIgnoreConsoleWarn'); + +const unexpectedErrorCallStacks = []; +const unexpectedWarnCallStacks = []; +const unexpectedLogCallStacks = []; + +// TODO: Consider consolidating this with `yieldValue`. In both cases, tests +// should not be allowed to exit without asserting on the entire log. +const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => { + const newMethod = function (format, ...args) { + // Ignore uncaught errors reported by jsdom + // and React addendums because they're too noisy. + if (shouldIgnoreConsoleError(format, args)) { + return; + } + + // Ignore certain React warnings causing test failures + if (methodName === 'warn' && shouldIgnoreConsoleWarn(format)) { + return; + } + + // Capture the call stack now so we can warn about it later. + // The call stack has helpful information for the test author. + // Don't throw yet though b'c it might be accidentally caught and suppressed. + const stack = new Error().stack; + unexpectedConsoleCallStacks.push([ + stack.slice(stack.indexOf('\n') + 1), + util.format(format, ...args), + ]); + }; + + console[methodName] = newMethod; + + return newMethod; +}; + +const flushUnexpectedConsoleCalls = ( + mockMethod, + methodName, + expectedMatcher, + unexpectedConsoleCallStacks, +) => { + if ( + console[methodName] !== mockMethod && + !jest.isMockFunction(console[methodName]) + ) { + // throw new Error( + // `Test did not tear down console.${methodName} mock properly.` + // ); + } + if (unexpectedConsoleCallStacks.length > 0) { + const messages = unexpectedConsoleCallStacks.map( + ([stack, message]) => + `${chalk.red(message)}\n` + + `${stack + .split('\n') + .map(line => chalk.gray(line)) + .join('\n')}`, + ); + + const type = methodName === 'log' ? 'log' : 'warning'; + const message = + `Expected test not to call ${chalk.bold( + `console.${methodName}()`, + )}.\n\n` + + `If the ${type} is expected, test for it explicitly by:\n` + + `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` + + `matcher, or...\n` + + `2. Mock it out using ${chalk.bold( + 'spyOnDev', + )}(console, '${methodName}') or ${chalk.bold( + 'spyOnProd', + )}(console, '${methodName}'), and test that the ${type} occurs.`; + + throw new Error(`${message}\n\n${messages.join('\n\n')}`); + } +}; + +let errorMethod; +let warnMethod; +let logMethod; +export function patchConsoleMethods({includeLog} = {includeLog: false}) { + errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks); + warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks); + + // Only assert console.log isn't called in CI so you can debug tests in DEV. + // The matchers will still work in DEV, so you can assert locally. + if (includeLog) { + logMethod = patchConsoleMethod('log', unexpectedLogCallStacks); + } +} + +export function flushAllUnexpectedConsoleCalls() { + flushUnexpectedConsoleCalls( + errorMethod, + 'error', + 'toErrorDev', + unexpectedErrorCallStacks, + ); + flushUnexpectedConsoleCalls( + warnMethod, + 'warn', + 'toWarnDev', + unexpectedWarnCallStacks, + ); + if (logMethod) { + flushUnexpectedConsoleCalls( + logMethod, + 'log', + 'toLogDev', + unexpectedLogCallStacks, + ); + unexpectedLogCallStacks.length = 0; + } + unexpectedErrorCallStacks.length = 0; + unexpectedWarnCallStacks.length = 0; +} + +export function resetAllUnexpectedConsoleCalls() { + unexpectedErrorCallStacks.length = 0; + unexpectedWarnCallStacks.length = 0; + if (logMethod) { + unexpectedLogCallStacks.length = 0; + } +} diff --git a/scripts/jest/shouldIgnoreConsoleError.js b/packages/internal-test-utils/shouldIgnoreConsoleError.js similarity index 99% rename from scripts/jest/shouldIgnoreConsoleError.js rename to packages/internal-test-utils/shouldIgnoreConsoleError.js index 9b64ea8c66b17..b0342368b14c6 100644 --- a/scripts/jest/shouldIgnoreConsoleError.js +++ b/packages/internal-test-utils/shouldIgnoreConsoleError.js @@ -19,10 +19,10 @@ module.exports = function shouldIgnoreConsoleError(format, args) { format.indexOf('ReactDOM.render was removed in React 19') !== -1 || format.indexOf('ReactDOM.hydrate was removed in React 19') !== -1 || format.indexOf( - 'ReactDOM.render has not been supported since React 18' + 'ReactDOM.render has not been supported since React 18', ) !== -1 || format.indexOf( - 'ReactDOM.hydrate has not been supported since React 18' + 'ReactDOM.hydrate has not been supported since React 18', ) !== -1 ) { // We haven't finished migrating our tests to use createRoot. diff --git a/scripts/jest/shouldIgnoreConsoleWarn.js b/packages/internal-test-utils/shouldIgnoreConsoleWarn.js similarity index 100% rename from scripts/jest/shouldIgnoreConsoleWarn.js rename to packages/internal-test-utils/shouldIgnoreConsoleWarn.js diff --git a/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js b/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js index dce37947dfb5d..76dea3e732632 100644 --- a/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js +++ b/packages/react-dom/src/__tests__/utils/ReactDOMServerIntegrationTestUtils.js @@ -10,7 +10,7 @@ 'use strict'; const stream = require('stream'); -const shouldIgnoreConsoleError = require('../../../../../scripts/jest/shouldIgnoreConsoleError'); +const shouldIgnoreConsoleError = require('internal-test-utils/shouldIgnoreConsoleError'); module.exports = function (initModules) { let ReactDOM; diff --git a/scripts/jest/matchers/toWarnDev.js b/scripts/jest/matchers/toWarnDev.js index e724653d4ef31..50267cc2010ce 100644 --- a/scripts/jest/matchers/toWarnDev.js +++ b/scripts/jest/matchers/toWarnDev.js @@ -2,7 +2,7 @@ const {diff: jestDiff} = require('jest-diff'); const util = require('util'); -const shouldIgnoreConsoleError = require('../shouldIgnoreConsoleError'); +const shouldIgnoreConsoleError = require('internal-test-utils/shouldIgnoreConsoleError'); function normalizeCodeLocInfo(str) { if (typeof str !== 'string') { diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index cd125de53eba5..c3fdefdf458b8 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -1,10 +1,11 @@ 'use strict'; -const chalk = require('chalk'); -const util = require('util'); -const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError'); -const shouldIgnoreConsoleWarn = require('./shouldIgnoreConsoleWarn'); const {getTestFlags} = require('./TestFlags'); +const { + flushAllUnexpectedConsoleCalls, + resetAllUnexpectedConsoleCalls, + patchConsoleMethods, +} = require('internal-test-utils/consoleMock'); if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { // Inside the class equivalence tester, we have a custom environment, let's @@ -62,126 +63,8 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { } }); - // TODO: Consider consolidating this with `yieldValue`. In both cases, tests - // should not be allowed to exit without asserting on the entire log. - const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => { - const newMethod = function (format, ...args) { - // Ignore uncaught errors reported by jsdom - // and React addendums because they're too noisy. - if (shouldIgnoreConsoleError(format, args)) { - return; - } - - // Ignore certain React warnings causing test failures - if (methodName === 'warn' && shouldIgnoreConsoleWarn(format)) { - return; - } - - // Capture the call stack now so we can warn about it later. - // The call stack has helpful information for the test author. - // Don't throw yet though b'c it might be accidentally caught and suppressed. - const stack = new Error().stack; - unexpectedConsoleCallStacks.push([ - stack.slice(stack.indexOf('\n') + 1), - util.format(format, ...args), - ]); - }; - - console[methodName] = newMethod; - - return newMethod; - }; - - const flushUnexpectedConsoleCalls = ( - mockMethod, - methodName, - expectedMatcher, - unexpectedConsoleCallStacks - ) => { - if ( - console[methodName] !== mockMethod && - !jest.isMockFunction(console[methodName]) - ) { - // throw new Error( - // `Test did not tear down console.${methodName} mock properly.` - // ); - } - if (unexpectedConsoleCallStacks.length > 0) { - const messages = unexpectedConsoleCallStacks.map( - ([stack, message]) => - `${chalk.red(message)}\n` + - `${stack - .split('\n') - .map(line => chalk.gray(line)) - .join('\n')}` - ); - - const type = methodName === 'log' ? 'log' : 'warning'; - const message = - `Expected test not to call ${chalk.bold( - `console.${methodName}()` - )}.\n\n` + - `If the ${type} is expected, test for it explicitly by:\n` + - `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` + - `matcher, or...\n` + - `2. Mock it out using ${chalk.bold( - 'spyOnDev' - )}(console, '${methodName}') or ${chalk.bold( - 'spyOnProd' - )}(console, '${methodName}'), and test that the ${type} occurs.`; - - throw new Error(`${message}\n\n${messages.join('\n\n')}`); - } - }; - - const unexpectedErrorCallStacks = []; - const unexpectedWarnCallStacks = []; - const unexpectedLogCallStacks = []; - - const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks); - const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks); - let logMethod; - - // Only assert console.log isn't called in CI so you can debug tests in DEV. - // The matchers will still work in DEV, so you can assert locally. - if (process.env.CI) { - logMethod = patchConsoleMethod('log', unexpectedLogCallStacks); - } - - const flushAllUnexpectedConsoleCalls = () => { - flushUnexpectedConsoleCalls( - errorMethod, - 'error', - 'toErrorDev', - unexpectedErrorCallStacks - ); - flushUnexpectedConsoleCalls( - warnMethod, - 'warn', - 'toWarnDev', - unexpectedWarnCallStacks - ); - if (logMethod) { - flushUnexpectedConsoleCalls( - logMethod, - 'log', - 'toLogDev', - unexpectedLogCallStacks - ); - unexpectedLogCallStacks.length = 0; - } - unexpectedErrorCallStacks.length = 0; - unexpectedWarnCallStacks.length = 0; - }; - - const resetAllUnexpectedConsoleCalls = () => { - unexpectedErrorCallStacks.length = 0; - unexpectedWarnCallStacks.length = 0; - if (logMethod) { - unexpectedLogCallStacks.length = 0; - } - }; - + // Patch the console to assert that all console error/warn/log calls assert. + patchConsoleMethods({includeLog: !!process.env.CI}); beforeEach(resetAllUnexpectedConsoleCalls); afterEach(flushAllUnexpectedConsoleCalls);