From 65710a8c91b8ab50edc005e63f33f169d7932fd2 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Sat, 6 May 2023 11:40:12 +0200 Subject: [PATCH 1/9] chore: extract user event common parts --- package.json | 3 +- src/fireEvent.ts | 6 +- src/test-utils/events.ts | 20 ++++++ src/user-event/event-builder/common.ts | 48 +++++++++++++ src/user-event/event-builder/index.ts | 5 ++ src/user-event/index.ts | 5 ++ src/user-event/setup/index.ts | 2 + src/user-event/setup/setup.ts | 80 +++++++++++++++++++++ src/user-event/utils/__tests__/wait.test.ts | 63 ++++++++++++++++ src/user-event/utils/dispatch-event.ts | 54 ++++++++++++++ src/user-event/utils/wait.ts | 15 ++++ website/docs/UserEvent.md | 30 ++++++++ website/sidebars.js | 2 +- 13 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 src/test-utils/events.ts create mode 100644 src/user-event/event-builder/common.ts create mode 100644 src/user-event/event-builder/index.ts create mode 100644 src/user-event/index.ts create mode 100644 src/user-event/setup/index.ts create mode 100644 src/user-event/setup/setup.ts create mode 100644 src/user-event/utils/__tests__/wait.test.ts create mode 100644 src/user-event/utils/dispatch-event.ts create mode 100644 src/user-event/utils/wait.ts create mode 100644 website/docs/UserEvent.md 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..3038c15bd --- /dev/null +++ b/src/test-utils/events.ts @@ -0,0 +1,20 @@ +interface EventEntry { + name: string; + payload: any; +} + +export function createEventToolkit() { + const events: EventEntry[] = []; + const handleEvent = (name: string) => { + return (event: unknown) => { + const eventEntry: EventEntry = { + name, + payload: event, + }; + + events.push(eventEntry); + }; + }; + + return { events, handleEvent }; +} diff --git a/src/user-event/event-builder/common.ts b/src/user-event/event-builder/common.ts new file mode 100644 index 000000000..f09f6c94b --- /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: Date.now(), + 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..c546cd501 --- /dev/null +++ b/src/user-event/index.ts @@ -0,0 +1,5 @@ +import { setup } from './setup'; + +export const userEvent = { + setup, +}; 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..c8a826e4e --- /dev/null +++ b/src/user-event/setup/setup.ts @@ -0,0 +1,80 @@ +import { jestFakeTimersAreEnabled } from '../../helpers/timers'; + +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; +} + +function createInstance(config: UserEventConfig): UserEventInstance { + const instance = { + config, + } as UserEventInstance; + + const api = { + // TODO: bind methods to the instance, e.g. type: type.bind(instance), + }; + + Object.assign(instance, api); + return instance; +} 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/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts new file mode 100644 index 000000000..6f67458d0 --- /dev/null +++ b/src/user-event/utils/dispatch-event.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 dispatchEvent( + 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/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. 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 1c22b21539ef862ef7e6881d1bb75a793190fa1f Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Sat, 6 May 2023 11:52:47 +0200 Subject: [PATCH 2/9] chore: stub press/type implementations --- src/user-event/press/index.ts | 1 + src/user-event/press/press.ts | 16 ++++++++++++++++ src/user-event/setup/setup.ts | 8 +++++++- src/user-event/type/index.ts | 1 + src/user-event/type/type.ts | 12 ++++++++++++ .../utils/{dispatch-event.ts => events.ts} | 2 +- src/user-event/utils/index.ts | 2 ++ 7 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/user-event/press/index.ts create mode 100644 src/user-event/press/press.ts create mode 100644 src/user-event/type/index.ts create mode 100644 src/user-event/type/type.ts rename src/user-event/utils/{dispatch-event.ts => events.ts} (97%) create mode 100644 src/user-event/utils/index.ts 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/setup.ts b/src/user-event/setup/setup.ts index c8a826e4e..090863986 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -1,4 +1,7 @@ +import { ReactTestInstance } from 'react-test-renderer'; import { jestFakeTimersAreEnabled } from '../../helpers/timers'; +import { press } from '../press'; +import { type } from '../type'; export interface UserEventSetupOptions { /** @@ -64,6 +67,8 @@ function createConfig(options?: UserEventSetupOptions): UserEventConfig { export interface UserEventInstance { config: UserEventConfig; + press: (element: ReactTestInstance) => Promise; + type: (element: ReactTestInstance, text: string) => Promise; } function createInstance(config: UserEventConfig): UserEventInstance { @@ -72,7 +77,8 @@ function createInstance(config: UserEventConfig): UserEventInstance { } as UserEventInstance; const api = { - // TODO: bind methods to the instance, e.g. type: type.bind(instance), + press: press.bind(instance), + type: type.bind(instance), }; Object.assign(instance, api); 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..c2f736fc8 --- /dev/null +++ b/src/user-event/type/type.ts @@ -0,0 +1,12 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { UserEventInstance } from '../setup'; +import { dispatchHostEvent, wait } from '../utils'; + +export async function type( + this: UserEventInstance, + element: ReactTestInstance, + text: string +) { + await wait(this.config); + dispatchHostEvent(element, 'changeText', text); +} diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/events.ts similarity index 97% rename from src/user-event/utils/dispatch-event.ts rename to src/user-event/utils/events.ts index 6f67458d0..d2ce4f4a9 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/user-event/utils/events.ts @@ -11,7 +11,7 @@ type EventHandler = (event: unknown) => void; * @param eventName name of the event * @param event event payload */ -export function dispatchEvent( +export function dispatchHostEvent( element: ReactTestInstance, eventName: string, event: unknown 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'; From a9114ab76b8b72020afd8473a98adf638799ac9c Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Sat, 6 May 2023 12:08:58 +0200 Subject: [PATCH 3/9] chore: add sample tests --- .../__snapshots__/press.test.tsx.snap | 54 +++++++++++++++++++ src/user-event/press/__tests__/press.test.tsx | 33 ++++++++++++ .../__snapshots__/type.test.tsx.snap | 10 ++++ src/user-event/type/__tests__/type.test.tsx | 21 ++++++++ src/user-event/type/type.ts | 1 + 5 files changed, 119 insertions(+) create mode 100644 src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap create mode 100644 src/user-event/press/__tests__/press.test.tsx create mode 100644 src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap create mode 100644 src/user-event/type/__tests__/type.test.tsx 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..39e7ae034 --- /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() should dispatches required events on Text 1`] = ` +[ + { + "name": "pressIn", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + }, + }, + { + "name": "press", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "touches": [], + }, + }, + }, + { + "name": "pressOut", + "payload": { + "nativeEvent": { + "changedTouches": [], + "identifier": 0, + "locationX": 0, + "locationY": 0, + "pageX": 0, + "pageY": 0, + "target": 0, + "timestamp": 100100100100, + "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..d0ac0f0d9 --- /dev/null +++ b/src/user-event/press/__tests__/press.test.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Text } from 'react-native'; +import { createEventToolkit } from '../../../test-utils/events'; +import { render } from '../../..'; +import { userEvent } from '../..'; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('user.press()', () => { + it('should dispatches required events on Text', async () => { + // Required for touch events which contain timestamp + jest.spyOn(Date, 'now').mockReturnValue(100100100100); + + const { events, handleEvent } = createEventToolkit(); + const screen = render( + + ); + + const user = userEvent.setup(); + await user.press(screen.getByTestId('view')); + + const eventNames = events.map((event) => event.name); + expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']); + expect(events).toMatchSnapshot(); + }); +}); 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..20b95fcb4 --- /dev/null +++ b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`user.type() should dispatches required events 1`] = ` +[ + { + "name": "changeText", + "payload": "Hello World!", + }, +] +`; 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..b8b9730a1 --- /dev/null +++ b/src/user-event/type/__tests__/type.test.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { TextInput } from 'react-native'; +import { createEventToolkit } from '../../../test-utils/events'; +import { render } from '../../..'; +import { userEvent } from '../..'; + +describe('user.type()', () => { + it('should dispatches required events', async () => { + const { events, handleEvent } = createEventToolkit(); + const screen = render( + + ); + + const user = userEvent.setup(); + await user.type(screen.getByTestId('input'), 'Hello World!'); + + const eventNames = events.map((event) => event.name); + expect(eventNames).toEqual(['changeText']); + expect(events).toMatchSnapshot(); + }); +}); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index c2f736fc8..ca47b7328 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -7,6 +7,7 @@ export async function type( element: ReactTestInstance, text: string ) { + // TODO provide real implementation await wait(this.config); dispatchHostEvent(element, 'changeText', text); } From d5f2bd20de3df0e72b108827e1c9f06559f8c807 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Sat, 6 May 2023 12:12:06 +0200 Subject: [PATCH 4/9] refactor: tweaks --- src/test-utils/events.ts | 6 +++--- src/test-utils/index.ts | 1 + src/user-event/press/__tests__/press.test.tsx | 12 ++++++------ src/user-event/setup/setup.ts | 1 + src/user-event/type/__tests__/type.test.tsx | 8 ++++---- 5 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 src/test-utils/index.ts diff --git a/src/test-utils/events.ts b/src/test-utils/events.ts index 3038c15bd..b6a0dd13c 100644 --- a/src/test-utils/events.ts +++ b/src/test-utils/events.ts @@ -3,9 +3,9 @@ interface EventEntry { payload: any; } -export function createEventToolkit() { +export function createEventLogger() { const events: EventEntry[] = []; - const handleEvent = (name: string) => { + const logEvent = (name: string) => { return (event: unknown) => { const eventEntry: EventEntry = { name, @@ -16,5 +16,5 @@ export function createEventToolkit() { }; }; - return { events, handleEvent }; + 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/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index d0ac0f0d9..a510ce751 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Text } from 'react-native'; -import { createEventToolkit } from '../../../test-utils/events'; +import { createEventLogger } from '../../../test-utils'; import { render } from '../../..'; import { userEvent } from '../..'; @@ -13,17 +13,17 @@ describe('user.press()', () => { // Required for touch events which contain timestamp jest.spyOn(Date, 'now').mockReturnValue(100100100100); - const { events, handleEvent } = createEventToolkit(); + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); const screen = render( ); - const user = userEvent.setup(); await user.press(screen.getByTestId('view')); const eventNames = events.map((event) => event.name); diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index 090863986..cb504f928 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -76,6 +76,7 @@ function createInstance(config: UserEventConfig): UserEventInstance { 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), diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index b8b9730a1..b29f39d75 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -1,17 +1,17 @@ import * as React from 'react'; import { TextInput } from 'react-native'; -import { createEventToolkit } from '../../../test-utils/events'; +import { createEventLogger } from '../../../test-utils'; import { render } from '../../..'; import { userEvent } from '../..'; describe('user.type()', () => { it('should dispatches required events', async () => { - const { events, handleEvent } = createEventToolkit(); + const { events, logEvent } = createEventLogger(); + const user = userEvent.setup(); const screen = render( - + ); - const user = userEvent.setup(); await user.type(screen.getByTestId('input'), 'Hello World!'); const eventNames = events.map((event) => event.name); From c407cff9936497b5d2d7b119fc5f990e816a579f Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Sat, 6 May 2023 12:33:56 +0200 Subject: [PATCH 5/9] chore: improve test coverage --- .../__snapshots__/press.test.tsx.snap | 2 +- src/user-event/press/__tests__/press.test.tsx | 2 +- .../__snapshots__/type.test.tsx.snap | 18 ++++++++++- src/user-event/type/__tests__/type.test.tsx | 31 +++++++++++++++++-- src/user-event/type/type.ts | 7 +++++ 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap index 39e7ae034..e86dde1ad 100644 --- a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap +++ b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`user.press() should dispatches required events on Text 1`] = ` +exports[`user.press() dispatches required events on Text 1`] = ` [ { "name": "pressIn", diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index a510ce751..11eb75cd7 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -9,7 +9,7 @@ beforeEach(() => { }); describe('user.press()', () => { - it('should dispatches required events on Text', async () => { + it('dispatches required events on Text', async () => { // Required for touch events which contain timestamp jest.spyOn(Date, 'now').mockReturnValue(100100100100); 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 20b95fcb4..45323775a 100644 --- a/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap +++ b/src/user-event/type/__tests__/__snapshots__/type.test.tsx.snap @@ -1,10 +1,26 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`user.type() should dispatches required events 1`] = ` +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 index b29f39d75..2708f0374 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -5,17 +5,42 @@ import { render } from '../../..'; import { userEvent } from '../..'; describe('user.type()', () => { - it('should dispatches required events', async () => { + 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(['changeText']); + expect(eventNames).toEqual(['focus', 'changeText', 'blur']); expect(events).toMatchSnapshot(); }); + + 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/type.ts b/src/user-event/type/type.ts index ca47b7328..82e514dcc 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -1,6 +1,7 @@ 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, @@ -8,6 +9,12 @@ export async function type( 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()); } From 416a7a5698a9d2a0b593c8a0cfac0ca9e6d8887e Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Sat, 6 May 2023 12:35:25 +0200 Subject: [PATCH 6/9] chore: more sample tests --- src/user-event/press/__tests__/press.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index 11eb75cd7..30211580d 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -30,4 +30,26 @@ describe('user.press()', () => { expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']); expect(events).toMatchSnapshot(); }); + + it.each(['modern', 'legacy'])('works with fake %s timers', async (type) => { + jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); + // Required for touch events which contain timestamp + jest.spyOn(Date, 'now').mockReturnValue(100100100100); + + 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']); + }); }); From 72fbd1237b01f66d149f7141962cd250d5fa8ecd Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 8 May 2023 19:20:20 +0200 Subject: [PATCH 7/9] refactor: remove Date.now touch event timestamp --- src/user-event/event-builder/common.ts | 2 +- .../press/__tests__/__snapshots__/press.test.tsx.snap | 6 +++--- src/user-event/press/__tests__/press.test.tsx | 5 ----- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/user-event/event-builder/common.ts b/src/user-event/event-builder/common.ts index f09f6c94b..1e732eb3f 100644 --- a/src/user-event/event-builder/common.ts +++ b/src/user-event/event-builder/common.ts @@ -14,7 +14,7 @@ export const CommonEventBuilder = { pageX: 0, pageY: 0, target: 0, - timestamp: Date.now(), + timestamp: 0, touches: [], }, }; diff --git a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap index e86dde1ad..87a9c5931 100644 --- a/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap +++ b/src/user-event/press/__tests__/__snapshots__/press.test.tsx.snap @@ -13,7 +13,7 @@ exports[`user.press() dispatches required events on Text 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 100100100100, + "timestamp": 0, "touches": [], }, }, @@ -29,7 +29,7 @@ exports[`user.press() dispatches required events on Text 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 100100100100, + "timestamp": 0, "touches": [], }, }, @@ -45,7 +45,7 @@ exports[`user.press() dispatches required events on Text 1`] = ` "pageX": 0, "pageY": 0, "target": 0, - "timestamp": 100100100100, + "timestamp": 0, "touches": [], }, }, diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index 30211580d..29edfc928 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -10,9 +10,6 @@ beforeEach(() => { describe('user.press()', () => { it('dispatches required events on Text', async () => { - // Required for touch events which contain timestamp - jest.spyOn(Date, 'now').mockReturnValue(100100100100); - const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); const screen = render( @@ -33,8 +30,6 @@ describe('user.press()', () => { it.each(['modern', 'legacy'])('works with fake %s timers', async (type) => { jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); - // Required for touch events which contain timestamp - jest.spyOn(Date, 'now').mockReturnValue(100100100100); const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); From 56da2fd74aae957a291cdbd4d1382b8697396fdc Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 9 May 2023 13:05:41 +0200 Subject: [PATCH 8/9] chore: hide new docs from website --- 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 6a65d5320e5b4deee402d8ac0958d354c9ca1b36 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 9 May 2023 13:22:44 +0200 Subject: [PATCH 9/9] feat: expose direct access --- src/user-event/index.ts | 6 ++++++ src/user-event/press/__tests__/press.test.tsx | 17 +++++++++++++++++ src/user-event/type/__tests__/type.test.tsx | 17 +++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/user-event/index.ts b/src/user-event/index.ts index c546cd501..c90f608bd 100644 --- a/src/user-event/index.ts +++ b/src/user-event/index.ts @@ -1,5 +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__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index 29edfc928..c7dda9f2a 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -28,6 +28,23 @@ describe('user.press()', () => { 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' }); diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 2708f0374..23cf56dd4 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -24,6 +24,23 @@ describe('user.type()', () => { 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' });