| 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 | } |