diff --git a/packages/relay-test-utils-internal/__tests__/warnings-test.js b/packages/relay-test-utils-internal/__tests__/warnings-test.js index a57d4bb1b9969..6541745f8fd26 100644 --- a/packages/relay-test-utils-internal/__tests__/warnings-test.js +++ b/packages/relay-test-utils-internal/__tests__/warnings-test.js @@ -31,7 +31,7 @@ describe('warnings', () => { }); it('throws when disallow warnings is called twice', () => { expect(disallowWarnings).toThrowError( - '`disallowWarnings` should be called at most once', + 'disallowWarnings should be called only once', ); }); it('throws when unexpected warning is fired', () => { @@ -56,7 +56,7 @@ describe('warnings', () => { it('warns on unfired contextual warning', () => { expect(() => expectToWarn(expected_message1, () => {})).toThrowError( - 'Expected callback to warn: ' + expected_message1, + 'Expected warning in callback: ' + expected_message1, ); }); @@ -96,6 +96,6 @@ describe('warnings', () => { warning(false, expected_message2); }, ); - }).toThrowError('Expected callback to warn: ' + expected_message3); + }).toThrowError('Expected warning in callback: ' + expected_message3); }); }); diff --git a/packages/relay-test-utils-internal/consoleError.js b/packages/relay-test-utils-internal/consoleError.js index c1b88560fe87c..cb2f4e0ecc71c 100644 --- a/packages/relay-test-utils-internal/consoleError.js +++ b/packages/relay-test-utils-internal/consoleError.js @@ -11,32 +11,55 @@ 'use strict'; -/* global jest, afterEach */ +/* global jest */ -let installed = false; +const {createConsoleInterceptionSystem} = require('./consoleErrorsAndWarnings'); + +const consoleErrorsSystem = createConsoleInterceptionSystem( + 'error', + 'expectConsoleError', + impl => { + jest.spyOn(console, 'error').mockImplementation(impl); + }, +); /** - * Similar to disallowWarnings. - * This method mocks the console.error and throws in the error is printed in the console. + * Mocks console.error so that errors printed to the console are instead thrown. + * Any expected errors need to be explicitly expected with `expectConsoleErrorWillFire(message)`. + * + * NOTE: This should be called on top of a test file. The test should NOT + * use `jest.resetModules()` or manually mock `console`. */ function disallowConsoleErrors(): void { - if (installed) { - throw new Error('`disallowConsoleErrors` should be called at most once'); - } - installed = true; - let errors = []; - jest.spyOn(console, 'error').mockImplementation(message => { - errors.push(`Unexpected \`console.error\`:\n${message}.`); - }); - afterEach(() => { - if (errors.length > 0) { - const message = errors.join('\n'); - errors = []; - throw new Error(message); - } - }); + consoleErrorsSystem.disallowMessages(); +} + +/** + * Expect an error with the given message. If the message isn't fired in the + * current test, the test will fail. + */ +function expectConsoleErrorWillFire(message: string): void { + consoleErrorsSystem.expectMessageWillFire(message); +} + +/** + * Expect the callback `fn` to print an error with the message, and otherwise fail. + */ +function expectConsoleError(message: string, fn: () => T): T { + return consoleErrorsSystem.expectMessage(message, fn); +} + +/** + * Expect the callback `fn` to trigger all console errors (in sequence), + * and otherwise fail. + */ +function expectConsoleErrorsMany(messages: Array, fn: () => T): T { + return consoleErrorsSystem.expectMessageMany(messages, fn); } module.exports = { disallowConsoleErrors, + expectConsoleErrorWillFire, + expectConsoleError, + expectConsoleErrorsMany, }; diff --git a/packages/relay-test-utils-internal/consoleErrorsAndWarnings.js b/packages/relay-test-utils-internal/consoleErrorsAndWarnings.js new file mode 100644 index 0000000000000..6509d22d1792f --- /dev/null +++ b/packages/relay-test-utils-internal/consoleErrorsAndWarnings.js @@ -0,0 +1,116 @@ +/** + * 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. + * + * @emails oncall+relay + * @flow strict-local + * @format + */ + +'use strict'; + +/* global jest, afterEach */ + +type API = $ReadOnly<{| + disallowMessages: () => void, + expectMessageWillFire: string => void, + expectMessage: (string, () => T) => T, + expectMessageMany: (Array, () => T) => T, +|}>; + +const originalConsoleError = console.error; + +function createConsoleInterceptionSystem( + typename: string, + expectFunctionName: string, + setUpMock: ((string) => void) => void, +): API { + let installed = false; + const expectedMessages: Array = []; + const contextualExpectedMessage: Array = []; + + const typenameCap = typename.charAt(0).toUpperCase() + typename.slice(1); + const typenameCapPlural = typenameCap + 's'; + const installerName = `disallow${typenameCap}s`; + + function handleMessage(message: string): void { + const index = expectedMessages.findIndex(expected => + message.startsWith(expected), + ); + if ( + contextualExpectedMessage.length > 0 && + contextualExpectedMessage[0] === message + ) { + contextualExpectedMessage.shift(); + } else if (index >= 0) { + expectedMessages.splice(index, 1); + } else { + // log to console in case the error gets swallowed somewhere + originalConsoleError(`Unexpected ${typenameCap}: ` + message); + throw new Error(`${typenameCap}: ` + message); + } + } + + function disallowMessages(): void { + if (installed) { + throw new Error(`${installerName} should be called only once.`); + } + installed = true; + setUpMock(handleMessage); + + afterEach(() => { + contextualExpectedMessage.length = 0; + if (expectedMessages.length > 0) { + const error = new Error( + `Some expected ${typename}s where not triggered:\n\n` + + Array.from(expectedMessages, message => ` * ${message}`).join( + '\n', + ) + + '\n', + ); + expectedMessages.length = 0; + throw error; + } + }); + } + + function expectMessageWillFire(message: string): void { + if (!installed) { + throw new Error( + `${installerName} needs to be called before expect${typenameCapPlural}WillFire`, + ); + } + expectedMessages.push(message); + } + + function expectMessage(message: string, fn: () => T): T { + return expectMessageMany([message], fn); + } + + function expectMessageMany(messages: Array, fn: () => T): T { + if (contextualExpectedMessage.length > 0) { + throw new Error(`Cannot nest ${expectFunctionName}() calls.`); + } + contextualExpectedMessage.push(...messages); + const result = fn(); + if (contextualExpectedMessage.length > 0) { + const notFired = contextualExpectedMessage.toString(); + contextualExpectedMessage.length = 0; + throw new Error(`Expected ${typename} in callback: ${notFired}`); + } + return result; + } + + return { + disallowMessages, + expectMessageWillFire, + expectMessage, + expectMessageMany, + }; +} + +module.exports = { + createConsoleInterceptionSystem, +}; diff --git a/packages/relay-test-utils-internal/index.js b/packages/relay-test-utils-internal/index.js index 83fead4a3edfc..8216d3ffe3878 100644 --- a/packages/relay-test-utils-internal/index.js +++ b/packages/relay-test-utils-internal/index.js @@ -12,7 +12,12 @@ 'use strict'; -const {disallowConsoleErrors} = require('./consoleError'); +const { + disallowConsoleErrors, + expectConsoleError, + expectConsoleErrorsMany, + expectConsoleErrorWillFire, +} = require('./consoleError'); const describeWithFeatureFlags = require('./describeWithFeatureFlags'); const { FIXTURE_TAG, @@ -53,6 +58,9 @@ module.exports = { describeWithFeatureFlags, disallowConsoleErrors, disallowWarnings, + expectConsoleError, + expectConsoleErrorsMany, + expectConsoleErrorWillFire, expectToWarn, expectToWarnMany, expectWarningWillFire, diff --git a/packages/relay-test-utils-internal/warnings.js b/packages/relay-test-utils-internal/warnings.js index cc0c06b2731a5..f1648ee068eb0 100644 --- a/packages/relay-test-utils-internal/warnings.js +++ b/packages/relay-test-utils-internal/warnings.js @@ -11,11 +11,25 @@ 'use strict'; -/* global jest, afterEach */ +/* global jest */ -let installed = false; -const expectedWarnings: Array = []; -const contextualExpectedWarning: Array = []; +const {createConsoleInterceptionSystem} = require('./consoleErrorsAndWarnings'); + +const warningsSystem = createConsoleInterceptionSystem( + 'warning', + 'expectToWarn', + impl => { + jest.mock('warning', () => + jest.fn((condition, format, ...args) => { + if (!condition) { + let argIndex = 0; + const message = format.replace(/%s/g, () => String(args[argIndex++])); + impl(message); + } + }), + ); + }, +); /** * Mocks the `warning` module to turn warnings into errors. Any expected @@ -25,44 +39,7 @@ const contextualExpectedWarning: Array = []; * use `jest.resetModules()` or manually mock `warning`. */ function disallowWarnings(): void { - if (installed) { - throw new Error('`disallowWarnings` should be called at most once'); - } - installed = true; - jest.mock('warning', () => { - return jest.fn((condition, format, ...args) => { - if (!condition) { - let argIndex = 0; - const message = format.replace(/%s/g, () => String(args[argIndex++])); - const index = expectedWarnings.indexOf(message); - - if ( - contextualExpectedWarning.length > 0 && - contextualExpectedWarning[0] === message - ) { - contextualExpectedWarning.shift(); - } else if (index >= 0) { - expectedWarnings.splice(index, 1); - } else { - // log to console in case the error gets swallowed somewhere - console.error('Unexpected Warning: ' + message); - throw new Error('Warning: ' + message); - } - } - }); - }); - afterEach(() => { - contextualExpectedWarning.length = 0; - if (expectedWarnings.length > 0) { - const error = new Error( - 'Some expected warnings where not triggered:\n\n' + - Array.from(expectedWarnings, message => ` * ${message}`).join('\n') + - '\n', - ); - expectedWarnings.length = 0; - throw error; - } - }); + warningsSystem.disallowMessages(); } /** @@ -70,19 +47,14 @@ function disallowWarnings(): void { * current test, the test will fail. */ function expectWarningWillFire(message: string): void { - if (!installed) { - throw new Error( - '`disallowWarnings` needs to be called before `expectWarningWillFire`', - ); - } - expectedWarnings.push(message); + warningsSystem.expectMessageWillFire(message); } /** * Expect the callback `fn` to trigger the warning message and otherwise fail. */ function expectToWarn(message: string, fn: () => T): T { - return expectToWarnMany([message], fn); + return warningsSystem.expectMessage(message, fn); } /** @@ -90,17 +62,7 @@ function expectToWarn(message: string, fn: () => T): T { * or otherwise fail. */ function expectToWarnMany(messages: Array, fn: () => T): T { - if (contextualExpectedWarning.length > 0) { - throw new Error('Cannot nest `expectToWarn()` calls.'); - } - contextualExpectedWarning.push(...messages); - const result = fn(); - if (contextualExpectedWarning.length > 0) { - const notFired = contextualExpectedWarning.toString(); - contextualExpectedWarning.length = 0; - throw new Error(`Expected callback to warn: ${notFired}`); - } - return result; + return warningsSystem.expectMessageMany(messages, fn); } module.exports = {