diff --git a/src/fireEvent.ts b/src/fireEvent.ts index a00dd7ac9..cf87ec9ff 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -1,7 +1,8 @@ import { ReactTestInstance } from 'react-test-renderer'; import act from './act'; -import { getHostParent, isHostElement } from './helpers/component-tree'; +import { isHostElement } from './helpers/component-tree'; import { getHostComponentNames } from './helpers/host-component-names'; +import { isPointerEventEnabled } from './helpers/pointer-events'; type EventHandler = (...args: unknown[]) => unknown; @@ -19,27 +20,6 @@ export function isTouchResponder(element: ReactTestInstance) { ); } -export function isPointerEventEnabled( - element: ReactTestInstance, - isParent?: boolean -): boolean { - const pointerEvents = element.props.pointerEvents; - if (pointerEvents === 'none') { - return false; - } - - if (isParent ? pointerEvents === 'box-only' : pointerEvents === 'box-none') { - return false; - } - - const parent = getHostParent(element); - if (!parent) { - return true; - } - - return isPointerEventEnabled(parent, true); -} - /** * List of events affected by `pointerEvents` prop. * diff --git a/src/helpers/pointer-events.ts b/src/helpers/pointer-events.ts new file mode 100644 index 000000000..fbd7495d3 --- /dev/null +++ b/src/helpers/pointer-events.ts @@ -0,0 +1,27 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { getHostParent } from './component-tree'; + +/** + * pointerEvents controls whether the View can be the target of touch events. + * 'auto': The View and its children can be the target of touch events. + * 'none': The View is never the target of touch events. + * 'box-none': The View is never the target of touch events but its subviews can be + * 'box-only': The view can be the target of touch events but its subviews cannot be + * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ +export const isPointerEventEnabled = ( + element: ReactTestInstance, + isParent?: boolean +): boolean => { + const parentCondition = isParent + ? element?.props.pointerEvents === 'box-only' + : element?.props.pointerEvents === 'box-none'; + + if (element?.props.pointerEvents === 'none' || parentCondition) { + return false; + } + + const hostParent = getHostParent(element); + if (!hostParent) return true; + + return isPointerEventEnabled(hostParent, true); +}; diff --git a/src/test-utils/events.ts b/src/test-utils/events.ts index b6a0dd13c..c34095346 100644 --- a/src/test-utils/events.ts +++ b/src/test-utils/events.ts @@ -18,3 +18,7 @@ export function createEventLogger() { return { events, logEvent }; } + +export function getEventsName(events: EventEntry[]) { + return events.map((event) => event.name); +} diff --git a/src/user-event/event-builder/common.ts b/src/user-event/event-builder/common.ts index 1e732eb3f..fd2842e55 100644 --- a/src/user-event/event-builder/common.ts +++ b/src/user-event/event-builder/common.ts @@ -6,6 +6,8 @@ export const CommonEventBuilder = { */ touch: () => { return { + persist: jest.fn(), + currentTarget: { measure: jest.fn() }, nativeEvent: { changedTouches: [], identifier: 0, @@ -14,7 +16,7 @@ export const CommonEventBuilder = { pageX: 0, pageY: 0, target: 0, - timestamp: 0, + timestamp: Date.now(), touches: [], }, }; diff --git a/src/user-event/index.ts b/src/user-event/index.ts index c90f608bd..fb6fa8d37 100644 --- a/src/user-event/index.ts +++ b/src/user-event/index.ts @@ -1,11 +1,14 @@ import { ReactTestInstance } from 'react-test-renderer'; import { setup } from './setup'; +import { PressOptions } from './press/press'; export const userEvent = { setup, // Direct access for User Event v13 compatibility press: (element: ReactTestInstance) => setup().press(element), + longPress: (element: ReactTestInstance, options?: PressOptions) => + setup().longPress(element, options), type: (element: ReactTestInstance, text: string) => setup().type(element, text), }; diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap deleted file mode 100644 index 87a9c5931..000000000 --- a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap +++ /dev/null @@ -1,54 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`user.press() dispatches required events on Text 1`] = ` -[ - { - "name": "pressIn", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 0, - "touches": [], - }, - }, - }, - { - "name": "press", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 0, - "touches": [], - }, - }, - }, - { - "name": "pressOut", - "payload": { - "nativeEvent": { - "changedTouches": [], - "identifier": 0, - "locationX": 0, - "locationY": 0, - "pageX": 0, - "pageY": 0, - "target": 0, - "timestamp": 0, - "touches": [], - }, - }, - }, -] -`; diff --git a/src/user-event/press/__tests__/longPress.real-timers.test.tsx b/src/user-event/press/__tests__/longPress.real-timers.test.tsx new file mode 100644 index 000000000..fec234c0c --- /dev/null +++ b/src/user-event/press/__tests__/longPress.real-timers.test.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Pressable, Text } from 'react-native'; +import { render, screen } from '../../../pure'; +import { userEvent } from '../..'; +import * as WarnAboutRealTimers from '../utils/warnAboutRealTimers'; + +describe('userEvent.longPress with real timers', () => { + beforeEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + jest.spyOn(WarnAboutRealTimers, 'warnAboutRealTimers').mockImplementation(); + }); + + test('calls onLongPress if the delayLongPress is the default one', async () => { + const mockOnLongPress = jest.fn(); + const user = userEvent.setup(); + + render( + + press me + + ); + await user.longPress(screen.getByText('press me')); + + expect(mockOnLongPress).toHaveBeenCalled(); + }); + + test('calls onLongPress when duration is greater than specified delayLongPress', async () => { + const mockOnLongPress = jest.fn(); + const mockOnPress = jest.fn(); + const user = userEvent.setup(); + + render( + + press me + + ); + + await user.longPress(screen.getByText('press me'), { + duration: 1000, + }); + + expect(mockOnLongPress).toHaveBeenCalled(); + expect(mockOnPress).not.toHaveBeenCalled(); + }); + + test('does not calls onLongPress when duration is lesser than specified delayLongPress', async () => { + const mockOnLongPress = jest.fn(); + const mockOnPress = jest.fn(); + const user = userEvent.setup(); + + render( + + press me + + ); + await user.longPress(screen.getByText('press me')); + + expect(mockOnLongPress).not.toHaveBeenCalled(); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + test('does not calls onPress when onLongPress is called', async () => { + const mockOnLongPress = jest.fn(); + const mockOnPress = jest.fn(); + const user = userEvent.setup(); + + render( + + press me + + ); + await user.longPress(screen.getByText('press me')); + + expect(mockOnLongPress).toHaveBeenCalled(); + expect(mockOnPress).not.toHaveBeenCalled(); + }); + + test('longPress is accessible directly in userEvent', async () => { + const mockOnLongPress = jest.fn(); + + render( + + press me + + ); + + await userEvent.longPress(screen.getByText('press me')); + + expect(mockOnLongPress).toHaveBeenCalled(); + }); +}); + +test('warns about using real timers with userEvent', async () => { + jest.restoreAllMocks(); + const mockConsoleWarn = jest.spyOn(console, 'warn').mockImplementation(); + + render(); + + await userEvent.longPress(screen.getByTestId('pressable')); + + expect(mockConsoleWarn.mock.calls[0][0]).toMatchInlineSnapshot(` + "It is recommended to use userEvent with fake timers + Some events involve duration so your tests may take a long time to run. + For instance calling userEvent.longPress with real timers will take 500 ms." + `); +}); diff --git a/src/user-event/press/__tests__/longPress.test.tsx b/src/user-event/press/__tests__/longPress.test.tsx new file mode 100644 index 000000000..c9a23e00f --- /dev/null +++ b/src/user-event/press/__tests__/longPress.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { Pressable, Text } from 'react-native'; +import { render, screen } from '../../../pure'; +import { userEvent } from '../..'; +import { createEventLogger } from '../../../test-utils'; + +describe('userEvent.longPress with fake timers', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(0); + }); + + test('calls onLongPress if the delayLongPress is the default one', async () => { + const { logEvent, events } = createEventLogger(); + const user = userEvent.setup(); + + render( + + press me + + ); + await user.longPress(screen.getByText('press me')); + + expect(events).toMatchInlineSnapshot(` + [ + { + "name": "longPress", + "payload": { + "currentTarget": { + "measure": [MockFunction] { + "calls": [ + [ + [Function], + ], + [ + [Function], + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + }, + "dispatchConfig": { + "registrationName": "onResponderGrant", + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + "persist": [MockFunction] { + "calls": [ + [], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ] + `); + }); + + test('calls onLongPress when duration is greater than specified delayLongPress', async () => { + const mockOnLongPress = jest.fn(); + const mockOnPress = jest.fn(); + const user = userEvent.setup(); + + render( + + press me + + ); + + await user.longPress(screen.getByText('press me'), { + duration: 1000, + }); + + expect(mockOnLongPress).toHaveBeenCalled(); + expect(mockOnPress).not.toHaveBeenCalled(); + }); + + test('does not calls onLongPress when duration is lesser than specified delayLongPress', async () => { + const mockOnLongPress = jest.fn(); + const mockOnPress = jest.fn(); + const user = userEvent.setup(); + + render( + + press me + + ); + await user.longPress(screen.getByText('press me')); + + expect(mockOnLongPress).not.toHaveBeenCalled(); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + test('does not calls onPress when onLongPress is called', async () => { + const mockOnLongPress = jest.fn(); + const mockOnPress = jest.fn(); + const user = userEvent.setup(); + + render( + + press me + + ); + await user.longPress(screen.getByText('press me')); + + expect(mockOnLongPress).toHaveBeenCalled(); + expect(mockOnPress).not.toHaveBeenCalled(); + }); + + test('longPress is accessible directly in userEvent', async () => { + const mockOnLongPress = jest.fn(); + + render( + + press me + + ); + + await userEvent.longPress(screen.getByText('press me')); + + expect(mockOnLongPress).toHaveBeenCalled(); + }); +}); diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx new file mode 100644 index 000000000..fda9bf914 --- /dev/null +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -0,0 +1,318 @@ +import * as React from 'react'; +import { + Pressable, + Text, + TextInput, + TouchableHighlight, + TouchableOpacity, + View, +} from 'react-native'; +import { createEventLogger, getEventsName } from '../../../test-utils'; +import { render, screen } from '../../..'; +import { userEvent } from '../..'; +import * as WarnAboutRealTimers from '../utils/warnAboutRealTimers'; + +describe('userEvent.press with real timers', () => { + beforeEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + jest.spyOn(WarnAboutRealTimers, 'warnAboutRealTimers').mockImplementation(); + }); + + test('calls onPressIn, onPress and onPressOut prop of touchable', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + + ); + await user.press(screen.getByTestId('pressable')); + + expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + }); + + test('does not trigger event when pressable is disabled', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + + ); + await user.press(screen.getByTestId('pressable')); + + expect(events).toEqual([]); + }); + + test('does not call press when pointer events is none', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + + ); + await user.press(screen.getByTestId('pressable')); + + expect(events).toEqual([]); + }); + + test('does not call press when pointer events is box-none', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + + ); + await user.press(screen.getByTestId('pressable')); + + expect(events).toEqual([]); + }); + + test('does not call press when parent has pointer events box-only', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + + + + ); + await user.press(screen.getByTestId('pressable')); + + expect(events).toEqual([]); + }); + + test('calls press when pressable has pointer events box-only', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + + ); + await user.press(screen.getByTestId('pressable')); + + expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + }); + + test('crawls up in the tree to find an element that responds to touch events', async () => { + const mockOnPress = jest.fn(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(mockOnPress).toHaveBeenCalled(); + }); + + test('does not call onLongPress', async () => { + const mockOnLongPress = jest.fn(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(mockOnLongPress).not.toHaveBeenCalled(); + }); + + test('works on TouchableOpacity', async () => { + const mockOnPress = jest.fn(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(mockOnPress).toHaveBeenCalled(); + }); + + test('works on TouchableHighlight', async () => { + const mockOnPress = jest.fn(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(mockOnPress).toHaveBeenCalled(); + }); + + test('works on Text', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + }); + + test('doesnt trigger on disabled Text', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(events).toEqual([]); + }); + + test('doesnt trigger on Text with disabled pointer events', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + + press me + + + ); + await userEvent.press(screen.getByText('press me')); + + expect(events).toEqual([]); + }); + + test('works on TetInput', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + ); + await userEvent.press(screen.getByPlaceholderText('email')); + + expect(getEventsName(events)).toEqual(['pressIn', 'pressOut']); + }); + + test('does not call onPressIn and onPressOut on non editable TetInput', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + ); + await userEvent.press(screen.getByPlaceholderText('email')); + expect(events).toEqual([]); + }); + + test('does not call onPressIn and onPressOut on TetInput with pointer events disabled', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + ); + await userEvent.press(screen.getByPlaceholderText('email')); + expect(events).toEqual([]); + }); + + test('press is accessible directly in userEvent', async () => { + const mockOnPress = jest.fn(); + + render( + + press me + + ); + + await userEvent.press(screen.getByText('press me')); + + expect(mockOnPress).toHaveBeenCalled(); + }); +}); + +test('warns about using real timers with userEvent', async () => { + jest.restoreAllMocks(); + const mockConsoleWarn = jest.spyOn(console, 'warn').mockImplementation(); + + render(); + + await userEvent.press(screen.getByTestId('pressable')); + + expect(mockConsoleWarn.mock.calls[0][0]).toMatchInlineSnapshot(` + "It is recommended to use userEvent with fake timers + Some events involve duration so your tests may take a long time to run. + For instance calling userEvent.longPress with real timers will take 500 ms." + `); +}); diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index c7dda9f2a..defe7c615 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -1,67 +1,422 @@ import * as React from 'react'; -import { Text } from 'react-native'; -import { createEventLogger } from '../../../test-utils'; -import { render } from '../../..'; +import { + Pressable, + Text, + TextInput, + TouchableHighlight, + TouchableOpacity, + View, +} from 'react-native'; +import { createEventLogger, getEventsName } from '../../../test-utils'; +import { render, screen } from '../../..'; import { userEvent } from '../..'; -beforeEach(() => { - jest.resetAllMocks(); -}); +describe('userEvent.press with fake timers', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(0); + }); -describe('user.press()', () => { - it('dispatches required events on Text', async () => { + test('calls onPressIn, onPress and onPressOut prop of touchable', async () => { const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); - const screen = render( - ); + await user.press(screen.getByTestId('pressable')); + + expect(events).toMatchInlineSnapshot(` + [ + { + "name": "pressIn", + "payload": { + "currentTarget": { + "measure": [MockFunction] { + "calls": [ + [ + [Function], + ], + [ + [Function], + ], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + { + "type": "return", + "value": undefined, + }, + ], + }, + }, + "dispatchConfig": { + "registrationName": "onResponderGrant", + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + "persist": [MockFunction] { + "calls": [ + [], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + { + "name": "press", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "dispatchConfig": { + "registrationName": "onResponderRelease", + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + "persist": [MockFunction] { + "calls": [ + [], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + { + "name": "pressOut", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "dispatchConfig": { + "registrationName": "onResponderRelease", + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + "persist": [MockFunction] { + "calls": [ + [], + ], + "results": [ + { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ] + `); + }); + + test('does not trigger event when pressable is disabled', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); - await user.press(screen.getByTestId('view')); + render( + + ); + await user.press(screen.getByTestId('pressable')); - const eventNames = events.map((event) => event.name); - expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']); - expect(events).toMatchSnapshot(); + expect(events).toEqual([]); }); - it('supports direct access', async () => { + test('does not call press when pointer events is none', async () => { const { events, logEvent } = createEventLogger(); - const screen = render( - ); + await user.press(screen.getByTestId('pressable')); + + expect(events).toEqual([]); + }); + + test('does not call press when pointer events is box-none', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); - await userEvent.press(screen.getByTestId('view')); + render( + + ); + await user.press(screen.getByTestId('pressable')); - const eventNames = events.map((event) => event.name); - expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']); + expect(events).toEqual([]); }); - it.each(['modern', 'legacy'])('works with fake %s timers', async (type) => { - jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + test('does not call press when parent has pointer events box-only', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + + + + ); + await user.press(screen.getByTestId('pressable')); + expect(events).toEqual([]); + }); + + test('calls press when pressable has pointer events box-only', async () => { const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); - const screen = render( + + render( + + ); + await user.press(screen.getByTestId('pressable')); + + expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + }); + + test('crawls up in the tree to find an element that responds to touch events', async () => { + const mockOnPress = jest.fn(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(mockOnPress).toHaveBeenCalled(); + }); + + test('does not call onLongPress', async () => { + const mockOnLongPress = jest.fn(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(mockOnLongPress).not.toHaveBeenCalled(); + }); + + test('works on TouchableOpacity', async () => { + const mockOnPress = jest.fn(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(mockOnPress).toHaveBeenCalled(); + }); + + test('works on TouchableHighlight', async () => { + const mockOnPress = jest.fn(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(mockOnPress).toHaveBeenCalled(); + }); + + test('works on Text', async () => { + const { events, logEvent } = createEventLogger(); + + render( + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(getEventsName(events)).toEqual(['pressIn', 'press', 'pressOut']); + }); + + test('doesnt trigger on disabled Text', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + press me + + ); + await userEvent.press(screen.getByText('press me')); + + expect(events).toEqual([]); + }); + + test('doesnt trigger on Text with disabled pointer events', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + + press me + + + ); + await userEvent.press(screen.getByText('press me')); + + expect(events).toEqual([]); + }); + + test('works on TetInput', async () => { + const { events, logEvent } = createEventLogger(); + + render( + ); + await userEvent.press(screen.getByPlaceholderText('email')); + + expect(getEventsName(events)).toEqual(['pressIn', 'pressOut']); + }); + + test('does not call onPressIn and onPressOut on non editable TetInput', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + ); + await userEvent.press(screen.getByPlaceholderText('email')); + expect(events).toEqual([]); + }); + + test('does not call onPressIn and onPressOut on TetInput with pointer events disabled', async () => { + const { events, logEvent } = createEventLogger(); + + render( + + ); + await userEvent.press(screen.getByPlaceholderText('email')); + expect(events).toEqual([]); + }); + + test('press is accessible directly in userEvent', async () => { + const mockOnPress = jest.fn(); + + render( + + press me + + ); - await user.press(screen.getByTestId('view')); + await userEvent.press(screen.getByText('press me')); - const eventNames = events.map((event) => event.name); - expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']); + expect(mockOnPress).toHaveBeenCalled(); }); }); diff --git a/src/user-event/press/constants.ts b/src/user-event/press/constants.ts new file mode 100644 index 000000000..8d237a0db --- /dev/null +++ b/src/user-event/press/constants.ts @@ -0,0 +1,7 @@ +// These are constants defined in the React Native repo + +// Used to define the delay before calling onPressOut after a press +export const DEFAULT_MIN_PRESS_DURATION = 130; + +// Default minimum press duration to trigger a long press +export const DEFAULT_LONG_PRESS_DELAY_MS = 500; diff --git a/src/user-event/press/index.ts b/src/user-event/press/index.ts index dffd68f90..c7acfc260 100644 --- a/src/user-event/press/index.ts +++ b/src/user-event/press/index.ts @@ -1 +1 @@ -export { press } from './press'; +export { press, longPress } from './press'; diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index be8178315..c69c04074 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,16 +1,134 @@ import { ReactTestInstance } from 'react-test-renderer'; import { EventBuilder } from '../event-builder'; import { UserEventInstance } from '../setup'; -import { dispatchHostEvent, wait } from '../utils'; +import { wait } from '../utils'; +import act from '../../act'; +import { getHostParent } from '../../helpers/component-tree'; +import { filterNodeByType } from '../../helpers/filterNodeByType'; +import { isPointerEventEnabled } from '../../helpers/pointer-events'; +import { getHostComponentNames } from '../../helpers/host-component-names'; +import { jestFakeTimersAreEnabled } from '../../helpers/timers'; +import { DEFAULT_MIN_PRESS_DURATION } from './constants'; +import { warnAboutRealTimers } from './utils/warnAboutRealTimers'; + +export type PressOptions = { + duration: number; +}; export async function press( this: UserEventInstance, element: ReactTestInstance -) { - // TODO provide real implementation - dispatchHostEvent(element, 'pressIn', EventBuilder.Common.touch()); +): Promise { + await basePress(this.config, element); +} - await wait(this.config); - dispatchHostEvent(element, 'press', EventBuilder.Common.touch()); - dispatchHostEvent(element, 'pressOut', EventBuilder.Common.touch()); +export async function longPress( + this: UserEventInstance, + element: ReactTestInstance, + options: PressOptions = { duration: 500 } +): Promise { + await basePress(this.config, element, options); } + +const basePress = async ( + config: UserEventInstance['config'], + element: ReactTestInstance, + options: PressOptions = { duration: 0 } +): Promise => { + // Text and TextInput components are mocked in React Native preset so the mock + // doesn't implement the pressability class + // Thus we need to call the props directly on the host component + if (isEnabledHostText(element) || isEnabledTextInput(element)) { + await triggerMockPressEvent(config, element, options); + return; + } + + if (isEnabledTouchResponder(element)) { + await triggerPressEvent(config, element, options); + return; + } + + const hostParentElement = getHostParent(element); + if (!hostParentElement) { + return; + } + + await basePress(config, hostParentElement, options); +}; + +const triggerPressEvent = async ( + config: UserEventInstance['config'], + element: ReactTestInstance, + options: PressOptions = { duration: 0 } +) => { + const areFakeTimersEnabled = jestFakeTimersAreEnabled(); + if (!areFakeTimersEnabled) { + warnAboutRealTimers(); + } + + await wait(config); + + await act(async () => { + element.props.onResponderGrant({ + ...EventBuilder.Common.touch(), + dispatchConfig: { registrationName: 'onResponderGrant' }, + }); + + await wait(config, options.duration); + + element.props.onResponderRelease({ + ...EventBuilder.Common.touch(), + dispatchConfig: { registrationName: 'onResponderRelease' }, + }); + + if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) { + await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration); + } + }); +}; + +const isEnabledTouchResponder = (element: ReactTestInstance) => { + return ( + isPointerEventEnabled(element) && + element.props.onStartShouldSetResponder?.() + ); +}; + +const isEnabledHostText = (element: ReactTestInstance) => { + return ( + filterNodeByType(element, getHostComponentNames().text) && + isPointerEventEnabled(element) && + !element.props.disabled && + element.props.onPress + ); +}; + +const isEnabledTextInput = (element: ReactTestInstance) => { + return ( + filterNodeByType(element, getHostComponentNames().textInput) && + isPointerEventEnabled(element) && + element.props.editable !== false + ); +}; + +const triggerMockPressEvent = async ( + config: UserEventInstance['config'], + element: ReactTestInstance, + options: PressOptions = { duration: 0 } +) => { + const { onPressIn, onPress, onPressOut } = element.props; + await wait(config); + if (onPressIn) { + onPressIn(EventBuilder.Common.touch()); + } + if (onPress) { + onPress(EventBuilder.Common.touch()); + } + await wait(config, options.duration); + if (onPressOut) { + if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) { + await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration); + } + onPressOut(EventBuilder.Common.touch()); + } +}; diff --git a/src/user-event/press/utils/warnAboutRealTimers.ts b/src/user-event/press/utils/warnAboutRealTimers.ts new file mode 100644 index 000000000..307afaef3 --- /dev/null +++ b/src/user-event/press/utils/warnAboutRealTimers.ts @@ -0,0 +1,6 @@ +export const warnAboutRealTimers = () => { + // eslint-disable-next-line no-console + console.warn(`It is recommended to use userEvent with fake timers +Some events involve duration so your tests may take a long time to run. +For instance calling userEvent.longPress with real timers will take 500 ms.`); +}; diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index cb504f928..c1adfd214 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -1,7 +1,8 @@ import { ReactTestInstance } from 'react-test-renderer'; import { jestFakeTimersAreEnabled } from '../../helpers/timers'; -import { press } from '../press'; +import { press, longPress } from '../press'; import { type } from '../type'; +import { PressOptions } from '../press/press'; export interface UserEventSetupOptions { /** @@ -68,6 +69,10 @@ function createConfig(options?: UserEventSetupOptions): UserEventConfig { export interface UserEventInstance { config: UserEventConfig; press: (element: ReactTestInstance) => Promise; + longPress: ( + element: ReactTestInstance, + options?: PressOptions + ) => Promise; type: (element: ReactTestInstance, text: string) => Promise; } @@ -79,6 +84,7 @@ function createInstance(config: UserEventConfig): UserEventInstance { // We need to bind these functions, as they access the config through 'this.config'. const api = { press: press.bind(instance), + longPress: longPress.bind(instance), type: type.bind(instance), }; diff --git a/src/user-event/utils/wait.ts b/src/user-event/utils/wait.ts index 7b355cd36..4c34e6a8b 100644 --- a/src/user-event/utils/wait.ts +++ b/src/user-event/utils/wait.ts @@ -1,7 +1,7 @@ import { UserEventConfig } from '../setup'; -export function wait(config: UserEventConfig) { - const delay = config.delay; +export function wait(config: UserEventConfig, durationInMs?: number) { + const delay = durationInMs ?? config.delay; if (typeof delay !== 'number') { return; } diff --git a/website/docs/UserEvent.md b/website/docs/UserEvent.md index 3ccdacab1..908002ea2 100644 --- a/website/docs/UserEvent.md +++ b/website/docs/UserEvent.md @@ -28,3 +28,39 @@ Creates User Event instances which can be used to trigger events. ### Options - `delay` - controls the default delay between subsequent events, e.g. keystrokes, etc. - `advanceTimers` - time advancement utility function that should be used for fake timers. The default setup handles both real and Jest fake timers. + + +## `press()` + +```ts +press( + element: ReactTestInstance, +): Promise +``` + +Example +```ts +const user = userEvent.setup(); + +await user.press(element); +``` + +This helper simulates a press on any pressable element, e.g. `Pressable`, `TouchableOpacity`, `Text`, `TextInput`, etc. Unlike `fireEvent.press()` which is a simpler API that will only call the `onPress` prop, this simulates the entire press event in a more realistic way by reproducing what really happens when a user presses an interface view. This will trigger additional events like `pressIn` and `pressOut`. + +## `longPress()` + +```ts +longPress( + element: ReactTestInstance, + options: { duration: number } = { duration: 500 } +): Promise +``` + +Example +```ts +const user = userEvent.setup(); + +await user.longPress(element); +``` + +Simulates a long press user interaction. In React Native the `longPress` event is emitted when the press duration exceeds long press threshold (by default 500 ms). In other aspects this actions behaves similar to regular `press` action, e.g. by emitting `pressIn` and `pressOut` events. The press duration is customisable through the options. This should be useful if you use the `delayLongPress` prop. When using real timers this will take 500 ms so it is highly recommended to use that API with fake timers to prevent test taking a long time to run. \ No newline at end of file