diff --git a/experiments-app/src/screens/TextInputEvents.tsx b/experiments-app/src/screens/TextInputEvents.tsx index d4dad3d90..d679791e9 100644 --- a/experiments-app/src/screens/TextInputEvents.tsx +++ b/experiments-app/src/screens/TextInputEvents.tsx @@ -7,7 +7,12 @@ const handlePressOut = buildEventLogger('pressOut'); const handleFocus = buildEventLogger('focus'); const handleBlur = buildEventLogger('blur'); const handleChange = buildEventLogger('change'); +const handleEndEditing = buildEventLogger('endEditing'); const handleSubmitEditing = buildEventLogger('submitEditing'); +const handleKeyPress = buildEventLogger('keyPress'); +const handleTextInput = buildEventLogger('textInput'); +const handleSelectionChange = buildEventLogger('selectionChange'); +const handleContentSizeChange = buildEventLogger('contentSizeChange'); export function TextInputEvents() { const [value, setValue] = React.useState(''); @@ -29,7 +34,12 @@ export function TextInputEvents() { onFocus={handleFocus} onBlur={handleBlur} onChange={handleChange} + onEndEditing={handleEndEditing} onSubmitEditing={handleSubmitEditing} + onKeyPress={handleKeyPress} + onTextInput={handleTextInput} + onSelectionChange={handleSelectionChange} + onContentSizeChange={handleContentSizeChange} /> ); diff --git a/src/__tests__/act.test.tsx b/src/__tests__/act.test.tsx index ca8e0e5b8..a43077bbf 100644 --- a/src/__tests__/act.test.tsx +++ b/src/__tests__/act.test.tsx @@ -50,3 +50,7 @@ test('should be able to await act', async () => { const result = await act(async () => {}); expect(result).toBe(undefined); }); + +test('should be able to await act when promise rejects', async () => { + await expect(act(async () => Promise.reject('error'))).rejects.toBe('error'); +}); diff --git a/src/fireEvent.ts b/src/fireEvent.ts index cf87ec9ff..3d39cf983 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -1,15 +1,11 @@ import { ReactTestInstance } from 'react-test-renderer'; import act from './act'; -import { isHostElement } from './helpers/component-tree'; -import { getHostComponentNames } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; +import { isHostElement } from './helpers/component-tree'; +import { isHostTextInput } from './helpers/host-component-names'; type EventHandler = (...args: unknown[]) => unknown; -const isHostTextInput = (element?: ReactTestInstance) => { - return element?.type === getHostComponentNames().textInput; -}; - export function isTouchResponder(element: ReactTestInstance) { if (!isHostElement(element)) { return false; diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index 6fa73f49f..99a4af60e 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -4,8 +4,8 @@ import { StyleSheet, } from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; -import { getConfig } from '../config'; import { getHostSiblings } from './component-tree'; +import { getHostComponentNames } from './host-component-names'; type IsInaccessibleOptions = { cache?: WeakMap; @@ -99,8 +99,7 @@ export function isAccessibilityElement( return element.props.accessible; } - const hostComponentNames = getConfig().hostComponentNames; - + const hostComponentNames = getHostComponentNames(); return ( element?.type === hostComponentNames?.text || element?.type === hostComponentNames?.textInput || diff --git a/src/helpers/deprecation.ts b/src/helpers/deprecation.ts index 3112574b6..7d77c6d15 100644 --- a/src/helpers/deprecation.ts +++ b/src/helpers/deprecation.ts @@ -37,7 +37,7 @@ function deprecateQuery any>( const warned: { [functionName: string]: boolean } = {}; -// istambul ignore next: Occasionally used +/* istanbul ignore next: occasionally used */ export function printDeprecationWarning(functionName: string) { if (warned[functionName]) { return; diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx index 61e31db0d..3fd339b0d 100644 --- a/src/helpers/host-component-names.tsx +++ b/src/helpers/host-component-names.tsx @@ -65,3 +65,11 @@ function getByTestId(instance: ReactTestInstance, testID: string) { return nodes[0]; } + +export function isHostText(element?: ReactTestInstance) { + return element?.type === getHostComponentNames().text; +} + +export function isHostTextInput(element?: ReactTestInstance) { + return element?.type === getHostComponentNames().textInput; +} diff --git a/src/user-event/event-builder/common.ts b/src/user-event/event-builder/common.ts index fd2842e55..7a3bad794 100644 --- a/src/user-event/event-builder/common.ts +++ b/src/user-event/event-builder/common.ts @@ -1,24 +1,40 @@ +/** + * Experimental values: + * - iOS: `{"changedTouches": [[Circular]], "identifier": 1, "locationX": 253, "locationY": 30.333328247070312, "pageX": 273, "pageY": 141.3333282470703, "target": 75, "timestamp": 875928682.0450834, "touches": [[Circular]]}` + * - Android: `{"changedTouches": [[Circular]], "identifier": 0, "locationX": 160, "locationY": 40.3636360168457, "pageX": 180, "pageY": 140.36363220214844, "target": 53, "targetSurface": -1, "timestamp": 10290805, "touches": [[Circular]]}` + */ +function touch() { + return { + persist: jest.fn(), + currentTarget: { measure: jest.fn() }, + nativeEvent: { + changedTouches: [], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: Date.now(), + touches: [], + }, + }; +} + export const CommonEventBuilder = { - /** - * Experimental values: - * - iOS: `{"changedTouches": [[Circular]], "identifier": 1, "locationX": 253, "locationY": 30.333328247070312, "pageX": 273, "pageY": 141.3333282470703, "target": 75, "timestamp": 875928682.0450834, "touches": [[Circular]]}` - * - Android: `{"changedTouches": [[Circular]], "identifier": 0, "locationX": 160, "locationY": 40.3636360168457, "pageX": 180, "pageY": 140.36363220214844, "target": 53, "targetSurface": -1, "timestamp": 10290805, "touches": [[Circular]]}` - */ - touch: () => { + touch, + + responderGrant: () => { return { - persist: jest.fn(), - currentTarget: { measure: jest.fn() }, - nativeEvent: { - changedTouches: [], - identifier: 0, - locationX: 0, - locationY: 0, - pageX: 0, - pageY: 0, - target: 0, - timestamp: Date.now(), - touches: [], - }, + ...touch(), + dispatchConfig: { registrationName: 'onResponderGrant' }, + }; + }, + + responderRelease: () => { + return { + ...touch(), + dispatchConfig: { registrationName: 'onResponderRelease' }, }; }, diff --git a/src/user-event/event-builder/index.ts b/src/user-event/event-builder/index.ts index 91cae64ea..04d21d268 100644 --- a/src/user-event/event-builder/index.ts +++ b/src/user-event/event-builder/index.ts @@ -1,5 +1,7 @@ import { CommonEventBuilder } from './common'; +import { TextInputEventBuilder } from './text-input'; export const EventBuilder = { Common: CommonEventBuilder, + TextInput: TextInputEventBuilder, }; diff --git a/src/user-event/event-builder/text-input.ts b/src/user-event/event-builder/text-input.ts new file mode 100644 index 000000000..c289bbe32 --- /dev/null +++ b/src/user-event/event-builder/text-input.ts @@ -0,0 +1,86 @@ +import { ContentSize } from '../utils/content-size'; +import { TextRange } from '../utils/text-range'; + +export const TextInputEventBuilder = { + /** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}` + */ + change: (text: string) => { + return { + nativeEvent: { text, target: 0, eventCount: 0 }, + }; + }, + + /** + * Experimental values: + * - iOS: `{"eventCount": 3, "key": "a", "target": 75}` + * - Android: `{"key": "a"}` + */ + keyPress: (key: string) => { + return { + nativeEvent: { key }, + }; + }, + + /** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"target": 53, "text": "Test"}` + */ + submitEditing: (text: string) => { + return { + nativeEvent: { text, target: 0 }, + }; + }, + + /** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"target": 53, "text": "Test"}` + */ + endEditing: (text: string) => { + return { + nativeEvent: { text, target: 0 }, + }; + }, + + /** + * Experimental values: + * - iOS: `{"selection": {"end": 4, "start": 4}, "target": 75}` + * - Android: `{"selection": {"end": 4, "start": 4}}` + */ + selectionChange: ({ start, end }: TextRange) => { + return { + nativeEvent: { selection: { start, end } }, + }; + }, + + /** + * Experimental values: + * - iOS: `{"eventCount": 2, "previousText": "Te", "range": {"end": 2, "start": 2}, "target": 75, "text": "s"}` + * - Android: `{"previousText": "Te", "range": {"end": 2, "start": 0}, "target": 53, "text": "Tes"}` + */ + textInput: (text: string, previousText: string) => { + return { + nativeEvent: { + text, + previousText, + range: { start: text.length, end: text.length }, + target: 0, + }, + }; + }, + + /** + * Experimental values: + * - iOS: `{"contentSize": {"height": 21.666666666666668, "width": 11.666666666666666}, "target": 75}` + * - Android: `{"contentSize": {"height": 61.45454406738281, "width": 352.7272644042969}, "target": 53}` + */ + contentSizeChange: ({ width, height }: ContentSize) => { + return { + nativeEvent: { contentSize: { width, height }, target: 0 }, + }; + }, +}; diff --git a/src/user-event/index.ts b/src/user-event/index.ts index fb6fa8d37..c3895e281 100644 --- a/src/user-event/index.ts +++ b/src/user-event/index.ts @@ -1,6 +1,7 @@ import { ReactTestInstance } from 'react-test-renderer'; import { setup } from './setup'; -import { PressOptions } from './press/press'; +import { PressOptions } from './press'; +import { TypeOptions } from './type'; export const userEvent = { setup, @@ -9,6 +10,6 @@ export const userEvent = { press: (element: ReactTestInstance) => setup().press(element), longPress: (element: ReactTestInstance, options?: PressOptions) => setup().longPress(element, options), - type: (element: ReactTestInstance, text: string) => - setup().type(element, text), + type: (element: ReactTestInstance, text: string, options?: TypeOptions) => + setup().type(element, text, options), }; diff --git a/src/user-event/press/__tests__/longPress.real-timers.test.tsx b/src/user-event/press/__tests__/longPress.real-timers.test.tsx index fec234c0c..b1083fcec 100644 --- a/src/user-event/press/__tests__/longPress.real-timers.test.tsx +++ b/src/user-event/press/__tests__/longPress.real-timers.test.tsx @@ -2,13 +2,15 @@ import React from 'react'; import { Pressable, Text } from 'react-native'; import { render, screen } from '../../../pure'; import { userEvent } from '../..'; -import * as WarnAboutRealTimers from '../utils/warnAboutRealTimers'; +import * as WarnAboutRealTimers from '../../utils/warn-about-real-timers'; describe('userEvent.longPress with real timers', () => { beforeEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); - jest.spyOn(WarnAboutRealTimers, 'warnAboutRealTimers').mockImplementation(); + jest + .spyOn(WarnAboutRealTimers, 'warnAboutRealTimersIfNeeded') + .mockImplementation(); }); test('calls onLongPress if the delayLongPress is the default one', async () => { 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 fda9bf914..001efb1fc 100644 --- a/src/user-event/press/__tests__/press.real-timers.test.tsx +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -10,13 +10,15 @@ import { import { createEventLogger, getEventsName } from '../../../test-utils'; import { render, screen } from '../../..'; import { userEvent } from '../..'; -import * as WarnAboutRealTimers from '../utils/warnAboutRealTimers'; +import * as WarnAboutRealTimers from '../../utils/warn-about-real-timers'; describe('userEvent.press with real timers', () => { beforeEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); - jest.spyOn(WarnAboutRealTimers, 'warnAboutRealTimers').mockImplementation(); + jest + .spyOn(WarnAboutRealTimers, 'warnAboutRealTimersIfNeeded') + .mockImplementation(); }); test('calls onPressIn, onPress and onPressOut prop of touchable', async () => { diff --git a/src/user-event/press/index.ts b/src/user-event/press/index.ts index c7acfc260..cf567543e 100644 --- a/src/user-event/press/index.ts +++ b/src/user-event/press/index.ts @@ -1 +1 @@ -export { press, longPress } from './press'; +export { PressOptions, press, longPress } from './press'; diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index c69c04074..08c84148a 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,15 +1,15 @@ import { ReactTestInstance } from 'react-test-renderer'; -import { EventBuilder } from '../event-builder'; -import { UserEventInstance } from '../setup'; -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 { + isHostText, + isHostTextInput, +} from '../../helpers/host-component-names'; +import { EventBuilder } from '../event-builder'; +import { UserEventConfig, UserEventInstance } from '../setup'; +import { dispatchEvent, wait, warnAboutRealTimersIfNeeded } from '../utils'; import { DEFAULT_MIN_PRESS_DURATION } from './constants'; -import { warnAboutRealTimers } from './utils/warnAboutRealTimers'; export type PressOptions = { duration: number; @@ -31,20 +31,20 @@ export async function longPress( } const basePress = async ( - config: UserEventInstance['config'], + config: UserEventConfig, 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); + if (isPressableText(element) || isEnabledTextInput(element)) { + await emitBasicPressEvents(config, element, options); return; } if (isEnabledTouchResponder(element)) { - await triggerPressEvent(config, element, options); + await emitPressablePressEvents(config, element, options); return; } @@ -56,31 +56,33 @@ const basePress = async ( await basePress(config, hostParentElement, options); }; -const triggerPressEvent = async ( - config: UserEventInstance['config'], +const emitPressablePressEvents = async ( + config: UserEventConfig, element: ReactTestInstance, options: PressOptions = { duration: 0 } ) => { - const areFakeTimersEnabled = jestFakeTimersAreEnabled(); - if (!areFakeTimersEnabled) { - warnAboutRealTimers(); - } + warnAboutRealTimersIfNeeded(); await wait(config); await act(async () => { - element.props.onResponderGrant({ - ...EventBuilder.Common.touch(), - dispatchConfig: { registrationName: 'onResponderGrant' }, - }); + dispatchEvent( + element, + 'responderGrant', + EventBuilder.Common.responderGrant() + ); await wait(config, options.duration); - element.props.onResponderRelease({ - ...EventBuilder.Common.touch(), - dispatchConfig: { registrationName: 'onResponderRelease' }, - }); + 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 - options.duration > 0) { await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration); } @@ -94,9 +96,9 @@ const isEnabledTouchResponder = (element: ReactTestInstance) => { ); }; -const isEnabledHostText = (element: ReactTestInstance) => { +const isPressableText = (element: ReactTestInstance) => { return ( - filterNodeByType(element, getHostComponentNames().text) && + isHostText(element) && isPointerEventEnabled(element) && !element.props.disabled && element.props.onPress @@ -105,30 +107,26 @@ const isEnabledHostText = (element: ReactTestInstance) => { const isEnabledTextInput = (element: ReactTestInstance) => { return ( - filterNodeByType(element, getHostComponentNames().textInput) && + isHostTextInput(element) && isPointerEventEnabled(element) && element.props.editable !== false ); }; -const triggerMockPressEvent = async ( - config: UserEventInstance['config'], +/** + * Dispatches a basic press event sequence on non-Pressable component, + * e.g. Text or TextInput. + */ +async function emitBasicPressEvents( + config: UserEventConfig, 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()); - } + dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); + + dispatchEvent(element, 'press', 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()); - } -}; + dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); +} diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index c1adfd214..cc6716c35 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -1,8 +1,7 @@ import { ReactTestInstance } from 'react-test-renderer'; import { jestFakeTimersAreEnabled } from '../../helpers/timers'; -import { press, longPress } from '../press'; -import { type } from '../type'; -import { PressOptions } from '../press/press'; +import { PressOptions, press, longPress } from '../press'; +import { TypeOptions, type } from '../type'; export interface UserEventSetupOptions { /** @@ -46,7 +45,7 @@ const defaultOptions: Required = { * Creates a new instance of user event instance with the given options. * * @param options - * @returns + * @returns UserEvent instance */ export function setup(options?: UserEventSetupOptions) { const config = createConfig(options); @@ -54,6 +53,12 @@ export function setup(options?: UserEventSetupOptions) { return instance; } +/** + * Options affecting all user event interactions. + * + * @param delay between some subsequent inputs like typing a series of characters + * @param advanceTimers function to be called to advance fake timers + */ export interface UserEventConfig { delay: number; advanceTimers: (delay: number) => Promise | void; @@ -66,14 +71,43 @@ function createConfig(options?: UserEventSetupOptions): UserEventConfig { }; } +/** + * UserEvent instance used to invoke user interaction functions. + */ export interface UserEventInstance { config: UserEventConfig; + press: (element: ReactTestInstance) => Promise; longPress: ( element: ReactTestInstance, options?: PressOptions ) => Promise; - type: (element: ReactTestInstance, text: string) => Promise; + + /** + * Simulate user pressing on given `TextInput` element and typing given text. + * + * This method will trigger the events for each character of the text: + * `keyPress`, `change`, `changeText`, `endEditing`, etc. + * + * It will also trigger events connected with entering and leaving the text + * input. + * + * The exact events sent depend on the props of TextInput (`editable`, + * `multiline`, value, defaultValue, etc) and passed options. + * + * @param element TextInput element to type on + * @param text Text to type + * @param options Options affecting typing behavior: + * - `skipPress` - if true, `pressIn` and `pressOut` events will not be + * triggered. + * - `submitEditing` - if true, `submitEditing` event will be triggered after + * typing the text. + */ + type: ( + element: ReactTestInstance, + text: string, + options?: TypeOptions + ) => Promise; } function createInstance(config: UserEventConfig): UserEventInstance { diff --git a/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap new file mode 100644 index 000000000..efeb2ff71 --- /dev/null +++ b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap @@ -0,0 +1,339 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "W", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "W", + }, + }, + }, + { + "name": "changeText", + "payload": "W", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 1, + "start": 1, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "o", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "Wo", + }, + }, + }, + { + "name": "changeText", + "payload": "Wo", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 2, + "start": 2, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "w", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "Wow", + }, + }, + }, + { + "name": "changeText", + "payload": "Wow", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 3, + "start": 3, + }, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "Wow", + }, + }, + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; + +exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC", value: "XXX" 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "A", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "XXXA", + }, + }, + }, + { + "name": "changeText", + "payload": "XXXA", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 4, + "start": 4, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "B", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "XXXB", + }, + }, + }, + { + "name": "changeText", + "payload": "XXXB", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 4, + "start": 4, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "C", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "XXXC", + }, + }, + }, + { + "name": "changeText", + "payload": "XXXC", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 4, + "start": 4, + }, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "XXX", + }, + }, + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; diff --git a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap index 45323775a..6b88653f6 100644 --- a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap +++ b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap @@ -1,7 +1,163 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`user.type() dispatches required events 1`] = ` +exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1`] = ` [ + { + "name": "pressIn", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "Backspace", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "xx", + }, + }, + }, + { + "name": "changeText", + "payload": "xx", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 2, + "start": 2, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "a", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "xxa", + }, + }, + }, + { + "name": "changeText", + "payload": "xxa", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 3, + "start": 3, + }, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "xxa", + }, + }, + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; + +exports[`type() supports basic case: input: "abc" 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, { "name": "focus", "payload": { @@ -10,9 +166,133 @@ exports[`user.type() dispatches required events 1`] = ` }, }, }, + { + "name": "pressOut", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "a", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "a", + }, + }, + }, + { + "name": "changeText", + "payload": "a", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 1, + "start": 1, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "b", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "ab", + }, + }, + }, + { + "name": "changeText", + "payload": "ab", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 2, + "start": 2, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "c", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "abc", + }, + }, + }, { "name": "changeText", - "payload": "Hello World!", + "payload": "abc", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 3, + "start": 3, + }, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "abc", + }, + }, }, { "name": "blur", @@ -24,3 +304,365 @@ exports[`user.type() dispatches required events 1`] = ` }, ] `; + +exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "a", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "xxxa", + }, + }, + }, + { + "name": "changeText", + "payload": "xxxa", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 4, + "start": 4, + }, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "b", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": "xxxab", + }, + }, + }, + { + "name": "changeText", + "payload": "xxxab", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 5, + "start": 5, + }, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "xxxab", + }, + }, + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; + +exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "currentTarget": { + "measure": [MockFunction], + }, + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + "persist": [MockFunction], + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "Enter", + }, + }, + }, + { + "name": "textInput", + "payload": { + "nativeEvent": { + "previousText": "", + "range": { + "end": 1, + "start": 1, + }, + "target": 0, + "text": " +", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": " +", + }, + }, + }, + { + "name": "changeText", + "payload": " +", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 1, + "start": 1, + }, + }, + }, + }, + { + "name": "contentSizeChange", + "payload": { + "nativeEvent": { + "contentSize": { + "height": 32, + "width": 0, + }, + "target": 0, + }, + }, + }, + { + "name": "keyPress", + "payload": { + "nativeEvent": { + "key": "Enter", + }, + }, + }, + { + "name": "textInput", + "payload": { + "nativeEvent": { + "previousText": " +", + "range": { + "end": 2, + "start": 2, + }, + "target": 0, + "text": " + +", + }, + }, + }, + { + "name": "change", + "payload": { + "nativeEvent": { + "eventCount": 0, + "target": 0, + "text": " + +", + }, + }, + }, + { + "name": "changeText", + "payload": " + +", + }, + { + "name": "selectionChange", + "payload": { + "nativeEvent": { + "selection": { + "end": 2, + "start": 2, + }, + }, + }, + }, + { + "name": "contentSizeChange", + "payload": { + "nativeEvent": { + "contentSize": { + "height": 48, + "width": 0, + }, + "target": 0, + }, + }, + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": " + +", + }, + }, + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; + +exports[`type() works when not all events have handlers: input: "abc" 1`] = ` +[ + { + "name": "changeText", + "payload": "a", + }, + { + "name": "changeText", + "payload": "ab", + }, + { + "name": "changeText", + "payload": "abc", + }, + { + "name": "endEditing", + "payload": { + "nativeEvent": { + "target": 0, + "text": "abc", + }, + }, + }, +] +`; diff --git a/src/user-event/type/__tests__/parseKeys.test.ts b/src/user-event/type/__tests__/parseKeys.test.ts new file mode 100644 index 000000000..0c9fb6797 --- /dev/null +++ b/src/user-event/type/__tests__/parseKeys.test.ts @@ -0,0 +1,23 @@ +import { parseKeys } from '../parseKeys'; + +test('parseKeys', () => { + expect(parseKeys('')).toEqual([]); + expect(parseKeys('a')).toEqual(['a']); + expect(parseKeys('Hello')).toEqual(['H', 'e', 'l', 'l', 'o']); + expect(parseKeys('ab{{cc')).toEqual(['a', 'b', '{', 'c', 'c']); + expect(parseKeys('AB{Enter}XY')).toEqual(['A', 'B', 'Enter', 'X', 'Y']); +}); + +test('parseKeys with special keys', () => { + expect(parseKeys('AB{Enter}XY')).toEqual(['A', 'B', 'Enter', 'X', 'Y']); + expect(parseKeys('{Enter}XY')).toEqual(['Enter', 'X', 'Y']); + expect(parseKeys('AB{Enter}')).toEqual(['A', 'B', 'Enter']); + expect(parseKeys('A{Backspace}B')).toEqual(['A', 'Backspace', 'B']); + expect(parseKeys('A{B}C')).toEqual(['A', 'B', 'C']); +}); + +test('parseKeys throws for invalid keys', () => { + expect(() => parseKeys('{WWW}')).toThrow('Unknown key "WWW" in "{WWW}"'); + expect(() => parseKeys('AA{F1}BB')).toThrow('Unknown key "F1" in "AA{F1}BB"'); + expect(() => parseKeys('AA{BB')).toThrow('Invalid key sequence "{BB"'); +}); diff --git a/src/user-event/type/__tests__/type-managed.test.tsx b/src/user-event/type/__tests__/type-managed.test.tsx new file mode 100644 index 000000000..c0914b976 --- /dev/null +++ b/src/user-event/type/__tests__/type-managed.test.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { TextInput } from 'react-native'; +import { createEventLogger } from '../../../test-utils/events'; +import { render } from '../../..'; +import { userEvent } from '../..'; + +beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +interface ManagedTextInputProps { + valueTransformer?: (text: string) => string; + logEvent: (name: string) => (event: any) => void; + initialValue?: string; +} + +function ManagedTextInput({ + logEvent, + valueTransformer, + initialValue = '', +}: ManagedTextInputProps) { + const [value, setValue] = React.useState(initialValue); + + const handleChangeText = (text: string) => { + logEvent('changeText')(text); + const newValue = valueTransformer?.(text) ?? text; + setValue(newValue); + }; + + return ( + + ); +} + +describe('type() for managed TextInput', () => { + it('supports basic case', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); + const { events, logEvent } = createEventLogger(); + const screen = render(); + + const user = userEvent.setup(); + await user.type(screen.getByTestId('input'), 'Wow'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot('input: "Wow"'); + }); + + test('supports rejecting TextInput', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); + const { events, logEvent } = createEventLogger(); + const screen = render( + 'XXX'} + /> + ); + + const user = userEvent.setup(); + await user.type(screen.getByTestId('input'), 'ABC'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot('input: "ABC", value: "XXX"'); + }); +}); diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 23cf56dd4..67fb610b1 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -1,51 +1,318 @@ import * as React from 'react'; -import { TextInput } from 'react-native'; -import { createEventLogger } from '../../../test-utils'; +import { View, TextInput, TextInputProps } from 'react-native'; +import { createEventLogger } from '../../../test-utils/events'; import { render } from '../../..'; import { userEvent } from '../..'; -describe('user.type()', () => { - it('dispatches required events', async () => { - const { events, logEvent } = createEventLogger(); +beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +function renderTextInputWithToolkit(props: TextInputProps = {}) { + const { events, logEvent } = createEventLogger(); + + const screen = render( + + ); + + return { + ...screen, + events, + }; +} + +describe('type()', () => { + it('supports basic case', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 100100100100); + const { events, ...queries } = renderTextInputWithToolkit(); + const user = userEvent.setup(); - const screen = render( - + await user.type(queries.getByTestId('input'), 'abc'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot('input: "abc"'); + }); + + it.each(['modern', 'legacy'])('works with %s fake timers', async (type) => { + jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + const { events, ...queries } = renderTextInputWithToolkit(); + + const user = userEvent.setup(); + await user.type(queries.getByTestId('input'), 'abc'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + }); + + it('supports defaultValue prop', async () => { + const { events, ...queries } = renderTextInputWithToolkit({ + defaultValue: 'xxx', + }); + + const user = userEvent.setup(); + await user.type(queries.getByTestId('input'), 'ab'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot('input: "ab", defaultValue: "xxx"'); + }); + + it('does respect editable prop', async () => { + const { events, ...queries } = renderTextInputWithToolkit({ + editable: false, + }); + + const user = userEvent.setup(); + await user.type(queries.getByTestId('input'), 'ab'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([]); + }); + + it('supports backspace', async () => { + const { events, ...queries } = renderTextInputWithToolkit({ + defaultValue: 'xxx', + }); + + const user = userEvent.setup(); + await user.type(queries.getByTestId('input'), '{Backspace}a'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot( + 'input: "{Backspace}a", defaultValue: "xxx"' ); + }); - await user.type(screen.getByTestId('input'), 'Hello World!'); + it('supports multiline', async () => { + const { events, ...queries } = renderTextInputWithToolkit({ + multiline: true, + }); - const eventNames = events.map((event) => event.name); - expect(eventNames).toEqual(['focus', 'changeText', 'blur']); - expect(events).toMatchSnapshot(); + const user = userEvent.setup(); + await user.type(queries.getByTestId('input'), '{Enter}\n'); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'textInput', + 'change', + 'changeText', + 'selectionChange', + 'contentSizeChange', + 'keyPress', + 'textInput', + 'change', + 'changeText', + 'selectionChange', + 'contentSizeChange', + 'endEditing', + 'blur', + ]); + + expect(events).toMatchSnapshot('input: "{Enter}\\n", multiline: true'); }); - it('supports direct access', async () => { + test('skips press events when `skipPress: true`', async () => { + const { events, ...queries } = renderTextInputWithToolkit(); + + const user = userEvent.setup(); + await user.type(queries.getByTestId('input'), 'a', { + skipPress: true, + }); + + const eventNames = events.map((e) => e.name); + expect(eventNames).not.toContainEqual('pressIn'); + expect(eventNames).not.toContainEqual('pressOut'); + expect(eventNames).toEqual([ + 'focus', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'endEditing', + 'blur', + ]); + }); + + it('triggers submit event with `submitEditing: true`', async () => { + const { events, ...queries } = renderTextInputWithToolkit(); + + const user = userEvent.setup(); + await user.type(queries.getByTestId('input'), 'a', { + submitEditing: true, + }); + + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'pressIn', + 'focus', + 'pressOut', + 'keyPress', + 'change', + 'changeText', + 'selectionChange', + 'submitEditing', + 'endEditing', + 'blur', + ]); + + expect(events[7].name).toBe('submitEditing'); + expect(events[7].payload).toEqual({ + nativeEvent: { text: 'a', target: 0 }, + }); + }); + + it('works when not all events have handlers', async () => { const { events, logEvent } = createEventLogger(); const screen = render( ); - await userEvent.type(screen.getByTestId('input'), 'Hello World!'); + const user = userEvent.setup(); + await user.type(screen.getByTestId('input'), 'abc'); - const eventNames = events.map((event) => event.name); - expect(eventNames).toEqual(['focus', 'changeText', 'blur']); + const eventNames = events.map((e) => e.name); + expect(eventNames).toEqual([ + 'changeText', + 'changeText', + 'changeText', + 'endEditing', + ]); + + expect(events).toMatchSnapshot('input: "abc"'); }); - it.each(['modern', 'legacy'])('works with fake %s timers', async (type) => { - jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + it('does NOT work on View', async () => { + const screen = render(); + + const user = userEvent.setup(); + await expect( + user.type(screen.getByTestId('input'), 'abc') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"type() works only with host "TextInput" elements. Passed element has type "View"."` + ); + }); + + // View that ignores props type checking + const AnyView = View as React.ComponentType; + + it('does NOT bubble up', async () => { + const parentHandler = jest.fn(); + const screen = render( + + + + ); - const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); + await user.type(screen.getByTestId('input'), 'abc'); + expect(parentHandler).not.toHaveBeenCalled(); + }); + + it('supports direct access', async () => { + const { events, logEvent } = createEventLogger(); const screen = render( { /> ); - await user.type(screen.getByTestId('input'), 'Hello World!'); + await userEvent.type(screen.getByTestId('input'), 'abc'); const eventNames = events.map((event) => event.name); - expect(eventNames).toEqual(['focus', 'changeText', 'blur']); + expect(eventNames).toEqual([ + 'focus', + 'changeText', + 'changeText', + 'changeText', + 'blur', + ]); }); }); diff --git a/src/user-event/type/index.ts b/src/user-event/type/index.ts index 13efb1f36..c99669e7d 100644 --- a/src/user-event/type/index.ts +++ b/src/user-event/type/index.ts @@ -1 +1 @@ -export { type } from './type'; +export { type, TypeOptions } from './type'; diff --git a/src/user-event/type/parseKeys.ts b/src/user-event/type/parseKeys.ts new file mode 100644 index 000000000..d074d9c85 --- /dev/null +++ b/src/user-event/type/parseKeys.ts @@ -0,0 +1,41 @@ +const knownKeys = new Set(['Enter', 'Backspace']); + +export function parseKeys(text: string) { + const result = []; + + let remainingText = text; + while (remainingText) { + const [token, rest] = getNextToken(remainingText); + if (token.length > 1 && !knownKeys.has(token)) { + throw new Error(`Unknown key "${token}" in "${text}"`); + } + + result.push(token); + remainingText = rest; + } + + return result; +} + +function getNextToken(text: string): [string, string] { + // Detect `{{` => escaped `{` + if (text[0] === '{' && text[1] === '{') { + return ['{', text.slice(2)]; + } + + // Detect `{key}` => special key + if (text[0] === '{') { + const endIndex = text.indexOf('}'); + if (endIndex === -1) { + throw new Error(`Invalid key sequence "${text}"`); + } + + return [text.slice(1, endIndex), text.slice(endIndex + 1)]; + } + + if (text[0] === '\n') { + return ['Enter', text.slice(1)]; + } + + return [text[0], text.slice(1)]; +} diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 82e514dcc..42534ce86 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -1,20 +1,135 @@ import { ReactTestInstance } from 'react-test-renderer'; -import { UserEventInstance } from '../setup'; -import { dispatchHostEvent, wait } from '../utils'; +import { isHostTextInput } from '../../helpers/host-component-names'; import { EventBuilder } from '../event-builder'; +import { ErrorWithStack } from '../../helpers/errors'; +import { isPointerEventEnabled } from '../../helpers/pointer-events'; +import { UserEventInstance } from '../setup'; +import { + dispatchEvent, + wait, + getTextRange, + getTextContentSize, +} from '../utils'; + +import { parseKeys } from './parseKeys'; + +export interface TypeOptions { + skipPress?: boolean; + submitEditing?: boolean; +} export async function type( this: UserEventInstance, element: ReactTestInstance, - text: string -) { - // TODO provide real implementation - await wait(this.config); - dispatchHostEvent(element, 'focus', EventBuilder.Common.focus()); + text: string, + options?: TypeOptions +): Promise { + if (!isHostTextInput(element)) { + throw new ErrorWithStack( + `type() works only with host "TextInput" elements. Passed element has type "${element.type}".`, + type + ); + } - await wait(this.config); - dispatchHostEvent(element, 'changeText', text); + // Skip events if the element is disabled + if (element.props.editable === false || !isPointerEventEnabled(element)) { + return; + } + + const keys = parseKeys(text); + + if (!options?.skipPress) { + dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); + } + + dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + + if (!options?.skipPress) { + await wait(this.config); + dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); + } + + let currentText = element.props.value ?? element.props.defaultValue ?? ''; + for (const key of keys) { + const previousText = element.props.value ?? currentText; + currentText = applyKey(previousText, key); + + await wait(this.config); + emitTypingEvents(element, key, currentText, previousText); + } + + const finalText = element.props.value ?? currentText; await wait(this.config); - dispatchHostEvent(element, 'blur', EventBuilder.Common.blur()); + + if (options?.submitEditing) { + dispatchEvent( + element, + 'submitEditing', + EventBuilder.TextInput.submitEditing(finalText) + ); + } + + dispatchEvent( + element, + 'endEditing', + EventBuilder.TextInput.endEditing(finalText) + ); + + dispatchEvent(element, 'blur', EventBuilder.Common.blur()); +} + +async function emitTypingEvents( + element: ReactTestInstance, + key: string, + currentText: string, + previousText: string +) { + const isMultiline = element.props.multiline === true; + + dispatchEvent(element, 'keyPress', EventBuilder.TextInput.keyPress(key)); + + // According to the docs only multiline TextInput emits textInput event + // @see: https://github.com/facebook/react-native/blob/42a2898617da1d7a98ef574a5b9e500681c8f738/packages/react-native/Libraries/Components/TextInput/TextInput.d.ts#L754 + if (isMultiline) { + dispatchEvent( + element, + 'textInput', + EventBuilder.TextInput.textInput(currentText, previousText) + ); + } + + dispatchEvent(element, 'change', EventBuilder.TextInput.change(currentText)); + + dispatchEvent(element, 'changeText', currentText); + + const selectionRange = getTextRange(currentText); + dispatchEvent( + element, + 'selectionChange', + EventBuilder.TextInput.selectionChange(selectionRange) + ); + + // According to the docs only multiline TextInput emits contentSizeChange event + // @see: https://reactnative.dev/docs/textinput#oncontentsizechange + if (isMultiline) { + const contentSize = getTextContentSize(currentText); + dispatchEvent( + element, + 'contentSizeChange', + EventBuilder.TextInput.contentSizeChange(contentSize) + ); + } +} + +function applyKey(text: string, key: string) { + if (key === 'Enter') { + return `${text}\n`; + } + + if (key === 'Backspace') { + return text.slice(0, -1); + } + + return text + key; } diff --git a/src/user-event/utils/__tests__/dispatch-event.test.tsx b/src/user-event/utils/__tests__/dispatch-event.test.tsx new file mode 100644 index 000000000..a97835016 --- /dev/null +++ b/src/user-event/utils/__tests__/dispatch-event.test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Text } from 'react-native'; +import render from '../../../render'; +import { dispatchEvent } from '../dispatch-event'; +import { EventBuilder } from '../../event-builder'; + +const TOUCH_EVENT = EventBuilder.Common.touch(); + +describe('dispatchEvent', () => { + it('does dispatch event', () => { + const onPress = jest.fn(); + const screen = render(); + + dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch event to parent host component', () => { + const onPressParent = jest.fn(); + const screen = render( + + + + ); + + dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + expect(onPressParent).not.toHaveBeenCalled(); + }); + + it('does NOT throw if no handler found', () => { + const screen = render( + + + + ); + + expect(() => + dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT) + ).not.toThrow(); + }); +}); diff --git a/src/user-event/utils/content-size.ts b/src/user-event/utils/content-size.ts new file mode 100644 index 000000000..51f100080 --- /dev/null +++ b/src/user-event/utils/content-size.ts @@ -0,0 +1,25 @@ +export interface ContentSize { + width: number; + height: number; +} + +/** + * Simple function for getting mock the size of given text. + * + * It works by calculating height based on number of lines and width based on + * the longest line length. It does not take into account font size, font + * family, as well as different letter sizes. + * + * @param text text to be measure + * @returns width and height of the text + */ + +export function getTextContentSize(text: string): ContentSize { + const lines = text.split('\n'); + const maxLineLength = Math.max(...lines.map((line) => line.length)); + + return { + width: maxLineLength * 5, + height: lines.length * 16, + }; +} diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts new file mode 100644 index 000000000..09bbf4dec --- /dev/null +++ b/src/user-event/utils/dispatch-event.ts @@ -0,0 +1,38 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import act from '../../act'; + +/** + * Basic dispatch event function used by User Event module. + * + * @param element element trigger event on + * @param eventName name of the event + * @param event event payload + */ +export function dispatchEvent( + element: ReactTestInstance, + eventName: string, + event: unknown +) { + const handler = getEventHandler(element, eventName); + if (!handler) { + return; + } + + act(() => { + handler(event); + }); +} + +function getEventHandler(element: ReactTestInstance, eventName: string) { + const handleName = getEventHandlerName(eventName); + const handle = element.props[handleName] as unknown; + if (typeof handle !== 'function') { + return undefined; + } + + return handle; +} + +function getEventHandlerName(eventName: string) { + return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`; +} diff --git a/src/user-event/utils/events.ts b/src/user-event/utils/events.ts deleted file mode 100644 index d2ce4f4a9..000000000 --- a/src/user-event/utils/events.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ReactTestInstance } from 'react-test-renderer'; -import act from '../../act'; -import { isEventEnabled, isTouchResponder } from '../../fireEvent'; - -type EventHandler = (event: unknown) => void; - -/** - * Dispatch event function used by User Event module. - * - * @param element element trigger event on - * @param eventName name of the event - * @param event event payload - */ -export function dispatchHostEvent( - element: ReactTestInstance, - eventName: string, - event: unknown -) { - const handler = getEnabledEventHandler(element, eventName); - if (!handler) { - return; - } - - act(() => { - handler(event); - }); -} - -function getEnabledEventHandler( - element: ReactTestInstance, - eventName: string -): EventHandler | null { - const touchResponder = isTouchResponder(element) ? element : undefined; - - const handler = getEventHandler(element, eventName); - if (handler && isEventEnabled(element, eventName, touchResponder)) { - return handler; - } - - return null; -} - -function getEventHandler(element: ReactTestInstance, eventName: string) { - const eventHandlerName = getEventHandlerName(eventName); - if (typeof element.props[eventHandlerName] === 'function') { - return element.props[eventHandlerName]; - } - - return undefined; -} - -function getEventHandlerName(eventName: string) { - return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`; -} diff --git a/src/user-event/utils/index.ts b/src/user-event/utils/index.ts index f106420d3..56e00613b 100644 --- a/src/user-event/utils/index.ts +++ b/src/user-event/utils/index.ts @@ -1,2 +1,5 @@ -export * from './events'; +export * from './content-size'; +export * from './dispatch-event'; +export * from './text-range'; export * from './wait'; +export * from './warn-about-real-timers'; diff --git a/src/user-event/utils/text-range.ts b/src/user-event/utils/text-range.ts new file mode 100644 index 000000000..05740ecee --- /dev/null +++ b/src/user-event/utils/text-range.ts @@ -0,0 +1,11 @@ +export interface TextRange { + start: number; + end: number; +} + +export function getTextRange(text: string): TextRange { + return { + start: text.length, + end: text.length, + }; +} diff --git a/src/user-event/press/utils/warnAboutRealTimers.ts b/src/user-event/utils/warn-about-real-timers.ts similarity index 54% rename from src/user-event/press/utils/warnAboutRealTimers.ts rename to src/user-event/utils/warn-about-real-timers.ts index 307afaef3..dea895b34 100644 --- a/src/user-event/press/utils/warnAboutRealTimers.ts +++ b/src/user-event/utils/warn-about-real-timers.ts @@ -1,4 +1,11 @@ -export const warnAboutRealTimers = () => { +import { jestFakeTimersAreEnabled } from '../../helpers/timers'; + +export const warnAboutRealTimersIfNeeded = () => { + const areFakeTimersEnabled = jestFakeTimersAreEnabled(); + if (areFakeTimersEnabled) { + return; + } + // 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. diff --git a/website/docs/UserEvent.md b/website/docs/UserEvent.md index 908002ea2..0c342aae5 100644 --- a/website/docs/UserEvent.md +++ b/website/docs/UserEvent.md @@ -7,6 +7,11 @@ title: User Event - [`userEvent.setup`](#usereventsetup) - [Options](#options) +- [`press()`](#press) +- [`longPress()`](#longpress) +- [`type()`](#type) + - [Options:](#options-1) + - [Sequence of events](#sequence-of-events) ## `userEvent.setup` @@ -41,7 +46,6 @@ press( Example ```ts const user = userEvent.setup(); - await user.press(element); ``` @@ -59,8 +63,62 @@ longPress( 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 +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. + +## `type()` + +```ts +type( + element: ReactTestInstance, + text: string, + options?: { + skipPress?: boolean + submitEditing?: boolean + } +``` + +Example +```ts +const user = userEvent.setup(); +await user.type(textInput, "Hello world!"); +``` + +This helper simulates user focusing on `TextInput` element, typing `text` one character at a time, and leaving the element. + +This function supports only host `TextInput` elements. Passing other element type will result in throwing error. + +### Options: + - `skipPress` - if true, `pressIn` and `pressOut` events will not be triggered. + - `submitEditing` - if true, `submitEditing` event will be triggered after typing the text. + +### Sequence of events + +The sequence of events depends on `multiline` prop, as well as passed options. + +Events will not be emitted if `editable` prop is set to `false`. + +Entering the element: +- `pressIn` (optional) +- `focus` +- `pressOut` (optional) + +The `pressIn` and `pressOut` events are sent by default, but can be skipped by passing `skipPress: true` option. + +Typing (for each character): +- `keyPress` +- `textInput` (optional) +- `change` +- `changeText` +- `selectionChange` + +The `textInput` event is sent only for mutliline text inputs. + +Leaving the element: +- `submitEditing` (optional) +- `endEditing` +- `blur` + +The `submitEditing` event is skipped by default. It can sent by setting `submitEditing: true` option.