Skip to content

Commit 4dbea4a

Browse files
authored
feat(browser): implement several userEvent methods, add fill and dragAndDrop events (#5882)
1 parent f969fb0 commit 4dbea4a

33 files changed

+1739
-281
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ docs/public/sponsors
2222
.eslintcache
2323
docs/.vitepress/cache/
2424
!test/cli/fixtures/dotted-files/**/.cache
25-
.vitest-reports
25+
test/browser/test/__screenshots__/**/*
26+
.vitest-reports

docs/config/index.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -923,15 +923,15 @@ Minimum number of workers to run tests in. `poolOptions.{threads,vmThreads}.minT
923923
### testTimeout
924924

925925
- **Type:** `number`
926-
- **Default:** `5000`
926+
- **Default:** `5_000` in Node.js, `15_000` if `browser.enabled` is `true`
927927
- **CLI:** `--test-timeout=5000`, `--testTimeout=5000`
928928

929929
Default timeout of a test in milliseconds
930930

931931
### hookTimeout
932932

933933
- **Type:** `number`
934-
- **Default:** `10000`
934+
- **Default:** `10_000` in Node.js, `30_000` if `browser.enabled` is `true`
935935
- **CLI:** `--hook-timeout=10000`, `--hookTimeout=10000`
936936

937937
Default timeout of a hook in milliseconds

docs/guide/browser.md

+454-29
Large diffs are not rendered by default.

packages/browser/context.d.ts

+120-22
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,6 @@ export interface FsOptions {
1919
flag?: string | number
2020
}
2121

22-
export interface TypePayload {
23-
type: string
24-
}
25-
export interface PressPayload {
26-
press: string
27-
}
28-
export interface DownPayload {
29-
down: string
30-
}
31-
export interface UpPayload {
32-
up: string
33-
}
34-
35-
export type SendKeysPayload =
36-
| TypePayload
37-
| PressPayload
38-
| DownPayload
39-
| UpPayload
40-
4122
export interface ScreenshotOptions {
4223
element?: Element
4324
/**
@@ -57,21 +38,138 @@ export interface BrowserCommands {
5738
options?: BufferEncoding | (FsOptions & { mode?: number | string })
5839
) => Promise<void>
5940
removeFile: (path: string) => Promise<void>
60-
sendKeys: (payload: SendKeysPayload) => Promise<void>
6141
}
6242

6343
export interface UserEvent {
44+
setup: () => UserEvent
6445
/**
6546
* Click on an element. Uses provider's API under the hood and supports all its options.
6647
* @see {@link https://playwright.dev/docs/api/class-locator#locator-click} Playwright API
6748
* @see {@link https://webdriver.io/docs/api/element/click/} WebdriverIO API
6849
* @see {@link https://testing-library.com/docs/user-event/convenience/#click} testing-library API
6950
*/
7051
click: (element: Element, options?: UserEventClickOptions) => Promise<void>
52+
/**
53+
* Triggers a double click event on an element. Uses provider's API under the hood.
54+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-dblclick} Playwright API
55+
* @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API
56+
* @see {@link https://testing-library.com/docs/user-event/convenience/#dblClick} testing-library API
57+
*/
58+
dblClick: (element: Element, options?: UserEventDoubleClickOptions) => Promise<void>
59+
/**
60+
* Choose one or more values from a select element. Uses provider's API under the hood.
61+
* If select doesn't have `multiple` attribute, only the first value will be selected.
62+
* @example
63+
* await userEvent.selectOptions(select, 'Option 1')
64+
* expect(select).toHaveValue('option-1')
65+
*
66+
* await userEvent.selectOptions(select, 'option-1')
67+
* expect(select).toHaveValue('option-1')
68+
*
69+
* await userEvent.selectOptions(select, [
70+
* screen.getByRole('option', { name: 'Option 1' }),
71+
* screen.getByRole('option', { name: 'Option 2' }),
72+
* ])
73+
* expect(select).toHaveValue(['option-1', 'option-2'])
74+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-select-option} Playwright API
75+
* @see {@link https://webdriver.io/docs/api/element/doubleClick/} WebdriverIO API
76+
* @see {@link https://testing-library.com/docs/user-event/utility/#-selectoptions-deselectoptions} testing-library API
77+
*/
78+
selectOptions: (
79+
element: Element,
80+
values: HTMLElement | HTMLElement[] | string | string[],
81+
options?: UserEventSelectOptions,
82+
) => Promise<void>
83+
/**
84+
* Type text on the keyboard. If any input is focused, it will receive the text,
85+
* otherwise it will be typed on the document. Uses provider's API under the hood.
86+
* **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers.
87+
* @example
88+
* await userEvent.keyboard('foo') // translates to: f, o, o
89+
* await userEvent.keyboard('{{a[[') // translates to: {, a, [
90+
* await userEvent.keyboard('{Shift}{f}{o}{o}') // translates to: Shift, f, o, o
91+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API
92+
* @see {@link https://webdriver.io/docs/api/browser/keys} WebdriverIO API
93+
* @see {@link https://testing-library.com/docs/user-event/keyboard} testing-library API
94+
*/
95+
keyboard: (text: string) => Promise<void>
96+
/**
97+
* Types text into an element. Uses provider's API under the hood.
98+
* **Supports** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`) even with `playwright` and `webdriverio` providers.
99+
* @example
100+
* await userEvent.type(input, 'foo') // translates to: f, o, o
101+
* await userEvent.type(input, '{{a[[') // translates to: {, a, [
102+
* await userEvent.type(input, '{Shift}{f}{o}{o}') // translates to: Shift, f, o, o
103+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API
104+
* @see {@link https://webdriver.io/docs/api/browser/action#key-input-source} WebdriverIO API
105+
* @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API
106+
*/
107+
type: (element: Element, text: string, options?: UserEventTypeOptions) => Promise<void>
108+
/**
109+
* Removes all text from an element. Uses provider's API under the hood.
110+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-clear} Playwright API
111+
* @see {@link https://webdriver.io/docs/api/element/clearValue} WebdriverIO API
112+
* @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API
113+
*/
114+
clear: (element: Element) => Promise<void>
115+
/**
116+
* Sends a `Tab` key event. Uses provider's API under the hood.
117+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-press} Playwright API
118+
* @see {@link https://webdriver.io/docs/api/element/keys} WebdriverIO API
119+
* @see {@link https://testing-library.com/docs/user-event/convenience/#tab} testing-library API
120+
*/
121+
tab: (options?: UserEventTabOptions) => Promise<void>
122+
/**
123+
* Hovers over an element. Uses provider's API under the hood.
124+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API
125+
* @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API
126+
* @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API
127+
*/
128+
hover: (element: Element, options?: UserEventHoverOptions) => Promise<void>
129+
/**
130+
* Moves cursor position to the body element. Uses provider's API under the hood.
131+
* By default, the cursor position is in the center (in webdriverio) or in some visible place (in playwright)
132+
* of the body element, so if the current element is already there, this will have no effect.
133+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-hover} Playwright API
134+
* @see {@link https://webdriver.io/docs/api/element/moveTo/} WebdriverIO API
135+
* @see {@link https://testing-library.com/docs/user-event/convenience/#hover} testing-library API
136+
*/
137+
unhover: (element: Element, options?: UserEventHoverOptions) => Promise<void>
138+
/**
139+
* Fills an input element with text. This will remove any existing text in the input before typing the new text.
140+
* Uses provider's API under the hood.
141+
* This API is faster than using `userEvent.type` or `userEvent.keyboard`, but it **doesn't support** [user-event `keyboard` syntax](https://testing-library.com/docs/user-event/keyboard) (e.g., `{Shift}`).
142+
* @example
143+
* await userEvent.fill(input, 'foo') // translates to: f, o, o
144+
* await userEvent.fill(input, '{{a[[') // translates to: {, {, a, [, [
145+
* await userEvent.fill(input, '{Shift}') // translates to: {, S, h, i, f, t, }
146+
* @see {@link https://playwright.dev/docs/api/class-locator#locator-fill} Playwright API
147+
* @see {@link https://webdriver.io/docs/api/element/setValue} WebdriverIO API
148+
* @see {@link https://testing-library.com/docs/user-event/utility/#type} testing-library API
149+
*/
150+
fill: (element: Element, text: string, options?: UserEventFillOptions) => Promise<void>
151+
/**
152+
* Drags a source element on top of the target element. This API is not supported by "preview" provider.
153+
* @see {@link https://playwright.dev/docs/api/class-frame#frame-drag-and-drop} Playwright API
154+
* @see {@link https://webdriver.io/docs/api/element/dragAndDrop/} WebdriverIO API
155+
*/
156+
dragAndDrop: (source: Element, target: Element, options?: UserEventDragAndDropOptions) => Promise<void>
157+
}
158+
159+
export interface UserEventFillOptions {}
160+
export interface UserEventHoverOptions {}
161+
export interface UserEventSelectOptions {}
162+
export interface UserEventClickOptions {}
163+
export interface UserEventDoubleClickOptions {}
164+
export interface UserEventDragAndDropOptions {}
165+
166+
export interface UserEventTabOptions {
167+
shift?: boolean
71168
}
72169

