Skip to content

Commit dab444d

Browse files
feat: User Event core code (#1405)
* chore: extract user event common parts * chore: stub press/type implementations * chore: add sample tests * refactor: tweaks * chore: improve test coverage * chore: more sample tests * refactor: remove Date.now touch event timestamp * chore: hide new docs from website * feat: expose direct access
1 parent 9906ab7 commit dab444d

File tree

22 files changed

+591
-4
lines changed

22 files changed

+591
-4
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"jest-preset/",
3030
"typings/index.flow.js",
3131
"pure.js",
32-
"dont-cleanup-after-each.js"
32+
"dont-cleanup-after-each.js",
33+
"!**/test-utils"
3334
],
3435
"devDependencies": {
3536
"@babel/cli": "^7.19.3",

src/fireEvent.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const isHostTextInput = (element?: ReactTestInstance) => {
99
return element?.type === getHostComponentNames().textInput;
1010
};
1111

12-
function isTouchResponder(element: ReactTestInstance) {
12+
export function isTouchResponder(element: ReactTestInstance) {
1313
if (!isHostElement(element)) {
1414
return false;
1515
}
@@ -19,7 +19,7 @@ function isTouchResponder(element: ReactTestInstance) {
1919
);
2020
}
2121

22-
function isPointerEventEnabled(
22+
export function isPointerEventEnabled(
2323
element: ReactTestInstance,
2424
isParent?: boolean
2525
): boolean {
@@ -63,7 +63,7 @@ const textInputEventsIgnoringEditableProp = new Set([
6363
'onScroll',
6464
]);
6565

66-
function isEventEnabled(
66+
export function isEventEnabled(
6767
element: ReactTestInstance,
6868
eventName: string,
6969
nearestTouchResponder?: ReactTestInstance

src/test-utils/events.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
interface EventEntry {
2+
name: string;
3+
payload: any;
4+
}
5+
6+
export function createEventLogger() {
7+
const events: EventEntry[] = [];
8+
const logEvent = (name: string) => {
9+
return (event: unknown) => {
10+
const eventEntry: EventEntry = {
11+
name,
12+
payload: event,
13+
};
14+
15+
events.push(eventEntry);
16+
};
17+
};
18+
19+
return { events, logEvent };
20+
}

src/test-utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './events';
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export const CommonEventBuilder = {
2+
/**
3+
* Experimental values:
4+
* - iOS: `{"changedTouches": [[Circular]], "identifier": 1, "locationX": 253, "locationY": 30.333328247070312, "pageX": 273, "pageY": 141.3333282470703, "target": 75, "timestamp": 875928682.0450834, "touches": [[Circular]]}`
5+
* - Android: `{"changedTouches": [[Circular]], "identifier": 0, "locationX": 160, "locationY": 40.3636360168457, "pageX": 180, "pageY": 140.36363220214844, "target": 53, "targetSurface": -1, "timestamp": 10290805, "touches": [[Circular]]}`
6+
*/
7+
touch: () => {
8+
return {
9+
nativeEvent: {
10+
changedTouches: [],
11+
identifier: 0,
12+
locationX: 0,
13+
locationY: 0,
14+
pageX: 0,
15+
pageY: 0,
16+
target: 0,
17+
timestamp: 0,
18+
touches: [],
19+
},
20+
};
21+
},
22+
23+
/**
24+
* Experimental values:
25+
* - iOS: `{"eventCount": 0, "target": 75, "text": ""}`
26+
* - Android: `{"target": 53}`
27+
*/
28+
focus: () => {
29+
return {
30+
nativeEvent: {
31+
target: 0,
32+
},
33+
};
34+
},
35+
36+
/**
37+
* Experimental values:
38+
* - iOS: `{"eventCount": 0, "target": 75, "text": ""}`
39+
* - Android: `{"target": 53}`
40+
*/
41+
blur: () => {
42+
return {
43+
nativeEvent: {
44+
target: 0,
45+
},
46+
};
47+
},
48+
};

src/user-event/event-builder/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { CommonEventBuilder } from './common';
2+
3+
export const EventBuilder = {
4+
Common: CommonEventBuilder,
5+
};

src/user-event/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ReactTestInstance } from 'react-test-renderer';
2+
import { setup } from './setup';
3+
4+
export const userEvent = {
5+
setup,
6+
7+
// Direct access for User Event v13 compatibility
8+
press: (element: ReactTestInstance) => setup().press(element),
9+
type: (element: ReactTestInstance, text: string) =>
10+
setup().type(element, text),
11+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`user.press() dispatches required events on Text 1`] = `
4+
[
5+
{
6+
"name": "pressIn",
7+
"payload": {
8+
"nativeEvent": {
9+
"changedTouches": [],
10+
"identifier": 0,
11+
"locationX": 0,
12+
"locationY": 0,
13+
"pageX": 0,
14+
"pageY": 0,
15+
"target": 0,
16+
"timestamp": 0,
17+
"touches": [],
18+
},
19+
},
20+
},
21+
{
22+
"name": "press",
23+
"payload": {
24+
"nativeEvent": {
25+
"changedTouches": [],
26+
"identifier": 0,
27+
"locationX": 0,
28+
"locationY": 0,
29+
"pageX": 0,
30+
"pageY": 0,
31+
"target": 0,
32+
"timestamp": 0,
33+
"touches": [],
34+
},
35+
},
36+
},
37+
{
38+
"name": "pressOut",
39+
"payload": {
40+
"nativeEvent": {
41+
"changedTouches": [],
42+
"identifier": 0,
43+
"locationX": 0,
44+
"locationY": 0,
45+
"pageX": 0,
46+
"pageY": 0,
47+
"target": 0,
48+
"timestamp": 0,
49+
"touches": [],
50+
},
51+
},
52+
},
53+
]
54+
`;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as React from 'react';
2+
import { Text } from 'react-native';
3+
import { createEventLogger } from '../../../test-utils';
4+
import { render } from '../../..';
5+
import { userEvent } from '../..';
6+
7+
beforeEach(() => {
8+
jest.resetAllMocks();
9+
});
10+
11+
describe('user.press()', () => {
12+
it('dispatches required events on Text', async () => {
13+
const { events, logEvent } = createEventLogger();
14+
const user = userEvent.setup();
15+
const screen = render(
16+
<Text
17+
testID="view"
18+
onPress={logEvent('press')}
19+
onPressIn={logEvent('pressIn')}
20+
onPressOut={logEvent('pressOut')}
21+
/>
22+
);
23+
24+
await user.press(screen.getByTestId('view'));
25+
26+
const eventNames = events.map((event) => event.name);
27+
expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']);
28+
expect(events).toMatchSnapshot();
29+
});
30+
31+
it('supports direct access', async () => {
32+
const { events, logEvent } = createEventLogger();
33+
const screen = render(
34+
<Text
35+
testID="view"
36+
onPress={logEvent('press')}
37+
onPressIn={logEvent('pressIn')}
38+
onPressOut={logEvent('pressOut')}
39+
/>
40+
);
41+
42+
await userEvent.press(screen.getByTestId('view'));
43+
44+
const eventNames = events.map((event) => event.name);
45+
expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']);
46+
});
47+
48+
it.each(['modern', 'legacy'])('works with fake %s timers', async (type) => {
49+
jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' });
50+
51+
const { events, logEvent } = createEventLogger();
52+
const user = userEvent.setup();
53+
const screen = render(
54+
<Text
55+
testID="view"
56+
onPress={logEvent('press')}
57+
onPressIn={logEvent('pressIn')}
58+
onPressOut={logEvent('pressOut')}
59+
/>
60+
);
61+
62+
await user.press(screen.getByTestId('view'));
63+
64+
const eventNames = events.map((event) => event.name);
65+
expect(eventNames).toEqual(['pressIn', 'press', 'pressOut']);
66+
});
67+
});

src/user-event/press/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { press } from './press';

src/user-event/press/press.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ReactTestInstance } from 'react-test-renderer';
2+
import { EventBuilder } from '../event-builder';
3+
import { UserEventInstance } from '../setup';
4+
import { dispatchHostEvent, wait } from '../utils';
5+
6+
export async function press(
7+
this: UserEventInstance,
8+
element: ReactTestInstance
9+
) {
10+
// TODO provide real implementation
11+
dispatchHostEvent(element, 'pressIn', EventBuilder.Common.touch());
12+
13+
await wait(this.config);
14+
dispatchHostEvent(element, 'press', EventBuilder.Common.touch());
15+
dispatchHostEvent(element, 'pressOut', EventBuilder.Common.touch());
16+
}

src/user-event/setup/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { UserEventConfig, UserEventInstance } from './setup';
2+
export { setup } from './setup';

src/user-event/setup/setup.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { ReactTestInstance } from 'react-test-renderer';
2+
import { jestFakeTimersAreEnabled } from '../../helpers/timers';
3+
import { press } from '../press';
4+
import { type } from '../type';
5+
6+
export interface UserEventSetupOptions {
7+
/**
8+
* Between some subsequent inputs like typing a series of characters
9+
* the code execution is delayed per `setTimeout` for (at least) `delay` seconds.
10+
* This moves the next changes at least to next macro task
11+
* and allows other (asynchronous) code to run between events.
12+
*
13+
* `null` prevents `setTimeout` from being called.
14+
*
15+
* @default 0
16+
*/
17+
delay?: number;
18+
19+
/**
20+
* Function to be called to advance fake timers. Setting it is necessary for
21+
* fake timers to work.
22+
*
23+
* @example jest.advanceTimersByTime
24+
*/
25+
advanceTimers?: (delay: number) => Promise<void> | void;
26+
}
27+
28+
/**
29+
* This functions allow wait to work correctly under both real and fake Jest timers.
30+
*/
31+
function universalJestAdvanceTimersBy(ms: number) {
32+
if (jestFakeTimersAreEnabled()) {
33+
return jest.advanceTimersByTime(ms);
34+
} else {
35+
return Promise.resolve();
36+
}
37+
}
38+
39+
const defaultOptions: Required<UserEventSetupOptions> = {
40+
delay: 0,
41+
advanceTimers: universalJestAdvanceTimersBy,
42+
};
43+
44+
/**
45+
* Creates a new instance of user event instance with the given options.
46+
*
47+
* @param options
48+
* @returns
49+
*/
50+
export function setup(options?: UserEventSetupOptions) {
51+
const config = createConfig(options);
52+
const instance = createInstance(config);
53+
return instance;
54+
}
55+
56+
export interface UserEventConfig {
57+
delay: number;
58+
advanceTimers: (delay: number) => Promise<void> | void;
59+
}
60+
61+
function createConfig(options?: UserEventSetupOptions): UserEventConfig {
62+
return {
63+
...defaultOptions,
64+
...options,
65+
};
66+
}
67+
68+
export interface UserEventInstance {
69+
config: UserEventConfig;
70+
press: (element: ReactTestInstance) => Promise<void>;
71+
type: (element: ReactTestInstance, text: string) => Promise<void>;
72+
}
73+
74+
function createInstance(config: UserEventConfig): UserEventInstance {
75+
const instance = {
76+
config,
77+
} as UserEventInstance;
78+
79+
// We need to bind these functions, as they access the config through 'this.config'.
80+
const api = {
81+
press: press.bind(instance),
82+
type: type.bind(instance),
83+
};
84+
85+
Object.assign(instance, api);
86+
return instance;
87+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`user.type() dispatches required events 1`] = `
4+
[
5+
{
6+
"name": "focus",
7+
"payload": {
8+
"nativeEvent": {
9+
"target": 0,
10+
},
11+
},
12+
},
13+
{
14+
"name": "changeText",
15+
"payload": "Hello World!",
16+
},
17+
{
18+
"name": "blur",
19+
"payload": {
20+
"nativeEvent": {
21+
"target": 0,
22+
},
23+
},
24+
},
25+
]
26+
`;

0 commit comments

Comments
 (0)