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.