Skip to content

Commit 883f348

Browse files
authored
fix(browser): userEvent.setup initiates a separate state for userEvent instance (#6088)
1 parent 12bb567 commit 883f348

File tree

7 files changed

+157
-76
lines changed

7 files changed

+157
-76
lines changed

docs/guide/browser/interactivity-api.md

+25-2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ Almost every `userEvent` method inherits its provider options. To see all availa
2929
```
3030
:::
3131

32+
## userEvent.setup
33+
34+
- **Type:** `() => UserEvent`
35+
36+
Creates a new user event instance. This is useful if you need to keep the state of keyboard to press and release buttons correctly.
37+
38+
::: warning
39+
Unlike `@testing-library/user-event`, the default `userEvent` instance from `@vitest/browser/context` is created once, not every time its methods are called! You can see the difference in how it works in this snippet:
40+
41+
```ts
42+
import { userEvent as vitestUserEvent } from '@vitest/browser/context'
43+
import { userEvent as originalUserEvent } from '@testing-library/user-event'
44+
45+
await vitestUserEvent.keyboard('{Shift}') // press shift without releasing
46+
await vitestUserEvent.keyboard('{/Shift}') // releases shift
47+
48+
await originalUserEvent.keyboard('{Shift}') // press shift without releasing
49+
await originalUserEvent.keyboard('{/Shift}') // DID NOT release shift because the state is different
50+
```
51+
52+
This behaviour is more useful because we do not emulate the keyboard, we actually press the Shift, so keeping the original behaviour would cause unexpected issues when typing in the field.
53+
:::
54+
3255
## userEvent.click
3356

3457
- **Type:** `(element: Element, options?: UserEventClickOptions) => Promise<void>`
@@ -163,7 +186,7 @@ test('trigger keystrokes', async () => {
163186

164187
References:
165188

166-
- [Playwright `locator.press` API](https://playwright.dev/docs/api/class-locator#locator-press)
189+
- [Playwright `Keyboard` API](https://playwright.dev/docs/api/class-keyboard)
167190
- [WebdriverIO `action('key')` API](https://webdriver.io/docs/api/browser/action#key-input-source)
168191
- [testing-library `type` API](https://testing-library.com/docs/user-event/utility/#type)
169192

@@ -194,7 +217,7 @@ test('tab works', async () => {
194217

195218
References:
196219

197-
- [Playwright `locator.press` API](https://playwright.dev/docs/api/class-locator#locator-press)
220+
- [Playwright `Keyboard` API](https://playwright.dev/docs/api/class-keyboard)
198221
- [WebdriverIO `action('key')` API](https://webdriver.io/docs/api/browser/action#key-input-source)
199222
- [testing-library `tab` API](https://testing-library.com/docs/user-event/convenience/#tab)
200223

packages/browser/context.d.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export interface BrowserCommands {
4949
}
5050

5151
export interface UserEvent {
52+
/**
53+
* Creates a new user event instance. This is useful if you need to keep the
54+
* state of keyboard to press and release buttons correctly.
55+
*
56+
* **Note:** Unlike `@testing-library/user-event`, the default `userEvent` instance
57+
* from `@vitest/browser/context` is created once, not every time its methods are called!
58+
* @see {@link https://vitest.dev/guide/browser/interactivity-api.html#userevent-setup}
59+
*/
5260
setup: () => UserEvent
5361
/**
5462
* Click on an element. Uses provider's API under the hood and supports all its options.
@@ -103,7 +111,7 @@ export interface UserEvent {
103111
* await userEvent.keyboard('foo') // translates to: f, o, o
104112
* await userEvent.keyboard('{{a[[') // translates to: {, a, [
105113
* await userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o
106-
* @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API
114+
* @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API
107115
* @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API
108116
* @see {@link https://testing-library.com/docs/user-event/keyboard} testing-library API
109117
*/
@@ -129,7 +137,7 @@ export interface UserEvent {
129137
clear: (element: Element) => Promise<void>
130138
/**
131139
* Sends a `Tab` key event. Uses provider's API under the hood.
132-
* @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API
140+
* @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API
133141
* @see {@link https://webdriver.io/docs/api/element/keys} WebdriverIO API
134142
* @see {@link https://testing-library.com/docs/user-event/convenience/#tab} testing-library API
135143
*/

packages/browser/src/client/tester/context.ts

+74-56
Original file line numberDiff line numberDiff line change
@@ -84,65 +84,83 @@ function getParent(el: Element) {
8484
return parent
8585
}
8686

87-
export const userEvent: UserEvent = {
88-
// TODO: actually setup userEvent with config options
89-
setup() {
90-
return userEvent
91-
},
92-
click(element: Element, options: UserEventClickOptions = {}) {
93-
const css = convertElementToCssSelector(element)
94-
return triggerCommand('__vitest_click', css, options)
95-
},
96-
dblClick(element: Element, options: UserEventClickOptions = {}) {
97-
const css = convertElementToCssSelector(element)
98-
return triggerCommand('__vitest_dblClick', css, options)
99-
},
100-
tripleClick(element: Element, options: UserEventClickOptions = {}) {
101-
const css = convertElementToCssSelector(element)
102-
return triggerCommand('__vitest_tripleClick', css, options)
103-
},
104-
selectOptions(element, value) {
105-
const values = provider === 'webdriverio'
106-
? getWebdriverioSelectOptions(element, value)
107-
: getSimpleSelectOptions(element, value)
108-
const css = convertElementToCssSelector(element)
109-
return triggerCommand('__vitest_selectOptions', css, values)
110-
},
111-
type(element: Element, text: string, options: UserEventTypeOptions = {}) {
112-
const css = convertElementToCssSelector(element)
113-
return triggerCommand('__vitest_type', css, text, options)
114-
},
115-
clear(element: Element) {
116-
const css = convertElementToCssSelector(element)
117-
return triggerCommand('__vitest_clear', css)
118-
},
119-
tab(options: UserEventTabOptions = {}) {
120-
return triggerCommand('__vitest_tab', options)
121-
},
122-
keyboard(text: string) {
123-
return triggerCommand('__vitest_keyboard', text)
124-
},
125-
hover(element: Element) {
126-
const css = convertElementToCssSelector(element)
127-
return triggerCommand('__vitest_hover', css)
128-
},
129-
unhover(element: Element) {
130-
const css = convertElementToCssSelector(element.ownerDocument.body)
131-
return triggerCommand('__vitest_hover', css)
132-
},
87+
function createUserEvent(): UserEvent {
88+
const keyboard = {
89+
unreleased: [] as string[],
90+
}
13391

134-
// non userEvent events, but still useful
135-
fill(element: Element, text: string, options) {
136-
const css = convertElementToCssSelector(element)
137-
return triggerCommand('__vitest_fill', css, text, options)
138-
},
139-
dragAndDrop(source: Element, target: Element, options = {}) {
140-
const sourceCss = convertElementToCssSelector(source)
141-
const targetCss = convertElementToCssSelector(target)
142-
return triggerCommand('__vitest_dragAndDrop', sourceCss, targetCss, options)
143-
},
92+
return {
93+
setup() {
94+
return createUserEvent()
95+
},
96+
click(element: Element, options: UserEventClickOptions = {}) {
97+
const css = convertElementToCssSelector(element)
98+
return triggerCommand('__vitest_click', css, options)
99+
},
100+
dblClick(element: Element, options: UserEventClickOptions = {}) {
101+
const css = convertElementToCssSelector(element)
102+
return triggerCommand('__vitest_dblClick', css, options)
103+
},
104+
tripleClick(element: Element, options: UserEventClickOptions = {}) {
105+
const css = convertElementToCssSelector(element)
106+
return triggerCommand('__vitest_tripleClick', css, options)
107+
},
108+
selectOptions(element, value) {
109+
const values = provider === 'webdriverio'
110+
? getWebdriverioSelectOptions(element, value)
111+
: getSimpleSelectOptions(element, value)
112+
const css = convertElementToCssSelector(element)
113+
return triggerCommand('__vitest_selectOptions', css, values)
114+
},
115+
async type(element: Element, text: string, options: UserEventTypeOptions = {}) {
116+
const css = convertElementToCssSelector(element)
117+
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
118+
'__vitest_type',
119+
css,
120+
text,
121+
{ ...options, unreleased: keyboard.unreleased },
122+
)
123+
keyboard.unreleased = unreleased
124+
},
125+
clear(element: Element) {
126+
const css = convertElementToCssSelector(element)
127+
return triggerCommand('__vitest_clear', css)
128+
},
129+
tab(options: UserEventTabOptions = {}) {
130+
return triggerCommand('__vitest_tab', options)
131+
},
132+
async keyboard(text: string) {
133+
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
134+
'__vitest_keyboard',
135+
text,
136+
keyboard,
137+
)
138+
keyboard.unreleased = unreleased
139+
},
140+
hover(element: Element) {
141+
const css = convertElementToCssSelector(element)
142+
return triggerCommand('__vitest_hover', css)
143+
},
144+
unhover(element: Element) {
145+
const css = convertElementToCssSelector(element.ownerDocument.body)
146+
return triggerCommand('__vitest_hover', css)
147+
},
148+
149+
// non userEvent events, but still useful
150+
fill(element: Element, text: string, options) {
151+
const css = convertElementToCssSelector(element)
152+
return triggerCommand('__vitest_fill', css, text, options)
153+
},
154+
dragAndDrop(source: Element, target: Element, options = {}) {
155+
const sourceCss = convertElementToCssSelector(source)
156+
const targetCss = convertElementToCssSelector(target)
157+
return triggerCommand('__vitest_dragAndDrop', sourceCss, targetCss, options)
158+
},
159+
}
144160
}
145161

162+
export const userEvent: UserEvent = createUserEvent()
163+
146164
function getWebdriverioSelectOptions(element: Element, value: string | string[] | HTMLElement[] | HTMLElement) {
147165
const options = [...element.querySelectorAll('option')] as HTMLOptionElement[]
148166

packages/browser/src/node/commands/keyboard.ts

+18-5
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ import { defaultKeyMap } from '@testing-library/user-event/dist/esm/keyboard/key
33
import type { BrowserProvider } from 'vitest/node'
44
import { PlaywrightBrowserProvider } from '../providers/playwright'
55
import { WebdriverBrowserProvider } from '../providers/webdriver'
6-
import type { UserEvent } from '../../../context'
76
import type { UserEventCommand } from './utils'
87

9-
export const keyboard: UserEventCommand<UserEvent['keyboard']> = async (
8+
export interface KeyboardState {
9+
unreleased: string[]
10+
}
11+
12+
export const keyboard: UserEventCommand<(text: string, state: KeyboardState) => Promise<{ unreleased: string[] }>> = async (
1013
context,
1114
text,
15+
state,
1216
) => {
1317
function focusIframe() {
1418
if (
@@ -28,7 +32,10 @@ export const keyboard: UserEventCommand<UserEvent['keyboard']> = async (
2832
await context.browser.execute(focusIframe)
2933
}
3034

35+
const pressed = new Set<string>(state.unreleased)
36+
3137
await keyboardImplementation(
38+
pressed,
3239
context.provider,
3340
context.contextId,
3441
text,
@@ -52,17 +59,20 @@ export const keyboard: UserEventCommand<UserEvent['keyboard']> = async (
5259
},
5360
true,
5461
)
62+
63+
return {
64+
unreleased: Array.from(pressed),
65+
}
5566
}
5667

5768
export async function keyboardImplementation(
69+
pressed: Set<string>,
5870
provider: BrowserProvider,
5971
contextId: string,
6072
text: string,
6173
selectAll: () => Promise<void>,
6274
skipRelease: boolean,
6375
) {
64-
const pressed = new Set<string>()
65-
6676
if (provider instanceof PlaywrightBrowserProvider) {
6777
const page = provider.getPage(contextId)
6878
const actions = parseKeyDef(defaultKeyMap, text)
@@ -145,7 +155,10 @@ export async function keyboardImplementation(
145155
}
146156
}
147157

148-
await keyboard.perform(skipRelease)
158+
// seems like webdriverio doesn't release keys automatically if skipRelease is true and all events are keyUp
159+
const allRelease = keyboard.toJSON().actions.every(action => action.type === 'keyUp')
160+
161+
await keyboard.perform(allRelease ? false : skipRelease)
149162
}
150163

151164
return {

packages/browser/src/node/commands/type.ts

+7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const type: UserEventCommand<UserEvent['type']> = async (
1111
options = {},
1212
) => {
1313
const { skipClick = false, skipAutoClose = false } = options
14+
const unreleased = new Set(Reflect.get(options, 'unreleased') as string[] ?? [])
1415

1516
if (context.provider instanceof PlaywrightBrowserProvider) {
1617
const { iframe } = context
@@ -21,6 +22,7 @@ export const type: UserEventCommand<UserEvent['type']> = async (
2122
}
2223

2324
await keyboardImplementation(
25+
unreleased,
2426
context.provider,
2527
context.contextId,
2628
text,
@@ -37,6 +39,7 @@ export const type: UserEventCommand<UserEvent['type']> = async (
3739
}
3840

3941
await keyboardImplementation(
42+
unreleased,
4043
context.provider,
4144
context.contextId,
4245
text,
@@ -52,4 +55,8 @@ export const type: UserEventCommand<UserEvent['type']> = async (
5255
else {
5356
throw new TypeError(`Provider "${context.provider.name}" does not support typing`)
5457
}
58+
59+
return {
60+
unreleased: Array.from(unreleased),
61+
}
5562
}

packages/browser/src/node/plugins/pluginContext.ts

+12-5
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,17 @@ function getUserEvent(provider: BrowserProvider) {
9494
}
9595
// TODO: have this in a separate file
9696
return `{
97-
...__vitest_user_event__,
98-
fill: async (element, text) => {
99-
await __vitest_user_event__.clear(element)
100-
await __vitest_user_event__.type(element, text)
97+
..._userEventSetup,
98+
setup() {
99+
const userEvent = __vitest_user_event__.setup()
100+
userEvent.setup = this.setup
101+
userEvent.fill = this.fill.bind(userEvent)
102+
userEvent.dragAndDrop = this.dragAndDrop
103+
return userEvent
104+
},
105+
async fill(element, text) {
106+
await this.clear(element)
107+
await this.type(element, text)
101108
},
102109
dragAndDrop: async () => {
103110
throw new Error('Provider "preview" does not support dragging elements')
@@ -115,5 +122,5 @@ async function getUserEventImport(provider: BrowserProvider, resolve: (id: strin
115122
}
116123
return `import { userEvent as __vitest_user_event__ } from '${slash(
117124
`/@fs/${resolved.id}`,
118-
)}'`
125+
)}'\nconst _userEventSetup = __vitest_user_event__.setup()\n`
119126
}

0 commit comments

Comments
 (0)