Skip to content

Commit 2e444d2

Browse files
andrewdacenkofacebook-github-bot
authored andcommitted
Add ReactNativetester#getRenderedOutput() API (#47970)
Summary: Pull Request resolved: #47970 Changelog: [Internal] Reviewed By: rubennorte Differential Revision: D65617491 fbshipit-source-id: 49369b694a81b7dfb541c75d9e24b62fc141d980
1 parent 366270e commit 2e444d2

3 files changed

Lines changed: 225 additions & 1 deletion

File tree

jest/integration/runtime/setup.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import deepEqual from 'deep-equal';
13+
import {diff} from 'jest-diff';
1314
import nullthrows from 'nullthrows';
1415

1516
export type TestCaseResult = {
@@ -227,7 +228,13 @@ class Expect {
227228
const pass = deepEqual(this.#received, expected, {strict: true});
228229
if (!this.#isExpectedResult(pass)) {
229230
throw new ErrorWithCustomBlame(
230-
`Expected${this.#maybeNotLabel()} to equal ${String(expected)} but received ${String(this.#received)}.`,
231+
`Expected${this.#maybeNotLabel()} to equal:\n${
232+
diff(expected, this.#received, {
233+
contextLines: 1,
234+
expand: false,
235+
omitAnnotationLines: true,
236+
}) ?? 'Failed to compare outputs'
237+
}`,
231238
).blameToPreviousFrame();
232239
}
233240
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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 strict-local
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
// $FlowExpectedError[untyped-import]
13+
import micromatch from 'micromatch';
14+
import * as React from 'react';
15+
16+
export type RenderOutputConfig = {
17+
...FantomRenderedOutputConfig,
18+
includeRoot?: boolean,
19+
includeLayoutMetrics?: boolean,
20+
};
21+
22+
// match RenderFormatOptions.h
23+
type NativeRenderFormatOptions = {
24+
includeRoot: boolean,
25+
includeLayoutMetrics: boolean,
26+
};
27+
28+
type FantomJsonObject = {
29+
type: string,
30+
props: {[key: string]: string},
31+
children: $ReadOnlyArray<FantomJsonObject | string>,
32+
};
33+
34+
type FantomJson = FantomJsonObject | $ReadOnlyArray<FantomJsonObject>;
35+
36+
type FantomRenderedOutputConfig = {
37+
// micromatch pattern to match prop names
38+
// see usage examples in https://github.com/micromatch/micromatch#examples
39+
props?: $ReadOnlyArray<string>,
40+
};
41+
42+
class FantomRenderedOutput {
43+
#json: FantomJson;
44+
45+
constructor(json: FantomJson, config: FantomRenderedOutputConfig) {
46+
this.#json = this.#filterJson(json, config);
47+
}
48+
49+
toJSON(): FantomJson {
50+
return Array.isArray(this.#json) ? [...this.#json] : {...this.#json};
51+
}
52+
53+
toJSX(): React.Node {
54+
return convertRawJsonToJSX(this.#json);
55+
}
56+
57+
#filterJson(
58+
json: FantomJson,
59+
config: FantomRenderedOutputConfig,
60+
): FantomJson {
61+
if (Array.isArray(json)) {
62+
return json.map(child => this.#filterJsonObject(child, config));
63+
} else {
64+
return this.#filterJsonObject(json, config);
65+
}
66+
}
67+
68+
#filterJsonObject(
69+
json: FantomJsonObject,
70+
config: FantomRenderedOutputConfig,
71+
): FantomJsonObject {
72+
const root: FantomJsonObject = {
73+
type: json.type,
74+
props: this.#filterProps(json.props, config),
75+
children: [],
76+
};
77+
78+
if (Array.isArray(json.children)) {
79+
root.children = json.children.map(child =>
80+
typeof child === 'object'
81+
? this.#filterJsonObject(child, config)
82+
: child,
83+
);
84+
} else {
85+
root.children = json.children;
86+
}
87+
88+
return root;
89+
}
90+
91+
#filterProps(
92+
props: FantomJsonObject['props'],
93+
config: FantomRenderedOutputConfig,
94+
): FantomJsonObject['props'] {
95+
if (config.props == null) {
96+
return {...props};
97+
}
98+
99+
return micromatch(Object.keys(props), config.props ?? []).reduce(
100+
(acc, name) => {
101+
acc[name] = props[name];
102+
return acc;
103+
},
104+
{},
105+
);
106+
}
107+
}
108+
109+
export type {FantomRenderedOutput};
110+
111+
export default function getFantomRenderedOutput(
112+
surfaceId: number,
113+
config: RenderOutputConfig,
114+
): FantomRenderedOutput {
115+
const {
116+
includeRoot = false,
117+
includeLayoutMetrics = false,
118+
...fantomConfig
119+
} = config;
120+
const nativeConfig: NativeRenderFormatOptions = {
121+
includeRoot,
122+
includeLayoutMetrics,
123+
};
124+
125+
return new FantomRenderedOutput(
126+
JSON.parse(
127+
global.$$JSTesterModuleName$$.getRenderedOutput(surfaceId, nativeConfig),
128+
),
129+
fantomConfig,
130+
);
131+
}
132+
133+
function convertRawJsonToJSX(
134+
actualJSON: FantomJsonObject | $ReadOnlyArray<FantomJsonObject>,
135+
): React.Node {
136+
let actualJSX;
137+
if (actualJSON === null || typeof actualJSON === 'string') {
138+
actualJSX = actualJSON;
139+
} else if (Array.isArray(actualJSON)) {
140+
if (actualJSON.length === 0) {
141+
actualJSX = null;
142+
} else if (actualJSON.length === 1) {
143+
actualJSX = jsonChildToJSXChild(actualJSON[0]);
144+
} else {
145+
const actualJSXChildren = jsonChildrenToJSXChildren(actualJSON);
146+
if (actualJSXChildren === null || typeof actualJSXChildren === 'string') {
147+
actualJSX = actualJSXChildren;
148+
} else {
149+
actualJSX = <>{actualJSXChildren}</>;
150+
}
151+
}
152+
} else {
153+
actualJSX = jsonChildToJSXChild(actualJSON);
154+
}
155+
156+
return actualJSX;
157+
}
158+
159+
function createJSXElementForTestComparison(
160+
type: string,
161+
props: mixed,
162+
): React.Node {
163+
const Tag = type;
164+
return <Tag {...props} />;
165+
}
166+
167+
function rnTypeToTestType(type: string): string {
168+
return `rn-${type.substring(0, 1).toLowerCase() + type.substring(1)}`;
169+
}
170+
171+
function jsonChildToJSXChild(jsonChild: FantomJsonObject | string): React.Node {
172+
if (typeof jsonChild === 'string') {
173+
return jsonChild;
174+
} else {
175+
const jsxChildren = jsonChildrenToJSXChildren(jsonChild.children);
176+
const type = rnTypeToTestType(jsonChild.type);
177+
return createJSXElementForTestComparison(
178+
type,
179+
jsxChildren == null
180+
? jsonChild.props
181+
: {...jsonChild.props, children: jsxChildren},
182+
);
183+
}
184+
}
185+
186+
function jsonChildrenToJSXChildren(jsonChildren: FantomJsonObject['children']) {
187+
if (jsonChildren.length === 1) {
188+
return jsonChildToJSXChild(jsonChildren[0]);
189+
} else if (jsonChildren.length > 1) {
190+
const jsxChildren = [];
191+
let allJSXChildrenAreStrings = true;
192+
let jsxChildrenString = '';
193+
for (let i = 0; i < jsonChildren.length; i++) {
194+
const jsxChild = jsonChildToJSXChild(jsonChildren[i]);
195+
jsxChildren.push(jsxChild);
196+
if (allJSXChildrenAreStrings) {
197+
if (typeof jsxChild === 'string') {
198+
jsxChildrenString += jsxChild;
199+
} else if (jsxChild !== null) {
200+
allJSXChildrenAreStrings = false;
201+
}
202+
}
203+
}
204+
return allJSXChildrenAreStrings ? jsxChildrenString : jsxChildren;
205+
}
206+
207+
return null;
208+
}

packages/react-native/src/private/__tests__/ReactNativeTester.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
* @format
99
*/
1010

11+
import type {
12+
FantomRenderedOutput,
13+
RenderOutputConfig,
14+
} from './FantomRenderedOutput';
1115
import type {MixedElement} from 'react';
1216

1317
import ReactFabric from '../../../Libraries/Renderer/shims/ReactFabric';
18+
import getFantomRenderedOutput from './FantomRenderedOutput';
1419

1520
let globalSurfaceIdCounter = 1;
1621

@@ -48,6 +53,10 @@ class Root {
4853
global.$$JSTesterModuleName$$.flushMessageQueue();
4954
}
5055

56+
getRenderedOutput(config: RenderOutputConfig = {}): FantomRenderedOutput {
57+
return getFantomRenderedOutput(this.#surfaceId, config);
58+
}
59+
5160
// TODO: add an API to check if all surfaces were deallocated when tests are finished.
5261
}
5362

0 commit comments

Comments
 (0)