From 1391c341bb46c29c5a7c0d9abaea3786e764f147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 27 Apr 2023 16:11:16 +0200 Subject: [PATCH 1/8] feat: userEvent type() chore: restore tests chore: more tests chore: improve coverage chore: moar tests docs: improve the docs chore: fix lint refactor: code review changes chore: fix lint chore: tweak return type refactor: flush microtasks after event refactor: re-organize waits refactor: improve dispatch event functions refactor: remove flushMicroTasks calls --- .../src/screens/TextInputEvents.tsx | 10 + src/__tests__/act.test.tsx | 4 + src/fireEvent.ts | 8 +- src/helpers/accessiblity.ts | 5 +- src/helpers/deprecation.ts | 2 +- src/helpers/host-component-names.tsx | 4 + src/user-event/event-builder/index.ts | 2 + src/user-event/event-builder/text-input.ts | 86 +++ src/user-event/index.ts | 7 +- src/user-event/press/index.ts | 2 +- src/user-event/setup/setup.ts | 44 +- .../__snapshots__/type-managed.test.tsx.snap | 323 +++++++++ .../__snapshots__/type.test.tsx.snap | 614 +++++++++++++++++- .../type/__tests__/parseKeys.test.ts | 23 + .../type/__tests__/type-managed.test.tsx | 121 ++++ src/user-event/type/__tests__/type.test.tsx | 327 +++++++++- src/user-event/type/index.ts | 2 +- src/user-event/type/parseKeys.ts | 41 ++ src/user-event/type/type.ts | 133 +++- .../utils/__tests__/dispatch-event.test.tsx | 89 +++ src/user-event/utils/content-size.ts | 25 + src/user-event/utils/dispatch-event.ts | 122 ++++ src/user-event/utils/events.ts | 54 -- src/user-event/utils/index.ts | 4 +- src/user-event/utils/text-range.ts | 11 + website/docs/UserEvent.md | 64 +- website/sidebars.js | 2 +- 27 files changed, 2011 insertions(+), 118 deletions(-) create mode 100644 src/user-event/event-builder/text-input.ts create mode 100644 src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap create mode 100644 src/user-event/type/__tests__/parseKeys.test.ts create mode 100644 src/user-event/type/__tests__/type-managed.test.tsx create mode 100644 src/user-event/type/parseKeys.ts create mode 100644 src/user-event/utils/__tests__/dispatch-event.test.tsx create mode 100644 src/user-event/utils/content-size.ts create mode 100644 src/user-event/utils/dispatch-event.ts delete mode 100644 src/user-event/utils/events.ts create mode 100644 src/user-event/utils/text-range.ts 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..8c125ee57 100644 --- a/src/helpers/host-component-names.tsx +++ b/src/helpers/host-component-names.tsx @@ -65,3 +65,7 @@ function getByTestId(instance: ReactTestInstance, testID: string) { return nodes[0]; } + +export function isHostTextInput(element?: ReactTestInstance) { + return element?.type === getHostComponentNames().textInput; +} 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/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/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..63dd37231 --- /dev/null +++ b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap @@ -0,0 +1,323 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "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": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "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..f2f94ba9c 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,151 @@ // 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": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "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": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, { "name": "focus", "payload": { @@ -10,9 +154,129 @@ exports[`user.type() dispatches required events 1`] = ` }, }, }, + { + "name": "pressOut", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "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 +288,349 @@ exports[`user.type() dispatches required events 1`] = ` }, ] `; + +exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "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": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "pressOut", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "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..ce310f447 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 "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..3b9c3456c 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -1,20 +1,133 @@ import { ReactTestInstance } from 'react-test-renderer'; -import { UserEventInstance } from '../setup'; -import { dispatchHostEvent, wait } from '../utils'; +import { getHostComponentNames } from '../../helpers/host-component-names'; import { EventBuilder } from '../event-builder'; +import { ErrorWithStack } from '../../helpers/errors'; +import { UserEventInstance } from '../setup'; +import { + dispatchOwnHostEvent, + 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 (element.type !== getHostComponentNames().textInput) { + throw new ErrorWithStack( + `type() works only with "TextInput" elements. Passed element has type "${element.type}".`, + type + ); + } - await wait(this.config); - dispatchHostEvent(element, 'changeText', text); + const keys = parseKeys(text); + + if (!options?.skipPress) { + dispatchOwnHostEvent(element, 'pressIn', EventBuilder.Common.touch()); + } + + dispatchOwnHostEvent(element, 'focus', EventBuilder.Common.focus()); + + if (!options?.skipPress) { + await wait(this.config); + dispatchOwnHostEvent(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) { + dispatchOwnHostEvent( + element, + 'submitEditing', + EventBuilder.TextInput.submitEditing(finalText) + ); + } + + dispatchOwnHostEvent( + element, + 'endEditing', + EventBuilder.TextInput.endEditing(finalText) + ); + + dispatchOwnHostEvent(element, 'blur', EventBuilder.Common.blur()); +} + +async function emitTypingEvents( + element: ReactTestInstance, + key: string, + currentText: string, + previousText: string +) { + const isMultiline = element.props.multiline === true; + + dispatchOwnHostEvent( + element, + 'keyPress', + EventBuilder.TextInput.keyPress(key) + ); + + if (isMultiline) { + dispatchOwnHostEvent( + element, + 'textInput', + EventBuilder.TextInput.textInput(currentText, previousText) + ); + } + + dispatchOwnHostEvent( + element, + 'change', + EventBuilder.TextInput.change(currentText) + ); + + dispatchOwnHostEvent(element, 'changeText', currentText); + + const selectionRange = getTextRange(currentText); + dispatchOwnHostEvent( + element, + 'selectionChange', + EventBuilder.TextInput.selectionChange(selectionRange) + ); + + if (isMultiline) { + const contentSize = getTextContentSize(currentText); + dispatchOwnHostEvent( + 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..4062dbc94 --- /dev/null +++ b/src/user-event/utils/__tests__/dispatch-event.test.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { Text } from 'react-native'; +import render from '../../../render'; +import { dispatchHostEvent, dispatchOwnHostEvent } from '../dispatch-event'; +import { EventBuilder } from '../../event-builder'; + +const TOUCH_EVENT = EventBuilder.Common.touch(); + +describe('dispatchHostEvent', () => { + it('does dispatch event', () => { + const onPress = jest.fn(); + const screen = render(); + + dispatchHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does dispatch event to parent host component if not handled', () => { + const onPressParent = jest.fn(); + const screen = render( + + + + ); + + dispatchHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + expect(onPressParent).toHaveBeenCalledTimes(1); + }); + + it('does NOT dispatch event to parent host component if handled', () => { + const onPress = jest.fn(); + const onPressParent = jest.fn(); + const screen = render( + + + + ); + + dispatchHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPressParent).not.toHaveBeenCalled(); + }); + + it('does NOT throw if no handler found', () => { + const screen = render( + + + + ); + + expect(() => + dispatchHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT) + ).not.toThrow(); + }); +}); + +describe('dispatchOwnHostEvent', () => { + it('does dispatch event', () => { + const onPress = jest.fn(); + const screen = render(); + + dispatchOwnHostEvent(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( + + + + ); + + dispatchOwnHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + expect(onPressParent).not.toHaveBeenCalled(); + }); + + it('does NOT throw if no handler found', () => { + const screen = render( + + + + ); + + expect(() => + dispatchOwnHostEvent(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..fcf24e857 --- /dev/null +++ b/src/user-event/utils/dispatch-event.ts @@ -0,0 +1,122 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import act from '../../act'; +import { getHostParent } from '../../helpers/component-tree'; +import { isHostTextInput } from '../../helpers/host-component-names'; + +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 = findEnabledEventHandler(element, eventName); + if (!handler) { + return; + } + + act(() => { + handler(event); + }); +} + +/** + * Dispatch event function only to given element. Does not look event handler + * in the ancestors. + * + * @param element element trigger event on + * @param eventName name of the event + * @param event event payload + */ +export function dispatchOwnHostEvent( + element: ReactTestInstance, + eventName: string, + event: unknown +) { + const handler = getEventHandler(element, eventName); + if (!handler) { + return; + } + + act(() => { + handler(event); + }); +} + +/** + * Looks up for event handler in the element and its ancestors. + */ +function findEnabledEventHandler( + element: ReactTestInstance, + eventName: string +): EventHandler | null { + let current: ReactTestInstance | null = element; + while (current != null) { + const handler = getEventHandler(current, eventName); + if (handler) { + return handler; + } + + current = getHostParent(current); + } + + return null; +} + +function getEventHandler(element: ReactTestInstance, eventName: string) { + const eventHandlerName = getEventHandlerName(eventName); + if (typeof element.props[eventHandlerName] !== 'function') { + return null; + } + + if (!isEventEnabled(element)) { + return null; + } + + return element.props[eventHandlerName]; +} + +function getEventHandlerName(eventName: string) { + return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`; +} + +function isEventEnabled(element: ReactTestInstance) { + if (isHostTextInput(element)) { + return element?.props.editable !== false; + } + + if (!isPointerEventEnabled(element)) { + return false; + } + + const touchStart = element?.props.onStartShouldSetResponder?.(); + return touchStart !== false; +} + +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); +} 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..9b738ad7b 100644 --- a/src/user-event/utils/index.ts +++ b/src/user-event/utils/index.ts @@ -1,2 +1,4 @@ -export * from './events'; +export * from './content-size'; +export * from './dispatch-event'; +export * from './text-range'; export * from './wait'; 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/website/docs/UserEvent.md b/website/docs/UserEvent.md index 908002ea2..7aa0ed598 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 send 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 be send by setting `submitEditing: true` option. diff --git a/website/sidebars.js b/website/sidebars.js index 1df46b141..b9aa3e275 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -1,7 +1,7 @@ module.exports = { docs: { Introduction: ['getting-started', 'faq'], - 'API Reference': ['api', 'api-queries'], + 'API Reference': ['api', 'api-queries', 'user-event'], Guides: [ 'troubleshooting', 'how-should-i-query', From 5a39b8e70094d415ee39ed558eb2f61f4428b050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 17 Jul 2023 18:03:59 +0200 Subject: [PATCH 2/8] chore: update snapshots --- .../__snapshots__/type-managed.test.tsx.snap | 24 ++++++++-- .../__snapshots__/type.test.tsx.snap | 48 +++++++++++++++---- 2 files changed, 60 insertions(+), 12 deletions(-) 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 index 63dd37231..efeb2ff71 100644 --- a/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap +++ b/src/user-event/type/__tests__/__snapshots__/type-managed.test.tsx.snap @@ -5,6 +5,9 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` { "name": "pressIn", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -13,9 +16,10 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -29,6 +33,9 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` { "name": "pressOut", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -37,9 +44,10 @@ exports[`type() for managed TextInput supports basic case: input: "Wow" 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -166,6 +174,9 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" { "name": "pressIn", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -174,9 +185,10 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -190,6 +202,9 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" { "name": "pressOut", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -198,9 +213,10 @@ exports[`type() for managed TextInput supports rejecting TextInput: input: "ABC" "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { 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 f2f94ba9c..6b88653f6 100644 --- a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap +++ b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap @@ -5,6 +5,9 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 { "name": "pressIn", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -13,9 +16,10 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -29,6 +33,9 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 { "name": "pressOut", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -37,9 +44,10 @@ exports[`type() supports backspace: input: "{Backspace}a", defaultValue: "xxx" 1 "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -133,6 +141,9 @@ exports[`type() supports basic case: input: "abc" 1`] = ` { "name": "pressIn", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -141,9 +152,10 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -157,6 +169,9 @@ exports[`type() supports basic case: input: "abc" 1`] = ` { "name": "pressOut", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -165,9 +180,10 @@ exports[`type() supports basic case: input: "abc" 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -294,6 +310,9 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] { "name": "pressIn", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -302,9 +321,10 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -318,6 +338,9 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] { "name": "pressOut", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -326,9 +349,10 @@ exports[`type() supports defaultValue prop: input: "ab", defaultValue: "xxx" 1`] "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -422,6 +446,9 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` { "name": "pressIn", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -430,9 +457,10 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { @@ -446,6 +474,9 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` { "name": "pressOut", "payload": { + "currentTarget": { + "measure": [MockFunction], + }, "nativeEvent": { "changedTouches": [], "identifier": 0, @@ -454,9 +485,10 @@ exports[`type() supports multiline: input: "{Enter}\\n", multiline: true 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 0, + "timestamp": 100100100100, "touches": [], }, + "persist": [MockFunction], }, }, { From 43545bb54101e63181c78be7794bbc82655f2c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 19 Jul 2023 14:22:24 +0200 Subject: [PATCH 3/8] chore: do not expose User Event docs yet --- website/sidebars.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/sidebars.js b/website/sidebars.js index b9aa3e275..1df46b141 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -1,7 +1,7 @@ module.exports = { docs: { Introduction: ['getting-started', 'faq'], - 'API Reference': ['api', 'api-queries', 'user-event'], + 'API Reference': ['api', 'api-queries'], Guides: [ 'troubleshooting', 'how-should-i-query', From d918d6245f3ebb1edbeb082947a9a14006371465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 19 Jul 2023 14:23:35 +0200 Subject: [PATCH 4/8] refactor: simplify dispatchEvent code --- src/user-event/type/__tests__/type.test.tsx | 2 +- src/user-event/type/type.ts | 46 ++++----- .../utils/__tests__/dispatch-event.test.tsx | 60 ++---------- src/user-event/utils/dispatch-event.ts | 98 ++----------------- 4 files changed, 38 insertions(+), 168 deletions(-) diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index ce310f447..67fb610b1 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -281,7 +281,7 @@ describe('type()', () => { await expect( user.type(screen.getByTestId('input'), 'abc') ).rejects.toThrowErrorMatchingInlineSnapshot( - `"type() works only with "TextInput" elements. Passed element has type "View"."` + `"type() works only with host "TextInput" elements. Passed element has type "View"."` ); }); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 3b9c3456c..7edb212ae 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -2,9 +2,10 @@ import { ReactTestInstance } from 'react-test-renderer'; import { getHostComponentNames } 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 { - dispatchOwnHostEvent, + dispatchEvent, wait, getTextRange, getTextContentSize, @@ -25,22 +26,27 @@ export async function type( ): Promise { if (element.type !== getHostComponentNames().textInput) { throw new ErrorWithStack( - `type() works only with "TextInput" elements. Passed element has type "${element.type}".`, + `type() works only with host "TextInput" elements. Passed element has type "${element.type}".`, type ); } + // Skip events if the element is disabled + if (element.props.editable === false || !isPointerEventEnabled(element)) { + return; + } + const keys = parseKeys(text); if (!options?.skipPress) { - dispatchOwnHostEvent(element, 'pressIn', EventBuilder.Common.touch()); + dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); } - dispatchOwnHostEvent(element, 'focus', EventBuilder.Common.focus()); + dispatchEvent(element, 'focus', EventBuilder.Common.focus()); if (!options?.skipPress) { await wait(this.config); - dispatchOwnHostEvent(element, 'pressOut', EventBuilder.Common.touch()); + dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); } let currentText = element.props.value ?? element.props.defaultValue ?? ''; @@ -57,20 +63,20 @@ export async function type( await wait(this.config); if (options?.submitEditing) { - dispatchOwnHostEvent( + dispatchEvent( element, 'submitEditing', EventBuilder.TextInput.submitEditing(finalText) ); } - dispatchOwnHostEvent( + dispatchEvent( element, 'endEditing', EventBuilder.TextInput.endEditing(finalText) ); - dispatchOwnHostEvent(element, 'blur', EventBuilder.Common.blur()); + dispatchEvent(element, 'blur', EventBuilder.Common.blur()); } async function emitTypingEvents( @@ -81,38 +87,34 @@ async function emitTypingEvents( ) { const isMultiline = element.props.multiline === true; - dispatchOwnHostEvent( - element, - 'keyPress', - EventBuilder.TextInput.keyPress(key) - ); + 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) { - dispatchOwnHostEvent( + dispatchEvent( element, 'textInput', EventBuilder.TextInput.textInput(currentText, previousText) ); } - dispatchOwnHostEvent( - element, - 'change', - EventBuilder.TextInput.change(currentText) - ); + dispatchEvent(element, 'change', EventBuilder.TextInput.change(currentText)); - dispatchOwnHostEvent(element, 'changeText', currentText); + dispatchEvent(element, 'changeText', currentText); const selectionRange = getTextRange(currentText); - dispatchOwnHostEvent( + 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); - dispatchOwnHostEvent( + dispatchEvent( element, 'contentSizeChange', EventBuilder.TextInput.contentSizeChange(contentSize) diff --git a/src/user-event/utils/__tests__/dispatch-event.test.tsx b/src/user-event/utils/__tests__/dispatch-event.test.tsx index 4062dbc94..a97835016 100644 --- a/src/user-event/utils/__tests__/dispatch-event.test.tsx +++ b/src/user-event/utils/__tests__/dispatch-event.test.tsx @@ -1,21 +1,21 @@ import * as React from 'react'; import { Text } from 'react-native'; import render from '../../../render'; -import { dispatchHostEvent, dispatchOwnHostEvent } from '../dispatch-event'; +import { dispatchEvent } from '../dispatch-event'; import { EventBuilder } from '../../event-builder'; const TOUCH_EVENT = EventBuilder.Common.touch(); -describe('dispatchHostEvent', () => { +describe('dispatchEvent', () => { it('does dispatch event', () => { const onPress = jest.fn(); const screen = render(); - dispatchHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); expect(onPress).toHaveBeenCalledTimes(1); }); - it('does dispatch event to parent host component if not handled', () => { + it('does not dispatch event to parent host component', () => { const onPressParent = jest.fn(); const screen = render( @@ -23,55 +23,7 @@ describe('dispatchHostEvent', () => { ); - dispatchHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); - expect(onPressParent).toHaveBeenCalledTimes(1); - }); - - it('does NOT dispatch event to parent host component if handled', () => { - const onPress = jest.fn(); - const onPressParent = jest.fn(); - const screen = render( - - - - ); - - dispatchHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); - expect(onPress).toHaveBeenCalledTimes(1); - expect(onPressParent).not.toHaveBeenCalled(); - }); - - it('does NOT throw if no handler found', () => { - const screen = render( - - - - ); - - expect(() => - dispatchHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT) - ).not.toThrow(); - }); -}); - -describe('dispatchOwnHostEvent', () => { - it('does dispatch event', () => { - const onPress = jest.fn(); - const screen = render(); - - dispatchOwnHostEvent(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( - - - - ); - - dispatchOwnHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); expect(onPressParent).not.toHaveBeenCalled(); }); @@ -83,7 +35,7 @@ describe('dispatchOwnHostEvent', () => { ); expect(() => - dispatchOwnHostEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT) + dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT) ).not.toThrow(); }); }); diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts index fcf24e857..09bbf4dec 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/user-event/utils/dispatch-event.ts @@ -1,41 +1,14 @@ import { ReactTestInstance } from 'react-test-renderer'; import act from '../../act'; -import { getHostParent } from '../../helpers/component-tree'; -import { isHostTextInput } from '../../helpers/host-component-names'; - -type EventHandler = (event: unknown) => void; /** - * Dispatch event function used by User Event module. + * 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 dispatchHostEvent( - element: ReactTestInstance, - eventName: string, - event: unknown -) { - const handler = findEnabledEventHandler(element, eventName); - if (!handler) { - return; - } - - act(() => { - handler(event); - }); -} - -/** - * Dispatch event function only to given element. Does not look event handler - * in the ancestors. - * - * @param element element trigger event on - * @param eventName name of the event - * @param event event payload - */ -export function dispatchOwnHostEvent( +export function dispatchEvent( element: ReactTestInstance, eventName: string, event: unknown @@ -50,73 +23,16 @@ export function dispatchOwnHostEvent( }); } -/** - * Looks up for event handler in the element and its ancestors. - */ -function findEnabledEventHandler( - element: ReactTestInstance, - eventName: string -): EventHandler | null { - let current: ReactTestInstance | null = element; - while (current != null) { - const handler = getEventHandler(current, eventName); - if (handler) { - return handler; - } - - current = getHostParent(current); - } - - return null; -} - function getEventHandler(element: ReactTestInstance, eventName: string) { - const eventHandlerName = getEventHandlerName(eventName); - if (typeof element.props[eventHandlerName] !== 'function') { - return null; + const handleName = getEventHandlerName(eventName); + const handle = element.props[handleName] as unknown; + if (typeof handle !== 'function') { + return undefined; } - if (!isEventEnabled(element)) { - return null; - } - - return element.props[eventHandlerName]; + return handle; } function getEventHandlerName(eventName: string) { return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`; } - -function isEventEnabled(element: ReactTestInstance) { - if (isHostTextInput(element)) { - return element?.props.editable !== false; - } - - if (!isPointerEventEnabled(element)) { - return false; - } - - const touchStart = element?.props.onStartShouldSetResponder?.(); - return touchStart !== false; -} - -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); -} From 1308cffa8919532af3bec6bc181a4eacd9fb1b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 19 Jul 2023 14:50:12 +0200 Subject: [PATCH 5/8] refactor: press --- src/user-event/event-builder/common.ts | 54 +++++++++++++++--------- src/user-event/press/press.ts | 58 +++++++++++++------------- 2 files changed, 63 insertions(+), 49 deletions(-) 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/press/press.ts b/src/user-event/press/press.ts index c69c04074..fd0b34225 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,7 +1,7 @@ import { ReactTestInstance } from 'react-test-renderer'; import { EventBuilder } from '../event-builder'; -import { UserEventInstance } from '../setup'; -import { wait } from '../utils'; +import { UserEventConfig, UserEventInstance } from '../setup'; +import { dispatchEvent, wait } from '../utils'; import act from '../../act'; import { getHostParent } from '../../helpers/component-tree'; import { filterNodeByType } from '../../helpers/filterNodeByType'; @@ -39,12 +39,12 @@ const basePress = async ( // 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); + await dispatchBasicPressEventSequence(config, element, options); return; } if (isEnabledTouchResponder(element)) { - await triggerPressEvent(config, element, options); + await dispatchPressablePressEventSequence(config, element, options); return; } @@ -56,8 +56,8 @@ const basePress = async ( await basePress(config, hostParentElement, options); }; -const triggerPressEvent = async ( - config: UserEventInstance['config'], +const dispatchPressablePressEventSequence = async ( + config: UserEventConfig, element: ReactTestInstance, options: PressOptions = { duration: 0 } ) => { @@ -69,17 +69,19 @@ const triggerPressEvent = async ( 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() + ); if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) { await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration); @@ -111,24 +113,20 @@ const isEnabledTextInput = (element: ReactTestInstance) => { ); }; -const triggerMockPressEvent = async ( +/** + * Dispatches a basic press event sequence on non-Pressable component, + * e.g. Text or TextInput. + */ +async function dispatchBasicPressEventSequence( 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()); - } + 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()); +} From 844bbe58529991e97fd9a893bba1b08f7fe299a8 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Wed, 19 Jul 2023 15:50:01 +0200 Subject: [PATCH 6/8] refactor: move warn method up --- .../__tests__/longPress.real-timers.test.tsx | 6 ++-- .../__tests__/press.real-timers.test.tsx | 6 ++-- src/user-event/press/press.ts | 32 +++++++++---------- src/user-event/utils/index.ts | 1 + .../warn-about-real-timers.ts} | 9 +++++- 5 files changed, 32 insertions(+), 22 deletions(-) rename src/user-event/{press/utils/warnAboutRealTimers.ts => utils/warn-about-real-timers.ts} (54%) 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/press.ts b/src/user-event/press/press.ts index fd0b34225..ffba0303c 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,15 +1,13 @@ import { ReactTestInstance } from 'react-test-renderer'; -import { EventBuilder } from '../event-builder'; -import { UserEventConfig, UserEventInstance } from '../setup'; -import { dispatchEvent, 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 { 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 +29,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 dispatchBasicPressEventSequence(config, element, options); + if (isPressableText(element) || isEnabledTextInput(element)) { + await emitBasicPressEvents(config, element, options); return; } if (isEnabledTouchResponder(element)) { - await dispatchPressablePressEventSequence(config, element, options); + await emitPressablePressEvents(config, element, options); return; } @@ -56,15 +54,12 @@ const basePress = async ( await basePress(config, hostParentElement, options); }; -const dispatchPressablePressEventSequence = async ( +const emitPressablePressEvents = async ( config: UserEventConfig, element: ReactTestInstance, options: PressOptions = { duration: 0 } ) => { - const areFakeTimersEnabled = jestFakeTimersAreEnabled(); - if (!areFakeTimersEnabled) { - warnAboutRealTimers(); - } + warnAboutRealTimersIfNeeded(); await wait(config); @@ -83,6 +78,9 @@ const dispatchPressablePressEventSequence = async ( 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); } @@ -96,7 +94,7 @@ const isEnabledTouchResponder = (element: ReactTestInstance) => { ); }; -const isEnabledHostText = (element: ReactTestInstance) => { +const isPressableText = (element: ReactTestInstance) => { return ( filterNodeByType(element, getHostComponentNames().text) && isPointerEventEnabled(element) && @@ -117,8 +115,8 @@ const isEnabledTextInput = (element: ReactTestInstance) => { * Dispatches a basic press event sequence on non-Pressable component, * e.g. Text or TextInput. */ -async function dispatchBasicPressEventSequence( - config: UserEventInstance['config'], +async function emitBasicPressEvents( + config: UserEventConfig, element: ReactTestInstance, options: PressOptions = { duration: 0 } ) { diff --git a/src/user-event/utils/index.ts b/src/user-event/utils/index.ts index 9b738ad7b..56e00613b 100644 --- a/src/user-event/utils/index.ts +++ b/src/user-event/utils/index.ts @@ -2,3 +2,4 @@ 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/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. From 1897463ef75559bafbd5b4ee41eec85d6a3f142e Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Wed, 19 Jul 2023 15:59:12 +0200 Subject: [PATCH 7/8] refactor: finishing touches --- src/helpers/host-component-names.tsx | 4 ++++ src/user-event/press/press.ts | 10 ++++++---- src/user-event/type/type.ts | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx index 8c125ee57..3fd339b0d 100644 --- a/src/helpers/host-component-names.tsx +++ b/src/helpers/host-component-names.tsx @@ -66,6 +66,10 @@ 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/press/press.ts b/src/user-event/press/press.ts index ffba0303c..08c84148a 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,9 +1,11 @@ import { ReactTestInstance } from 'react-test-renderer'; 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 { + isHostText, + isHostTextInput, +} from '../../helpers/host-component-names'; import { EventBuilder } from '../event-builder'; import { UserEventConfig, UserEventInstance } from '../setup'; import { dispatchEvent, wait, warnAboutRealTimersIfNeeded } from '../utils'; @@ -96,7 +98,7 @@ const isEnabledTouchResponder = (element: ReactTestInstance) => { const isPressableText = (element: ReactTestInstance) => { return ( - filterNodeByType(element, getHostComponentNames().text) && + isHostText(element) && isPointerEventEnabled(element) && !element.props.disabled && element.props.onPress @@ -105,7 +107,7 @@ const isPressableText = (element: ReactTestInstance) => { const isEnabledTextInput = (element: ReactTestInstance) => { return ( - filterNodeByType(element, getHostComponentNames().textInput) && + isHostTextInput(element) && isPointerEventEnabled(element) && element.props.editable !== false ); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 7edb212ae..42534ce86 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -1,5 +1,5 @@ import { ReactTestInstance } from 'react-test-renderer'; -import { getHostComponentNames } from '../../helpers/host-component-names'; +import { isHostTextInput } from '../../helpers/host-component-names'; import { EventBuilder } from '../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; @@ -24,7 +24,7 @@ export async function type( text: string, options?: TypeOptions ): Promise { - if (element.type !== getHostComponentNames().textInput) { + if (!isHostTextInput(element)) { throw new ErrorWithStack( `type() works only with host "TextInput" elements. Passed element has type "${element.type}".`, type From 8284dbbc8ddd3acdb519e78c6d63adc3a258e1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Mon, 24 Jul 2023 11:49:08 +0200 Subject: [PATCH 8/8] docs: correct typos --- website/docs/UserEvent.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/UserEvent.md b/website/docs/UserEvent.md index 7aa0ed598..0c342aae5 100644 --- a/website/docs/UserEvent.md +++ b/website/docs/UserEvent.md @@ -105,7 +105,7 @@ Entering the element: - `focus` - `pressOut` (optional) -The `pressIn` and `pressOut` events are send by default, but can be skipped by passing `skipPress: true` option. +The `pressIn` and `pressOut` events are sent by default, but can be skipped by passing `skipPress: true` option. Typing (for each character): - `keyPress` @@ -121,4 +121,4 @@ Leaving the element: - `endEditing` - `blur` -The `submitEditing` event is skipped by default. It can be send by setting `submitEditing: true` option. +The `submitEditing` event is skipped by default. It can sent by setting `submitEditing: true` option.