73-
export interface UserEventClickOptions {
74-
[key: string]: any
170+
export interface UserEventTypeOptions {
171+
skipClick?: boolean
172+
skipAutoClose?: boolean
75173
}
76174

77175
type Platform =

packages/browser/providers/playwright.d.ts

+23-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type {
2+
BrowserContext,
23
BrowserContextOptions,
3-
FrameLocator,
4+
Frame,
45
LaunchOptions,
5-
Locator,
66
Page,
77
} from 'playwright'
88

@@ -17,7 +17,26 @@ declare module 'vitest/node' {
1717

1818
export interface BrowserCommandContext {
1919
page: Page
20-
tester: FrameLocator
21-
body: Locator
20+
frame: Frame
21+
context: BrowserContext
2222
}
2323
}
24+
25+
type PWHoverOptions = Parameters<Page['hover']>[1]
26+
type PWClickOptions = Parameters<Page['click']>[1]
27+
type PWDoubleClickOptions = Parameters<Page['dblclick']>[1]
28+
type PWFillOptions = Parameters<Page['fill']>[2]
29+
type PWScreenshotOptions = Parameters<Page['screenshot']>[0]
30+
type PWSelectOptions = Parameters<Page['selectOption']>[2]
31+
type PWDragAndDropOptions = Parameters<Page['dragAndDrop']>[2]
32+
33+
declare module '@vitest/browser/context' {
34+
export interface UserEventHoverOptions extends PWHoverOptions {}
35+
export interface UserEventClickOptions extends PWClickOptions {}
36+
export interface UserEventDoubleClickOptions extends PWDoubleClickOptions {}
37+
export interface UserEventFillOptions extends PWFillOptions {}
38+
export interface UserEventSelectOptions extends PWSelectOptions {}
39+
export interface UserEventDragOptions extends UserEventDragAndDropOptions {}
40+
41+
export interface ScreenshotOptions extends PWScreenshotOptions {}
42+
}

packages/browser/src/client/context.ts

+97-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import type { Task, WorkerGlobalState } from 'vitest'
2-
import type {
3-
BrowserPage,
4-
UserEvent,
5-
UserEventClickOptions,
6-
} from '../../context'
2+
import type { BrowserPage, UserEvent, UserEventClickOptions, UserEventTabOptions, UserEventTypeOptions } from '../../context'
73
import type { BrowserRPC } from './client'
84
import type { BrowserRunnerState } from './utils'
95

@@ -62,11 +58,107 @@ function triggerCommand<T>(command: string, ...args: any[]) {
6258
return rpc().triggerCommand<T>(contextId, command, filepath(), args)
6359
}
6460

61+
const provider = runner().provider
62+
6563
export const userEvent: UserEvent = {
64+
// TODO: actually setup userEvent with config options
65+
setup() {
66+
return userEvent
67+
},
6668
click(element: Element, options: UserEventClickOptions = {}) {
6769
const xpath = convertElementToXPath(element)
6870
return triggerCommand('__vitest_click', xpath, options)
6971
},
72+
dblClick(element: Element, options: UserEventClickOptions = {}) {
73+
const xpath = convertElementToXPath(element)
74+
return triggerCommand('__vitest_dblClick', xpath, options)
75+
},
76+
selectOptions(element, value) {
77+
const values = provider === 'webdriverio'
78+
? getWebdriverioSelectOptions(element, value)
79+
: getSimpleSelectOptions(element, value)
80+
return triggerCommand('__vitest_selectOptions', convertElementToXPath(element), values)
81+
},
82+
type(element: Element, text: string, options: UserEventTypeOptions = {}) {
83+
const xpath = convertElementToXPath(element)
84+
return triggerCommand('__vitest_type', xpath, text, options)
85+
},
86+
clear(element: Element) {
87+
const xpath = convertElementToXPath(element)
88+
return triggerCommand('__vitest_clear', xpath)
89+
},
90+
tab(options: UserEventTabOptions = {}) {
91+
return triggerCommand('__vitest_tab', options)
92+
},
93+
keyboard(text: string) {
94+
return triggerCommand('__vitest_keyboard', text)
95+
},
96+
hover(element: Element) {
97+
const xpath = convertElementToXPath(element)
98+
return triggerCommand('__vitest_hover', xpath)
99+
},
100+
unhover(element: Element) {
101+
const xpath = convertElementToXPath(element.ownerDocument.body)
102+
return triggerCommand('__vitest_hover', xpath)
103+
},
104+
105+
// non userEvent events, but still useful
106+
fill(element: Element, text: string, options) {
107+
const xpath = convertElementToXPath(element)
108+
return triggerCommand('__vitest_fill', xpath, text, options)
109+
},
110+
dragAndDrop(source: Element, target: Element, options = {}) {
111+
const sourceXpath = convertElementToXPath(source)
112+
const targetXpath = convertElementToXPath(target)
113+
return triggerCommand('__vitest_dragAndDrop', sourceXpath, targetXpath, options)
114+
},
115+
}
116+
117+
function getWebdriverioSelectOptions(element: Element, value: string | string[] | HTMLElement[] | HTMLElement) {
118+
const options = [...element.querySelectorAll('option')] as HTMLOptionElement[]
119+
120+
const arrayValues = Array.isArray(value) ? value : [value]
121+
122+
if (!arrayValues.length) {
123+
return []
124+
}
125+
126+
if (arrayValues.length > 1) {
127+
throw new Error('Provider "webdriverio" doesn\'t support selecting multiple values at once')
128+
}
129+
130+
const optionValue = arrayValues[0]
131+
132+
if (typeof optionValue !== 'string') {
133+
const index = options.indexOf(optionValue as HTMLOptionElement)
134+
if (index === -1) {
135+
throw new Error(`The element ${convertElementToXPath(optionValue)} was not found in the "select" options.`)
136+
}
137+
138+
return [{ index }]
139+
}
140+
141+
const valueIndex = options.findIndex(option => option.value === optionValue)
142+
if (valueIndex !== -1) {
143+
return [{ index: valueIndex }]
144+
}
145+
146+
const labelIndex = options.findIndex(option => option.textContent?.trim() === optionValue || option.ariaLabel === optionValue)
147+
148+
if (labelIndex === -1) {
149+
throw new Error(`The option "${optionValue}" was not found in the "select" options.`)
150+
}
151+
152+
return [{ index: labelIndex }]
153+
}
154+
155+
function getSimpleSelectOptions(element: Element, value: string | string[] | HTMLElement[] | HTMLElement) {
156+
return (Array.isArray(value) ? value : [value]).map((v) => {
157+
if (typeof v !== 'string') {
158+
return { element: convertElementToXPath(v) }
159+
}
160+
return v
161+
})
70162
}
71163

72164
const screenshotIds: Record<string, Record<string, string>> = {}

packages/browser/src/client/orchestrator.ts

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ function createIframe(container: HTMLDivElement, file: string) {
5252
iframe.style.position = 'relative'
5353
iframe.setAttribute('allowfullscreen', 'true')
5454
iframe.setAttribute('allow', 'clipboard-write;')
55+
iframe.setAttribute('name', 'vitest-iframe')
5556

5657
iframes.set(file, iframe)
5758
container.appendChild(iframe)

packages/browser/src/client/public/esm-client-injector.js

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ window.__vitest_browser_runner__ = {
2323
files: { __VITEST_FILES__ },
2424
type: { __VITEST_TYPE__ },
2525
contextId: { __VITEST_CONTEXT_ID__ },
26+
provider: { __VITEST_PROVIDER__ },
2627
};
2728

2829
const config = __vitest_browser_runner__.config;

packages/browser/src/client/tester.html

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
{__VITEST_SCRIPTS__}
2121
</head>
2222
<body
23+
data-vitest-body
2324
style="
2425
width: 100%;
2526
height: 100%;

packages/browser/src/client/utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface BrowserRunnerState {
1414
runningFiles: string[]
1515
moduleCache: WorkerGlobalState['moduleCache']
1616
config: ResolvedConfig
17+
provider: string
1718
viteConfig: {
1819
root: string
1920
}

0 commit comments

Comments
 (0)