diff --git a/src/__tests__/fire-event-textInput.test.tsx b/src/__tests__/fire-event-textInput.test.tsx deleted file mode 100644 index 7223b1b2..00000000 --- a/src/__tests__/fire-event-textInput.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from 'react'; -import type { TextInputProps } from 'react-native'; -import { Text, TextInput } from 'react-native'; - -import { fireEvent, render, screen } from '..'; - -function WrappedTextInput(props: TextInputProps) { - return ; -} - -function DoubleWrappedTextInput(props: TextInputProps) { - return ; -} - -const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; - -test('should fire only non-touch-related events on non-editable TextInput', async () => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const subject = screen.getByTestId('subject'); - await fireEvent(subject, 'focus'); - await fireEvent.changeText(subject, 'Text'); - await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(subject, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); -}); - -test('should fire only non-touch-related events on non-editable TextInput with nested Text', async () => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - - Nested Text - , - ); - - const subject = screen.getByText('Nested Text'); - await fireEvent(subject, 'focus'); - await fireEvent(subject, 'onFocus'); - await fireEvent.changeText(subject, 'Text'); - await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(subject, 'onSubmitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(subject, 'layout', layoutEvent); - await fireEvent(subject, 'onLayout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledTimes(2); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); -}); - -/** - * Historically there were problems with custom TextInput wrappers, as they - * could creat a hierarchy of three or more composite text input views with - * very similar event props. - * - * Typical hierarchy would be: - * - User composite TextInput - * - UI library composite TextInput - * - RN composite TextInput - * - RN host TextInput - * - * Previous implementation of fireEvent only checked `editable` prop for - * RN TextInputs, both host & composite but did not check on the UI library or - * user composite TextInput level, hence invoking the event handlers that - * should be blocked by `editable={false}` prop. - */ -test('should fire only non-touch-related events on non-editable wrapped TextInput', async () => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const subject = screen.getByTestId('subject'); - await fireEvent(subject, 'focus'); - await fireEvent.changeText(subject, 'Text'); - await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(subject, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); -}); - -/** - * Ditto testing for even deeper hierarchy of TextInput wrappers. - */ -test('should fire only non-touch-related events on non-editable double wrapped TextInput', async () => { - const onFocus = jest.fn(); - const onChangeText = jest.fn(); - const onSubmitEditing = jest.fn(); - const onLayout = jest.fn(); - - await render( - , - ); - - const subject = screen.getByTestId('subject'); - await fireEvent(subject, 'focus'); - await fireEvent.changeText(subject, 'Text'); - await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); - await fireEvent(subject, 'layout', layoutEvent); - - expect(onFocus).not.toHaveBeenCalled(); - expect(onChangeText).not.toHaveBeenCalled(); - expect(onSubmitEditing).not.toHaveBeenCalled(); - expect(onLayout).toHaveBeenCalledWith(layoutEvent); -}); diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index 96566350..df4fc8b4 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -1,659 +1,583 @@ import * as React from 'react'; +import type { TextInputProps } from 'react-native'; import { PanResponder, Pressable, ScrollView, Text, TextInput, + TouchableHighlight, + TouchableNativeFeedback, TouchableOpacity, + TouchableWithoutFeedback, View, } from 'react-native'; -import { fireEvent, render, screen, waitFor } from '..'; +import { fireEvent, render, screen } from '..'; +import { nativeState } from '../native-state'; -type OnPressComponentProps = { - onPress: () => void; - text: string; -}; -const OnPressComponent = ({ onPress, text }: OnPressComponentProps) => ( - - - {text} - - -); - -type CustomEventComponentProps = { - onCustomEvent: () => void; -}; -const CustomEventComponent = ({ onCustomEvent }: CustomEventComponentProps) => ( - - Custom event component - -); - -type MyCustomButtonProps = { - handlePress: () => void; - text: string; -}; -const MyCustomButton = ({ handlePress, text }: MyCustomButtonProps) => ( - -); - -type CustomEventComponentWithCustomNameProps = { - handlePress: () => void; -}; -const CustomEventComponentWithCustomName = ({ - handlePress, -}: CustomEventComponentWithCustomNameProps) => ( - -); - -describe('fireEvent', () => { - test('should invoke specified event', async () => { - const onPressMock = jest.fn(); - await render(); - - await fireEvent(screen.getByText('Press me'), 'press'); - - expect(onPressMock).toHaveBeenCalled(); - }); +const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; +const verticalScrollEvent = { nativeEvent: { contentOffset: { y: 200 } } }; +const horizontalScrollEvent = { nativeEvent: { contentOffset: { x: 50 } } }; +const pressEventData = { nativeEvent: { pageX: 20, pageY: 30 } }; - test('should invoke specified event on parent element', async () => { - const onPressMock = jest.fn(); - const text = 'New press text'; - await render(); - - await fireEvent(screen.getByText(text), 'press'); - expect(onPressMock).toHaveBeenCalled(); - }); - - test('should invoke event with custom name', async () => { - const handlerMock = jest.fn(); - const EVENT_DATA = 'event data'; - - await render( - - - , - ); +test('fireEvent accepts event name with or without "on" prefix', async () => { + const onPress = jest.fn(); + await render(); - await fireEvent(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); + await fireEvent(screen.getByTestId('btn'), 'press'); + expect(onPress).toHaveBeenCalledTimes(1); - expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); - }); + await fireEvent(screen.getByTestId('btn'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); }); -test('fireEvent.press', async () => { - const onPressMock = jest.fn(); - const text = 'Fireevent press'; - const eventData = { - nativeEvent: { - pageX: 20, - pageY: 30, - }, - }; - await render(); - - await fireEvent.press(screen.getByText(text), eventData); - - expect(onPressMock).toHaveBeenCalledWith(eventData); +test('fireEvent passes event data to handler', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('btn'), pressEventData); + expect(onPress).toHaveBeenCalledWith(pressEventData); }); -test('fireEvent.scroll', async () => { - const onScrollMock = jest.fn(); - const eventData = { - nativeEvent: { - contentOffset: { - y: 200, - }, - }, - }; - - await render( - - XD - , - ); - - await fireEvent.scroll(screen.getByText('XD'), eventData); - - expect(onScrollMock).toHaveBeenCalledWith(eventData); +test('fireEvent passes multiple parameters to handler', async () => { + const handlePress = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('btn'), 'press', 'param1', 'param2', 'param3'); + expect(handlePress).toHaveBeenCalledWith('param1', 'param2', 'param3'); }); -test('fireEvent.changeText', async () => { - const onChangeTextMock = jest.fn(); +test('fireEvent returns handler return value', async () => { + const handler = jest.fn().mockReturnValue('result'); + await render(); + const result = await fireEvent.press(screen.getByTestId('btn')); + expect(result).toBe('result'); +}); +test('fireEvent bubbles event to parent handler', async () => { + const onPress = jest.fn(); await render( - - - , + + Press me + , ); - - const input = screen.getByPlaceholderText('Customer placeholder'); - await fireEvent.changeText(input, 'content'); - expect(onChangeTextMock).toHaveBeenCalledWith('content'); + await fireEvent.press(screen.getByText('Press me')); + expect(onPress).toHaveBeenCalled(); }); -it('sets native state value for unmanaged text inputs', async () => { - await render(); - - const input = screen.getByTestId('input'); - expect(input).toHaveDisplayValue(''); - - await fireEvent.changeText(input, 'abc'); - expect(input).toHaveDisplayValue('abc'); +describe('fireEvent.press', () => { + test.each([ + ['Pressable', Pressable], + ['TouchableOpacity', TouchableOpacity], + ['TouchableHighlight', TouchableHighlight], + ['TouchableWithoutFeedback', TouchableWithoutFeedback], + ['TouchableNativeFeedback', TouchableNativeFeedback], + ])('works on %s', async (_, Component) => { + const onPress = jest.fn(); + await render( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Component is a valid React component - but some RN versions have incorrect type definitions + + Press me + , + ); + await fireEvent.press(screen.getByTestId('subject')); + expect(onPress).toHaveBeenCalled(); + }); }); -test('custom component with custom event name', async () => { - const handlePress = jest.fn(); - - await render(); - - await fireEvent(screen.getByText('Custom component'), 'handlePress'); +describe('fireEvent.changeText', () => { + test('works on TextInput', async () => { + const onChangeText = jest.fn(); + await render(); + const input = screen.getByTestId('input'); + await fireEvent.changeText(input, 'new text'); + expect(onChangeText).toHaveBeenCalledWith('new text'); + expect(nativeState.valueForElement.get(input)).toBe('new text'); + }); - expect(handlePress).toHaveBeenCalled(); + test('does not fire on non-editable TextInput', async () => { + const onChangeText = jest.fn(); + await render(); + const input = screen.getByTestId('input'); + await fireEvent.changeText(input, 'new text'); + expect(onChangeText).not.toHaveBeenCalled(); + expect(nativeState.valueForElement.get(input)).toBeUndefined(); + }); }); -test('event with multiple handler parameters', async () => { - const handlePress = jest.fn(); - - await render(); - - await fireEvent(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); +describe('fireEvent.scroll', () => { + test('works on ScrollView', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, verticalScrollEvent); + expect(onScroll).toHaveBeenCalledWith(verticalScrollEvent); + expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 0, y: 200 }); + }); - expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); -}); + test.each([ + ['onScroll', 'scroll'], + ['onScrollBeginDrag', 'scrollBeginDrag'], + ['onScrollEndDrag', 'scrollEndDrag'], + ['onMomentumScrollBegin', 'momentumScrollBegin'], + ['onMomentumScrollEnd', 'momentumScrollEnd'], + ])('fires %s on ScrollView', async (propName, eventName) => { + const handler = jest.fn(); + await render(); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, eventName, verticalScrollEvent); + expect(handler).toHaveBeenCalledWith(verticalScrollEvent); + expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 0, y: 200 }); + }); -test('should not fire on disabled TouchableOpacity', async () => { - const handlePress = jest.fn(); - await render( - - - Trigger - - , - ); + test('without contentOffset does not update native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, {}); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForElement.get(scrollView)).toBeUndefined(); + }); - await fireEvent.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); -}); + test('with non-finite contentOffset values uses 0', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { y: Infinity } }, + }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 0, y: 0 }); + }); -test('should not fire on disabled Pressable', async () => { - const handlePress = jest.fn(); - await render( - - - Trigger - - , - ); + test('with horizontal scroll updates native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, horizontalScrollEvent); + expect(onScroll).toHaveBeenCalledWith(horizontalScrollEvent); + expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 50, y: 0 }); + }); - await fireEvent.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); + test('with non-finite x contentOffset value uses 0', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent.scroll(scrollView, { + nativeEvent: { contentOffset: { x: Infinity } }, + }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 0, y: 0 }); + }); }); -test('should not fire inside View with pointerEvents="none"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , +test('fireEvent fires custom event (onCustomEvent) on composite component', async () => { + const CustomComponent = ({ onCustomEvent }: { onCustomEvent: (data: string) => void }) => ( + onCustomEvent('event data')}> + Custom + ); - - await fireEvent.press(screen.getByText('Trigger')); - await fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); + const handler = jest.fn(); + await render(); + await fireEvent(screen.getByText('Custom'), 'customEvent', 'event data'); + expect(handler).toHaveBeenCalledWith('event data'); }); -test('should not fire inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , +test('fireEvent fires event with custom prop name (handlePress) on composite component', async () => { + const MyButton = ({ handlePress }: { handlePress: () => void }) => ( + + Button + ); - - await fireEvent.press(screen.getByText('Trigger')); - await fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); + const handler = jest.fn(); + await render(); + await fireEvent(screen.getByText('Button'), 'handlePress'); + expect(handler).toHaveBeenCalled(); }); -test('should fire inside View with pointerEvents="box-none"', async () => { - const onPress = jest.fn(); - await render( - - - Trigger - - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - await fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); +test('fireEvent returns undefined when handler does not return a value', async () => { + const handler = jest.fn(); + await render(); + const result = await fireEvent.press(screen.getByTestId('btn')); + expect(result).toBeUndefined(); }); -test('should fire inside View with pointerEvents="auto"', async () => { - const onPress = jest.fn(); +test('fireEvent calls handler on element when both element and parent have handlers', async () => { + const childHandler = jest.fn(); + const parentHandler = jest.fn(); await render( - - - Trigger + + + Press me - , + , ); - - await fireEvent.press(screen.getByText('Trigger')); - await fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); + await fireEvent.press(screen.getByTestId('child')); + expect(childHandler).toHaveBeenCalledTimes(1); + expect(parentHandler).not.toHaveBeenCalled(); }); -test('should not fire deeply inside View with pointerEvents="box-only"', async () => { +test('fireEvent does nothing when element is unmounted', async () => { const onPress = jest.fn(); await render( - - - - Trigger - - + + , ); + const element = screen.getByTestId('btn'); - await fireEvent.press(screen.getByText('Trigger')); - await fireEvent(screen.getByText('Trigger'), 'onPress'); + await screen.rerender(); + await fireEvent.press(element); expect(onPress).not.toHaveBeenCalled(); }); -test('should fire non-pointer events inside View with pointerEvents="box-none"', async () => { - const onTouchStart = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('view'), 'touchStart'); - expect(onTouchStart).toHaveBeenCalled(); -}); - -test('should fire non-touch events inside View with pointerEvents="box-none"', async () => { - const onLayout = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('view'), 'layout'); - expect(onLayout).toHaveBeenCalled(); -}); - -// This test if pointerEvents="box-only" on composite `Pressable` is blocking -// the 'press' event on host View rendered by pressable. -test('should fire on Pressable with pointerEvents="box-only', async () => { - const onPress = jest.fn(); - await render(); - - await fireEvent.press(screen.getByTestId('pressable')); - expect(onPress).toHaveBeenCalled(); +test('fireEvent does not throw when called with non-existent event name', async () => { + await render(); + const element = screen.getByTestId('btn'); + await expect(fireEvent(element, 'nonExistentEvent' as any)).resolves.toBeUndefined(); }); -test('should pass event up on disabled TouchableOpacity', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - await render( - - - Inner Trigger - - , - ); - - await fireEvent.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); -}); - -test('should pass event up on disabled Pressable', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - await render( - - - Inner Trigger - - , - ); - - await fireEvent.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); -}); - -type TestComponentProps = { - onPress: () => void; - disabled?: boolean; -}; -const TestComponent = ({ onPress }: TestComponentProps) => { - return ( - - Trigger Test - - ); -}; - -test('is not fooled by non-native disabled prop', async () => { - const handlePress = jest.fn(); - await render(); - - await fireEvent.press(screen.getByText('Trigger Test')); - expect(handlePress).toHaveBeenCalledTimes(1); +test('fireEvent handles handler that throws gracefully', async () => { + const error = new Error('Handler error'); + const onPress = jest.fn(() => { + throw error; + }); + await render(); + await expect(fireEvent.press(screen.getByTestId('btn'))).rejects.toThrow('Handler error'); + expect(onPress).toHaveBeenCalledTimes(1); }); -type TestChildTouchableComponentProps = { - onPress: () => void; - someProp: boolean; -}; - -function TestChildTouchableComponent({ onPress, someProp }: TestChildTouchableComponentProps) { - return ( - - +describe('disabled elements', () => { + test('does not fire on disabled Pressable', async () => { + const onPress = jest.fn(); + await render( + Trigger - - - ); -} - -test('is not fooled by non-responder wrapping host elements', async () => { - const handlePress = jest.fn(); - - await render( - - - , - ); - - await fireEvent.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); -}); - -type TestDraggableComponentProps = { onDrag: () => void }; - -function TestDraggableComponent({ onDrag }: TestDraggableComponentProps) { - const responderHandlers = PanResponder.create({ - onMoveShouldSetPanResponder: (_evt, _gestureState) => true, - onPanResponderMove: onDrag, - }).panHandlers; + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); - return ( - - Trigger - - ); -} + test('does not fire on disabled TouchableOpacity', async () => { + const onPress = jest.fn(); + await render( + + Trigger + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); + }); -test('has only onMove', async () => { - const handleDrag = jest.fn(); + test('bubbles event past disabled inner to enabled outer Pressable', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + await render( + + + Inner Trigger + + , + ); + await fireEvent.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); + }); - await render(); + test('bubbles event past disabled inner to enabled outer TouchableOpacity', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + await render( + + + Inner Trigger + + , + ); + await fireEvent.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); + }); - await fireEvent(screen.getByText('Trigger'), 'responderMove', { - touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, + test('ignores custom disabled prop on composite component (only respects native disabled)', async () => { + const TestComponent = ({ onPress }: { onPress: () => void; disabled?: boolean }) => ( + + Trigger Test + + ); + const handlePress = jest.fn(); + await render(); + await fireEvent.press(screen.getByText('Trigger Test')); + expect(handlePress).toHaveBeenCalledTimes(1); }); - expect(handleDrag).toHaveBeenCalled(); }); -// Those events ideally should be triggered through `fireEvent.scroll`, but they are handled at the -// native level, so we need to support manually triggering them -describe('native events', () => { - test('triggers onScrollBeginDrag', async () => { - const onScrollBeginDragSpy = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('test-id'), 'onScrollBeginDrag'); - expect(onScrollBeginDragSpy).toHaveBeenCalled(); +describe('pointerEvents prop', () => { + test('does not fire inside View with pointerEvents="none"', async () => { + const onPress = jest.fn(); + await render( + + + , + ); + await fireEvent.press(screen.getByTestId('btn')); + expect(onPress).not.toHaveBeenCalled(); }); - test('triggers onScrollEndDrag', async () => { - const onScrollEndDragSpy = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('test-id'), 'onScrollEndDrag'); - expect(onScrollEndDragSpy).toHaveBeenCalled(); + test('does not fire inside View with pointerEvents="box-only"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); }); - test('triggers onMomentumScrollBegin', async () => { - const onMomentumScrollBeginSpy = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); - expect(onMomentumScrollBeginSpy).toHaveBeenCalled(); + test('fires inside View with pointerEvents="box-none"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledTimes(1); }); - test('triggers onMomentumScrollEnd', async () => { - const onMomentumScrollEndSpy = jest.fn(); - await render(); - - await fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); - expect(onMomentumScrollEndSpy).toHaveBeenCalled(); + test('fires inside View with pointerEvents="auto"', async () => { + const onPress = jest.fn(); + await render( + + + Trigger + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).toHaveBeenCalledTimes(1); }); -}); -describe('React.Suspense integration', () => { - let mockPromise: Promise; - let resolveMockPromise: (value: string) => void; - - beforeEach(() => { - mockPromise = new Promise((resolve) => { - resolveMockPromise = resolve; - }); + test('does not fire deeply inside View with pointerEvents="box-only"', async () => { + const onPress = jest.fn(); + await render( + + + + Trigger + + + , + ); + await fireEvent.press(screen.getByText('Trigger')); + expect(onPress).not.toHaveBeenCalled(); }); - type AsyncComponentProps = { - onPress: () => void; - shouldSuspend: boolean; - }; + test('fires non-pointer events inside View with pointerEvents="box-none"', async () => { + const onLayout = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('view'), 'layout'); + expect(onLayout).toHaveBeenCalled(); + }); - function AsyncComponent({ onPress, shouldSuspend }: AsyncComponentProps) { - if (shouldSuspend) { - throw mockPromise; - } + test('fires on Pressable with pointerEvents="box-only" on itself', async () => { + const onPress = jest.fn(); + await render(); + await fireEvent.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); + }); +}); - return ( - - Async Component Loaded - - ); +describe('non-editable TextInput', () => { + function WrappedTextInput(props: TextInputProps) { + return ; } - function SuspenseWrapper({ children }: { children: React.ReactNode }) { - return Loading...}>{children}; + function DoubleWrappedTextInput(props: TextInputProps) { + return ; } - test('should handle events after Suspense resolves', async () => { - const onPressMock = jest.fn(); + test('blocks touch-related events but allows non-touch events', async () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); await render( - - - , + , ); - // Initially shows fallback - expect(screen.getByText('Loading...')).toBeTruthy(); - - // Resolve the promise - resolveMockPromise('loaded'); - await waitFor(async () => { - await screen.rerender( - - - , - ); - }); - - // Component should be loaded now - await waitFor(() => { - expect(screen.getByText('Async Component Loaded')).toBeTruthy(); - }); + const input = screen.getByTestId('input'); + await fireEvent(input, 'focus'); + await fireEvent.changeText(input, 'Text'); + await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(input, 'layout', layoutEvent); - // fireEvent should work on the resolved component - await fireEvent.press(screen.getByText('Async Component Loaded')); - expect(onPressMock).toHaveBeenCalled(); + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); }); - test('should handle events on Suspense fallback components', async () => { - const fallbackPressMock = jest.fn(); - - function InteractiveFallback() { - return ( - - Loading with button... - - ); - } + test('blocks touch-related events when firing on nested Text child', async () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); await render( - }> - - , + + Nested Text + , ); - // Should be able to interact with fallback - expect(screen.getByText('Loading with button...')).toBeTruthy(); - - await fireEvent.press(screen.getByText('Loading with button...')); - expect(fallbackPressMock).toHaveBeenCalled(); + const subject = screen.getByText('Nested Text'); + await fireEvent(subject, 'focus'); + await fireEvent(subject, 'onFocus'); + await fireEvent.changeText(subject, 'Text'); + await fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(subject, 'onSubmitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(subject, 'layout', layoutEvent); + await fireEvent(subject, 'onLayout', layoutEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledTimes(2); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); }); - test('should work with nested Suspense boundaries', async () => { - const outerPressMock = jest.fn(); - const innerPressMock = jest.fn(); - - type NestedAsyncProps = { - onPress: () => void; - shouldSuspend: boolean; - level: string; - }; - - function NestedAsync({ onPress, shouldSuspend, level }: NestedAsyncProps) { - if (shouldSuspend) { - throw mockPromise; - } + test.each([ + ['WrappedTextInput', WrappedTextInput], + ['DoubleWrappedTextInput', DoubleWrappedTextInput], + ])('blocks touch-related events on %s', async (_, Component) => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); - return ( - - {level} Component Loaded - - ); - } - - const { rerender } = await render( - Outer Loading...}> - - Inner Loading...}> - - - , + await render( + , ); - // Outer component should be loaded, inner should show fallback - expect(screen.getByText('Outer Component Loaded')).toBeTruthy(); - expect(screen.getByText('Inner Loading...')).toBeTruthy(); - - // Should be able to interact with outer component - await fireEvent.press(screen.getByText('Outer Component Loaded')); - expect(outerPressMock).toHaveBeenCalled(); - - // Resolve inner component - resolveMockPromise('inner-loaded'); - await waitFor(async () => { - await rerender( - Outer Loading...}> - - Inner Loading...}> - - - , - ); - }); + const input = screen.getByTestId('input'); + await fireEvent(input, 'focus'); + await fireEvent.changeText(input, 'Text'); + await fireEvent(input, 'submitEditing', { nativeEvent: { text: 'Text' } }); + await fireEvent(input, 'layout', layoutEvent); - // Both components should be loaded now - await waitFor(() => { - expect(screen.getByText('Inner Component Loaded')).toBeTruthy(); - }); - - // Should be able to interact with inner component - await fireEvent.press(screen.getByText('Inner Component Loaded')); - expect(innerPressMock).toHaveBeenCalled(); + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); }); - test('should work when events cause components to suspend', async () => { - const onPressMock = jest.fn(); - let shouldSuspend = false; + test('fires layout event', async () => { + const onLayout = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('input'), 'layout'); + expect(onLayout).toHaveBeenCalled(); + }); - function DataComponent() { - if (shouldSuspend) { - throw mockPromise; // This will cause suspense - } - return Data loaded; - } + test('fires scroll event', async () => { + const onScroll = jest.fn(); + await render(); + await fireEvent(screen.getByTestId('input'), 'scroll'); + expect(onScroll).toHaveBeenCalled(); + }); +}); - function ButtonComponent() { +describe('responder system', () => { + test('respects disabled prop through composite wrappers', async () => { + function TestChildTouchableComponent({ + onPress, + someProp, + }: { + onPress: () => void; + someProp: boolean; + }) { return ( - { - onPressMock(); - shouldSuspend = true; // This will cause DataComponent to suspend on next render - }} - > - Load Data - + + + Trigger + + ); } - + const handlePress = jest.fn(); await render( - - Loading data...}> - - + , ); - - // Initially data is loaded - expect(screen.getByText('Data loaded')).toBeTruthy(); - - // Click button - this triggers the state change that will cause suspension - await fireEvent.press(screen.getByText('Load Data')); - expect(onPressMock).toHaveBeenCalled(); - - // Rerender - now DataComponent should suspend - await screen.rerender( - - - Loading data...}> - - - , - ); - - // Should show loading fallback - expect(screen.getByText('Loading data...')).toBeTruthy(); + await fireEvent.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); }); -}); -test('should handle unmounted elements gracefully', async () => { - const onPress = jest.fn(); - await render( - - Test - , - ); - - const element = screen.getByText('Test'); - await screen.rerender(); - - // Firing async event on unmounted element should not crash - await fireEvent.press(element); - expect(onPress).not.toHaveBeenCalled(); + test('fires responderMove on PanResponder component', async () => { + const onDrag = jest.fn(); + function TestDraggableComponent({ onDrag }: { onDrag: () => void }) { + const responderHandlers = PanResponder.create({ + onMoveShouldSetPanResponder: () => true, + onPanResponderMove: onDrag, + }).panHandlers; + return ( + + Trigger + + ); + } + await render(); + await fireEvent(screen.getByText('Trigger'), 'responderMove', { + touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, + }); + expect(onDrag).toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index ec4d9a17..08dc4c6b 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '..'; +import { logger } from '../helpers/logger'; test('renders a simple component', async () => { const TestComponent = () => ( @@ -203,6 +204,14 @@ describe('toJSON', () => { }); describe('debug', () => { + beforeEach(() => { + jest.spyOn(logger, 'info').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + test('debug outputs formatted component tree', async () => { const TestComponent = () => ( diff --git a/src/fire-event.ts b/src/fire-event.ts index 96276643..0a1a176f 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -10,7 +10,7 @@ import type { Fiber, HostElement } from 'test-renderer'; import { act } from './act'; import type { EventHandler } from './event-handler'; import { getEventHandlerFromProps } from './event-handler'; -import { isElementMounted, isHostElement } from './helpers/component-tree'; +import { isElementMounted } from './helpers/component-tree'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isEditableTextInput } from './helpers/text-input'; @@ -18,10 +18,6 @@ import { nativeState } from './native-state'; import type { Point, StringWithAutocomplete } from './types'; export function isTouchResponder(element: HostElement) { - if (!isHostElement(element)) { - return false; - } - return Boolean(element.props.onStartShouldSetResponder) || isHostTextInput(element); } @@ -184,6 +180,7 @@ function tryGetContentOffset(event: unknown): Point | null { const contentOffset = event?.nativeEvent?.contentOffset; const x = contentOffset?.x; const y = contentOffset?.y; + if (typeof x === 'number' || typeof y === 'number') { return { x: Number.isFinite(x) ? x : 0, diff --git a/src/react-versions.ts b/src/react-versions.ts deleted file mode 100644 index be8d78fc..00000000 --- a/src/react-versions.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; - -export function checkReactVersionAtLeast(major: number, minor: number): boolean { - if (React.version === undefined) return false; - const [actualMajor, actualMinor] = React.version.split('.').map(Number); - - return actualMajor > major || (actualMajor === major && actualMinor >= minor); -}