main
ts 310 lines 8.95 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
8 import {render} from '@testing-library/react';
9 import {JSDOM} from 'jsdom';
10 import React, {MutableRefObject} from 'react';
11 import util from 'util';
12 import {z} from 'zod/v4';
13 import {fromZodError} from 'zod-validation-error/v4';
14 import {initFbt, toJSON} from './shared-runtime';
15
16 /**
17 * Set up the global environment for JSDOM tests.
18 * This is a hack to let us share code and setup between the test
19 * and runner environments. As an alternative, we could evaluate all setup
20 * in the jsdom test environment (which provides more isolation), but that
21 * may be slower.
22 */
23 const {window: testWindow} = new JSDOM(undefined);
24 (globalThis as any).document = testWindow.document;
25 (globalThis as any).window = testWindow.window;
26 (globalThis as any).React = React;
27 (globalThis as any).render = render;
28 initFbt();
29
30 (globalThis as any).placeholderFn = function (..._args: Array<any>) {
31 throw new Error('Fixture not implemented!');
32 };
33 export type EvaluatorResult = {
34 kind: 'ok' | 'exception' | 'UnexpectedError';
35 value: string;
36 logs: Array<string>;
37 };
38
39 /**
40 * Define types and schemas for fixture entrypoint
41 */
42 const EntrypointSchema = z.strictObject({
43 fn: z.union([z.function(), z.object({})]),
44 params: z.array(z.any()),
45
46 // DEPRECATED, unused
47 isComponent: z.optional(z.boolean()),
48
49 // if enabled, the `fn` is assumed to be a component and this is assumed
50 // to be an array of props. the component is mounted once and rendered
51 // once per set of props in this array.
52 sequentialRenders: z.optional(z.nullable(z.array(z.any()))).default(null),
53 });
54 const ExportSchema = z.object({
55 FIXTURE_ENTRYPOINT: EntrypointSchema,
56 });
57
58 const NO_ERROR_SENTINEL = Symbol();
59 /**
60 * Wraps WrapperTestComponent in an error boundary to simplify re-rendering
61 * when an exception is thrown.
62 * A simpler alternative may be to re-mount test components manually.
63 */
64 class WrapperTestComponentWithErrorBoundary extends React.Component<
65 {fn: any; params: Array<any>},
66 {errorFromLastRender: any}
67 > {
68 /**
69 * Limit retries of the child component by caching seen errors.
70 */
71 propsErrorMap: Map<any, any>;
72 lastProps: any | null;
73 // lastProps: object | null;
74 constructor(props: any) {
75 super(props);
76 this.lastProps = null;
77 this.propsErrorMap = new Map<any, any>();
78 this.state = {
79 errorFromLastRender: NO_ERROR_SENTINEL,
80 };
81 }
82 static getDerivedStateFromError(error: any) {
83 // Reschedule a second render that immediately returns the cached error
84 return {errorFromLastRender: error};
85 }
86 override componentDidUpdate() {
87 if (this.state.errorFromLastRender !== NO_ERROR_SENTINEL) {
88 // Reschedule a third render that immediately returns the cached error
89 this.setState({errorFromLastRender: NO_ERROR_SENTINEL});
90 }
91 }
92 override render() {
93 if (
94 this.state.errorFromLastRender !== NO_ERROR_SENTINEL &&
95 this.props === this.lastProps
96 ) {
97 /**
98 * The last render errored, cache the error message to avoid running the
99 * test fixture more than once
100 */
101 const errorMsg = `[[ (exception in render) ${this.state.errorFromLastRender?.toString()} ]]`;
102 this.propsErrorMap.set(this.lastProps, errorMsg);
103 return errorMsg;
104 }
105 this.lastProps = this.props;
106 const cachedError = this.propsErrorMap.get(this.props);
107 if (cachedError != null) {
108 return cachedError;
109 }
110 return React.createElement(WrapperTestComponent, this.props);
111 }
112 }
113
114 function WrapperTestComponent(props: {fn: any; params: Array<any>}) {
115 const result = props.fn(...props.params);
116 // Hacky solution to determine whether the fixture returned jsx (which
117 // needs to passed through to React's runtime as-is) or a non-jsx value
118 // (which should be converted to a string).
119 if (typeof result === 'object' && result != null && '$$typeof' in result) {
120 return result;
121 } else {
122 return toJSON(result);
123 }
124 }
125
126 function renderComponentSequentiallyForEachProps(
127 fn: any,
128 sequentialRenders: Array<any>,
129 ): string {
130 if (sequentialRenders.length === 0) {
131 throw new Error(
132 'Expected at least one set of props when using `sequentialRenders`',
133 );
134 }
135 const initialProps = sequentialRenders[0]!;
136 const results = [];
137 const {rerender, container} = render(
138 React.createElement(WrapperTestComponentWithErrorBoundary, {
139 fn,
140 params: [initialProps],
141 }),
142 );
143 results.push(container.innerHTML);
144
145 for (let i = 1; i < sequentialRenders.length; i++) {
146 rerender(
147 React.createElement(WrapperTestComponentWithErrorBoundary, {
148 fn,
149 params: [sequentialRenders[i]],
150 }),
151 );
152 results.push(container.innerHTML);
153 }
154 return results.join('\n');
155 }
156
157 type FixtureEvaluatorResult = Omit<EvaluatorResult, 'logs'>;
158 (globalThis as any).evaluateFixtureExport = function (
159 exports: unknown,
160 ): FixtureEvaluatorResult {
161 const parsedExportResult = ExportSchema.safeParse(exports);
162 if (!parsedExportResult.success) {
163 const exportDetail =
164 typeof exports === 'object' && exports != null
165 ? `object ${util.inspect(exports)}`
166 : `${exports}`;
167 return {
168 kind: 'UnexpectedError',
169 value: `${fromZodError(parsedExportResult.error)}\nFound ` + exportDetail,
170 };
171 }
172 const entrypoint = parsedExportResult.data.FIXTURE_ENTRYPOINT;
173 if (entrypoint.sequentialRenders !== null) {
174 const result = renderComponentSequentiallyForEachProps(
175 entrypoint.fn,
176 entrypoint.sequentialRenders,
177 );
178
179 return {
180 kind: 'ok',
181 value: result ?? 'null',
182 };
183 } else if (typeof entrypoint.fn === 'object') {
184 // Try to run fixture as a react component. This is necessary because not
185 // all components are functions (some are ForwardRef or Memo objects).
186 const result = render(
187 React.createElement(entrypoint.fn as any, entrypoint.params[0]),
188 ).container.innerHTML;
189
190 return {
191 kind: 'ok',
192 value: result ?? 'null',
193 };
194 } else {
195 const result = render(React.createElement(WrapperTestComponent, entrypoint))
196 .container.innerHTML;
197
198 return {
199 kind: 'ok',
200 value: result ?? 'null',
201 };
202 }
203 };
204
205 export function doEval(source: string): EvaluatorResult {
206 'use strict';
207
208 const originalConsole = globalThis.console;
209 const logs: Array<string> = [];
210 const mockedLog = (...args: Array<any>) => {
211 logs.push(
212 `${args.map(arg => {
213 if (arg instanceof Error) {
214 return arg.toString();
215 } else {
216 return util.inspect(arg);
217 }
218 })}`,
219 );
220 };
221
222 (globalThis.console as any) = {
223 info: mockedLog,
224 log: mockedLog,
225 warn: mockedLog,
226 error: (...args: Array<any>) => {
227 if (
228 typeof args[0] === 'string' &&
229 args[0].includes('ReactDOMTestUtils.act` is deprecated')
230 ) {
231 // remove this once @testing-library/react is upgraded to React 19.
232 return;
233 }
234
235 const stack = new Error().stack?.split('\n', 5) ?? [];
236 for (const stackFrame of stack) {
237 // React warns on exceptions thrown during render, we avoid printing
238 // here to reduce noise in test fixture outputs.
239 if (
240 (stackFrame.includes('at logCaughtError') &&
241 stackFrame.includes('react-dom-client.development.js')) ||
242 (stackFrame.includes('at defaultOnRecoverableError') &&
243 stackFrame.includes('react-dom-client.development.js'))
244 ) {
245 return;
246 }
247 }
248 mockedLog(...args);
249 },
250 table: mockedLog,
251 trace: () => {},
252 };
253 try {
254 // source needs to be evaluated in the same scope as invoke
255 const evalResult: any = eval(`
256 (() => {
257 // Exports should be overwritten by source
258 let exports = {
259 FIXTURE_ENTRYPOINT: {
260 fn: globalThis.placeholderFn,
261 params: [],
262 },
263 };
264 let reachedInvoke = false;
265 try {
266 // run in an iife to avoid naming collisions
267 (() => {${source}})();
268 reachedInvoke = true;
269 if (exports.FIXTURE_ENTRYPOINT?.fn === globalThis.placeholderFn) {
270 return {
271 kind: "exception",
272 value: "Fixture not implemented",
273 };
274 }
275 return evaluateFixtureExport(exports);
276 } catch (e) {
277 if (!reachedInvoke) {
278 return {
279 kind: "UnexpectedError",
280 value: e.message,
281 };
282 } else {
283 return {
284 kind: "exception",
285 value: e.message,
286 };
287 }
288 }
289 })()`);
290
291 const result = {
292 ...evalResult,
293 logs,
294 };
295 return result;
296 } catch (e) {
297 // syntax errors will cause the eval to throw and bubble up here
298 return {
299 kind: 'UnexpectedError',
300 value:
301 'Unexpected error during eval, possible syntax error?\n' +
302 e.message +
303 '\n\nsource:\n' +
304 source,
305 logs,
306 };
307 } finally {
308 globalThis.console = originalConsole;
309 }
310 }