diff --git a/jest/integration/runtime/RenderOutputMatcher.js b/jest/integration/runtime/RenderOutputMatcher.js new file mode 100644 index 00000000000000..e76243ce9ccec0 --- /dev/null +++ b/jest/integration/runtime/RenderOutputMatcher.js @@ -0,0 +1,183 @@ +/** + * 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. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {RenderOutput} from 'react-native/src/private/__tests__/ReactNativeTester'; + +import {diff} from 'jest-diff'; +import {format, plugins} from 'pretty-format'; + +type MatchResult = + | { + pass: true, + } + | { + pass: false, + reason: string, + }; + +export function toMatchRenderedOutput( + renderOutput: RenderOutput, + expected: mixed, +): MatchResult { + const renderedOutput = renderOutput.getResult(); + + if (typeof expected === 'string') { + return renderedOutput === expected + ? {pass: true} + : { + pass: false, + reason: `Expected\n:${expected}\nReceived:\n${renderedOutput}`, + }; + } + + if (typeof expected !== 'object') { + return { + pass: false, + reason: 'Expected value must be a string or an object', + }; + } + + if (renderedOutput === '') { + return { + pass: false, + reason: 'Receieved empty string', + }; + } + + const json = convertRawJsonToJSX(JSON.parse(renderedOutput)); + const prettyExpected = format(expected, {plugins: [plugins.ReactElement]}); + const prettyReceived = format(json, {plugins: [plugins.ReactElement]}); + const pass = prettyExpected === prettyReceived; + if (pass) { + return {pass: true}; + } + + return { + pass: false, + reason: diff(prettyExpected, prettyReceived) ?? 'Failed to compare outputs', + }; +} + +const REACT_ELEMENT_TYPE = + typeof Symbol === 'function' && Symbol.for && Symbol.for('react.element'); + +const REACT_FRAGMENT_TYPE = + typeof Symbol === 'function' && Symbol.for && Symbol.for('react.fragment'); + +type JSXJson = { + type: string, + props: mixed, + children: null | string | Array, +}; + +function createJSXElementForTestComparison( + type: string | typeof REACT_ELEMENT_TYPE | typeof REACT_FRAGMENT_TYPE, + props: mixed, +): mixed { + if (__DEV__) { + const element = { + $$typeof: REACT_ELEMENT_TYPE, + type: type, + key: null, + props: props, + _owner: null, + _store: __DEV__ ? {} : undefined, + }; + // $FlowExpectedError[prop-missing] + Object.defineProperty(element, 'ref', { + enumerable: false, + value: null, + }); + return element; + } else { + return { + $$typeof: REACT_ELEMENT_TYPE, + type: type, + key: null, + ref: null, + props: props, + }; + } +} + +function convertRawJsonToJSX(actualJSON: JSXJson | Array): mixed { + let actualJSX; + if (actualJSON === null || typeof actualJSON === 'string') { + actualJSX = actualJSON; + } else if (Array.isArray(actualJSON)) { + if (actualJSON.length === 0) { + actualJSX = null; + } else if (actualJSON.length === 1) { + actualJSX = jsonChildToJSXChild(actualJSON[0]); + } else { + const actualJSXChildren = jsonChildrenToJSXChildren(actualJSON); + if (actualJSXChildren === null || typeof actualJSXChildren === 'string') { + actualJSX = actualJSXChildren; + } else { + actualJSX = createJSXElementForTestComparison(REACT_FRAGMENT_TYPE, { + children: actualJSXChildren, + }); + } + } + } else { + actualJSX = jsonChildToJSXChild(actualJSON); + } + + return actualJSX; +} + +function rnTypeToTestType(type: string): string | typeof REACT_FRAGMENT_TYPE { + if (type === 'Fragment') { + return REACT_FRAGMENT_TYPE; + } + + return `rn-${type.substring(0, 1).toLowerCase() + type.substring(1)}`; +} + +function jsonChildToJSXChild(jsonChild: null | string | JSXJson): mixed { + if (jsonChild === null || typeof jsonChild === 'string') { + return jsonChild; + } else { + const jsxChildren = jsonChildrenToJSXChildren(jsonChild.children); + const type = rnTypeToTestType(jsonChild.type); + return createJSXElementForTestComparison( + type, + jsxChildren == null + ? jsonChild.props + : {...jsonChild.props, children: jsxChildren}, + ); + } +} + +function jsonChildrenToJSXChildren(jsonChildren: JSXJson['children']) { + if (jsonChildren != null) { + if (jsonChildren.length === 1) { + return jsonChildToJSXChild(jsonChildren[0]); + } else if (jsonChildren.length > 1) { + const jsxChildren = []; + let allJSXChildrenAreStrings = true; + let jsxChildrenString = ''; + for (let i = 0; i < jsonChildren.length; i++) { + const jsxChild = jsonChildToJSXChild(jsonChildren[i]); + jsxChildren.push(jsxChild); + if (allJSXChildrenAreStrings) { + if (typeof jsxChild === 'string') { + jsxChildrenString += jsxChild; + } else if (jsxChild !== null) { + allJSXChildrenAreStrings = false; + } + } + } + return allJSXChildrenAreStrings ? jsxChildrenString : jsxChildren; + } + } + return null; +} diff --git a/jest/integration/runtime/setup.js b/jest/integration/runtime/setup.js index 775c88cfebe182..8ed74dfc5a2ff6 100644 --- a/jest/integration/runtime/setup.js +++ b/jest/integration/runtime/setup.js @@ -9,8 +9,12 @@ * @oncall react_native */ +import type {RenderOutput} from 'react-native/src/private/__tests__/ReactNativeTester'; + +import {toMatchRenderedOutput} from './RenderOutputMatcher'; import deepEqual from 'deep-equal'; import nullthrows from 'nullthrows'; +import {maybeRenderOutput} from 'react-native/src/private/__tests__/ReactNativeTester'; export type TestCaseResult = { ancestorTitles: Array, @@ -221,6 +225,11 @@ class Expect { } toEqual(expected: mixed): void { + const renderOutput = maybeRenderOutput(this.#received); + if (renderOutput) { + return this.#toMatchRenderedOutput(renderOutput, expected); + } + const pass = deepEqual(this.#received, expected, {strict: true}); if (!this.#isExpectedResult(pass)) { throw new ErrorWithCustomBlame( @@ -230,6 +239,11 @@ class Expect { } toBe(expected: mixed): void { + const renderOutput = maybeRenderOutput(this.#received); + if (renderOutput) { + return this.#toMatchRenderedOutput(renderOutput, expected); + } + const pass = this.#received === expected; if (!this.#isExpectedResult(pass)) { throw new ErrorWithCustomBlame( @@ -307,6 +321,15 @@ class Expect { } } + #toMatchRenderedOutput(renderOutput: RenderOutput, expected: mixed): void { + const result = toMatchRenderedOutput(renderOutput, expected); + if (!this.#isExpectedResult(result.pass)) { + throw new Error( + `Expected${this.#maybeNotLabel()} to match rendered output${result.pass ? '' : `:\n${result.reason}`}`, + ); + } + } + #isExpectedResult(pass: boolean): boolean { return this.#isNot ? !pass : pass; }