diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index df4fc8b49..d3e2798ef 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -36,7 +36,7 @@ test('fireEvent passes event data to handler', async () => { const onPress = jest.fn(); await render(); await fireEvent.press(screen.getByTestId('btn'), pressEventData); - expect(onPress).toHaveBeenCalledWith(pressEventData); + expect(onPress.mock.calls[0][0]).toMatchObject(pressEventData); }); test('fireEvent passes multiple parameters to handler', async () => { @@ -46,11 +46,11 @@ test('fireEvent passes multiple parameters to handler', async () => { expect(handlePress).toHaveBeenCalledWith('param1', 'param2', 'param3'); }); -test('fireEvent returns handler return value', async () => { +test('fireEvent.press returns undefined when event handler returns a value', async () => { const handler = jest.fn().mockReturnValue('result'); await render(); const result = await fireEvent.press(screen.getByTestId('btn')); - expect(result).toBe('result'); + expect(result).toBe(undefined); }); test('fireEvent bubbles event to parent handler', async () => { @@ -115,7 +115,7 @@ describe('fireEvent.scroll', () => { ); const scrollView = screen.getByTestId('scroll'); await fireEvent.scroll(scrollView, verticalScrollEvent); - expect(onScroll).toHaveBeenCalledWith(verticalScrollEvent); + expect(onScroll.mock.calls[0][0]).toMatchObject(verticalScrollEvent); expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 0, y: 200 }); }); @@ -134,7 +134,7 @@ describe('fireEvent.scroll', () => { expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 0, y: 200 }); }); - test('without contentOffset does not update native state', async () => { + test('without contentOffset scrolls to (0, 0)', async () => { const onScroll = jest.fn(); await render( @@ -143,8 +143,10 @@ describe('fireEvent.scroll', () => { ); const scrollView = screen.getByTestId('scroll'); await fireEvent.scroll(scrollView, {}); - expect(onScroll).toHaveBeenCalled(); - expect(nativeState.contentOffsetForElement.get(scrollView)).toBeUndefined(); + expect(onScroll.mock.calls[0][0]).toMatchObject({ + nativeEvent: { contentOffset: { x: 0, y: 0 } }, + }); + expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 0, y: 0 }); }); test('with non-finite contentOffset values uses 0', async () => { @@ -171,10 +173,23 @@ describe('fireEvent.scroll', () => { ); const scrollView = screen.getByTestId('scroll'); await fireEvent.scroll(scrollView, horizontalScrollEvent); - expect(onScroll).toHaveBeenCalledWith(horizontalScrollEvent); + expect(onScroll.mock.calls[0][0]).toMatchObject(horizontalScrollEvent); expect(nativeState.contentOffsetForElement.get(scrollView)).toEqual({ x: 50, y: 0 }); }); + test('without contentOffset via fireEvent() does not update native state', async () => { + const onScroll = jest.fn(); + await render( + + Content + , + ); + const scrollView = screen.getByTestId('scroll'); + await fireEvent(scrollView, 'scroll', { nativeEvent: {} }); + expect(onScroll).toHaveBeenCalled(); + expect(nativeState.contentOffsetForElement.get(scrollView)).toBeUndefined(); + }); + test('with non-finite x contentOffset value uses 0', async () => { const onScroll = jest.fn(); await render( diff --git a/src/user-event/event-builder/__tests__/base.test.ts b/src/event-builder/__tests__/base.test.ts similarity index 100% rename from src/user-event/event-builder/__tests__/base.test.ts rename to src/event-builder/__tests__/base.test.ts diff --git a/src/event-builder/__tests__/common.test.ts b/src/event-builder/__tests__/common.test.ts new file mode 100644 index 000000000..a70e78332 --- /dev/null +++ b/src/event-builder/__tests__/common.test.ts @@ -0,0 +1,57 @@ +import { + buildBlurEvent, + buildFocusEvent, + buildResponderGrantEvent, + buildResponderReleaseEvent, + buildTouchEvent, +} from '../common'; + +test('buildTouchEvent returns event with touch nativeEvent', () => { + const event = buildTouchEvent(); + + expect(event.nativeEvent).toEqual({ + changedTouches: [], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: expect.any(Number), + touches: [], + }); + expect(event.currentTarget).toHaveProperty('measure'); + expect(event).toHaveProperty('preventDefault'); +}); + +test('buildResponderGrantEvent returns touch event with dispatchConfig', () => { + const event = buildResponderGrantEvent(); + + expect(event.dispatchConfig).toEqual({ + registrationName: 'onResponderGrant', + }); + expect(event.nativeEvent).toHaveProperty('touches'); +}); + +test('buildResponderReleaseEvent returns touch event with dispatchConfig', () => { + const event = buildResponderReleaseEvent(); + + expect(event.dispatchConfig).toEqual({ + registrationName: 'onResponderRelease', + }); + expect(event.nativeEvent).toHaveProperty('touches'); +}); + +test('buildFocusEvent returns event with target', () => { + const event = buildFocusEvent(); + + expect(event.nativeEvent).toEqual({ target: 0 }); + expect(event).toHaveProperty('preventDefault'); +}); + +test('buildBlurEvent returns event with target', () => { + const event = buildBlurEvent(); + + expect(event.nativeEvent).toEqual({ target: 0 }); + expect(event).toHaveProperty('preventDefault'); +}); diff --git a/src/event-builder/__tests__/index.test.ts b/src/event-builder/__tests__/index.test.ts new file mode 100644 index 000000000..8497e250a --- /dev/null +++ b/src/event-builder/__tests__/index.test.ts @@ -0,0 +1,16 @@ +import * as eventBuilder from '..'; + +test('re-exports all event builders', () => { + expect(eventBuilder.buildTouchEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildResponderGrantEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildResponderReleaseEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildFocusEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildBlurEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildScrollEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildTextChangeEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildKeyPressEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildSubmitEditingEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildEndEditingEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildTextSelectionChangeEvent).toBeInstanceOf(Function); + expect(eventBuilder.buildContentSizeChangeEvent).toBeInstanceOf(Function); +}); diff --git a/src/event-builder/__tests__/scroll.test.ts b/src/event-builder/__tests__/scroll.test.ts new file mode 100644 index 000000000..90363934c --- /dev/null +++ b/src/event-builder/__tests__/scroll.test.ts @@ -0,0 +1,34 @@ +import { buildScrollEvent } from '../scroll'; + +test('buildScrollEvent returns default scroll event', () => { + const event = buildScrollEvent(); + + expect(event.nativeEvent).toEqual({ + contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, + contentOffset: { y: 0, x: 0 }, + contentSize: { height: 0, width: 0 }, + layoutMeasurement: { height: 0, width: 0 }, + responderIgnoreScroll: true, + target: 0, + velocity: { y: 0, x: 0 }, + }); +}); + +test('buildScrollEvent uses provided offset', () => { + const event = buildScrollEvent({ y: 100, x: 50 }); + + expect(event.nativeEvent.contentOffset).toEqual({ y: 100, x: 50 }); +}); + +test('buildScrollEvent uses provided options', () => { + const event = buildScrollEvent( + { y: 0, x: 0 }, + { + contentSize: { height: 1000, width: 400 }, + layoutMeasurement: { height: 800, width: 400 }, + }, + ); + + expect(event.nativeEvent.contentSize).toEqual({ height: 1000, width: 400 }); + expect(event.nativeEvent.layoutMeasurement).toEqual({ height: 800, width: 400 }); +}); diff --git a/src/event-builder/__tests__/text.test.ts b/src/event-builder/__tests__/text.test.ts new file mode 100644 index 000000000..98b5f6bdd --- /dev/null +++ b/src/event-builder/__tests__/text.test.ts @@ -0,0 +1,47 @@ +import { + buildContentSizeChangeEvent, + buildEndEditingEvent, + buildKeyPressEvent, + buildSubmitEditingEvent, + buildTextChangeEvent, + buildTextSelectionChangeEvent, +} from '../text'; + +test('buildTextChangeEvent returns event with text', () => { + const event = buildTextChangeEvent('Hello'); + + expect(event.nativeEvent).toEqual({ text: 'Hello', target: 0, eventCount: 0 }); +}); + +test('buildKeyPressEvent returns event with key', () => { + const event = buildKeyPressEvent('a'); + + expect(event.nativeEvent).toEqual({ key: 'a' }); +}); + +test('buildSubmitEditingEvent returns event with text', () => { + const event = buildSubmitEditingEvent('Hello'); + + expect(event.nativeEvent).toEqual({ text: 'Hello', target: 0 }); +}); + +test('buildEndEditingEvent returns event with text', () => { + const event = buildEndEditingEvent('Hello'); + + expect(event.nativeEvent).toEqual({ text: 'Hello', target: 0 }); +}); + +test('buildTextSelectionChangeEvent returns event with selection', () => { + const event = buildTextSelectionChangeEvent({ start: 0, end: 4 }); + + expect(event.nativeEvent).toEqual({ selection: { start: 0, end: 4 } }); +}); + +test('buildContentSizeChangeEvent returns event with contentSize', () => { + const event = buildContentSizeChangeEvent({ width: 100, height: 50 }); + + expect(event.nativeEvent).toEqual({ + contentSize: { width: 100, height: 50 }, + target: 0, + }); +}); diff --git a/src/user-event/event-builder/base.ts b/src/event-builder/base.ts similarity index 100% rename from src/user-event/event-builder/base.ts rename to src/event-builder/base.ts diff --git a/src/event-builder/common.ts b/src/event-builder/common.ts new file mode 100644 index 000000000..43b6606ae --- /dev/null +++ b/src/event-builder/common.ts @@ -0,0 +1,68 @@ +import { baseSyntheticEvent } from './base'; + +/** + * 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]]}` + */ +export function buildTouchEvent() { + return { + ...baseSyntheticEvent(), + nativeEvent: { + changedTouches: [] as unknown[], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: Date.now(), + touches: [] as unknown[], + }, + currentTarget: { measure: () => {} }, + }; +} + +export type TouchEvent = ReturnType; + +export function buildResponderGrantEvent() { + return { + ...buildTouchEvent(), + dispatchConfig: { registrationName: 'onResponderGrant' }, + }; +} + +export function buildResponderReleaseEvent() { + return { + ...buildTouchEvent(), + dispatchConfig: { registrationName: 'onResponderRelease' }, + }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` + * - Android: `{"target": 53}` + */ +export function buildFocusEvent() { + return { + ...baseSyntheticEvent(), + nativeEvent: { + target: 0, + }, + }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` + * - Android: `{"target": 53}` + */ +export function buildBlurEvent() { + return { + ...baseSyntheticEvent(), + nativeEvent: { + target: 0, + }, + }; +} diff --git a/src/event-builder/index.ts b/src/event-builder/index.ts new file mode 100644 index 000000000..007e9d2c0 --- /dev/null +++ b/src/event-builder/index.ts @@ -0,0 +1,3 @@ +export * from './common'; +export * from './scroll'; +export * from './text'; diff --git a/src/user-event/event-builder/scroll-view.ts b/src/event-builder/scroll.ts similarity index 54% rename from src/user-event/event-builder/scroll-view.ts rename to src/event-builder/scroll.ts index cd2626e42..610d1d956 100644 --- a/src/user-event/event-builder/scroll-view.ts +++ b/src/event-builder/scroll.ts @@ -1,4 +1,4 @@ -import type { Point, Size } from '../../types'; +import type { Point, Size } from '../types'; import { baseSyntheticEvent } from './base'; /** @@ -14,25 +14,23 @@ export type ScrollEventOptions = { * - iOS: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 5.333333333333333}, "contentSize": {"height": 1676.6666259765625, "width": 390}, "layoutMeasurement": {"height": 753, "width": 390}, "zoomScale": 1}` * - Android: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 31.619047164916992}, "contentSize": {"height": 1624.761962890625, "width": 411.4285583496094}, "layoutMeasurement": {"height": 785.5238037109375, "width": 411.4285583496094}, "responderIgnoreScroll": true, "target": 139, "velocity": {"x": -1.3633992671966553, "y": -1.3633992671966553}}` */ -export const ScrollViewEventBuilder = { - scroll: (offset: Point = { y: 0, x: 0 }, options?: ScrollEventOptions) => { - return { - ...baseSyntheticEvent(), - nativeEvent: { - contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, - contentOffset: { y: offset.y, x: offset.x }, - contentSize: { - height: options?.contentSize?.height ?? 0, - width: options?.contentSize?.width ?? 0, - }, - layoutMeasurement: { - height: options?.layoutMeasurement?.height ?? 0, - width: options?.layoutMeasurement?.width ?? 0, - }, - responderIgnoreScroll: true, - target: 0, - velocity: { y: 0, x: 0 }, +export function buildScrollEvent(offset: Point = { y: 0, x: 0 }, options?: ScrollEventOptions) { + return { + ...baseSyntheticEvent(), + nativeEvent: { + contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, + contentOffset: { y: offset.y, x: offset.x }, + contentSize: { + height: options?.contentSize?.height ?? 0, + width: options?.contentSize?.width ?? 0, }, - }; - }, -}; + layoutMeasurement: { + height: options?.layoutMeasurement?.height ?? 0, + width: options?.layoutMeasurement?.width ?? 0, + }, + responderIgnoreScroll: true, + target: 0, + velocity: { y: 0, x: 0 }, + }, + }; +} diff --git a/src/event-builder/text.ts b/src/event-builder/text.ts new file mode 100644 index 000000000..c8effcb74 --- /dev/null +++ b/src/event-builder/text.ts @@ -0,0 +1,74 @@ +import type { Size, TextRange } from '../types'; +import { baseSyntheticEvent } from './base'; + +/** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}` + */ +export function buildTextChangeEvent(text: string) { + return { + ...baseSyntheticEvent(), + nativeEvent: { text, target: 0, eventCount: 0 }, + }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 3, "key": "a", "target": 75}` + * - Android: `{"key": "a"}` + */ +export function buildKeyPressEvent(key: string) { + return { + ...baseSyntheticEvent(), + nativeEvent: { key }, + }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"target": 53, "text": "Test"}` + */ +export function buildSubmitEditingEvent(text: string) { + return { + ...baseSyntheticEvent(), + nativeEvent: { text, target: 0 }, + }; +} + +/** + * Experimental values: + * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` + * - Android: `{"target": 53, "text": "Test"}` + */ +export function buildEndEditingEvent(text: string) { + return { + ...baseSyntheticEvent(), + nativeEvent: { text, target: 0 }, + }; +} + +/** + * Experimental values: + * - iOS: `{"selection": {"end": 4, "start": 4}, "target": 75}` + * - Android: `{"selection": {"end": 4, "start": 4}}` + */ +export function buildTextSelectionChangeEvent({ start, end }: TextRange) { + return { + ...baseSyntheticEvent(), + nativeEvent: { selection: { start, end } }, + }; +} + +/** + * Experimental values: + * - iOS: `{"contentSize": {"height": 21.666666666666668, "width": 11.666666666666666}, "target": 75}` + * - Android: `{"contentSize": {"height": 61.45454406738281, "width": 352.7272644042969}, "target": 53}` + */ +export function buildContentSizeChangeEvent({ width, height }: Size) { + return { + ...baseSyntheticEvent(), + nativeEvent: { contentSize: { width, height }, target: 0 }, + }; +} diff --git a/src/fire-event.ts b/src/fire-event.ts index ba4914ffe..e6f733642 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -8,6 +8,7 @@ import type { import type { Fiber, HostElement } from 'test-renderer'; import { act } from './act'; +import { buildScrollEvent, buildTouchEvent } from './event-builder'; import type { EventHandler } from './event-handler'; import { getEventHandlerFromProps } from './event-handler'; import { isElementMounted } from './helpers/component-tree'; @@ -142,14 +143,28 @@ async function fireEvent(element: HostElement, eventName: EventName, ...data: un return returnValue; } -fireEvent.press = async (element: HostElement, ...data: unknown[]) => - await fireEvent(element, 'press', ...data); +type EventProps = Record; -fireEvent.changeText = async (element: HostElement, ...data: unknown[]) => - await fireEvent(element, 'changeText', ...data); +fireEvent.changeText = async (element: HostElement, text: string) => + await fireEvent(element, 'changeText', text); -fireEvent.scroll = async (element: HostElement, ...data: unknown[]) => - await fireEvent(element, 'scroll', ...data); +fireEvent.press = async (element: HostElement, eventProps?: EventProps) => { + const event = buildTouchEvent(); + if (eventProps) { + mergeEventProps(event, eventProps); + } + + await fireEvent(element, 'press', event); +}; + +fireEvent.scroll = async (element: HostElement, eventProps?: EventProps) => { + const event = buildScrollEvent(); + if (eventProps) { + mergeEventProps(event, eventProps); + } + + await fireEvent(element, 'scroll', event); +}; export { fireEvent }; @@ -193,3 +208,25 @@ function tryGetContentOffset(event: unknown): Point | null { return null; } + +function mergeEventProps(target: Record, source: Record) { + for (const key of Object.keys(source)) { + const sourceValue = source[key]; + const targetValue = target[key]; + if ( + sourceValue != null && + typeof sourceValue === 'object' && + !Array.isArray(sourceValue) && + targetValue && + typeof targetValue === 'object' && + !Array.isArray(targetValue) + ) { + mergeEventProps( + targetValue as Record, + sourceValue as Record, + ); + } else { + target[key] = sourceValue; + } + } +} diff --git a/src/types.ts b/src/types.ts index 8da61033c..6e32637b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,14 @@ export interface Size { width: number; } +/** + * Range of text in a text input. + */ +export interface TextRange { + start: number; + end: number; +} + // TS autocomplete trick // Ref: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-567871939 export type StringWithAutocomplete = T | (string & {}); diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index 61f123f7d..1ce414e04 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -1,10 +1,15 @@ import type { HostElement } from 'test-renderer'; +import { + buildBlurEvent, + buildEndEditingEvent, + buildFocusEvent, + buildTextSelectionChangeEvent, +} from '../event-builder'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; import { isPointerEventEnabled } from '../helpers/pointer-events'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; -import { EventBuilder } from './event-builder'; import type { UserEventInstance } from './setup'; import { emitTypingEvents } from './type/type'; import { dispatchEvent, wait } from './utils'; @@ -22,7 +27,7 @@ export async function clear(this: UserEventInstance, element: HostElement): Prom } // 1. Enter element - await dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + await dispatchEvent(element, 'focus', buildFocusEvent()); // 2. Select all const textToClear = getTextInputValue(element); @@ -30,11 +35,7 @@ export async function clear(this: UserEventInstance, element: HostElement): Prom start: 0, end: textToClear.length, }; - await dispatchEvent( - element, - 'selectionChange', - EventBuilder.TextInput.selectionChange(selectionRange), - ); + await dispatchEvent(element, 'selectionChange', buildTextSelectionChangeEvent(selectionRange)); // 3. Press backspace with selected text const emptyText = ''; @@ -46,6 +47,6 @@ export async function clear(this: UserEventInstance, element: HostElement): Prom // 4. Exit element await wait(this.config); - await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(emptyText)); - await dispatchEvent(element, 'blur', EventBuilder.Common.blur()); + await dispatchEvent(element, 'endEditing', buildEndEditingEvent(emptyText)); + await dispatchEvent(element, 'blur', buildBlurEvent()); } diff --git a/src/user-event/event-builder/common.ts b/src/user-event/event-builder/common.ts deleted file mode 100644 index 173c8ec7e..000000000 --- a/src/user-event/event-builder/common.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { baseSyntheticEvent } from './base'; - -/** - * 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 { - ...baseSyntheticEvent(), - nativeEvent: { - changedTouches: [], - identifier: 0, - locationX: 0, - locationY: 0, - pageX: 0, - pageY: 0, - target: 0, - timestamp: Date.now(), - touches: [], - }, - currentTarget: { measure: () => {} }, - }; -} - -export const CommonEventBuilder = { - touch, - - responderGrant: () => { - return { - ...touch(), - dispatchConfig: { registrationName: 'onResponderGrant' }, - }; - }, - - responderRelease: () => { - return { - ...touch(), - dispatchConfig: { registrationName: 'onResponderRelease' }, - }; - }, - - /** - * Experimental values: - * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` - * - Android: `{"target": 53}` - */ - focus: () => { - return { - ...baseSyntheticEvent(), - nativeEvent: { - target: 0, - }, - }; - }, - - /** - * Experimental values: - * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` - * - Android: `{"target": 53}` - */ - blur: () => { - return { - ...baseSyntheticEvent(), - nativeEvent: { - target: 0, - }, - }; - }, -}; diff --git a/src/user-event/event-builder/index.ts b/src/user-event/event-builder/index.ts deleted file mode 100644 index bee87cff4..000000000 --- a/src/user-event/event-builder/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { CommonEventBuilder } from './common'; -import { ScrollViewEventBuilder } from './scroll-view'; -import { TextInputEventBuilder } from './text-input'; - -export const EventBuilder = { - Common: CommonEventBuilder, - ScrollView: ScrollViewEventBuilder, - TextInput: TextInputEventBuilder, -}; diff --git a/src/user-event/event-builder/text-input.ts b/src/user-event/event-builder/text-input.ts deleted file mode 100644 index 4369d0718..000000000 --- a/src/user-event/event-builder/text-input.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Size } from '../../types'; -import type { TextRange } from '../utils/text-range'; -import { baseSyntheticEvent } from './base'; - -export const TextInputEventBuilder = { - /** - * Experimental values: - * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` - * - Android: `{"eventCount": 6, "target": 53, "text": "Tes"}` - */ - change: (text: string) => { - return { - ...baseSyntheticEvent(), - nativeEvent: { text, target: 0, eventCount: 0 }, - }; - }, - - /** - * Experimental values: - * - iOS: `{"eventCount": 3, "key": "a", "target": 75}` - * - Android: `{"key": "a"}` - */ - keyPress: (key: string) => { - return { - ...baseSyntheticEvent(), - nativeEvent: { key }, - }; - }, - - /** - * Experimental values: - * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` - * - Android: `{"target": 53, "text": "Test"}` - */ - submitEditing: (text: string) => { - return { - ...baseSyntheticEvent(), - nativeEvent: { text, target: 0 }, - }; - }, - - /** - * Experimental values: - * - iOS: `{"eventCount": 4, "target": 75, "text": "Test"}` - * - Android: `{"target": 53, "text": "Test"}` - */ - endEditing: (text: string) => { - return { - ...baseSyntheticEvent(), - 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 { - ...baseSyntheticEvent(), - nativeEvent: { selection: { start, end } }, - }; - }, - - /** - * 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 }: Size) => { - return { - ...baseSyntheticEvent(), - nativeEvent: { contentSize: { width, height }, target: 0 }, - }; - }, -}; diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index f869588f5..0eef523df 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -1,11 +1,18 @@ import type { HostElement } from 'test-renderer'; +import { + buildBlurEvent, + buildContentSizeChangeEvent, + buildEndEditingEvent, + buildFocusEvent, + buildTextChangeEvent, + buildTextSelectionChangeEvent, +} from '../event-builder'; import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; import { isPointerEventEnabled } from '../helpers/pointer-events'; import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; import { nativeState } from '../native-state'; -import { EventBuilder } from './event-builder'; import type { UserEventInstance } from './setup'; import { dispatchEvent, getTextContentSize, wait } from './utils'; @@ -26,43 +33,31 @@ export async function paste( } // 1. Enter element - await dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + await dispatchEvent(element, 'focus', buildFocusEvent()); // 2. Select all const textToClear = getTextInputValue(element); const rangeToClear = { start: 0, end: textToClear.length }; - await dispatchEvent( - element, - 'selectionChange', - EventBuilder.TextInput.selectionChange(rangeToClear), - ); + await dispatchEvent(element, 'selectionChange', buildTextSelectionChangeEvent(rangeToClear)); // 3. Paste the text nativeState.valueForElement.set(element, text); - await dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); + await dispatchEvent(element, 'change', buildTextChangeEvent(text)); await dispatchEvent(element, 'changeText', text); const rangeAfter = { start: text.length, end: text.length }; - await dispatchEvent( - element, - 'selectionChange', - EventBuilder.TextInput.selectionChange(rangeAfter), - ); + await dispatchEvent(element, 'selectionChange', buildTextSelectionChangeEvent(rangeAfter)); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange const isMultiline = element.props.multiline === true; if (isMultiline) { const contentSize = getTextContentSize(text); - await dispatchEvent( - element, - 'contentSizeChange', - EventBuilder.TextInput.contentSizeChange(contentSize), - ); + await dispatchEvent(element, 'contentSizeChange', buildContentSizeChangeEvent(contentSize)); } // 4. Exit element await wait(this.config); - await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(text)); - await dispatchEvent(element, 'blur', EventBuilder.Common.blur()); + await dispatchEvent(element, 'endEditing', buildEndEditingEvent(text)); + await dispatchEvent(element, 'blur', buildBlurEvent()); } diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 6b908f9ad..6d3b69cf1 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,12 +1,16 @@ import type { HostElement } from 'test-renderer'; import { act } from '../../act'; +import { + buildResponderGrantEvent, + buildResponderReleaseEvent, + buildTouchEvent, +} from '../../event-builder'; import { getEventHandlerFromProps } from '../../event-handler'; import { isHostElement } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostText, isHostTextInput } from '../../helpers/host-component-names'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; -import { EventBuilder } from '../event-builder'; import type { UserEventConfig, UserEventInstance } from '../setup'; import { dispatchEvent, wait } from '../utils'; @@ -109,23 +113,23 @@ async function emitDirectPressEvents( options: BasePressOptions, ) { await wait(config); - await dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); + await dispatchEvent(element, 'pressIn', buildTouchEvent()); await wait(config, options.duration); // Long press events are emitted before `pressOut`. if (options.type === 'longPress') { - await dispatchEvent(element, 'longPress', EventBuilder.Common.touch()); + await dispatchEvent(element, 'longPress', buildTouchEvent()); } - await dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); + await dispatchEvent(element, 'pressOut', buildTouchEvent()); // Regular press events are emitted after `pressOut` according to the React Native docs. // See: https://reactnative.dev/docs/pressable#onpress // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but // we will ignore that as in reality most pressed would be above the 130ms threshold. if (options.type === 'press') { - await dispatchEvent(element, 'press', EventBuilder.Common.touch()); + await dispatchEvent(element, 'press', buildTouchEvent()); } } @@ -136,12 +140,12 @@ async function emitPressabilityPressEvents( ) { await wait(config); - await dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant()); + await dispatchEvent(element, 'responderGrant', buildResponderGrantEvent()); const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION; await wait(config, duration); - await dispatchEvent(element, 'responderRelease', EventBuilder.Common.responderRelease()); + await dispatchEvent(element, 'responderRelease', buildResponderReleaseEvent()); // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION // before emitting the `pressOut` event. We need to wait here, so that diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx index 6b1500f5e..5575a815b 100644 --- a/src/user-event/scroll/__tests__/scroll-to.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx @@ -135,7 +135,7 @@ describe('scrollTo()', () => { }); await user.scrollTo(screen.getByTestId('scrollView'), { y: 200 }); expect(mapEventsToShortForm(events)).toEqual([ - ['scroll', 100, undefined], + ['scroll', 100, 0], ['scrollBeginDrag', 100, 0], ['scroll', 125, 0], ['scroll', 150, 0], diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index 1ac320424..e561e1e4f 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -1,12 +1,12 @@ import { stringify } from 'jest-matcher-utils'; import type { HostElement } from 'test-renderer'; +import { buildScrollEvent } from '../../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; import { pick } from '../../helpers/object'; import { nativeState } from '../../native-state'; import type { Point, Size } from '../../types'; -import { EventBuilder } from '../event-builder'; import type { UserEventConfig, UserEventInstance } from '../setup'; import { dispatchEvent, wait } from '../utils'; import { createScrollSteps, inertialInterpolator, linearInterpolator } from './utils'; @@ -88,31 +88,19 @@ async function emitDragScrollEvents( } await wait(config); - await dispatchEvent( - element, - 'scrollBeginDrag', - EventBuilder.ScrollView.scroll(scrollSteps[0], scrollOptions), - ); + await dispatchEvent(element, 'scrollBeginDrag', buildScrollEvent(scrollSteps[0], scrollOptions)); // Note: experimentally, in case of drag scroll the last scroll step // will not trigger `scroll` event. // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events for (let i = 1; i < scrollSteps.length - 1; i += 1) { await wait(config); - await dispatchEvent( - element, - 'scroll', - EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions), - ); + await dispatchEvent(element, 'scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); } await wait(config); const lastStep = scrollSteps.at(-1); - await dispatchEvent( - element, - 'scrollEndDrag', - EventBuilder.ScrollView.scroll(lastStep, scrollOptions), - ); + await dispatchEvent(element, 'scrollEndDrag', buildScrollEvent(lastStep, scrollOptions)); } async function emitMomentumScrollEvents( @@ -129,7 +117,7 @@ async function emitMomentumScrollEvents( await dispatchEvent( element, 'momentumScrollBegin', - EventBuilder.ScrollView.scroll(scrollSteps[0], scrollOptions), + buildScrollEvent(scrollSteps[0], scrollOptions), ); // Note: experimentally, in case of momentum scroll the last scroll step @@ -137,20 +125,12 @@ async function emitMomentumScrollEvents( // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); - await dispatchEvent( - element, - 'scroll', - EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions), - ); + await dispatchEvent(element, 'scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); } await wait(config); const lastStep = scrollSteps.at(-1); - await dispatchEvent( - element, - 'momentumScrollEnd', - EventBuilder.ScrollView.scroll(lastStep, scrollOptions), - ); + await dispatchEvent(element, 'momentumScrollEnd', buildScrollEvent(lastStep, scrollOptions)); } function ensureScrollViewDirection(element: HostElement, options: ScrollToOptions) { diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 4d1d040b3..c823ce75c 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -1,11 +1,21 @@ import type { HostElement } from 'test-renderer'; +import { + buildBlurEvent, + buildContentSizeChangeEvent, + buildEndEditingEvent, + buildFocusEvent, + buildKeyPressEvent, + buildSubmitEditingEvent, + buildTextChangeEvent, + buildTextSelectionChangeEvent, + buildTouchEvent, +} from '../../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostTextInput } from '../../helpers/host-component-names'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; import { getTextInputValue, isEditableTextInput } from '../../helpers/text-input'; import { nativeState } from '../../native-state'; -import { EventBuilder } from '../event-builder'; import type { UserEventConfig, UserEventInstance } from '../setup'; import { dispatchEvent, getTextContentSize, wait } from '../utils'; import { parseKeys } from './parse-keys'; @@ -37,14 +47,14 @@ export async function type( const keys = parseKeys(text); if (!options?.skipPress) { - await dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); + await dispatchEvent(element, 'pressIn', buildTouchEvent()); } - await dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + await dispatchEvent(element, 'focus', buildFocusEvent()); if (!options?.skipPress) { await wait(this.config); - await dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); + await dispatchEvent(element, 'pressOut', buildTouchEvent()); } let currentText = getTextInputValue(element); @@ -66,12 +76,12 @@ export async function type( await wait(this.config); if (options?.submitEditing) { - await dispatchEvent(element, 'submitEditing', EventBuilder.TextInput.submitEditing(finalText)); + await dispatchEvent(element, 'submitEditing', buildSubmitEditingEvent(finalText)); } if (!options?.skipBlur) { - await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(finalText)); - await dispatchEvent(element, 'blur', EventBuilder.Common.blur()); + await dispatchEvent(element, 'endEditing', buildEndEditingEvent(finalText)); + await dispatchEvent(element, 'blur', buildBlurEvent()); } } @@ -89,7 +99,7 @@ export async function emitTypingEvents( const isMultiline = element.props.multiline === true; await wait(config); - await dispatchEvent(element, 'keyPress', EventBuilder.TextInput.keyPress(key)); + await dispatchEvent(element, 'keyPress', buildKeyPressEvent(key)); // Platform difference (based on experiments): // - iOS and RN Web: TextInput emits only `keyPress` event when max length has been reached @@ -99,28 +109,20 @@ export async function emitTypingEvents( } nativeState.valueForElement.set(element, text); - await dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); + await dispatchEvent(element, 'change', buildTextChangeEvent(text)); await dispatchEvent(element, 'changeText', text); const selectionRange = { start: text.length, end: text.length, }; - await dispatchEvent( - element, - 'selectionChange', - EventBuilder.TextInput.selectionChange(selectionRange), - ); + await dispatchEvent(element, 'selectionChange', buildTextSelectionChangeEvent(selectionRange)); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange if (isMultiline) { const contentSize = getTextContentSize(text); - await dispatchEvent( - element, - 'contentSizeChange', - EventBuilder.TextInput.contentSizeChange(contentSize), - ); + await dispatchEvent(element, 'contentSizeChange', buildContentSizeChangeEvent(contentSize)); } } diff --git a/src/user-event/utils/__tests__/dispatch-event.test.tsx b/src/user-event/utils/__tests__/dispatch-event.test.tsx index 81f308e2d..cf8f5e49e 100644 --- a/src/user-event/utils/__tests__/dispatch-event.test.tsx +++ b/src/user-event/utils/__tests__/dispatch-event.test.tsx @@ -2,10 +2,10 @@ import * as React from 'react'; import { Text } from 'react-native'; import { render, screen } from '../../..'; -import { EventBuilder } from '../../event-builder'; +import { buildTouchEvent } from '../../../event-builder'; import { dispatchEvent } from '../dispatch-event'; -const TOUCH_EVENT = EventBuilder.Common.touch(); +const TOUCH_EVENT = buildTouchEvent(); describe('dispatchEvent', () => { it('does dispatch event', async () => { diff --git a/src/user-event/utils/index.ts b/src/user-event/utils/index.ts index 9b738ad7b..28acda681 100644 --- a/src/user-event/utils/index.ts +++ b/src/user-event/utils/index.ts @@ -1,4 +1,3 @@ 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 deleted file mode 100644 index 31a2cf593..000000000 --- a/src/user-event/utils/text-range.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface TextRange { - start: number; - end: number; -}