main
js 345 lines 10.5 KB
Raw
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 });