| 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 | * @flow |
| 8 | */ |
| 9 | |
| 10 | import {CustomConsole} from '@jest/console'; |
| 11 | |
| 12 | import type { |
| 13 | BackendBridge, |
| 14 | FrontendBridge, |
| 15 | } from 'react-devtools-shared/src/bridge'; |
| 16 | |
| 17 | type TestBridgeMessage = {event: string, payload: mixed}; |
| 18 | type TestBridgeWall = { |
| 19 | disconnect: () => void, |
| 20 | reconnect: () => void, |
| 21 | listen: (callback: (message: mixed) => void) => () => void, |
| 22 | send: ( |
| 23 | event: string, |
| 24 | payload: mixed, |
| 25 | transferable?: $ReadOnlyArray<mixed>, |
| 26 | ) => void, |
| 27 | }; |
| 28 | |
| 29 | const {getTestFlags} = require('../../../../scripts/jest/TestFlags'); |
| 30 | |
| 31 | // Argument is serialized when passed from jest-cli script through to setupTests. |
| 32 | const compactConsole = process.env.compactConsole === 'true'; |
| 33 | if (compactConsole) { |
| 34 | const formatter = (type, message) => { |
| 35 | switch (type) { |
| 36 | case 'error': |
| 37 | return '\x1b[31m' + message + '\x1b[0m'; |
| 38 | case 'warn': |
| 39 | return '\x1b[33m' + message + '\x1b[0m'; |
| 40 | case 'log': |
| 41 | default: |
| 42 | return message; |
| 43 | } |
| 44 | }; |
| 45 | |
| 46 | global.console = new CustomConsole(process.stdout, process.stderr, formatter); |
| 47 | } |
| 48 | |
| 49 | const expectTestToFail = async (callback, error) => { |
| 50 | if (callback.length > 0) { |
| 51 | throw Error( |
| 52 | 'Gated test helpers do not support the `done` callback. Return a ' + |
| 53 | 'promise instead.', |
| 54 | ); |
| 55 | } |
| 56 | try { |
| 57 | const maybePromise = callback(); |
| 58 | if ( |
| 59 | maybePromise !== undefined && |
| 60 | maybePromise !== null && |
| 61 | typeof maybePromise.then === 'function' |
| 62 | ) { |
| 63 | await maybePromise; |
| 64 | } |
| 65 | } catch (testError) { |
| 66 | return; |
| 67 | } |
| 68 | throw error; |
| 69 | }; |
| 70 | |
| 71 | const gatedErrorMessage = 'Gated test was expected to fail, but it passed.'; |
| 72 | global._test_gate = (gateFn, testName, callback) => { |
| 73 | let shouldPass; |
| 74 | try { |
| 75 | const flags = getTestFlags(); |
| 76 | shouldPass = gateFn(flags); |
| 77 | } catch (e) { |
| 78 | test(testName, () => { |
| 79 | throw e; |
| 80 | }); |
| 81 | return; |
| 82 | } |
| 83 | if (shouldPass) { |
| 84 | test(testName, callback); |
| 85 | } else { |
| 86 | const error = new Error(gatedErrorMessage); |
| 87 | Error.captureStackTrace(error, global._test_gate); |
| 88 | test(`[GATED, SHOULD FAIL] ${testName}`, () => |
| 89 | expectTestToFail(callback, error)); |
| 90 | } |
| 91 | }; |
| 92 | global._test_gate_focus = (gateFn, testName, callback) => { |
| 93 | let shouldPass; |
| 94 | try { |
| 95 | const flags = getTestFlags(); |
| 96 | shouldPass = gateFn(flags); |
| 97 | } catch (e) { |
| 98 | test.only(testName, () => { |
| 99 | throw e; |
| 100 | }); |
| 101 | return; |
| 102 | } |
| 103 | if (shouldPass) { |
| 104 | test.only(testName, callback); |
| 105 | } else { |
| 106 | const error = new Error(gatedErrorMessage); |
| 107 | Error.captureStackTrace(error, global._test_gate_focus); |
| 108 | test.only(`[GATED, SHOULD FAIL] ${testName}`, () => |
| 109 | expectTestToFail(callback, error)); |
| 110 | } |
| 111 | }; |
| 112 | |
| 113 | // Dynamic version of @gate pragma |
| 114 | global.gate = fn => { |
| 115 | const flags = getTestFlags(); |
| 116 | return fn(flags); |
| 117 | }; |
| 118 | |
| 119 | function shouldIgnoreConsoleErrorOrWarn(args) { |
| 120 | let firstArg = args[0]; |
| 121 | if ( |
| 122 | firstArg !== null && |
| 123 | typeof firstArg === 'object' && |
| 124 | String(firstArg).indexOf('Error: Uncaught [') === 0 |
| 125 | ) { |
| 126 | firstArg = String(firstArg); |
| 127 | } else if (typeof firstArg !== 'string') { |
| 128 | return false; |
| 129 | } |
| 130 | |
| 131 | const maybeError = args[1]; |
| 132 | if ( |
| 133 | maybeError !== null && |
| 134 | typeof maybeError === 'object' && |
| 135 | maybeError.message === 'Simulated error coming from DevTools' |
| 136 | ) { |
| 137 | // Error from forcing an error boundary. |
| 138 | return true; |
| 139 | } |
| 140 | |
| 141 | return global._ignoredErrorOrWarningMessages.some(errorOrWarningMessage => { |
| 142 | return firstArg.indexOf(errorOrWarningMessage) !== -1; |
| 143 | }); |
| 144 | } |
| 145 | |
| 146 | function patchConsoleForTestingBeforeHookInstallation() { |
| 147 | const originalConsoleError = console.error; |
| 148 | const originalConsoleWarn = console.warn; |
| 149 | const originalConsoleLog = console.log; |
| 150 | |
| 151 | const consoleErrorMock = jest.fn(); |
| 152 | const consoleWarnMock = jest.fn(); |
| 153 | const consoleLogMock = jest.fn(); |
| 154 | |
| 155 | global.consoleErrorMock = consoleErrorMock; |
| 156 | global.consoleWarnMock = consoleWarnMock; |
| 157 | global.consoleLogMock = consoleLogMock; |
| 158 | |
| 159 | console.error = (...args) => { |
| 160 | let firstArg = args[0]; |
| 161 | if (typeof firstArg === 'string' && firstArg.startsWith('Warning: ')) { |
| 162 | // Older React versions might use the Warning: prefix. I'm not sure |
| 163 | // if they use this code path. |
| 164 | firstArg = firstArg.slice(9); |
| 165 | } |
| 166 | if (firstArg === 'React instrumentation encountered an error: %o') { |
| 167 | // Rethrow errors from React. |
| 168 | throw args[1]; |
| 169 | } else if ( |
| 170 | typeof firstArg === 'string' && |
| 171 | (firstArg.startsWith("It looks like you're using the wrong act()") || |
| 172 | firstArg.startsWith( |
| 173 | 'The current testing environment is not configured to support act', |
| 174 | ) || |
| 175 | firstArg.startsWith('You seem to have overlapping act() calls') || |
| 176 | firstArg.startsWith( |
| 177 | 'ReactDOM.render is no longer supported in React 18.', |
| 178 | )) |
| 179 | ) { |
| 180 | // DevTools intentionally wraps updates with acts from both DOM and test-renderer, |
| 181 | // since test updates are expected to impact both renderers. |
| 182 | return; |
| 183 | } else if (shouldIgnoreConsoleErrorOrWarn(args)) { |
| 184 | // Allows testing how DevTools behaves when it encounters console.error without cluttering the test output. |
| 185 | // Errors can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored |
| 186 | return; |
| 187 | } |
| 188 | |
| 189 | consoleErrorMock(...args); |
| 190 | originalConsoleError.apply(console, args); |
| 191 | }; |
| 192 | console.warn = (...args) => { |
| 193 | if (shouldIgnoreConsoleErrorOrWarn(args)) { |
| 194 | // Allows testing how DevTools behaves when it encounters console.warn without cluttering the test output. |
| 195 | // Warnings can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored |
| 196 | return; |
| 197 | } |
| 198 | |
| 199 | consoleWarnMock(...args); |
| 200 | originalConsoleWarn.apply(console, args); |
| 201 | }; |
| 202 | console.log = (...args) => { |
| 203 | consoleLogMock(...args); |
| 204 | originalConsoleLog.apply(console, args); |
| 205 | }; |
| 206 | } |
| 207 | |
| 208 | function unpatchConsoleAfterTesting() { |
| 209 | delete global.consoleErrorMock; |
| 210 | delete global.consoleWarnMock; |
| 211 | delete global.consoleLogMock; |
| 212 | } |
| 213 | |
| 214 | beforeEach(() => { |
| 215 | patchConsoleForTestingBeforeHookInstallation(); |
| 216 | |
| 217 | global.mockClipboardCopy = jest.fn(); |
| 218 | |
| 219 | // Test environment doesn't support document methods like execCommand() |
| 220 | // Also once the backend components below have been required, |
| 221 | // it's too late for a test to mock the clipboard-js modules. |
| 222 | jest.mock('clipboard-js', () => ({copy: global.mockClipboardCopy})); |
| 223 | |
| 224 | // These files should be required (and re-required) before each test, |
| 225 | // rather than imported at the head of the module. |
| 226 | // That's because we reset modules between tests, |
| 227 | // which disconnects the DevTool's cache from the current dispatcher ref. |
| 228 | const Agent = require('react-devtools-shared/src/backend/agent').default; |
| 229 | const {initBackend} = require('react-devtools-shared/src/backend'); |
| 230 | const Bridge = require('react-devtools-shared/src/bridge').default; |
| 231 | const Store = require('react-devtools-shared/src/devtools/store').default; |
| 232 | const {installHook} = require('react-devtools-shared/src/hook'); |
| 233 | const { |
| 234 | getDefaultComponentFilters, |
| 235 | setSavedComponentFilters, |
| 236 | } = require('react-devtools-shared/src/utils'); |
| 237 | |
| 238 | // Fake timers let us flush Bridge operations between setup and assertions. |
| 239 | jest.useFakeTimers(); |
| 240 | |
| 241 | // We use fake timers heavily in tests but the bridge batching now uses microtasks. |
| 242 | global.devtoolsJestTestScheduler = callback => { |
| 243 | setTimeout(callback, 0); |
| 244 | }; |
| 245 | |
| 246 | // Use utils.js#withErrorsOrWarningsIgnored instead of directly mutating this array. |
| 247 | global._ignoredErrorOrWarningMessages = [ |
| 248 | 'react-test-renderer is deprecated.', |
| 249 | ]; |
| 250 | |
| 251 | // Initialize filters to a known good state. |
| 252 | setSavedComponentFilters(getDefaultComponentFilters()); |
| 253 | |
| 254 | installHook(global, getDefaultComponentFilters(), { |
| 255 | appendComponentStack: true, |
| 256 | breakOnConsoleErrors: false, |
| 257 | showInlineWarningsAndErrors: true, |
| 258 | hideConsoleLogsInStrictMode: false, |
| 259 | disableSecondConsoleLogDimmingInStrictMode: false, |
| 260 | }); |
| 261 | |
| 262 | let bridgeListeners: Array<(message: mixed) => void> = []; |
| 263 | let disconnectedBridgeListeners: Array<(message: mixed) => void> | null = |
| 264 | null; |
| 265 | let pendingBridgeMessages: Array<TestBridgeMessage> = []; |
| 266 | const bridgeWall: TestBridgeWall = { |
| 267 | disconnect() { |
| 268 | if (disconnectedBridgeListeners === null) { |
| 269 | disconnectedBridgeListeners = bridgeListeners; |
| 270 | bridgeListeners = []; |
| 271 | } |
| 272 | }, |
| 273 | reconnect() { |
| 274 | if (disconnectedBridgeListeners !== null) { |
| 275 | bridgeListeners = disconnectedBridgeListeners; |
| 276 | disconnectedBridgeListeners = null; |
| 277 | |
| 278 | const messages = pendingBridgeMessages; |
| 279 | pendingBridgeMessages = []; |
| 280 | messages.forEach(message => { |
| 281 | bridgeListeners.forEach(callback => callback(message)); |
| 282 | }); |
| 283 | } |
| 284 | }, |
| 285 | listen(callback) { |
| 286 | const listeners = |
| 287 | disconnectedBridgeListeners !== null |
| 288 | ? disconnectedBridgeListeners |
| 289 | : bridgeListeners; |
| 290 | listeners.push(callback); |
| 291 | return () => { |
| 292 | let index = bridgeListeners.indexOf(callback); |
| 293 | if (index >= 0) { |
| 294 | bridgeListeners.splice(index, 1); |
| 295 | } |
| 296 | if (disconnectedBridgeListeners !== null) { |
| 297 | index = disconnectedBridgeListeners.indexOf(callback); |
| 298 | if (index >= 0) { |
| 299 | disconnectedBridgeListeners.splice(index, 1); |
| 300 | } |
| 301 | } |
| 302 | }; |
| 303 | }, |
| 304 | send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) { |
| 305 | const message = {event, payload}; |
| 306 | if (disconnectedBridgeListeners === null) { |
| 307 | bridgeListeners.forEach(callback => callback(message)); |
| 308 | } else { |
| 309 | pendingBridgeMessages.push(message); |
| 310 | } |
| 311 | }, |
| 312 | }; |
| 313 | const bridge = new Bridge(bridgeWall); |
| 314 | |
| 315 | const store = new Store(((bridge: any): FrontendBridge)); |
| 316 | |
| 317 | const agent = new Agent(((bridge: any): BackendBridge)); |
| 318 | const hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__; |
| 319 | initBackend(hook, agent, global); |
| 320 | |
| 321 | global.agent = agent; |
| 322 | global.bridge = bridge; |
| 323 | global.store = store; |
| 324 | |
| 325 | const readFileSync = require('fs').readFileSync; |
| 326 | async function mockFetch(url) { |
| 327 | return { |
| 328 | ok: true, |
| 329 | status: 200, |
| 330 | text: async () => readFileSync(__dirname + url, 'utf-8'), |
| 331 | }; |
| 332 | } |
| 333 | global.fetch = mockFetch; |
| 334 | }); |
| 335 | |
| 336 | afterEach(() => { |
| 337 | delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__; |
| 338 | unpatchConsoleAfterTesting(); |
| 339 | |
| 340 | // It's important to reset modules between test runs; |
| 341 | // Without this, ReactDOM won't re-inject itself into the new hook. |
| 342 | // It's also important to reset after tests, rather than before, |
| 343 | // so that we don't disconnect the ReactCurrentDispatcher ref. |
| 344 | jest.resetModules(); |
| 345 | }); |