Skip to content

Commit

Permalink
Add toMatchRenderedOutput() API (#47970)
Browse files Browse the repository at this point in the history
Summary:

Changelog: [Internal]

Differential Revision: D65617491
  • Loading branch information
andrewdacenko authored and facebook-github-bot committed Nov 29, 2024
1 parent 91e217f commit 239594a
Show file tree
Hide file tree
Showing 2 changed files with 206 additions and 0 deletions.
183 changes: 183 additions & 0 deletions jest/integration/runtime/RenderOutputMatcher.js
Original file line number Diff line number Diff line change
@@ -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<JSXJson>,
};

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<JSXJson>): 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;
}
23 changes: 23 additions & 0 deletions jest/integration/runtime/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down

0 comments on commit 239594a

Please sign in to comment.