diff --git a/examples/basic/components/__tests__/AnimatedView.test.tsx b/examples/basic/components/__tests__/AnimatedView.test.tsx index 0a4a3a29b..25bf5ce80 100644 --- a/examples/basic/components/__tests__/AnimatedView.test.tsx +++ b/examples/basic/components/__tests__/AnimatedView.test.tsx @@ -20,7 +20,7 @@ describe('AnimatedView', () => { ); expect(screen.root).toHaveStyle({ opacity: 0 }); - await act(() => jest.advanceTimersByTime(250)); + act(() => jest.advanceTimersByTime(250)); expect(screen.root).toHaveStyle({ opacity: 1 }); }); @@ -32,7 +32,7 @@ describe('AnimatedView', () => { ); expect(screen.root).toHaveStyle({ opacity: 0 }); - await act(() => jest.advanceTimersByTime(250)); + act(() => jest.advanceTimersByTime(250)); expect(screen.root).toHaveStyle({ opacity: 1 }); }); }); diff --git a/package.json b/package.json index 77836fb39..d833bc56e 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "chalk": "^4.1.2", "jest-matcher-utils": "^29.7.0", "pretty-format": "^29.7.0", + "react-native-gesture-handler": "^2.23.1", "redent": "^3.0.0" }, "peerDependencies": { diff --git a/src/__tests__/react-native-animated.test.tsx b/src/__tests__/react-native-animated.test.tsx index eb7b5ae55..389bde268 100644 --- a/src/__tests__/react-native-animated.test.tsx +++ b/src/__tests__/react-native-animated.test.tsx @@ -43,7 +43,7 @@ describe('AnimatedView', () => { jest.useRealTimers(); }); - it('should use native driver when useNativeDriver is true', async () => { + it('should use native driver when useNativeDriver is true', () => { render( Test @@ -51,12 +51,12 @@ describe('AnimatedView', () => { ); expect(screen.root).toHaveStyle({ opacity: 0 }); - await act(() => jest.advanceTimersByTime(250)); + act(() => jest.advanceTimersByTime(250)); // This stopped working in tests in RN 0.77 // expect(screen.root).toHaveStyle({ opacity: 0 }); }); - it('should not use native driver when useNativeDriver is false', async () => { + it('should not use native driver when useNativeDriver is false', () => { render( Test @@ -64,7 +64,7 @@ describe('AnimatedView', () => { ); expect(screen.root).toHaveStyle({ opacity: 0 }); - await act(() => jest.advanceTimersByTime(250)); + act(() => jest.advanceTimersByTime(250)); expect(screen.root).toHaveStyle({ opacity: 1 }); }); }); diff --git a/src/__tests__/react-native-gesture-handler.test.tsx b/src/__tests__/react-native-gesture-handler.test.tsx new file mode 100644 index 000000000..644090f36 --- /dev/null +++ b/src/__tests__/react-native-gesture-handler.test.tsx @@ -0,0 +1,61 @@ +import 'react-native-gesture-handler/jestSetup.js'; +import React from 'react'; +import { View } from 'react-native'; +import { Pressable } from 'react-native-gesture-handler'; + +import { fireEvent, render, screen, userEvent } from '..'; +import { createEventLogger, getEventsNames } from '../test-utils'; + +test('fireEvent can invoke press events for RNGH Pressable', () => { + const onPress = jest.fn(); + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + const onLongPress = jest.fn(); + + render( + + + , + ); + + const pressable = screen.getByTestId('pressable'); + + fireEvent.press(pressable); + expect(onPress).toHaveBeenCalled(); + + fireEvent(pressable, 'pressIn'); + expect(onPressIn).toHaveBeenCalled(); + + fireEvent(pressable, 'pressOut'); + expect(onPressOut).toHaveBeenCalled(); + + fireEvent(pressable, 'longPress'); + expect(onLongPress).toHaveBeenCalled(); +}); + +test('userEvent can invoke press events for RNGH Pressable', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + + + , + ); + + const pressable = screen.getByTestId('pressable'); + await user.press(pressable); + expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); +}); diff --git a/src/user-event/press/__tests__/longPress.test.tsx b/src/user-event/press/__tests__/longPress.test.tsx index 9ae5f34d7..3dfb29142 100644 --- a/src/user-event/press/__tests__/longPress.test.tsx +++ b/src/user-event/press/__tests__/longPress.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Pressable, Text, TouchableHighlight, TouchableOpacity } from 'react-native'; +import { Pressable, Text, TouchableHighlight, TouchableOpacity, View } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; import { render, screen } from '../../..'; import { createEventLogger, getEventsNames } from '../../../test-utils'; @@ -152,4 +153,35 @@ describe('userEvent.longPress with fake timers', () => { expect(mockOnLongPress).toHaveBeenCalled(); }); + + test('longPress accepts custom duration', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + + render( + , + ); + + await user.longPress(screen.getByTestId('pressable'), { duration: 50 }); + expect(getEventsNames(events)).toEqual(['pressIn', 'press', 'pressOut']); + }); + + it('longPress throws on composite components', async () => { + render(); + const user = userEvent.setup(); + + const compositeView = screen.getByTestId('view').parent as ReactTestInstance; + await expect(user.longPress(compositeView)).rejects.toThrowErrorMatchingInlineSnapshot(` + "longPress() works only with host elements. Passed element has type "function Component() { + (0, _classCallCheck2.default)(this, Component); + return _callSuper(this, Component, arguments); + }"." + `); + }); }); diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx index 3e8550b5e..903e353cf 100644 --- a/src/user-event/press/__tests__/press.real-timers.test.tsx +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -34,6 +34,8 @@ describe('userEvent.press with real timers', () => { ); await user.press(screen.getByTestId('pressable')); + // Typical event order is pressIn, pressOut, press + // But sometimes due to a race condition, the order is pressIn, press, pressOut. const eventSequence = getEventsNames(events).join(', '); expect( eventSequence === 'pressIn, pressOut, press' || eventSequence === 'pressIn, press, pressOut', @@ -201,11 +203,11 @@ describe('userEvent.press with real timers', () => { ); await user.press(screen.getByTestId('pressable')); - const eventsNames = getEventsNames(events).join(', '); + const eventSequence = getEventsNames(events).join(', '); // Typical event order is pressIn, pressOut, press // But sometimes due to a race condition, the order is pressIn, press, pressOut. expect( - eventsNames === 'pressIn, pressOut, press' || eventsNames === 'pressIn, press, pressOut', + eventSequence === 'pressIn, pressOut, press' || eventSequence === 'pressIn, press, pressOut', ).toBe(true); }); diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index 2f8058219..a73d9813d 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -8,6 +8,7 @@ import { TouchableOpacity, View, } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; import { render, screen } from '../../..'; import { createEventLogger, getEventsNames } from '../../../test-utils'; @@ -331,6 +332,19 @@ describe('userEvent.press with fake timers', () => { expect(mockOnPress).toHaveBeenCalled(); }); + it('press throws on composite components', async () => { + render(); + const user = userEvent.setup(); + + const compositeView = screen.getByTestId('view').parent as ReactTestInstance; + await expect(user.press(compositeView)).rejects.toThrowErrorMatchingInlineSnapshot(` + "press() works only with host elements. Passed element has type "function Component() { + (0, _classCallCheck2.default)(this, Component); + return _callSuper(this, Component, arguments); + }"." + `); + }); + test('disables act environmennt', async () => { // In this test there is state update during await when typing // Since wait is not wrapped by act there would be a warning diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 3c964ad17..e0f432367 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,10 +1,12 @@ import type { ReactTestInstance } from 'react-test-renderer'; import act from '../../act'; -import { getHostParent } from '../../helpers/component-tree'; -import { isHostText } from '../../helpers/host-component-names'; +import { getEventHandler } from '../../event-handler'; +import type { HostTestInstance } from '../../helpers/component-tree'; +import { getHostParent, isHostElement } from '../../helpers/component-tree'; +import { ErrorWithStack } from '../../helpers/errors'; +import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; -import { isEditableTextInput } from '../../helpers/text-input'; import { EventBuilder } from '../event-builder'; import type { UserEventConfig, UserEventInstance } from '../setup'; import { dispatchEvent, wait } from '../utils'; @@ -19,6 +21,13 @@ export interface PressOptions { } export async function press(this: UserEventInstance, element: ReactTestInstance): Promise { + if (!isHostElement(element)) { + throw new ErrorWithStack( + `press() works only with host elements. Passed element has type "${element.type}".`, + press, + ); + } + await basePress(this.config, element, { type: 'press', }); @@ -29,6 +38,13 @@ export async function longPress( element: ReactTestInstance, options?: PressOptions, ): Promise { + if (!isHostElement(element)) { + throw new ErrorWithStack( + `longPress() works only with host elements. Passed element has type "${element.type}".`, + longPress, + ); + } + await basePress(this.config, element, { type: 'longPress', duration: options?.duration ?? DEFAULT_LONG_PRESS_DELAY_MS, @@ -42,21 +58,16 @@ interface BasePressOptions { const basePress = async ( config: UserEventConfig, - element: ReactTestInstance, + element: HostTestInstance, options: BasePressOptions, ): Promise => { - if (isPressableText(element)) { - await emitTextPressEvents(config, element, options); - return; - } - - if (isEditableTextInput(element) && isPointerEventEnabled(element)) { - await emitTextInputPressEvents(config, element, options); + if (isEnabledHostElement(element) && hasPressEventHandler(element)) { + await emitDirectPressEvents(config, element, options); return; } if (isEnabledTouchResponder(element)) { - await emitPressablePressEvents(config, element, options); + await emitPressabilityPressEvents(config, element, options); return; } @@ -68,56 +79,42 @@ const basePress = async ( await basePress(config, hostParentElement, options); }; -const emitPressablePressEvents = async ( - config: UserEventConfig, - element: ReactTestInstance, - options: BasePressOptions, -) => { - await wait(config); - - dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant()); - - const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION; - await wait(config, duration); +function isEnabledHostElement(element: HostTestInstance) { + if (!isPointerEventEnabled(element)) { + return false; + } - dispatchEvent(element, 'responderRelease', EventBuilder.Common.responderRelease()); + if (isHostText(element)) { + return element.props.disabled !== true; + } - // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION - // before emitting the `pressOut` event. We need to wait here, so that - // `press()` function does not return before that. - if (DEFAULT_MIN_PRESS_DURATION - duration > 0) { - await act(async () => { - await wait(config, DEFAULT_MIN_PRESS_DURATION - duration); - }); + if (isHostTextInput(element)) { + // @ts-expect-error - workaround incorrect ReactTestInstance type + return element.props.editable !== false; } -}; -const isEnabledTouchResponder = (element: ReactTestInstance) => { - return isPointerEventEnabled(element) && element.props.onStartShouldSetResponder?.(); -}; + return true; +} -const isPressableText = (element: ReactTestInstance) => { - const hasPressEventHandler = Boolean( - element.props.onPress || - element.props.onLongPress || - element.props.onPressIn || - element.props.onPressOut, - ); +function isEnabledTouchResponder(element: HostTestInstance) { + return isPointerEventEnabled(element) && element.props.onStartShouldSetResponder?.(); +} +function hasPressEventHandler(element: HostTestInstance) { return ( - isHostText(element) && - isPointerEventEnabled(element) && - !element.props.disabled && - hasPressEventHandler + getEventHandler(element, 'press') || + getEventHandler(element, 'longPress') || + getEventHandler(element, 'pressIn') || + getEventHandler(element, 'pressOut') ); -}; +} /** - * Dispatches a press event sequence for Text. + * Dispatches a press event sequence for host elements that have `onPress*` event handlers. */ -async function emitTextPressEvents( +async function emitDirectPressEvents( config: UserEventConfig, - element: ReactTestInstance, + element: HostTestInstance, options: BasePressOptions, ) { await wait(config); @@ -141,19 +138,24 @@ async function emitTextPressEvents( } } -/** - * Dispatches a press event sequence for TextInput. - */ -async function emitTextInputPressEvents( +async function emitPressabilityPressEvents( config: UserEventConfig, - element: ReactTestInstance, + element: HostTestInstance, options: BasePressOptions, ) { await wait(config); - dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); - // Note: TextInput does not have `onPress`/`onLongPress` props. + dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant()); - await wait(config, options.duration); - dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); + const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION; + await wait(config, duration); + + dispatchEvent(element, 'responderRelease', EventBuilder.Common.responderRelease()); + + // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION + // before emitting the `pressOut` event. We need to wait here, so that + // `press()` function does not return before that. + if (DEFAULT_MIN_PRESS_DURATION - duration > 0) { + await act(() => wait(config, DEFAULT_MIN_PRESS_DURATION - duration)); + } } diff --git a/website/docs/12.x/docs/advanced/understanding-act.mdx b/website/docs/12.x/docs/advanced/understanding-act.mdx index a6dfd519d..cb0f9f261 100644 --- a/website/docs/12.x/docs/advanced/understanding-act.mdx +++ b/website/docs/12.x/docs/advanced/understanding-act.mdx @@ -169,9 +169,7 @@ If we wanted to stick with real timers then things get a bit more complex. Let ```jsx test('render with real timers - sleep', async () => { render(); - await act(async () => { - await sleep(100); // Wait a bit longer than setTimeout in `TestAsyncComponent` - }); + await act(() => sleep(100)); // Wait a bit longer than setTimeout in `TestAsyncComponent` expect(screen.getByText('Count 1')).toBeOnTheScreen(); }); diff --git a/yarn.lock b/yarn.lock index f74d8cdfe..4b5b106b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1601,6 +1601,15 @@ __metadata: languageName: node linkType: hard +"@egjs/hammerjs@npm:^2.0.17": + version: 2.0.17 + resolution: "@egjs/hammerjs@npm:2.0.17" + dependencies: + "@types/hammerjs": "npm:^2.0.36" + checksum: 10c0/dbedc15a0e633f887c08394bd636faf6a3abd05726dc0909a0e01209d5860a752d9eca5e512da623aecfabe665f49f1d035de3103eb2f9022c5cea692f9cc9be + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.4.1 resolution: "@eslint-community/eslint-utils@npm:4.4.1" @@ -2844,6 +2853,7 @@ __metadata: pretty-format: "npm:^29.7.0" react: "npm:^19.0.0" react-native: "npm:0.78.0" + react-native-gesture-handler: "npm:^2.23.1" react-test-renderer: "npm:^19.0.0" redent: "npm:^3.0.0" release-it: "npm:^18.0.0" @@ -2924,6 +2934,13 @@ __metadata: languageName: node linkType: hard +"@types/hammerjs@npm:^2.0.36": + version: 2.0.46 + resolution: "@types/hammerjs@npm:2.0.46" + checksum: 10c0/f3c1cb20dc2f0523f7b8c76065078544d50d8ae9b0edc1f62fed657210ed814266ff2dfa835d2c157a075991001eec3b64c88bf92e3e6e895c0db78d05711d06 + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" @@ -6103,6 +6120,15 @@ __metadata: languageName: node linkType: hard +"hoist-non-react-statics@npm:^3.3.0": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: "npm:^16.7.0" + checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74 + languageName: node + linkType: hard + "hosted-git-info@npm:^7.0.0": version: 7.0.2 resolution: "hosted-git-info@npm:7.0.2" @@ -9141,7 +9167,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1": +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 @@ -9162,6 +9188,20 @@ __metadata: languageName: node linkType: hard +"react-native-gesture-handler@npm:^2.23.1": + version: 2.23.1 + resolution: "react-native-gesture-handler@npm:2.23.1" + dependencies: + "@egjs/hammerjs": "npm:^2.0.17" + hoist-non-react-statics: "npm:^3.3.0" + invariant: "npm:^2.2.4" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/02ad664662a529836aaae2f6bce5943ccd3193d33641e6328d1306e6cb82eb5d859c5f9975c557f6e6daf1ffb7e7f6cd8432dac70a727110bff887e554673c7c + languageName: node + linkType: hard + "react-native@npm:0.78.0": version: 0.78.0 resolution: "react-native@npm:0.78.0"