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