From c7479b07aedeb2ebac0c0f621b2f34b9ef0d46ea Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Tue, 30 Jan 2024 12:54:26 -0800 Subject: [PATCH] Add react-native-test-renderer package (#42644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The goal is to provide testing utilities that use Fabric and concurrent rendering by default to support the new RN architecture. Currently most testing is done through ReactTestRenderer, which is an overly-simplified rendering environment, non-concurrent by default, and over exposes internals. A dedicated RN environment in JS can allow for more realistic test execution. This is the initial commit to create the `react-native-test-renderer` package. It currently only offers a simple toJSON() method on the root of a test, which is used for snapshot unit tests. We will be iterating here to add a query interface, event handling, and more. ## Changelog: [GENERAL] [ADDED] - Added react-native-test-renderer package for Fabric rendered integration tests Pull Request resolved: https://github.com/facebook/react-native/pull/42644 Test Plan: ``` $> cd packages/react-native-test-renderer $> yarn jest ``` Output: ``` PASS src/renderer/__tests__/render-test.js render toJSON ✓ returns expected JSON output based on renderer component (7 ms) Test Suites: 1 passed, 1 total 1 passed, 1 total Snapshots: 1 passed, 1 total Time: 2.869 s ``` Reviewed By: yungsters Differential Revision: D53183101 Pulled By: jackpope fbshipit-source-id: 8e29ba35f55f6c4eb2613ab106bc669d72f33d1d --- jest.config.js | 1 + .../babel.config.js | 17 ++ .../react-native-test-renderer/jest.config.js | 25 ++ .../react-native-test-renderer/package.json | 20 ++ .../src/jest/environment.js | 74 ++++++ .../src/jest/setup-files-after-env.js | 222 ++++++++++++++++++ .../__snapshots__/render-test.js.snap | 12 + .../src/renderer/__tests__/render-test.js | 34 +++ .../src/renderer/index.js | 85 +++++++ .../ReactNative/__mocks__/FabricUIManager.js | 12 + 10 files changed, 502 insertions(+) create mode 100644 packages/react-native-test-renderer/babel.config.js create mode 100644 packages/react-native-test-renderer/jest.config.js create mode 100644 packages/react-native-test-renderer/package.json create mode 100644 packages/react-native-test-renderer/src/jest/environment.js create mode 100644 packages/react-native-test-renderer/src/jest/setup-files-after-env.js create mode 100644 packages/react-native-test-renderer/src/renderer/__tests__/__snapshots__/render-test.js.snap create mode 100644 packages/react-native-test-renderer/src/renderer/__tests__/render-test.js create mode 100644 packages/react-native-test-renderer/src/renderer/index.js diff --git a/jest.config.js b/jest.config.js index 35f1bffd875..c21230d2913 100644 --- a/jest.config.js +++ b/jest.config.js @@ -34,6 +34,7 @@ module.exports = { '/packages/react-native/sdks', '/packages/react-native/Libraries/Renderer', '/packages/rn-tester/e2e', + '/packages/react-native-test-renderer/src', ], transformIgnorePatterns: ['node_modules/(?!@react-native/)'], haste: { diff --git a/packages/react-native-test-renderer/babel.config.js b/packages/react-native-test-renderer/babel.config.js new file mode 100644 index 00000000000..7cd251d7825 --- /dev/null +++ b/packages/react-native-test-renderer/babel.config.js @@ -0,0 +1,17 @@ +/** + * 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 + */ + +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/preset-flow', + ], + plugins: ['@babel/plugin-transform-react-jsx'], +}; diff --git a/packages/react-native-test-renderer/jest.config.js b/packages/react-native-test-renderer/jest.config.js new file mode 100644 index 00000000000..d1c17f8cc89 --- /dev/null +++ b/packages/react-native-test-renderer/jest.config.js @@ -0,0 +1,25 @@ +/** + * 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. + * + * @format + */ + +'use strict'; + +module.exports = { + haste: { + defaultPlatform: 'ios', + platforms: ['android', 'ios', 'native'], + }, + transform: { + '^.+\\.(js|ts|tsx)$': 'babel-jest', + }, + transformIgnorePatterns: [ + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)', + ], + setupFilesAfterEnv: ['./src/jest/setup-files-after-env'], + testEnvironment: './src/jest/environment', +}; diff --git a/packages/react-native-test-renderer/package.json b/packages/react-native-test-renderer/package.json new file mode 100644 index 00000000000..a9d05c1c64c --- /dev/null +++ b/packages/react-native-test-renderer/package.json @@ -0,0 +1,20 @@ +{ + "name": "@react-native/test-renderer", + "private": true, + "version": "0.0.0", + "description": "A Test rendering library for React Native", + "license": "MIT", + "devDependencies": { + "@babel/core": "^7.20.0", + "@babel/plugin-transform-react-jsx": "^7.0.0", + "@babel/preset-env": "^7.20.0", + "@babel/preset-flow": "^7.20.0" + }, + "dependencies": {}, + "exports": { + ".": "./index.js", + "./jest-environment": "./dist/jest-environment/index.js", + "./jest-setup": "./dist/jest-setup/index.js" + }, + "peerDependencies": { "jest": "^29.7.0" } + } diff --git a/packages/react-native-test-renderer/src/jest/environment.js b/packages/react-native-test-renderer/src/jest/environment.js new file mode 100644 index 00000000000..43376cfeb53 --- /dev/null +++ b/packages/react-native-test-renderer/src/jest/environment.js @@ -0,0 +1,74 @@ +/** + * 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. + * + * @format + */ + +'use strict'; + +const NodeEnv = require('jest-environment-node').TestEnvironment; + +module.exports = class ReactNativeEnvironment extends NodeEnv { + customExportConditions = ['require', 'react-native']; + + constructor(config, context) { + super(config, context); + } + + async setup() { + await super.setup(); + this.assignGlobals(); + this.initializeTurboModuleRegistry(); + } + + assignGlobals() { + Object.defineProperties(this.global, { + __DEV__: { + configurable: true, + enumerable: true, + value: true, + writable: true, + }, + }); + this.global.IS_REACT_ACT_ENVIRONMENT = true; + } + + initializeTurboModuleRegistry() { + const dims = {width: 100, height: 100, scale: 1, fontScale: 1}; + const DIMS = { + screen: { + ...dims, + }, + window: { + ...dims, + }, + }; + this.global.nativeModuleProxy = name => ({})[name]; + this.global.__turboModuleProxy = name => + ({ + SourceCode: {getConstants: () => ({scriptURL: ''})}, + WebSocketModule: {connect: () => {}}, + FileReaderModule: {}, + AppState: {getConstants: () => ({}), getCurrentAppState: () => ({})}, + DeviceInfo: {getConstants: () => ({Dimensions: DIMS})}, + UIManager: {getConstants: () => ({})}, + Timing: {}, + DevSettings: {}, + PlatformConstants: { + getConstants: () => ({reactNativeVersion: '1000.0.0'}), + }, + Networking: {}, + ImageLoader: {}, + NativePerformanceCxx: {}, + NativePerformanceObserverCxx: {}, + LogBox: {}, + SettingsManager: { + getConstants: () => ({settings: {}}), + }, + LinkingManager: {}, + })[name]; + } +}; diff --git a/packages/react-native-test-renderer/src/jest/setup-files-after-env.js b/packages/react-native-test-renderer/src/jest/setup-files-after-env.js new file mode 100644 index 00000000000..6dc49eedab9 --- /dev/null +++ b/packages/react-native-test-renderer/src/jest/setup-files-after-env.js @@ -0,0 +1,222 @@ +/** + * 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. + * + * @format + */ + +'use strict'; + +jest.requireActual('@react-native/js-polyfills/error-guard'); + +jest + .mock('react-native/Libraries/ReactNative/UIManager', () => ({ + AndroidViewPager: { + Commands: { + setPage: jest.fn(), + setPageWithoutAnimation: jest.fn(), + }, + }, + blur: jest.fn(), + createView: jest.fn(), + customBubblingEventTypes: {}, + customDirectEventTypes: {}, + getConstants: () => ({ + ViewManagerNames: [], + }), + getDefaultEventTypes: jest.fn(), + dispatchViewManagerCommand: jest.fn(), + focus: jest.fn(), + getViewManagerConfig: jest.fn(name => { + if (name === 'AndroidDrawerLayout') { + return { + Constants: { + DrawerPosition: { + Left: 10, + }, + }, + }; + } + + return {NativeProps: {}}; + }), + hasViewManagerConfig: jest.fn(name => { + return name === 'AndroidDrawerLayout'; + }), + measure: jest.fn(), + manageChildren: jest.fn(), + removeSubviewsFromContainerWithID: jest.fn(), + replaceExistingNonRootView: jest.fn(), + setChildren: jest.fn(), + updateView: jest.fn(), + AndroidDrawerLayout: { + Constants: { + DrawerPosition: { + Left: 10, + }, + }, + }, + AndroidTextInput: { + Commands: {}, + }, + ScrollView: { + Constants: {}, + }, + View: { + Constants: {}, + }, + })) + // Mock modules defined by the native layer (ex: Objective-C, Java) + .mock('react-native/Libraries/BatchedBridge/NativeModules', () => ({ + AlertManager: { + alertWithArgs: jest.fn(), + }, + AsyncLocalStorage: { + multiGet: jest.fn((keys, callback) => + process.nextTick(() => callback(null, [])), + ), + multiSet: jest.fn((entries, callback) => + process.nextTick(() => callback(null)), + ), + multiRemove: jest.fn((keys, callback) => + process.nextTick(() => callback(null)), + ), + multiMerge: jest.fn((entries, callback) => + process.nextTick(() => callback(null)), + ), + clear: jest.fn(callback => process.nextTick(() => callback(null))), + getAllKeys: jest.fn(callback => + process.nextTick(() => callback(null, [])), + ), + }, + DeviceInfo: { + getConstants() { + return { + Dimensions: { + window: { + fontScale: 2, + height: 1334, + scale: 2, + width: 750, + }, + screen: { + fontScale: 2, + height: 1334, + scale: 2, + width: 750, + }, + }, + }; + }, + }, + DevSettings: { + addMenuItem: jest.fn(), + reload: jest.fn(), + }, + ImageLoader: { + getSize: jest.fn(url => Promise.resolve([320, 240])), + prefetchImage: jest.fn(), + }, + ImageViewManager: { + getSize: jest.fn((uri, success) => + process.nextTick(() => success(320, 240)), + ), + prefetchImage: jest.fn(), + }, + KeyboardObserver: { + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + Networking: { + sendRequest: jest.fn(), + abortRequest: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + PlatformConstants: { + getConstants() { + return { + reactNativeVersion: { + major: 1000, + minor: 0, + patch: 0, + }, + }; + }, + }, + PushNotificationManager: { + presentLocalNotification: jest.fn(), + scheduleLocalNotification: jest.fn(), + cancelAllLocalNotifications: jest.fn(), + removeAllDeliveredNotifications: jest.fn(), + getDeliveredNotifications: jest.fn(callback => + process.nextTick(() => []), + ), + removeDeliveredNotifications: jest.fn(), + setApplicationIconBadgeNumber: jest.fn(), + getApplicationIconBadgeNumber: jest.fn(callback => + process.nextTick(() => callback(0)), + ), + cancelLocalNotifications: jest.fn(), + getScheduledLocalNotifications: jest.fn(callback => + process.nextTick(() => callback()), + ), + requestPermissions: jest.fn(() => + Promise.resolve({alert: true, badge: true, sound: true}), + ), + abandonPermissions: jest.fn(), + checkPermissions: jest.fn(callback => + process.nextTick(() => + callback({alert: true, badge: true, sound: true}), + ), + ), + getInitialNotification: jest.fn(() => Promise.resolve(null)), + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + StatusBarManager: { + setColor: jest.fn(), + setStyle: jest.fn(), + setHidden: jest.fn(), + setNetworkActivityIndicatorVisible: jest.fn(), + setBackgroundColor: jest.fn(), + setTranslucent: jest.fn(), + getConstants: () => ({ + HEIGHT: 42, + }), + }, + Timing: { + createTimer: jest.fn(), + deleteTimer: jest.fn(), + }, + UIManager: {}, + BlobModule: { + getConstants: () => ({BLOB_URI_SCHEME: 'content', BLOB_URI_HOST: null}), + addNetworkingHandler: jest.fn(), + enableBlobSupport: jest.fn(), + disableBlobSupport: jest.fn(), + createFromParts: jest.fn(), + sendBlob: jest.fn(), + release: jest.fn(), + }, + WebSocketModule: { + connect: jest.fn(), + send: jest.fn(), + sendBinary: jest.fn(), + ping: jest.fn(), + close: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + I18nManager: { + allowRTL: jest.fn(), + forceRTL: jest.fn(), + swapLeftAndRightInRTL: jest.fn(), + getConstants: () => ({ + isRTL: false, + doLeftAndRightSwapInRTL: true, + }), + }, + })); diff --git a/packages/react-native-test-renderer/src/renderer/__tests__/__snapshots__/render-test.js.snap b/packages/react-native-test-renderer/src/renderer/__tests__/__snapshots__/render-test.js.snap new file mode 100644 index 00000000000..5d417516d01 --- /dev/null +++ b/packages/react-native-test-renderer/src/renderer/__tests__/__snapshots__/render-test.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render toJSON returns expected JSON output based on renderer component 1`] = ` +"<> + + + + + + +" +`; diff --git a/packages/react-native-test-renderer/src/renderer/__tests__/render-test.js b/packages/react-native-test-renderer/src/renderer/__tests__/render-test.js new file mode 100644 index 00000000000..9c5f73e3e0a --- /dev/null +++ b/packages/react-native-test-renderer/src/renderer/__tests__/render-test.js @@ -0,0 +1,34 @@ +/** + * 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. + * + * @format + * @flow + */ + +'use strict'; + +import * as ReactNativeTestRenderer from '../index'; +import * as React from 'react'; +import {Text, View} from 'react-native'; +import 'react-native/Libraries/Components/View/ViewNativeComponent'; + +function TestComponent() { + return ( + + Hello + + + ); +} + +describe('render', () => { + describe('toJSON', () => { + it('returns expected JSON output based on renderer component', () => { + const result = ReactNativeTestRenderer.render(); + expect(result.toJSON()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/react-native-test-renderer/src/renderer/index.js b/packages/react-native-test-renderer/src/renderer/index.js new file mode 100644 index 00000000000..7f657381622 --- /dev/null +++ b/packages/react-native-test-renderer/src/renderer/index.js @@ -0,0 +1,85 @@ +/** + * 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. + * + * @format + * @flow + */ + +'use strict'; + +import type {Element, ElementType} from 'react'; + +import * as FabricUIManager from 'react-native/Libraries/ReactNative/__mocks__/FabricUIManager'; +import ReactFabric from 'react-native/Libraries/Renderer/shims/ReactFabric'; +import {act} from 'react-test-renderer'; + +type ReactNode = { + children: Array, + props: {...}, + viewName: string, + instanceHandle: { + memoizedProps: {testID: ?string, ...}, + ... + }, +}; + +type RootReactNode = $ReadOnlyArray; + +type RenderResult = { + toJSON: () => string, +}; + +function buildRenderResult(rootNode: RootReactNode): RenderResult { + return { + toJSON: () => stringify(rootNode), + }; +} + +export function render(element: Element): RenderResult { + const manager = FabricUIManager.getFabricUIManager(); + if (!manager) { + throw new Error('No FabricUIManager found'); + } + const containerTag = Math.round(Math.random() * 1000000); + act(() => { + ReactFabric.render(element, containerTag, () => {}, true); + }); + + // $FlowFixMe + const root: RootReactNode = manager.getRoot(containerTag); + + if (root == null) { + throw new Error('No root found for containerTag ' + containerTag); + } + + return buildRenderResult(root); +} + +function stringify( + node: RootReactNode | ReactNode, + indent: string = '', +): string { + const nextIndent = ' ' + indent; + if (Array.isArray(node)) { + return `<> +${node.map(n => nextIndent + stringify(n, nextIndent)).join('\n')} +`; + } + const children = node.children; + const props = node.props + ? Object.entries(node.props) + .map(([k, v]) => ` ${k}=${JSON.stringify(v) ?? ''}`) + .join('') + : ''; + + if (children.length > 0) { + return `<${node.viewName}${props}> +${children.map(c => nextIndent + stringify(c, nextIndent)).join('\n')} +${indent}`; + } else { + return `<${node.viewName}${props} />`; + } +} diff --git a/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js index c51df7240c1..676bfb17830 100644 --- a/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js +++ b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js @@ -24,6 +24,8 @@ import type { Spec as FabricUIManager, } from '../FabricUIManager'; +import {createRootTag} from '../RootTag.js'; + export type NodeMock = { children: NodeSet, instanceHandle: InternalInstanceHandle, @@ -160,6 +162,7 @@ function hasDisplayNone(node: Node): boolean { } interface IFabricUIManagerMock extends FabricUIManager { + getRoot(rootTag: RootTag | number): NodeSet; __getInstanceHandleFromNode(node: Node): InternalInstanceHandle; __addCommitHook(commitHook: UIManagerCommitHook): void; __removeCommitHook(commitHook: UIManagerCommitHook): void; @@ -638,6 +641,15 @@ const FabricUIManagerMock: IFabricUIManagerMock = { return 'RN:' + fromNode(node).viewName; }), + getRoot(containerTag: RootTag | number): NodeSet { + const tag = createRootTag(containerTag); + const root = roots.get(tag); + if (!root) { + throw new Error('No root found for containerTag ' + Number(tag)); + } + return root; + }, + __getInstanceHandleFromNode(node: Node): InternalInstanceHandle { return fromNode(node).instanceHandle; },