diff --git a/package.json b/package.json index 800cf8f0b..fc912a64c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "jest-preset/", "typings/index.flow.js", "pure.js", - "dont-cleanup-after-each.js" + "dont-cleanup-after-each.js", + "!**/test-utils" ], "devDependencies": { "@babel/cli": "^7.19.3", diff --git a/src/fireEvent.ts b/src/fireEvent.ts index 5fd454bf5..a00dd7ac9 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -9,7 +9,7 @@ const isHostTextInput = (element?: ReactTestInstance) => { return element?.type === getHostComponentNames().textInput; }; -function isTouchResponder(element: ReactTestInstance) { +export function isTouchResponder(element: ReactTestInstance) { if (!isHostElement(element)) { return false; } @@ -19,7 +19,7 @@ function isTouchResponder(element: ReactTestInstance) { ); } -function isPointerEventEnabled( +export function isPointerEventEnabled( element: ReactTestInstance, isParent?: boolean ): boolean { @@ -63,7 +63,7 @@ const textInputEventsIgnoringEditableProp = new Set([ 'onScroll', ]); -function isEventEnabled( +export function isEventEnabled( element: ReactTestInstance, eventName: string, nearestTouchResponder?: ReactTestInstance diff --git a/src/test-utils/events.ts b/src/test-utils/events.ts new file mode 100644 index 000000000..b6a0dd13c --- /dev/null +++ b/src/test-utils/events.ts @@ -0,0 +1,20 @@ +interface EventEntry { + name: string; + payload: any; +} + +export function createEventLogger() { + const events: EventEntry[] = []; + const logEvent = (name: string) => { + return (event: unknown) => { + const eventEntry: EventEntry = { + name, + payload: event, + }; + + events.push(eventEntry); + }; + }; + + return { events, logEvent }; +} diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts new file mode 100644 index 000000000..7981d6b64 --- /dev/null +++ b/src/test-utils/index.ts @@ -0,0 +1 @@ +export * from './events'; diff --git a/src/user-event/event-builder/common.ts b/src/user-event/event-builder/common.ts new file mode 100644 index 000000000..1e732eb3f --- /dev/null +++ b/src/user-event/event-builder/common.ts @@ -0,0 +1,48 @@ +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: () => { + return { + nativeEvent: { + changedTouches: [], + identifier: 0, + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: 0, + timestamp: 0, + touches: [], + }, + }; + }, + + /** + * Experimental values: + * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` + * - Android: `{"target": 53}` + */ + focus: () => { + return { + nativeEvent: { + target: 0, + }, + }; + }, + + /** + * Experimental values: + * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` + * - Android: `{"target": 53}` + */ + blur: () => { + return { + nativeEvent: { + target: 0, + }, + }; + }, +}; diff --git a/src/user-event/event-builder/index.ts b/src/user-event/event-builder/index.ts new file mode 100644 index 000000000..91cae64ea --- /dev/null +++ b/src/user-event/event-builder/index.ts @@ -0,0 +1,5 @@ +import { CommonEventBuilder } from './common'; + +export const EventBuilder = { + Common: CommonEventBuilder, +}; diff --git a/src/user-event/index.ts b/src/user-event/index.ts new file mode 100644 index 000000000..c90f608bd --- /dev/null +++ b/src/user-event/index.ts @@ -0,0 +1,11 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { setup } from './setup'; + +export const userEvent = { + setup, + + // Direct access for User Event v13 compatibility + press: (element: ReactTestInstance) => setup().press(element), + type: (element: ReactTestInstance, text: string) => + setup().type(element, text), +}; diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap new file mode 100644 index 000000000..87a9c5931 --- /dev/null +++ b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`user.press() dispatches required events on Text 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "name": "press", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, + { + "name": "pressOut", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 0, + "touches": [], + }, + }, + }, +] +`; diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx new file mode 100644 index 000000000..c7dda9f2a --- /dev/null +++ b/src/user-event/press/__tests__/press.test.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { Text } from 'react-native'; +import { createEventLogger } from '../../../test-utils'; +import { render } from '../../..'; +import { userEvent } from '../..'; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('user.press()', () => { + it('dispatches required events on Text', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + const screen = render( + + ); + + await user.press(screen.getByTestId('view')); + + const eventNames = events.map((event) => event.name); + expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']); + expect(events).toMatchSnapshot(); + }); + + it('supports direct access', async () => { + const { events, logEvent } = createEventLogger(); + const screen = render( + + ); + + await userEvent.press(screen.getByTestId('view')); + + const eventNames = events.map((event) => event.name); + expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']); + }); + + it.each(['modern', 'legacy'])('works with fake %s timers', async (type) => { + jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + const screen = render( + + ); + + await user.press(screen.getByTestId('view')); + + const eventNames = events.map((event) => event.name); + expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']); + }); +}); diff --git a/src/user-event/press/index.ts b/src/user-event/press/index.ts new file mode 100644 index 000000000..dffd68f90 --- /dev/null +++ b/src/user-event/press/index.ts @@ -0,0 +1 @@ +export { press } from './press'; diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts new file mode 100644 index 000000000..be8178315 --- /dev/null +++ b/src/user-event/press/press.ts @@ -0,0 +1,16 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { EventBuilder } from '../event-builder'; +import { UserEventInstance } from '../setup'; +import { dispatchHostEvent, wait } from '../utils'; + +export async function press( + this: UserEventInstance, + element: ReactTestInstance +) { + // TODO provide real implementation + dispatchHostEvent(element, 'pressIn', EventBuilder.Common.touch()); + + await wait(this.config); + dispatchHostEvent(element, 'press', EventBuilder.Common.touch()); + dispatchHostEvent(element, 'pressOut', EventBuilder.Common.touch()); +} diff --git a/src/user-event/setup/index.ts b/src/user-event/setup/index.ts new file mode 100644 index 000000000..21ada8ef0 --- /dev/null +++ b/src/user-event/setup/index.ts @@ -0,0 +1,2 @@ +export type { UserEventConfig, UserEventInstance } from './setup'; +export { setup } from './setup'; diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts new file mode 100644 index 000000000..cb504f928 --- /dev/null +++ b/src/user-event/setup/setup.ts @@ -0,0 +1,87 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { jestFakeTimersAreEnabled } from '../../helpers/timers'; +import { press } from '../press'; +import { type } from '../type'; + +export interface UserEventSetupOptions { + /** + * Between some subsequent inputs like typing a series of characters + * the code execution is delayed per `setTimeout` for (at least) `delay` seconds. + * This moves the next changes at least to next macro task + * and allows other (asynchronous) code to run between events. + * + * `null` prevents `setTimeout` from being called. + * + * @default 0 + */ + delay?: number; + + /** + * Function to be called to advance fake timers. Setting it is necessary for + * fake timers to work. + * + * @example jest.advanceTimersByTime + */ + advanceTimers?: (delay: number) => Promise | void; +} + +/** + * This functions allow wait to work correctly under both real and fake Jest timers. + */ +function universalJestAdvanceTimersBy(ms: number) { + if (jestFakeTimersAreEnabled()) { + return jest.advanceTimersByTime(ms); + } else { + return Promise.resolve(); + } +} + +const defaultOptions: Required = { + delay: 0, + advanceTimers: universalJestAdvanceTimersBy, +}; + +/** + * Creates a new instance of user event instance with the given options. + * + * @param options + * @returns + */ +export function setup(options?: UserEventSetupOptions) { + const config = createConfig(options); + const instance = createInstance(config); + return instance; +} + +export interface UserEventConfig { + delay: number; + advanceTimers: (delay: number) => Promise | void; +} + +function createConfig(options?: UserEventSetupOptions): UserEventConfig { + return { + ...defaultOptions, + ...options, + }; +} + +export interface UserEventInstance { + config: UserEventConfig; + press: (element: ReactTestInstance) => Promise; + type: (element: ReactTestInstance, text: string) => Promise; +} + +function createInstance(config: UserEventConfig): UserEventInstance { + const instance = { + config, + } as UserEventInstance; + + // We need to bind these functions, as they access the config through 'this.config'. + const api = { + press: press.bind(instance), + type: type.bind(instance), + }; + + Object.assign(instance, api); + return instance; +} diff --git a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap new file mode 100644 index 000000000..45323775a --- /dev/null +++ b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`user.type() dispatches required events 1`] = ` +[ + { + "name": "focus", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, + { + "name": "changeText", + "payload": "Hello World!", + }, + { + "name": "blur", + "payload": { + "nativeEvent": { + "target": 0, + }, + }, + }, +] +`; diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx new file mode 100644 index 000000000..23cf56dd4 --- /dev/null +++ b/src/user-event/type/__tests__/type.test.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { TextInput } from 'react-native'; +import { createEventLogger } from '../../../test-utils'; +import { render } from '../../..'; +import { userEvent } from '../..'; + +describe('user.type()', () => { + it('dispatches required events', async () => { + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + const screen = render( + + ); + + await user.type(screen.getByTestId('input'), 'Hello World!'); + + const eventNames = events.map((event) => event.name); + expect(eventNames).toEqual(['focus', 'changeText', 'blur']); + expect(events).toMatchSnapshot(); + }); + + it('supports direct access', async () => { + const { events, logEvent } = createEventLogger(); + const screen = render( + + ); + + await userEvent.type(screen.getByTestId('input'), 'Hello World!'); + + const eventNames = events.map((event) => event.name); + expect(eventNames).toEqual(['focus', 'changeText', 'blur']); + }); + + it.each(['modern', 'legacy'])('works with fake %s timers', async (type) => { + jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); + const screen = render( + + ); + + await user.type(screen.getByTestId('input'), 'Hello World!'); + + const eventNames = events.map((event) => event.name); + expect(eventNames).toEqual(['focus', 'changeText', 'blur']); + }); +}); diff --git a/src/user-event/type/index.ts b/src/user-event/type/index.ts new file mode 100644 index 000000000..13efb1f36 --- /dev/null +++ b/src/user-event/type/index.ts @@ -0,0 +1 @@ +export { type } from './type'; diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts new file mode 100644 index 000000000..82e514dcc --- /dev/null +++ b/src/user-event/type/type.ts @@ -0,0 +1,20 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { UserEventInstance } from '../setup'; +import { dispatchHostEvent, wait } from '../utils'; +import { EventBuilder } from '../event-builder'; + +export async function type( + this: UserEventInstance, + element: ReactTestInstance, + text: string +) { + // TODO provide real implementation + await wait(this.config); + dispatchHostEvent(element, 'focus', EventBuilder.Common.focus()); + + await wait(this.config); + dispatchHostEvent(element, 'changeText', text); + + await wait(this.config); + dispatchHostEvent(element, 'blur', EventBuilder.Common.blur()); +} diff --git a/src/user-event/utils/__tests__/wait.test.ts b/src/user-event/utils/__tests__/wait.test.ts new file mode 100644 index 000000000..2606bcd4f --- /dev/null +++ b/src/user-event/utils/__tests__/wait.test.ts @@ -0,0 +1,63 @@ +import { wait } from '../wait'; + +beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +describe('wait()', () => { + it('wait works with real timers', async () => { + jest.spyOn(globalThis, 'setTimeout'); + const advanceTimers = jest.fn(() => Promise.resolve()); + await wait({ delay: 20, advanceTimers }); + + expect(globalThis.setTimeout).toHaveBeenCalledTimes(1); + expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.anything(), 20); + expect(advanceTimers).toHaveBeenCalledTimes(1); + expect(advanceTimers).toHaveBeenCalledWith(20); + }); + + it.each(['modern', 'legacy'])( + 'wait works with %s fake timers', + async (type) => { + jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + jest.spyOn(globalThis, 'setTimeout'); + const advanceTimers = jest.fn((n) => jest.advanceTimersByTime(n)); + await wait({ delay: 100, advanceTimers }); + + expect(globalThis.setTimeout).toHaveBeenCalledTimes(1); + expect(globalThis.setTimeout).toHaveBeenCalledWith( + expect.anything(), + 100 + ); + expect(advanceTimers).toHaveBeenCalledTimes(1); + expect(advanceTimers).toHaveBeenCalledWith(100); + } + ); + + it('wait with null delay does not wait with real timers', async () => { + jest.spyOn(globalThis, 'setTimeout'); + const advanceTimers = jest.fn(); + + // @ts-expect-error + await wait({ delay: null, advanceTimers }); + + expect(globalThis.setTimeout).not.toHaveBeenCalled(); + expect(advanceTimers).not.toHaveBeenCalled(); + }); + + it.each(['modern', 'legacy'])( + 'wait with null delay does not wait with %s fake timers', + async (type) => { + jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + jest.spyOn(globalThis, 'setTimeout'); + const advanceTimers = jest.fn(); + + // @ts-expect-error + await wait({ delay: null, advanceTimers }); + + expect(globalThis.setTimeout).not.toHaveBeenCalled(); + expect(advanceTimers).not.toHaveBeenCalled(); + } + ); +}); diff --git a/src/user-event/utils/events.ts b/src/user-event/utils/events.ts new file mode 100644 index 000000000..d2ce4f4a9 --- /dev/null +++ b/src/user-event/utils/events.ts @@ -0,0 +1,54 @@ +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 new file mode 100644 index 000000000..f106420d3 --- /dev/null +++ b/src/user-event/utils/index.ts @@ -0,0 +1,2 @@ +export * from './events'; +export * from './wait'; diff --git a/src/user-event/utils/wait.ts b/src/user-event/utils/wait.ts new file mode 100644 index 000000000..7b355cd36 --- /dev/null +++ b/src/user-event/utils/wait.ts @@ -0,0 +1,15 @@ +import { UserEventConfig } from '../setup'; + +export function wait(config: UserEventConfig) { + const delay = config.delay; + if (typeof delay !== 'number') { + return; + } + + return Promise.all([ + new Promise((resolve) => + globalThis.setTimeout(() => resolve(), delay) + ), + config.advanceTimers(delay), + ]); +} diff --git a/website/docs/UserEvent.md b/website/docs/UserEvent.md new file mode 100644 index 000000000..3ccdacab1 --- /dev/null +++ b/website/docs/UserEvent.md @@ -0,0 +1,30 @@ +--- +id: user-event +title: User Event +--- + +### Table of contents + +- [`userEvent.setup`](#usereventsetup) + - [Options](#options) + + +## `userEvent.setup` + +```ts +userEvent.setup(options?: { + delay: number; + advanceTimers: (delay: number) => Promise | void; +}) +``` + +Example +```ts +const user = userEvent.setup(); +``` + +Creates User Event instances which can be used to trigger events. + +### Options +- `delay` - controls the default delay between subsequent events, e.g. keystrokes, etc. +- `advanceTimers` - time advancement utility function that should be used for fake timers. The default setup handles both real and Jest fake timers.