From 25939f4358026953e77f6e5b2a96e8492e6cee04 Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Tue, 11 Feb 2025 16:55:58 -0800 Subject: [PATCH] Update fantom test, add helpers (#49337) Summary: *Note: This diff adds helpers and updates the one test we have. The next diff adds a bunch of tests, see that diff for how these helpers are used to scale to a large number of tests.* ----- ## Overview Adds helpers for the LogBox e2e test to make it easier to write simple, readable, tests with a healthy level of abstraction over the e2e APIs. ## API The helpers expose an ability to render a component, and return methods to get the UI state: ``` // Returns the LogBox inspector UI as an object. getInspectorUI: () => ?InspectorUI, // Returns the LogBox notification UI as an object. getNotificationUI: () => ?NotificationUI, ``` These return objects that represent the main text elements in the UI like `title` and `message`. The helpers also provide methods for interacting with the LogBox UI: ``` // True if the LogBox inspector is open. isOpen: () => boolean, // Tap the notification to open the LogBox inspector. openNotification: () => void, // Tap to close the notification. dimissNotification: () => void, // Tap the minimize button to collapse the LogBox inspector. mimimizeInspector: () => void, // Tap the dismiss button to close the LogBox inspector. dismissInspector: () => void, // Tap the next button to go to the next log. nextLog: () => void, // Tap the previous button to go to the previous log. previousLog: () => void, ``` ## Example test This allows writing tests like: ``` test('should log error', () => { const logBox = renderLogBox(); // Should show notification expect(logBox.isOpen()).toBe(false); expect(logBox.getNotifciationUI()).toEqual({ count: '!', message: 'error message', }); // Tap the notification logBox.openNotification(); // Should show log expect(logBox.isOpen()).toBe(true); expect(logBox.getInspectorUI()).toEqual({ header: 'Log 1 of 1', title: 'Console Error', message: 'error message', stackFrames: [ 'ComponentThatErrors' ], componentStackFrames: [ '', ], isDismissable: true, }); }) ``` ## Changelog Changelog: [internal] Reviewed By: rubennorte Differential Revision: D69443041 --- .../Libraries/LogBox/UI/AnsiHighlight.js | 5 +- .../Libraries/LogBox/UI/LogBoxInspector.js | 2 +- .../LogBox/UI/LogBoxInspectorFooter.js | 14 +- .../LogBox/UI/LogBoxInspectorFooterButton.js | 2 + .../LogBox/UI/LogBoxInspectorHeader.js | 2 + .../LogBox/UI/LogBoxInspectorHeaderButton.js | 2 + .../LogBox/UI/LogBoxInspectorReactFrames.js | 4 +- .../LogBox/UI/LogBoxInspectorStackFrame.js | 4 +- .../Libraries/LogBox/UI/LogBoxNotification.js | 9 +- .../LogBox/UI/LogBoxNotificationCountBadge.js | 4 +- .../UI/LogBoxNotificationDismissButton.js | 2 + .../LogBox/UI/LogBoxNotificationMessage.js | 5 +- .../LogBoxInspector-test.js.snap | 2 + .../LogBoxInspectorFooter-test.js.snap | 7 + .../LogBoxInspectorHeader-test.js.snap | 6 + .../LogBoxInspectorReactFrames-test.js.snap | 6 + .../LogBoxInspectorStackFrame-test.js.snap | 3 + .../LogBoxInspectorStackFrames-test.js.snap | 1 + .../LogBoxNotification-test.js.snap | 4 +- .../LogBox/__tests__/LogBox-itest.js | 115 +++----- .../LogBox/__tests__/fantomHelpers.js | 265 ++++++++++++++++++ .../__snapshots__/public-api-test.js.snap | 3 + 22 files changed, 375 insertions(+), 92 deletions(-) create mode 100644 packages/react-native/Libraries/LogBox/__tests__/fantomHelpers.js diff --git a/packages/react-native/Libraries/LogBox/UI/AnsiHighlight.js b/packages/react-native/Libraries/LogBox/UI/AnsiHighlight.js index a71f033ed81e91..b7163f1512f54f 100644 --- a/packages/react-native/Libraries/LogBox/UI/AnsiHighlight.js +++ b/packages/react-native/Libraries/LogBox/UI/AnsiHighlight.js @@ -98,7 +98,10 @@ export default function Ansi({ backgroundColor: bundle.bg && COLORS[bundle.bg], }; return ( - + {getText(bundle.content, key)} ); diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspector.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspector.js index ae6ddbc551ff34..be889b06fd58e8 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspector.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspector.js @@ -64,7 +64,7 @@ export default function LogBoxInspector(props: Props): React.Node { } return ( - + - + This error cannot be dismissed. @@ -38,8 +38,16 @@ export default function LogBoxInspectorFooter(props: Props): React.Node { return ( - - + + ); } diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js index d2208c773e2af4..e75d7a23e83bbb 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorFooterButton.js @@ -17,6 +17,7 @@ import * as LogBoxStyle from './LogBoxStyle'; import * as React from 'react'; type ButtonProps = $ReadOnly<{ + id: string, onPress: () => void, text: string, }>; @@ -27,6 +28,7 @@ export default function LogBoxInspectorFooterButton( return ( export default function LogBoxInspectorHeaderButton( props: $ReadOnly<{ + id: string, disabled: boolean, image: ImageSource, level: LogLevel, @@ -47,6 +48,7 @@ export default function LogBoxInspectorHeaderButton( ): React.Node { return ( diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorReactFrames.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorReactFrames.js index 4baa1aab450b35..1490cc571394ad 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorReactFrames.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorReactFrames.js @@ -101,7 +101,9 @@ function LogBoxInspectorReactFrames(props: Props): React.Node { } style={componentStyles.frame}> - + {'<'} {frame.content} {' />'} diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js index 98326f9d0d7349..54a14db37025da 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxInspectorStackFrame.js @@ -43,7 +43,9 @@ function LogBoxInspectorStackFrame(props: Props): React.Node { }} onPress={onPress} style={styles.frame}> - + {frame.methodName} + - + diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxNotificationCountBadge.js b/packages/react-native/Libraries/LogBox/UI/LogBoxNotificationCountBadge.js index 42c91fdc4b5247..1777de4d69565d 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxNotificationCountBadge.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxNotificationCountBadge.js @@ -24,7 +24,9 @@ export default function LogBoxNotificationCountBadge(props: { * when fixing the type of `StyleSheet.create`. Remove this comment to * see the error. */} - {props.count <= 1 ? '!' : props.count} + + {props.count <= 1 ? '!' : props.count} + ); diff --git a/packages/react-native/Libraries/LogBox/UI/LogBoxNotificationDismissButton.js b/packages/react-native/Libraries/LogBox/UI/LogBoxNotificationDismissButton.js index 01ff2383288434..cdcc0de1c6a4f3 100644 --- a/packages/react-native/Libraries/LogBox/UI/LogBoxNotificationDismissButton.js +++ b/packages/react-native/Libraries/LogBox/UI/LogBoxNotificationDismissButton.js @@ -16,11 +16,13 @@ import * as LogBoxStyle from './LogBoxStyle'; import * as React from 'react'; export default function LogBoxNotificationDismissButton(props: { + id: string, onPress: () => void, }): React.Node { return ( - + {props.message && ( @@ -88,10 +91,12 @@ exports[`LogBoxInspectorFooter should render two buttons for fatal 1`] = ` } > @@ -115,10 +120,12 @@ exports[`LogBoxInspectorFooter should render two buttons for warning 1`] = ` } > diff --git a/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorHeader-test.js.snap b/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorHeader-test.js.snap index f467fd5d1cc6d4..5e8fbb4738ce26 100644 --- a/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorHeader-test.js.snap +++ b/packages/react-native/Libraries/LogBox/UI/__tests__/__snapshots__/LogBoxInspectorHeader-test.js.snap @@ -18,6 +18,7 @@ exports[`LogBoxInspectorHeader should render both buttons for two total 1`] = ` > diff --git a/packages/react-native/Libraries/LogBox/__tests__/LogBox-itest.js b/packages/react-native/Libraries/LogBox/__tests__/LogBox-itest.js index d417b606929998..7c4cc2a80745dd 100644 --- a/packages/react-native/Libraries/LogBox/__tests__/LogBox-itest.js +++ b/packages/react-native/Libraries/LogBox/__tests__/LogBox-itest.js @@ -10,35 +10,8 @@ * @fantom_flags enableAccessToHostTreeInFabric:true */ -import ensureInstance from '../../../src/private/utilities/ensureInstance'; -import ReadOnlyElement from '../../../src/private/webapis/dom/nodes/ReadOnlyElement'; import View from '../../Components/View/View'; -import AppContainer from '../../ReactNative/AppContainer'; -import LogBoxInspectorContainer from '../LogBoxInspectorContainer'; -import { - ManualConsoleError, - // $FlowExpectedError[untyped-import] -} from './__fixtures__/ReactWarningFixtures'; -import Fantom from '@react-native/fantom'; -import nullthrows from 'nullthrows'; -import * as React from 'react'; - -import '../../Core/InitializeCore.js'; - -function findById(node: ReadOnlyElement, id: string): ?ReadOnlyElement { - if (node.id === id) { - return node; - } - - for (const child of node.children) { - const found = findById(child, id); - if (found) { - return found; - } - } - - return null; -} +import {renderLogBox} from './fantomHelpers'; describe('LogBox', () => { let originalConsoleError; @@ -60,8 +33,12 @@ describe('LogBox', () => { }); // $FlowExpectedError[cannot-write] console.error = mockError; + // $FlowExpectedError[prop-missing] + console.error.displayName = 'MockConsoleErrorForTesting'; // $FlowExpectedError[cannot-write] console.warn = mockWarn; + // $FlowExpectedError[prop-missing] + console.warn.displayName = 'MockConsoleWarnForTesting'; }); afterEach(() => { @@ -72,65 +49,45 @@ describe('LogBox', () => { }); it('renders an empty screen if there are no errors', () => { - const logBoxRoot = Fantom.createRoot(); - Fantom.runTask(() => { - logBoxRoot.render(); - }); + const logBox = renderLogBox(); - expect(logBoxRoot.getRenderedOutput().toJSX()).toBe(null); + expect(logBox.isOpen()).toBe(false); + expect(logBox.getInspectorUI()).toBe(null); + expect(logBox.getNotificationUI()).toBe(null); }); - it('handles a manual console.error without a component stack in LogBox', () => { - let maybeViewNode; + it('handles a soft error in render, and dismisses', () => { + function ManualConsoleErrorCall() { + console.error('HIT'); + } + const logBox = renderLogBox(); - const logBoxRoot = Fantom.createRoot(); - Fantom.runTask(() => { - logBoxRoot.render( - { - maybeViewNode = node; - }}> - - , - ); + // Console error should not pop a dialog. + expect(logBox.isOpen()).toBe(false); + expect(logBox.getNotificationUI()).toEqual({ + count: '!', + message: 'HIT', }); - expect(logBoxRoot.getRenderedOutput().toJSX()).toBe(null); - - const logBoxRootNode = ensureInstance(maybeViewNode, ReadOnlyElement); - - const root = Fantom.createRoot(); - Fantom.runTask(() => { - root.render( - { - maybeViewNode = node; - }}> - - - - , - ); + // Open LogBox. + logBox.openNotification(); + + expect(logBox.isOpen()).toBe(true); + expect(logBox.getInspectorUI()).toEqual({ + header: 'Log 1 of 1', + title: 'Console Error', + message: 'HIT', + // TODO: There should be component frames for console errors. + componentStackFrames: [], + stackFrames: ['ManualConsoleErrorCall'], + isDismissable: true, }); - expect(logBoxRoot.getRenderedOutput().toJSX()).toBe(null); - - const appRootNode = ensureInstance(maybeViewNode, ReadOnlyElement); - const logBoxButton = nullthrows( - findById(appRootNode, 'logbox_button_error'), - ); - - Fantom.dispatchNativeEvent(logBoxButton, 'click'); - - const headerTitle = findById(logBoxRootNode, 'logbox_header_title_text'); - const messageTitle = findById(logBoxRootNode, 'logbox_message_title_text'); - const messageContents = findById( - logBoxRootNode, - 'logbox_message_contents_text', - ); + // Dismiss LogBox. + logBox.dismissInspector(); - expect(headerTitle?.textContent).toBe('Log 1 of 1'); - expect(messageTitle?.textContent).toBe('Console Error'); - expect(messageContents?.textContent).toBe('Manual console error'); + // All logs should be cleared. + expect(logBox.getInspectorUI()).toBe(null); + expect(logBox.getNotificationUI()).toBe(null); }); }); diff --git a/packages/react-native/Libraries/LogBox/__tests__/fantomHelpers.js b/packages/react-native/Libraries/LogBox/__tests__/fantomHelpers.js new file mode 100644 index 00000000000000..9f1ca6cccdbe32 --- /dev/null +++ b/packages/react-native/Libraries/LogBox/__tests__/fantomHelpers.js @@ -0,0 +1,265 @@ +/** + * 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 ensureInstance from '../../../src/private/utilities/ensureInstance'; +import ReadOnlyElement from '../../../src/private/webapis/dom/nodes/ReadOnlyElement'; +import View from '../../Components/View/View'; +import AppContainer from '../../ReactNative/AppContainer'; +import LogBoxInspectorContainer from '../LogBoxInspectorContainer'; +import Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; + +import '../../Core/InitializeCore.js'; + +interface InspectorUI { + header: ?string; + title: ?string; + message: ?string; + // TODO: We get code frames from Metro, + // which is not yet implemented in the fantom tests. + // codeFrames: ?Array, + componentStackFrames: ?Array; + stackFrames: ?Array; + isDismissable: ?boolean; +} + +interface NotificationUI { + count: ?string; + message: ?string; +} + +function findById(node: ReadOnlyElement, id: string): ?ReadOnlyElement { + if (node.id === id) { + return node; + } + + for (const child of node.children) { + const found = findById(child, id); + if (found) { + return found; + } + } + + return null; +} + +function findTextById(node: ReadOnlyElement, id: string): ?string { + if (node.id === id) { + return node.textContent; + } + + for (const child of node.children) { + const found = findTextById(child, id); + if (found != null) { + return found; + } + } + + return null; +} + +function findTextByIds( + node: ReadOnlyElement, + id: string, + text: Array = [], +): Array { + if (node.id === id && node.textContent != null) { + text.push(node.textContent); + return text; + } + + for (const child of node.children) { + findTextByIds(child, id, text); + } + + return text; +} + +// Finds the LogBox notification UI by searching for the text IDs. +function findLogBoxNotificationUI(node: ReadOnlyElement): NotificationUI { + return { + count: findTextById(node, 'logbox_notification_count_text'), + message: findTextById(node, 'logbox_notification_message_text'), + }; +} + +// Finds the LogBox inspector UI by searching for the text IDs. +function findLogBoxInspectorUI(node: ReadOnlyElement): InspectorUI { + return { + header: findTextById(node, 'logbox_header_title_text'), + title: findTextById(node, 'logbox_message_title_text'), + message: findTextById(node, 'logbox_message_contents_text'), + // codeFrames: undefined, + componentStackFrames: findTextByIds( + node, + 'logbox_component_stack_frame_text', + ), + stackFrames: getStackFrames(node), + isDismissable: findTextById(node, 'logbox_dismissable_text') == null, + }; +} + +// Return the frames between the mock console and the bottom frame. +// These are the application frames not related to internals. +// For example: +// _construct +// Wrapper +// _callSuper +// reactConsoleErrorHandler +// registerError +// anonymous +// anonymous +// MockConsoleErrorForTesting <-- mockConsoleIndex +// ManualConsoleError +// reactStackBottomFrame <-- react bottom frame +// +// +// Becomes: +// ManualConsoleError +// +// If there are no matches, we return all frames, to prevent false negatives. +function getStackFrames(node: ReadOnlyElement): ?Array { + const text = findTextByIds(node, 'logbox_stack_frame_text'); + const mockConsoleIndex = text.includes('MockConsoleErrorForTesting') + ? text.indexOf('MockConsoleErrorForTesting') + 1 + : 0; + const bottomFrameIndex = text.includes('reactStackBottomFrame') + ? text.indexOf('reactStackBottomFrame') + : text.length; + return text.slice(mockConsoleIndex, bottomFrameIndex); +} + +/** + * renderLogBox is a helper function to render a component, and render LogBox. + * It returns an object with methods to interact with the LogBox, and assert the UI. + * + */ +export function renderLogBox( + children: React.Node, + options?: {crash: boolean}, +): { + // True if the LogBox inspector is open. + isOpen: () => boolean, + // Tap the notification to open the LogBox inspector. + openNotification: () => void, + // Tap to close the notification. + dimissNotification: () => void, + // Tap the minimize button to collapse the LogBox inspector. + mimimizeInspector: () => void, + // Tap the dismiss button to close the LogBox inspector. + dismissInspector: () => void, + // Tap the next button to go to the next log. + nextLog: () => void, + // Tap the previous button to go to the previous log. + previousLog: () => void, + // Returns the LogBox inspector UI as an object. + getInspectorUI: () => ?InspectorUI, + // Returns the LogBox notification UI as an object. + getNotificationUI: () => ?NotificationUI, +} { + let inspectorNode; + + const logBoxRoot = Fantom.createRoot(); + Fantom.runTask(() => { + logBoxRoot.render( + { + inspectorNode = node; + }}> + + , + ); + }); + + expect(logBoxRoot.getRenderedOutput().toJSX()).toBe(null); + + const logBoxInstance = ensureInstance(inspectorNode, ReadOnlyElement); + + let appNode; + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + { + appNode = node; + }}> + {children} + , + ); + }); + + return { + isOpen: () => { + return logBoxRoot.getRenderedOutput().toJSX() != null; + }, + openNotification: () => { + const appInstance = ensureInstance(appNode, ReadOnlyElement); + const button = nullthrows( + findById(appInstance, 'logbox_open_button_error'), + ); + + Fantom.dispatchNativeEvent(button, 'click'); + }, + dimissNotification: () => { + const appInstance = ensureInstance(appNode, ReadOnlyElement); + const button = nullthrows( + findById(appInstance, 'logbox_dismiss_button_error'), + ); + + Fantom.dispatchNativeEvent(button, 'click'); + }, + mimimizeInspector: () => { + const button = nullthrows( + findById(logBoxInstance, 'logbox_footer_button_minimize'), + ); + + Fantom.dispatchNativeEvent(button, 'click'); + }, + dismissInspector: () => { + const button = nullthrows( + findById(logBoxInstance, 'logbox_footer_button_dismiss'), + ); + + Fantom.dispatchNativeEvent(button, 'click'); + }, + nextLog: () => { + const button = nullthrows( + findById(logBoxInstance, 'logbox_header_button_next'), + ); + + Fantom.dispatchNativeEvent(button, 'click'); + }, + previousLog: () => { + const button = nullthrows( + findById(logBoxInstance, 'logbox_header_button_prev'), + ); + + Fantom.dispatchNativeEvent(button, 'click'); + }, + getInspectorUI: () => { + if (findById(logBoxInstance, 'logbox_inspector') == null) { + return null; + } + return findLogBoxInspectorUI(logBoxInstance); + }, + getNotificationUI: () => { + if (appNode == null) { + return null; + } + const appInstance = ensureInstance(appNode, ReadOnlyElement); + if (findById(appInstance, 'logbox_notification') == null) { + return null; + } + return findLogBoxNotificationUI(appInstance); + }, + }; +} diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 0bb5a46d9ae7da..a6cff01924fcfd 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -5552,6 +5552,7 @@ declare export default function LogBoxInspectorFooter(props: Props): React.Node; exports[`public API should not change unintentionally Libraries/LogBox/UI/LogBoxInspectorFooterButton.js 1`] = ` "type ButtonProps = $ReadOnly<{ + id: string, onPress: () => void, text: string, }>; @@ -5575,6 +5576,7 @@ declare export default function LogBoxInspectorHeader(props: Props): React.Node; exports[`public API should not change unintentionally Libraries/LogBox/UI/LogBoxInspectorHeaderButton.js 1`] = ` "declare export default function LogBoxInspectorHeaderButton( props: $ReadOnly<{ + id: string, disabled: boolean, image: ImageSource, level: LogLevel, @@ -5686,6 +5688,7 @@ exports[`public API should not change unintentionally Libraries/LogBox/UI/LogBox exports[`public API should not change unintentionally Libraries/LogBox/UI/LogBoxNotificationDismissButton.js 1`] = ` "declare export default function LogBoxNotificationDismissButton(props: { + id: string, onPress: () => void, }): React.Node; "