Skip to content

Commit 5eb4cd1

Browse files
sheremet-vahi-ogawa
andauthoredMar 7, 2025··
fix(browser): fail playwright timeouts earlier than a test timeout (#7565)
Co-authored-by: Hiroshi Ogawa <hi.ogawa.zz@gmail.com>
1 parent b7f5526 commit 5eb4cd1

File tree

34 files changed

+549
-338
lines changed

34 files changed

+549
-338
lines changed
 

‎docs/guide/browser/interactivity-api.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ References:
292292
## userEvent.clear
293293
294294
```ts
295-
function clear(element: Element | Locator): Promise<void>
295+
function clear(element: Element | Locator, options?: UserEventClearOptions): Promise<void>
296296
```
297297

298298
This method clears the input element content.
@@ -451,6 +451,7 @@ References:
451451
function upload(
452452
element: Element | Locator,
453453
files: string[] | string | File[] | File,
454+
options?: UserEventUploadOptions,
454455
): Promise<void>
455456
```
456457

‎docs/guide/browser/locators.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: Locators | Browser Mode
33
outline: [2, 3]
44
---
55

6-
# Locators <Version>2.1.0</Version>
6+
# Locators
77

88
A locator is a representation of an element or a number of elements. Every locator is defined by a string called a selector. Vitest abstracts this selector by providing convenient methods that generate those selectors behind the scenes.
99

@@ -505,7 +505,7 @@ await page.getByRole('img', { name: 'Rose' }).tripleClick()
505505
### clear
506506

507507
```ts
508-
function clear(): Promise<void>
508+
function clear(options?: UserEventClearOptions): Promise<void>
509509
```
510510

511511
Clears the input element content.

‎packages/browser/context.d.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export interface UserEvent {
142142
* @see {@link https://webdriver.io/docs/api/element/clearValue} WebdriverIO API
143143
* @see {@link https://testing-library.com/docs/user-event/utility/#clear} testing-library API
144144
*/
145-
clear: (element: Element | Locator) => Promise<void>
145+
clear: (element: Element | Locator, options?: UserEventClearOptions) => Promise<void>
146146
/**
147147
* Sends a `Tab` key event. Uses provider's API under the hood.
148148
* @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API
@@ -171,7 +171,7 @@ export interface UserEvent {
171171
* @see {@link https://playwright.dev/docs/api/class-locator#locator-set-input-files} Playwright API
172172
* @see {@link https://testing-library.com/docs/user-event/utility#upload} testing-library API
173173
*/
174-
upload: (element: Element | Locator, files: File | File[] | string | string[]) => Promise<void>
174+
upload: (element: Element | Locator, files: File | File[] | string | string[], options?: UserEventUploadOptions) => Promise<void>
175175
/**
176176
* Copies the selected content.
177177
* @see {@link https://playwright.dev/docs/api/class-keyboard} Playwright API
@@ -218,9 +218,11 @@ export interface UserEventFillOptions {}
218218
export interface UserEventHoverOptions {}
219219
export interface UserEventSelectOptions {}
220220
export interface UserEventClickOptions {}
221+
export interface UserEventClearOptions {}
221222
export interface UserEventDoubleClickOptions {}
222223
export interface UserEventTripleClickOptions {}
223224
export interface UserEventDragAndDropOptions {}
225+
export interface UserEventUploadOptions {}
224226

225227
export interface LocatorOptions {
226228
/**
@@ -358,7 +360,7 @@ export interface Locator extends LocatorSelectors {
358360
* Clears the input element content
359361
* @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-clear}
360362
*/
361-
clear(): Promise<void>
363+
clear(options?: UserEventClearOptions): Promise<void>
362364
/**
363365
* Moves the cursor position to the selected element
364366
* @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-hover}
@@ -391,7 +393,7 @@ export interface Locator extends LocatorSelectors {
391393
* Change a file input element to have the specified files. Uses provider's API under the hood.
392394
* @see {@link https://vitest.dev/guide/browser/interactivity-api#userevent-upload}
393395
*/
394-
upload(files: File | File[] | string | string[]): Promise<void>
396+
upload(files: File | File[] | string | string[], options?: UserEventUploadOptions): Promise<void>
395397

396398
/**
397399
* Make a screenshot of an element matching the locator.

‎packages/browser/matchers.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ declare module 'vitest' {
1919
interface ExpectStatic {
2020
/**
2121
* `expect.element(locator)` is a shorthand for `expect.poll(() => locator.element())`.
22-
* You can set default timeout via `expect.poll.timeout` config.
22+
* You can set default timeout via `expect.poll.timeout` option in the config.
23+
* @see {@link https://vitest.dev/api/expect#poll}
2324
*/
2425
element: <T extends Element | Locator>(element: T, options?: ExpectPollOptions) => PromisifyDomAssertion<Awaited<Element | null>>
2526
}

‎packages/browser/providers/playwright.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type PWFillOptions = NonNullable<Parameters<Page['fill']>[2]>
4141
type PWScreenshotOptions = NonNullable<Parameters<Page['screenshot']>[0]>
4242
type PWSelectOptions = NonNullable<Parameters<Page['selectOption']>[2]>
4343
type PWDragAndDropOptions = NonNullable<Parameters<Page['dragAndDrop']>[2]>
44+
type PWSetInputFiles = NonNullable<Parameters<Page['setInputFiles']>[2]>
4445

4546
declare module '@vitest/browser/context' {
4647
export interface UserEventHoverOptions extends PWHoverOptions {}
@@ -50,6 +51,7 @@ declare module '@vitest/browser/context' {
5051
export interface UserEventFillOptions extends PWFillOptions {}
5152
export interface UserEventSelectOptions extends PWSelectOptions {}
5253
export interface UserEventDragAndDropOptions extends PWDragAndDropOptions {}
54+
export interface UserEventUploadOptions extends PWSetInputFiles {}
5355

5456
export interface ScreenshotOptions extends PWScreenshotOptions {}
5557

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

+53-182
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,20 @@ import type {
44
BrowserPage,
55
Locator,
66
UserEvent,
7-
UserEventClickOptions,
8-
UserEventDragAndDropOptions,
9-
UserEventHoverOptions,
10-
UserEventTabOptions,
11-
UserEventTypeOptions,
127
} from '../../../context'
138
import type { BrowserRunnerState } from '../utils'
14-
import { convertElementToCssSelector, ensureAwaited, getBrowserState, getWorkerState } from '../utils'
9+
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
10+
import { convertElementToCssSelector, processTimeoutOptions } from './utils'
1511

1612
// this file should not import anything directly, only types and utils
1713

18-
const state = () => getWorkerState()
1914
// @ts-expect-error not typed global
2015
const provider = __vitest_browser_runner__.provider
2116
const sessionId = getBrowserState().sessionId
2217
const channel = new BroadcastChannel(`vitest:${sessionId}`)
2318

24-
function triggerCommand<T>(command: string, ...args: any[]) {
25-
return getBrowserState().commands.triggerCommand<T>(command, args)
19+
function triggerCommand<T>(command: string, args: any[], error?: Error) {
20+
return getBrowserState().commands.triggerCommand<T>(command, args, error)
2621
}
2722

2823
export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent, options?: TestingLibraryOptions): UserEvent {
@@ -51,68 +46,71 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
5146
if (!keyboard.unreleased.length) {
5247
return
5348
}
54-
return ensureAwaited(async () => {
55-
await triggerCommand('__vitest_cleanup', keyboard)
49+
return ensureAwaited(async (error) => {
50+
await triggerCommand('__vitest_cleanup', [keyboard], error)
5651
keyboard.unreleased = []
5752
})
5853
},
59-
click(element: Element | Locator, options: UserEventClickOptions = {}) {
60-
return convertToLocator(element).click(processClickOptions(options))
54+
click(element, options) {
55+
return convertToLocator(element).click(options)
6156
},
62-
dblClick(element: Element | Locator, options: UserEventClickOptions = {}) {
63-
return convertToLocator(element).dblClick(processClickOptions(options))
57+
dblClick(element, options) {
58+
return convertToLocator(element).dblClick(options)
6459
},
65-
tripleClick(element: Element | Locator, options: UserEventClickOptions = {}) {
66-
return convertToLocator(element).tripleClick(processClickOptions(options))
60+
tripleClick(element, options) {
61+
return convertToLocator(element).tripleClick(options)
6762
},
68-
selectOptions(element, value) {
69-
return convertToLocator(element).selectOptions(value)
63+
selectOptions(element, value, options) {
64+
return convertToLocator(element).selectOptions(value, options)
7065
},
71-
clear(element: Element | Locator) {
72-
return convertToLocator(element).clear()
66+
clear(element, options) {
67+
return convertToLocator(element).clear(options)
7368
},
74-
hover(element: Element | Locator, options: UserEventHoverOptions = {}) {
75-
return convertToLocator(element).hover(processHoverOptions(options))
69+
hover(element, options) {
70+
return convertToLocator(element).hover(options)
7671
},
77-
unhover(element: Element | Locator, options: UserEventHoverOptions = {}) {
72+
unhover(element, options) {
7873
return convertToLocator(element).unhover(options)
7974
},
80-
upload(element: Element | Locator, files: string | string[] | File | File[]) {
81-
return convertToLocator(element).upload(files)
75+
upload(element, files: string | string[] | File | File[], options) {
76+
return convertToLocator(element).upload(files, options)
8277
},
8378

8479
// non userEvent events, but still useful
85-
fill(element: Element | Locator, text: string, options) {
80+
fill(element, text, options) {
8681
return convertToLocator(element).fill(text, options)
8782
},
88-
dragAndDrop(source: Element | Locator, target: Element | Locator, options = {}) {
83+
dragAndDrop(source, target, options) {
8984
const sourceLocator = convertToLocator(source)
9085
const targetLocator = convertToLocator(target)
91-
return sourceLocator.dropTo(targetLocator, processDragAndDropOptions(options))
86+
return sourceLocator.dropTo(targetLocator, options)
9287
},
9388

9489
// testing-library user-event
95-
async type(element: Element | Locator, text: string, options: UserEventTypeOptions = {}) {
96-
return ensureAwaited(async () => {
90+
async type(element, text, options) {
91+
return ensureAwaited(async (error) => {
9792
const selector = convertToSelector(element)
9893
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
9994
'__vitest_type',
100-
selector,
101-
text,
102-
{ ...options, unreleased: keyboard.unreleased },
95+
[
96+
selector,
97+
text,
98+
{ ...options, unreleased: keyboard.unreleased },
99+
],
100+
error,
103101
)
104102
keyboard.unreleased = unreleased
105103
})
106104
},
107-
tab(options: UserEventTabOptions = {}) {
108-
return ensureAwaited(() => triggerCommand('__vitest_tab', options))
105+
tab(options = {}) {
106+
return ensureAwaited(error => triggerCommand('__vitest_tab', [options], error))
109107
},
110-
async keyboard(text: string) {
111-
return ensureAwaited(async () => {
108+
async keyboard(text) {
109+
return ensureAwaited(async (error) => {
112110
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
113111
'__vitest_keyboard',
114-
text,
115-
keyboard,
112+
[text, keyboard],
113+
error,
116114
)
117115
keyboard.unreleased = unreleased
118116
})
@@ -175,7 +173,7 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options:
175173
async unhover(element: Element | Locator) {
176174
await userEvent.unhover(toElement(element))
177175
},
178-
async upload(element: Element | Locator, files: string | string[] | File | File[]) {
176+
async upload(element, files: string | string[] | File | File[]) {
179177
const uploadPromise = (Array.isArray(files) ? files : [files]).map(async (file) => {
180178
if (typeof file !== 'string') {
181179
return file
@@ -185,7 +183,7 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options:
185183
content: string
186184
basename: string
187185
mime: string
188-
}>('__vitest_fileInfo', file, 'base64')
186+
}>('__vitest_fileInfo', [file, 'base64'])
189187

190188
const fileInstance = fetch(`data:${mime};base64,${base64}`)
191189
.then(r => r.blob())
@@ -196,18 +194,18 @@ function createPreviewUserEvent(userEventBase: TestingLibraryUserEvent, options:
196194
return userEvent.upload(toElement(element) as HTMLElement, uploadFiles)
197195
},
198196

199-
async fill(element: Element | Locator, text: string) {
197+
async fill(element, text) {
200198
await userEvent.clear(toElement(element))
201199
return userEvent.type(toElement(element), text)
202200
},
203201
async dragAndDrop() {
204202
throw new Error(`The "preview" provider doesn't support 'userEvent.dragAndDrop'`)
205203
},
206204

207-
async type(element: Element | Locator, text: string, options: UserEventTypeOptions = {}) {
205+
async type(element, text, options) {
208206
await userEvent.type(toElement(element), text, options)
209207
},
210-
async tab(options: UserEventTabOptions = {}) {
208+
async tab(options) {
211209
await userEvent.tab(options)
212210
},
213211
async keyboard(text: string) {
@@ -282,36 +280,36 @@ export const page: BrowserPage = {
282280
const name
283281
= options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png`
284282

285-
return ensureAwaited(() => triggerCommand('__vitest_screenshot', name, {
283+
return ensureAwaited(error => triggerCommand('__vitest_screenshot', [name, processTimeoutOptions({
286284
...options,
287285
element: options.element
288286
? convertToSelector(options.element)
289287
: undefined,
290-
}))
288+
})], error))
291289
},
292290
getByRole() {
293-
throw new Error('Method "getByRole" is not implemented in the current provider.')
291+
throw new Error(`Method "getByRole" is not implemented in the "${provider}" provider.`)
294292
},
295293
getByLabelText() {
296-
throw new Error('Method "getByLabelText" is not implemented in the current provider.')
294+
throw new Error(`Method "getByLabelText" is not implemented in the "${provider}" provider.`)
297295
},
298296
getByTestId() {
299-
throw new Error('Method "getByTestId" is not implemented in the current provider.')
297+
throw new Error(`Method "getByTestId" is not implemented in the "${provider}" provider.`)
300298
},
301299
getByAltText() {
302-
throw new Error('Method "getByAltText" is not implemented in the current provider.')
300+
throw new Error(`Method "getByAltText" is not implemented in the "${provider}" provider.`)
303301
},
304302
getByPlaceholder() {
305-
throw new Error('Method "getByPlaceholder" is not implemented in the current provider.')
303+
throw new Error(`Method "getByPlaceholder" is not implemented in the "${provider}" provider.`)
306304
},
307305
getByText() {
308-
throw new Error('Method "getByText" is not implemented in the current provider.')
306+
throw new Error(`Method "getByText" is not implemented in the "${provider}" provider.`)
309307
},
310308
getByTitle() {
311-
throw new Error('Method "getByTitle" is not implemented in the current provider.')
309+
throw new Error(`Method "getByTitle" is not implemented in the "${provider}" provider.`)
312310
},
313311
elementLocator() {
314-
throw new Error('Method "elementLocator" is not implemented in the current provider.')
312+
throw new Error(`Method "elementLocator" is not implemented in the "${provider}" provider.`)
315313
},
316314
extend(methods) {
317315
for (const key in methods) {
@@ -344,130 +342,3 @@ function convertToSelector(elementOrLocator: Element | Locator): string {
344342
function getTaskFullName(task: RunnerTask): string {
345343
return task.suite ? `${getTaskFullName(task.suite)} ${task.name}` : task.name
346344
}
347-
348-
function processClickOptions(options_?: UserEventClickOptions) {
349-
// only ui scales the iframe, so we need to adjust the position
350-
if (!options_ || !state().config.browser.ui) {
351-
return options_
352-
}
353-
if (provider === 'playwright') {
354-
const options = options_ as NonNullable<
355-
Parameters<import('playwright').Page['click']>[1]
356-
>
357-
if (options.position) {
358-
options.position = processPlaywrightPosition(options.position)
359-
}
360-
}
361-
if (provider === 'webdriverio') {
362-
const options = options_ as import('webdriverio').ClickOptions
363-
if (options.x != null || options.y != null) {
364-
const cache = {}
365-
if (options.x != null) {
366-
options.x = scaleCoordinate(options.x, cache)
367-
}
368-
if (options.y != null) {
369-
options.y = scaleCoordinate(options.y, cache)
370-
}
371-
}
372-
}
373-
return options_
374-
}
375-
376-
function processHoverOptions(options_?: UserEventHoverOptions) {
377-
// only ui scales the iframe, so we need to adjust the position
378-
if (!options_ || !state().config.browser.ui) {
379-
return options_
380-
}
381-
382-
if (provider === 'playwright') {
383-
const options = options_ as NonNullable<
384-
Parameters<import('playwright').Page['hover']>[1]
385-
>
386-
if (options.position) {
387-
options.position = processPlaywrightPosition(options.position)
388-
}
389-
}
390-
if (provider === 'webdriverio') {
391-
const options = options_ as import('webdriverio').MoveToOptions
392-
const cache = {}
393-
if (options.xOffset != null) {
394-
options.xOffset = scaleCoordinate(options.xOffset, cache)
395-
}
396-
if (options.yOffset != null) {
397-
options.yOffset = scaleCoordinate(options.yOffset, cache)
398-
}
399-
}
400-
return options_
401-
}
402-
403-
function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) {
404-
// only ui scales the iframe, so we need to adjust the position
405-
if (!options_ || !state().config.browser.ui) {
406-
return options_
407-
}
408-
if (provider === 'playwright') {
409-
const options = options_ as NonNullable<
410-
Parameters<import('playwright').Page['dragAndDrop']>[2]
411-
>
412-
if (options.sourcePosition) {
413-
options.sourcePosition = processPlaywrightPosition(options.sourcePosition)
414-
}
415-
if (options.targetPosition) {
416-
options.targetPosition = processPlaywrightPosition(options.targetPosition)
417-
}
418-
}
419-
if (provider === 'webdriverio') {
420-
const cache = {}
421-
const options = options_ as import('webdriverio').DragAndDropOptions & {
422-
targetX?: number
423-
targetY?: number
424-
sourceX?: number
425-
sourceY?: number
426-
}
427-
if (options.sourceX != null) {
428-
options.sourceX = scaleCoordinate(options.sourceX, cache)
429-
}
430-
if (options.sourceY != null) {
431-
options.sourceY = scaleCoordinate(options.sourceY, cache)
432-
}
433-
if (options.targetX != null) {
434-
options.targetX = scaleCoordinate(options.targetX, cache)
435-
}
436-
if (options.targetY != null) {
437-
options.targetY = scaleCoordinate(options.targetY, cache)
438-
}
439-
}
440-
return options_
441-
}
442-
443-
function scaleCoordinate(coordinate: number, cache: any) {
444-
return Math.round(coordinate * getCachedScale(cache))
445-
}
446-
447-
function getCachedScale(cache: { scale: number | undefined }) {
448-
return cache.scale ??= getIframeScale()
449-
}
450-
451-
function processPlaywrightPosition(position: { x: number; y: number }) {
452-
const scale = getIframeScale()
453-
if (position.x != null) {
454-
position.x *= scale
455-
}
456-
if (position.y != null) {
457-
position.y *= scale
458-
}
459-
return position
460-
}
461-
462-
function getIframeScale() {
463-
const testerUi = window.parent.document.querySelector('#tester-ui') as HTMLElement | null
464-
if (!testerUi) {
465-
throw new Error(`Cannot find Tester element. This is a bug in Vitest. Please, open a new issue with reproduction.`)
466-
}
467-
const scaleAttribute = testerUi.getAttribute('data-scale')
468-
const scale = Number(scaleAttribute)
469-
if (Number.isNaN(scale)) {
470-
throw new TypeError(`Cannot parse scale value from Tester element (${scaleAttribute}). This is a bug in Vitest. Please, open a new issue with reproduction.`)
471-
}
472-
return scale
473-
}

‎packages/browser/src/client/tester/expect-element.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Locator } from '@vitest/browser/context'
22
import type { ExpectPollOptions } from 'vitest'
33
import * as matchers from '@testing-library/jest-dom/matchers'
44
import { chai, expect } from 'vitest'
5+
import { processTimeoutOptions } from './utils'
56

67
export async function setupExpectDom(): Promise<void> {
78
expect.extend(matchers)
@@ -38,6 +39,6 @@ export async function setupExpectDom(): Promise<void> {
3839
}
3940

4041
return result
41-
}, options)
42+
}, processTimeoutOptions(options))
4243
}
4344
}

‎packages/browser/src/client/tester/locators/index.ts

+16-9
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import type {
22
LocatorByRoleOptions,
33
LocatorOptions,
44
LocatorScreenshotOptions,
5+
UserEventClearOptions,
56
UserEventClickOptions,
67
UserEventDragAndDropOptions,
78
UserEventFillOptions,
89
UserEventHoverOptions,
10+
UserEventSelectOptions,
11+
UserEventUploadOptions,
912
} from '@vitest/browser/context'
1013
import { page, server } from '@vitest/browser/context'
1114
import {
@@ -57,23 +60,23 @@ export abstract class Locator {
5760
return this.triggerCommand<void>('__vitest_tripleClick', this.selector, options)
5861
}
5962

60-
public clear(): Promise<void> {
61-
return this.triggerCommand<void>('__vitest_clear', this.selector)
63+
public clear(options?: UserEventClearOptions): Promise<void> {
64+
return this.triggerCommand<void>('__vitest_clear', this.selector, options)
6265
}
6366

64-
public hover(options: UserEventHoverOptions): Promise<void> {
67+
public hover(options?: UserEventHoverOptions): Promise<void> {
6568
return this.triggerCommand<void>('__vitest_hover', this.selector, options)
6669
}
6770

68-
public unhover(options: UserEventHoverOptions): Promise<void> {
71+
public unhover(options?: UserEventHoverOptions): Promise<void> {
6972
return this.triggerCommand<void>('__vitest_hover', 'html > body', options)
7073
}
7174

7275
public fill(text: string, options?: UserEventFillOptions): Promise<void> {
7376
return this.triggerCommand<void>('__vitest_fill', this.selector, text, options)
7477
}
7578

76-
public async upload(files: string | string[] | File | File[]): Promise<void> {
79+
public async upload(files: string | string[] | File | File[], options?: UserEventUploadOptions): Promise<void> {
7780
const filesPromise = (Array.isArray(files) ? files : [files]).map(async (file) => {
7881
if (typeof file === 'string') {
7982
return file
@@ -91,7 +94,7 @@ export abstract class Locator {
9194
base64: bas64String,
9295
}
9396
})
94-
return this.triggerCommand<void>('__vitest_upload', this.selector, await Promise.all(filesPromise))
97+
return this.triggerCommand<void>('__vitest_upload', this.selector, await Promise.all(filesPromise), options)
9598
}
9699

97100
public dropTo(target: Locator, options: UserEventDragAndDropOptions = {}): Promise<void> {
@@ -103,15 +106,18 @@ export abstract class Locator {
103106
)
104107
}
105108

106-
public selectOptions(value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[]): Promise<void> {
109+
public selectOptions(
110+
value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[],
111+
options?: UserEventSelectOptions,
112+
): Promise<void> {
107113
const values = (Array.isArray(value) ? value : [value]).map((v) => {
108114
if (typeof v !== 'string') {
109115
const selector = 'element' in v ? v.selector : selectorEngine.generateSelectorSimple(v)
110116
return { element: selector }
111117
}
112118
return v
113119
})
114-
return this.triggerCommand('__vitest_selectOptions', this.selector, values)
120+
return this.triggerCommand('__vitest_selectOptions', this.selector, values, options)
115121
}
116122

117123
public screenshot(options: Omit<LocatorScreenshotOptions, 'base64'> & { base64: true }): Promise<{
@@ -204,9 +210,10 @@ export abstract class Locator {
204210

205211
protected triggerCommand<T>(command: string, ...args: any[]): Promise<T> {
206212
const commands = getBrowserState().commands
207-
return ensureAwaited(() => commands.triggerCommand<T>(
213+
return ensureAwaited(error => commands.triggerCommand<T>(
208214
command,
209215
args,
216+
error,
210217
))
211218
}
212219
}

‎packages/browser/src/client/tester/locators/playwright.ts

+103
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { UserEventClearOptions, UserEventClickOptions, UserEventDragAndDropOptions, UserEventFillOptions, UserEventHoverOptions, UserEventSelectOptions, UserEventUploadOptions } from '@vitest/browser/context'
12
import { page, server } from '@vitest/browser/context'
23
import {
34
getByAltTextSelector,
@@ -8,6 +9,8 @@ import {
89
getByTextSelector,
910
getByTitleSelector,
1011
} from 'ivya'
12+
import { getBrowserState } from '../../utils'
13+
import { getIframeScale, processTimeoutOptions } from '../utils'
1114
import { Locator, selectorEngine } from './index'
1215

1316
page.extend({
@@ -46,6 +49,50 @@ class PlaywrightLocator extends Locator {
4649
super()
4750
}
4851

52+
public override click(options?: UserEventClickOptions) {
53+
return super.click(processTimeoutOptions(processClickOptions(options)))
54+
}
55+
56+
public override dblClick(options?: UserEventClickOptions): Promise<void> {
57+
return super.dblClick(processTimeoutOptions(processClickOptions(options)))
58+
}
59+
60+
public override tripleClick(options?: UserEventClickOptions): Promise<void> {
61+
return super.tripleClick(processTimeoutOptions(processClickOptions(options)))
62+
}
63+
64+
public override selectOptions(
65+
value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[],
66+
options?: UserEventSelectOptions,
67+
): Promise<void> {
68+
return super.selectOptions(value, processTimeoutOptions(options))
69+
}
70+
71+
public override clear(options?: UserEventClearOptions): Promise<void> {
72+
return super.clear(processTimeoutOptions(options))
73+
}
74+
75+
public override hover(options?: UserEventHoverOptions): Promise<void> {
76+
return super.hover(processTimeoutOptions(processHoverOptions(options)))
77+
}
78+
79+
public override upload(
80+
files: string | string[] | File | File[],
81+
options?: UserEventUploadOptions,
82+
): Promise<void> {
83+
return super.upload(files, processTimeoutOptions(options))
84+
}
85+
86+
public override fill(text: string, options?: UserEventFillOptions): Promise<void> {
87+
return super.fill(text, processTimeoutOptions(options))
88+
}
89+
90+
public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise<void> {
91+
return super.dropTo(target, processTimeoutOptions(
92+
processDragAndDropOptions(options),
93+
))
94+
}
95+
4996
protected locator(selector: string) {
5097
return new PlaywrightLocator(`${this.selector} >> ${selector}`, this._container)
5198
}
@@ -57,3 +104,59 @@ class PlaywrightLocator extends Locator {
57104
)
58105
}
59106
}
107+
108+
function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) {
109+
// only ui scales the iframe, so we need to adjust the position
110+
if (!options_ || !getBrowserState().config.browser.ui) {
111+
return options_
112+
}
113+
const options = options_ as NonNullable<
114+
Parameters<import('playwright').Page['dragAndDrop']>[2]
115+
>
116+
if (options.sourcePosition) {
117+
options.sourcePosition = processPlaywrightPosition(options.sourcePosition)
118+
}
119+
if (options.targetPosition) {
120+
options.targetPosition = processPlaywrightPosition(options.targetPosition)
121+
}
122+
return options_
123+
}
124+
125+
function processHoverOptions(options_?: UserEventHoverOptions) {
126+
// only ui scales the iframe, so we need to adjust the position
127+
if (!options_ || !getBrowserState().config.browser.ui) {
128+
return options_
129+
}
130+
const options = options_ as NonNullable<
131+
Parameters<import('playwright').Page['hover']>[1]
132+
>
133+
if (options.position) {
134+
options.position = processPlaywrightPosition(options.position)
135+
}
136+
return options_
137+
}
138+
139+
function processClickOptions(options_?: UserEventClickOptions) {
140+
// only ui scales the iframe, so we need to adjust the position
141+
if (!options_ || !getBrowserState().config.browser.ui) {
142+
return options_
143+
}
144+
const options = options_ as NonNullable<
145+
Parameters<import('playwright').Page['click']>[1]
146+
>
147+
if (options.position) {
148+
options.position = processPlaywrightPosition(options.position)
149+
}
150+
return options
151+
}
152+
153+
function processPlaywrightPosition(position: { x: number; y: number }) {
154+
const scale = getIframeScale()
155+
if (position.x != null) {
156+
position.x *= scale
157+
}
158+
if (position.y != null) {
159+
position.y *= scale
160+
}
161+
return position
162+
}

‎packages/browser/src/client/tester/locators/preview.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
getByTextSelector,
99
getByTitleSelector,
1010
} from 'ivya'
11-
import { convertElementToCssSelector } from '../../utils'
1211
import { getElementError } from '../public-utils'
12+
import { convertElementToCssSelector } from '../utils'
1313
import { Locator, selectorEngine } from './index'
1414

1515
page.extend({

‎packages/browser/src/client/tester/locators/webdriverio.ts

+98-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { UserEventClickOptions, UserEventDragAndDropOptions, UserEventHoverOptions, UserEventSelectOptions } from '@vitest/browser/context'
12
import { page, server } from '@vitest/browser/context'
23
import {
34
getByAltTextSelector,
@@ -8,8 +9,9 @@ import {
89
getByTextSelector,
910
getByTitleSelector,
1011
} from 'ivya'
11-
import { convertElementToCssSelector } from '../../utils'
12+
import { getBrowserState } from '../../utils'
1213
import { getElementError } from '../public-utils'
14+
import { convertElementToCssSelector, getIframeScale } from '../utils'
1315
import { Locator, selectorEngine } from './index'
1416

1517
page.extend({
@@ -45,17 +47,40 @@ class WebdriverIOLocator extends Locator {
4547
super()
4648
}
4749

48-
override get selector() {
50+
override get selector(): string {
4951
const selectors = this.elements().map(element => convertElementToCssSelector(element))
5052
if (!selectors.length) {
5153
throw getElementError(this._pwSelector, this._container || document.body)
5254
}
5355
return selectors.join(', ')
5456
}
5557

56-
public selectOptions(value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[]): Promise<void> {
58+
public override click(options?: UserEventClickOptions): Promise<void> {
59+
return super.click(processClickOptions(options))
60+
}
61+
62+
public override dblClick(options?: UserEventClickOptions): Promise<void> {
63+
return super.dblClick(processClickOptions(options))
64+
}
65+
66+
public override tripleClick(options?: UserEventClickOptions): Promise<void> {
67+
return super.tripleClick(processClickOptions(options))
68+
}
69+
70+
public selectOptions(
71+
value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[],
72+
options?: UserEventSelectOptions,
73+
): Promise<void> {
5774
const values = getWebdriverioSelectOptions(this.element(), value)
58-
return this.triggerCommand('__vitest_selectOptions', this.selector, values)
75+
return this.triggerCommand('__vitest_selectOptions', this.selector, values, options)
76+
}
77+
78+
public override hover(options?: UserEventHoverOptions): Promise<void> {
79+
return super.hover(processHoverOptions(options))
80+
}
81+
82+
public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise<void> {
83+
return super.dropTo(target, processDragAndDropOptions(options))
5984
}
6085

6186
protected locator(selector: string) {
@@ -107,3 +132,72 @@ function getWebdriverioSelectOptions(element: Element, value: string | string[]
107132

108133
return [{ index: labelIndex }]
109134
}
135+
136+
function processClickOptions(options_?: UserEventClickOptions) {
137+
// only ui scales the iframe, so we need to adjust the position
138+
if (!options_ || !getBrowserState().config.browser.ui) {
139+
return options_
140+
}
141+
const options = options_ as import('webdriverio').ClickOptions
142+
if (options.x != null || options.y != null) {
143+
const cache = {}
144+
if (options.x != null) {
145+
options.x = scaleCoordinate(options.x, cache)
146+
}
147+
if (options.y != null) {
148+
options.y = scaleCoordinate(options.y, cache)
149+
}
150+
}
151+
return options_
152+
}
153+
154+
function processHoverOptions(options_?: UserEventHoverOptions) {
155+
// only ui scales the iframe, so we need to adjust the position
156+
if (!options_ || !getBrowserState().config.browser.ui) {
157+
return options_
158+
}
159+
const options = options_ as import('webdriverio').MoveToOptions
160+
const cache = {}
161+
if (options.xOffset != null) {
162+
options.xOffset = scaleCoordinate(options.xOffset, cache)
163+
}
164+
if (options.yOffset != null) {
165+
options.yOffset = scaleCoordinate(options.yOffset, cache)
166+
}
167+
return options_
168+
}
169+
170+
function processDragAndDropOptions(options_?: UserEventDragAndDropOptions) {
171+
// only ui scales the iframe, so we need to adjust the position
172+
if (!options_ || !getBrowserState().config.browser.ui) {
173+
return options_
174+
}
175+
const cache = {}
176+
const options = options_ as import('webdriverio').DragAndDropOptions & {
177+
targetX?: number
178+
targetY?: number
179+
sourceX?: number
180+
sourceY?: number
181+
}
182+
if (options.sourceX != null) {
183+
options.sourceX = scaleCoordinate(options.sourceX, cache)
184+
}
185+
if (options.sourceY != null) {
186+
options.sourceY = scaleCoordinate(options.sourceY, cache)
187+
}
188+
if (options.targetX != null) {
189+
options.targetX = scaleCoordinate(options.targetX, cache)
190+
}
191+
if (options.targetY != null) {
192+
options.targetY = scaleCoordinate(options.targetY, cache)
193+
}
194+
return options_
195+
}
196+
197+
function scaleCoordinate(coordinate: number, cache: any) {
198+
return Math.round(coordinate * getCachedScale(cache))
199+
}
200+
201+
function getCachedScale(cache: { scale: number | undefined }) {
202+
return cache.scale ??= getIframeScale()
203+
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { channel, client, onCancel } from '@vitest/browser/client'
22
import { page, server, userEvent } from '@vitest/browser/context'
33
import { collectTests, setupCommonEnv, SpyModule, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser'
4-
import { CommandsManager, executor, getBrowserState, getConfig, getWorkerState } from '../utils'
4+
import { executor, getBrowserState, getConfig, getWorkerState } from '../utils'
55
import { setupDialogsSpy } from './dialog'
66
import { setupExpectDom } from './expect-element'
77
import { setupConsoleLogSpy } from './logger'
88
import { VitestBrowserClientMocker } from './mocker'
99
import { createModuleMockerInterceptor } from './msw'
1010
import { createSafeRpc } from './rpc'
1111
import { browserHashMap, initiateRunner } from './runner'
12+
import { CommandsManager } from './utils'
1213

1314
const cleanupSymbol = Symbol.for('vitest:component-cleanup')
1415

+180
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type { BrowserRPC } from '../client'
2+
import { getBrowserState, getWorkerState } from '../utils'
3+
4+
const provider = getBrowserState().provider
5+
6+
/* @__NO_SIDE_EFFECTS__ */
7+
export function convertElementToCssSelector(element: Element): string {
8+
if (!element || !(element instanceof Element)) {
9+
throw new Error(
10+
`Expected DOM element to be an instance of Element, received ${typeof element}`,
11+
)
12+
}
13+
14+
return getUniqueCssSelector(element)
15+
}
16+
17+
function escapeIdForCSSSelector(id: string) {
18+
return id
19+
.split('')
20+
.map((char) => {
21+
const code = char.charCodeAt(0)
22+
23+
if (char === ' ' || char === '#' || char === '.' || char === ':' || char === '[' || char === ']' || char === '>' || char === '+' || char === '~' || char === '\\') {
24+
// Escape common special characters with backslashes
25+
return `\\${char}`
26+
}
27+
else if (code >= 0x10000) {
28+
// Unicode escape for characters outside the BMP
29+
return `\\${code.toString(16).toUpperCase().padStart(6, '0')} `
30+
}
31+
else if (code < 0x20 || code === 0x7F) {
32+
// Non-printable ASCII characters (0x00-0x1F and 0x7F) are escaped
33+
return `\\${code.toString(16).toUpperCase().padStart(2, '0')} `
34+
}
35+
else if (code >= 0x80) {
36+
// Non-ASCII characters (0x80 and above) are escaped
37+
return `\\${code.toString(16).toUpperCase().padStart(2, '0')} `
38+
}
39+
else {
40+
// Allowable characters are used directly
41+
return char
42+
}
43+
})
44+
.join('')
45+
}
46+
47+
function getUniqueCssSelector(el: Element) {
48+
const path = []
49+
let parent: null | ParentNode
50+
let hasShadowRoot = false
51+
// eslint-disable-next-line no-cond-assign
52+
while (parent = getParent(el)) {
53+
if ((parent as Element).shadowRoot) {
54+
hasShadowRoot = true
55+
}
56+
57+
const tag = el.tagName
58+
if (el.id) {
59+
path.push(`#${escapeIdForCSSSelector(el.id)}`)
60+
}
61+
else if (!el.nextElementSibling && !el.previousElementSibling) {
62+
path.push(tag.toLowerCase())
63+
}
64+
else {
65+
let index = 0
66+
let sameTagSiblings = 0
67+
let elementIndex = 0
68+
69+
for (const sibling of parent.children) {
70+
index++
71+
if (sibling.tagName === tag) {
72+
sameTagSiblings++
73+
}
74+
if (sibling === el) {
75+
elementIndex = index
76+
}
77+
}
78+
79+
if (sameTagSiblings > 1) {
80+
path.push(`${tag.toLowerCase()}:nth-child(${elementIndex})`)
81+
}
82+
else {
83+
path.push(tag.toLowerCase())
84+
}
85+
}
86+
el = parent as Element
87+
};
88+
return `${getBrowserState().provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}`
89+
}
90+
91+
function getParent(el: Element) {
92+
const parent = el.parentNode
93+
if (parent instanceof ShadowRoot) {
94+
return parent.host
95+
}
96+
return parent
97+
}
98+
99+
export class CommandsManager {
100+
private _listeners: ((command: string, args: any[]) => void)[] = []
101+
102+
public onCommand(listener: (command: string, args: any[]) => void): void {
103+
this._listeners.push(listener)
104+
}
105+
106+
public async triggerCommand<T>(
107+
command: string,
108+
args: any[],
109+
// error makes sure the stack trace is correct on webkit,
110+
// if we make the error here, it looses the context
111+
clientError: Error = new Error('empty'),
112+
): Promise<T> {
113+
const state = getWorkerState()
114+
const rpc = state.rpc as any as BrowserRPC
115+
const { sessionId } = getBrowserState()
116+
const filepath = state.filepath || state.current?.file?.filepath
117+
args = args.filter(arg => arg !== undefined) // remove optional fields
118+
if (this._listeners.length) {
119+
await Promise.all(this._listeners.map(listener => listener(command, args)))
120+
}
121+
return rpc.triggerCommand<T>(sessionId, command, filepath, args).catch((err) => {
122+
// rethrow an error to keep the stack trace in browser
123+
// const clientError = new Error(err.message)
124+
clientError.message = err.message
125+
clientError.name = err.name
126+
clientError.stack = clientError.stack?.replace(clientError.message, err.message)
127+
throw clientError
128+
})
129+
}
130+
}
131+
132+
const now = Date.now
133+
134+
export function processTimeoutOptions<T extends { timeout?: number }>(options_?: T): T | undefined {
135+
if (
136+
// if timeout is set, keep it
137+
(options_ && options_.timeout != null)
138+
// timeout can only be set for playwright commands
139+
|| provider !== 'playwright'
140+
) {
141+
return options_
142+
}
143+
// if there is a default action timeout, use it
144+
if (getWorkerState().config.browser.providerOptions.actionTimeout != null) {
145+
return options_
146+
}
147+
const currentTest = getWorkerState().current
148+
const startTime = currentTest?.result?.startTime
149+
// ignore timeout if this is called outside of a test
150+
if (!currentTest || currentTest.type === 'suite' || !startTime) {
151+
return options_
152+
}
153+
const timeout = currentTest.timeout
154+
if (timeout === 0 || timeout === Number.POSITIVE_INFINITY) {
155+
return options_
156+
}
157+
options_ = options_ || {} as T
158+
const currentTime = now()
159+
const endTime = startTime + timeout
160+
const remainingTime = endTime - currentTime
161+
if (remainingTime <= 0) {
162+
return options_
163+
}
164+
// give us some time to process the timeout
165+
options_.timeout = remainingTime - 100
166+
return options_
167+
}
168+
169+
export function getIframeScale(): number {
170+
const testerUi = window.parent.document.querySelector('#tester-ui') as HTMLElement | null
171+
if (!testerUi) {
172+
throw new Error(`Cannot find Tester element. This is a bug in Vitest. Please, open a new issue with reproduction.`)
173+
}
174+
const scaleAttribute = testerUi.getAttribute('data-scale')
175+
const scale = Number(scaleAttribute)
176+
if (Number.isNaN(scale)) {
177+
throw new TypeError(`Cannot parse scale value from Tester element (${scaleAttribute}). This is a bug in Vitest. Please, open a new issue with reproduction.`)
178+
}
179+
return scale
180+
}

‎packages/browser/src/client/utils.ts

+5-117
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { SerializedConfig, WorkerGlobalState } from 'vitest'
2-
import type { BrowserRPC } from './client'
2+
import type { CommandsManager } from './tester/utils'
33

44
export async function importId(id: string): Promise<any> {
55
const name = `/@id/${id}`.replace(/\\/g, '/')
@@ -26,7 +26,7 @@ export function getConfig(): SerializedConfig {
2626
return getBrowserState().config
2727
}
2828

29-
export function ensureAwaited<T>(promise: () => Promise<T>): Promise<T> {
29+
export function ensureAwaited<T>(promise: (error?: Error) => Promise<T>): Promise<T> {
3030
const test = getWorkerState().current
3131
if (!test || test.type !== 'test') {
3232
return promise()
@@ -48,13 +48,13 @@ export function ensureAwaited<T>(promise: () => Promise<T>): Promise<T> {
4848
return {
4949
then(onFulfilled, onRejected) {
5050
awaited = true
51-
return (promiseResult ||= promise()).then(onFulfilled, onRejected)
51+
return (promiseResult ||= promise(sourceError)).then(onFulfilled, onRejected)
5252
},
5353
catch(onRejected) {
54-
return (promiseResult ||= promise()).catch(onRejected)
54+
return (promiseResult ||= promise(sourceError)).catch(onRejected)
5555
},
5656
finally(onFinally) {
57-
return (promiseResult ||= promise()).finally(onFinally)
57+
return (promiseResult ||= promise(sourceError)).finally(onFinally)
5858
},
5959
[Symbol.toStringTag]: 'Promise',
6060
} satisfies Promise<T>
@@ -103,115 +103,3 @@ export function getWorkerState(): WorkerGlobalState {
103103
}
104104
return state
105105
}
106-
107-
/* @__NO_SIDE_EFFECTS__ */
108-
export function convertElementToCssSelector(element: Element): string {
109-
if (!element || !(element instanceof Element)) {
110-
throw new Error(
111-
`Expected DOM element to be an instance of Element, received ${typeof element}`,
112-
)
113-
}
114-
115-
return getUniqueCssSelector(element)
116-
}
117-
118-
function escapeIdForCSSSelector(id: string) {
119-
return id
120-
.split('')
121-
.map((char) => {
122-
const code = char.charCodeAt(0)
123-
124-
if (char === ' ' || char === '#' || char === '.' || char === ':' || char === '[' || char === ']' || char === '>' || char === '+' || char === '~' || char === '\\') {
125-
// Escape common special characters with backslashes
126-
return `\\${char}`
127-
}
128-
else if (code >= 0x10000) {
129-
// Unicode escape for characters outside the BMP
130-
return `\\${code.toString(16).toUpperCase().padStart(6, '0')} `
131-
}
132-
else if (code < 0x20 || code === 0x7F) {
133-
// Non-printable ASCII characters (0x00-0x1F and 0x7F) are escaped
134-
return `\\${code.toString(16).toUpperCase().padStart(2, '0')} `
135-
}
136-
else if (code >= 0x80) {
137-
// Non-ASCII characters (0x80 and above) are escaped
138-
return `\\${code.toString(16).toUpperCase().padStart(2, '0')} `
139-
}
140-
else {
141-
// Allowable characters are used directly
142-
return char
143-
}
144-
})
145-
.join('')
146-
}
147-
148-
function getUniqueCssSelector(el: Element) {
149-
const path = []
150-
let parent: null | ParentNode
151-
let hasShadowRoot = false
152-
// eslint-disable-next-line no-cond-assign
153-
while (parent = getParent(el)) {
154-
if ((parent as Element).shadowRoot) {
155-
hasShadowRoot = true
156-
}
157-
158-
const tag = el.tagName
159-
if (el.id) {
160-
path.push(`#${escapeIdForCSSSelector(el.id)}`)
161-
}
162-
else if (!el.nextElementSibling && !el.previousElementSibling) {
163-
path.push(tag.toLowerCase())
164-
}
165-
else {
166-
let index = 0
167-
let sameTagSiblings = 0
168-
let elementIndex = 0
169-
170-
for (const sibling of parent.children) {
171-
index++
172-
if (sibling.tagName === tag) {
173-
sameTagSiblings++
174-
}
175-
if (sibling === el) {
176-
elementIndex = index
177-
}
178-
}
179-
180-
if (sameTagSiblings > 1) {
181-
path.push(`${tag.toLowerCase()}:nth-child(${elementIndex})`)
182-
}
183-
else {
184-
path.push(tag.toLowerCase())
185-
}
186-
}
187-
el = parent as Element
188-
};
189-
return `${getBrowserState().provider === 'webdriverio' && hasShadowRoot ? '>>>' : ''}${path.reverse().join(' > ')}`
190-
}
191-
192-
function getParent(el: Element) {
193-
const parent = el.parentNode
194-
if (parent instanceof ShadowRoot) {
195-
return parent.host
196-
}
197-
return parent
198-
}
199-
200-
export class CommandsManager {
201-
private _listeners: ((command: string, args: any[]) => void)[] = []
202-
203-
public onCommand(listener: (command: string, args: any[]) => void): void {
204-
this._listeners.push(listener)
205-
}
206-
207-
public async triggerCommand<T>(command: string, args: any[]): Promise<T> {
208-
const state = getWorkerState()
209-
const rpc = state.rpc as any as BrowserRPC
210-
const { sessionId } = getBrowserState()
211-
const filepath = state.filepath || state.current?.file?.filepath
212-
if (this._listeners.length) {
213-
await Promise.all(this._listeners.map(listener => listener(command, args)))
214-
}
215-
return rpc.triggerCommand<T>(sessionId, command, filepath, args)
216-
}
217-
}

‎packages/browser/src/node/commands/click.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const tripleClick: UserEventCommand<UserEvent['tripleClick']> = async (
5959
await browser
6060
.action('pointer', { parameters: { pointerType: 'mouse' } })
6161
// move the pointer over the button
62-
.move({ origin: await browser.$(selector) })
62+
.move({ origin: browser.$(selector) })
6363
// simulate 3 clicks
6464
.down()
6565
.up()

‎packages/browser/src/node/commands/fill.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ export const fill: UserEventCommand<UserEvent['fill']> = async (
1919
await browser.$(selector).setValue(text)
2020
}
2121
else {
22-
throw new TypeError(`Provider "${context.provider.name}" does not support clearing elements`)
22+
throw new TypeError(`Provider "${context.provider.name}" does not support filling inputs`)
2323
}
2424
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ function focusIframe() {
202202

203203
function selectAll() {
204204
const element = document.activeElement as HTMLInputElement
205-
if (element && element.select) {
205+
if (element && typeof element.select === 'function') {
206206
element.select()
207207
}
208208
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const type: UserEventCommand<UserEvent['type']> = async (
4545
text,
4646
() => browser.execute(() => {
4747
const element = document.activeElement as HTMLInputElement
48-
if (element) {
48+
if (element && typeof element.select === 'function') {
4949
element.select()
5050
}
5151
}),

‎packages/runner/src/suite.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ function createSuiteCollector(
303303
initSuite(true)
304304

305305
const task = function (name = '', options: TaskCustomOptions = {}) {
306+
const timeout = options?.timeout ?? runner.config.testTimeout
306307
const task: Test = {
307308
id: '',
308309
name,
@@ -312,6 +313,7 @@ function createSuiteCollector(
312313
context: undefined!,
313314
type: 'test',
314315
file: undefined!,
316+
timeout,
315317
retry: options.retry ?? runner.config.retry,
316318
repeats: options.repeats,
317319
mode: options.only
@@ -345,7 +347,7 @@ function createSuiteCollector(
345347
task,
346348
withTimeout(
347349
withAwaitAsyncAssertions(withFixtures(handler, context), task),
348-
options?.timeout ?? runner.config.testTimeout,
350+
timeout,
349351
),
350352
)
351353
}

‎packages/runner/src/types/tasks.ts

+4
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ export interface Test<ExtraContext = object> extends TaskPopulated {
247247
* Test context that will be passed to the test function.
248248
*/
249249
context: TestContext & ExtraContext
250+
/**
251+
* The test timeout in milliseconds.
252+
*/
253+
timeout: number
250254
}
251255

252256
/**

‎packages/vitest/src/integrations/chai/poll.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
9191
const rejectWithCause = (cause: any) => {
9292
reject(
9393
copyStackTrace(
94-
new Error(`Matcher did not succeed in ${timeout}ms`, {
94+
new Error('Matcher did not succeed in time.', {
9595
cause,
9696
}),
9797
STACK_TRACE_ERROR,

‎packages/vitest/src/node/config/serializeConfig.ts

+5
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ export function serializeConfig(
156156
locators: {
157157
testIdAttribute: browser.locators.testIdAttribute,
158158
},
159+
providerOptions: browser.provider === 'playwright'
160+
? {
161+
actionTimeout: (browser.providerOptions as any)?.context?.actionTimeout,
162+
}
163+
: {},
159164
}
160165
})(config.browser),
161166
standalone: config.standalone,

‎packages/vitest/src/node/error.ts

+3
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,9 @@ const skipErrorProperties = new Set([
264264
'actual',
265265
'expected',
266266
'diffOptions',
267+
'sourceURL',
268+
'column',
269+
'line',
267270
'VITEST_TEST_NAME',
268271
'VITEST_TEST_PATH',
269272
'VITEST_AFTER_ENV_TEARDOWN',

‎packages/vitest/src/node/reporters/junit.ts

+1
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ export class JUnitReporter implements Reporter {
317317
mode: 'run',
318318
result: file.result,
319319
meta: {},
320+
timeout: 0,
320321
// NOTE: not used in JUnitReporter
321322
context: null as any,
322323
suite: null as any,

‎packages/vitest/src/runtime/config.ts

+4
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ export interface SerializedConfig {
126126
testIdAttribute: string
127127
}
128128
screenshotFailures: boolean
129+
providerOptions: {
130+
// for playwright
131+
actionTimeout?: number
132+
}
129133
}
130134
standalone: boolean
131135
logHeapUsage: boolean | undefined

‎packages/vitest/src/typecheck/collect.ts

+1
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ export async function collectTests(
202202
suite: latestSuite,
203203
file,
204204
mode,
205+
timeout: 0,
205206
context: {} as any, // not used in typecheck
206207
name: definition.name,
207208
end: definition.end,

‎test/browser/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"test:safaridriver": "PROVIDER=webdriverio BROWSER=safari pnpm run test:unit",
1111
"test-fixtures": "vitest",
1212
"test-mocking": "vitest --root ./fixtures/mocking",
13+
"test-timeout": "vitest --root ./fixtures/timeout",
1314
"test-mocking-watch": "vitest --root ./fixtures/mocking-watch",
1415
"test-locators": "vitest --root ./fixtures/locators",
1516
"test-different-configs": "vitest --root ./fixtures/multiple-different-configs",

‎test/browser/specs/runner.test.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,20 @@ error with a stack
175175

176176
expect(stderr).toMatch(/bundled-lib\/src\/b.js:2:(8|18)/)
177177
expect(stderr).toMatch(/bundled-lib\/src\/index.js:5:(15|17)/)
178-
expect(stderr).toMatch(/test\/failing.test.ts:25:(2|8)/)
178+
179+
if (provider === 'playwright') {
180+
// page.getByRole('code').click()
181+
expect(stderr).toContain('locator.click: Timeout')
182+
// playwright error is proxied from the server to the client and back correctly
183+
expect(stderr).toContain('waiting for locator(\'[data-vitest="true"]\').contentFrame().getByRole(\'code\')')
184+
expect(stderr).toMatch(/test\/failing.test.ts:27:(33|39)/)
185+
// await expect.element().toBeVisible()
186+
expect(stderr).toContain('Cannot find element with locator: getByRole(\'code\')')
187+
expect(stderr).toMatch(/test\/failing.test.ts:31:(49|61)/)
188+
}
189+
190+
// index() is called from a bundled file
191+
expect(stderr).toMatch(/test\/failing.test.ts:36:(2|8)/)
179192
})
180193

181194
test('popup apis should log a warning', () => {
@@ -210,7 +223,7 @@ test('timeout', async () => {
210223
const { stderr } = await runBrowserTests({
211224
root: './fixtures/timeout',
212225
})
213-
expect(stderr).toContain('Matcher did not succeed in 500ms')
226+
expect(stderr).toContain('Matcher did not succeed in time.')
214227
if (provider === 'playwright') {
215228
expect(stderr).toContain('locator.click: Timeout 500ms exceeded.')
216229
expect(stderr).toContain('locator.click: Timeout 345ms exceeded.')

‎test/browser/test/failing.test.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { page } from '@vitest/browser/context'
1+
import { page, server } from '@vitest/browser/context'
22
import { index } from '@vitest/bundled-lib'
3-
import { expect, it } from 'vitest'
3+
import { describe, expect, it } from 'vitest'
44
import { throwError } from '../src/error'
55

66
document.body.innerHTML = `
@@ -21,6 +21,17 @@ it('several locator methods are not awaited', () => {
2121
page.getByRole('button').tripleClick()
2222
})
2323

24+
describe.runIf(server.provider === 'playwright')('timeouts are failing correctly', () => {
25+
it('click on non-existing element fails', async () => {
26+
await new Promise(r => setTimeout(r, 100))
27+
await page.getByRole('code').click()
28+
}, 1000)
29+
30+
it('expect.element on non-existing element fails', async () => {
31+
await expect.element(page.getByRole('code')).toBeVisible()
32+
}, 1000)
33+
})
34+
2435
it('correctly prints error from a bundled file', () => {
2536
index()
2637
})

‎test/cli/fixtures/custom-pool/pool/custom-pool.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export default (vitest: Vitest): ProcessPool => {
4242
suite: taskFile,
4343
mode: 'run',
4444
meta: {},
45+
timeout: 0,
4546
file: taskFile,
4647
result: {
4748
state: 'pass',

‎test/core/test/expect-poll.test.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ test('timeout', async () => {
3737
await expect(async () => {
3838
await expect.poll(() => false, { timeout: 100, interval: 10 }).toBe(true)
3939
}).rejects.toThrowError(expect.objectContaining({
40-
message: 'Matcher did not succeed in 100ms',
40+
message: 'Matcher did not succeed in time.',
4141
stack: expect.stringContaining('expect-poll.test.ts:38:68'),
4242
cause: expect.objectContaining({
4343
message: 'expected false to be true // Object.is equality',
@@ -60,7 +60,7 @@ test('fake timers don\'t break it', async () => {
6060
vi.useFakeTimers()
6161
await expect(async () => {
6262
await expect.poll(() => false, { timeout: 100 }).toBe(true)
63-
}).rejects.toThrowError('Matcher did not succeed in 100ms')
63+
}).rejects.toThrowError('Matcher did not succeed in time.')
6464
vi.useRealTimers()
6565
const diff = Date.now() - now
6666
expect(diff >= 100).toBe(true)
@@ -91,7 +91,7 @@ test('toBeDefined', async () => {
9191
await expect(() =>
9292
expect.poll(() => 1, { timeout: 100, interval: 10 }).not.toBeDefined(),
9393
).rejects.toThrowError(expect.objectContaining({
94-
message: 'Matcher did not succeed in 100ms',
94+
message: 'Matcher did not succeed in time.',
9595
cause: expect.objectContaining({
9696
message: 'expected 1 to be undefined',
9797
}),
@@ -100,7 +100,7 @@ test('toBeDefined', async () => {
100100
await expect(() =>
101101
expect.poll(() => undefined, { timeout: 100, interval: 10 }).toBeDefined(),
102102
).rejects.toThrowError(expect.objectContaining({
103-
message: 'Matcher did not succeed in 100ms',
103+
message: 'Matcher did not succeed in time.',
104104
cause: expect.objectContaining({
105105
message: 'expected undefined to be defined',
106106
}),
@@ -140,7 +140,7 @@ test('should handle failure on last attempt', async () => {
140140
await expect(async () => {
141141
await expect.poll(fn, { interval: 10, timeout: 100 }).toBe(1)
142142
}).rejects.toThrowError(expect.objectContaining({
143-
message: 'Matcher did not succeed in 100ms',
143+
message: 'Matcher did not succeed in time.',
144144
cause: expect.objectContaining({
145145
// makes sure cause message reflects the last attempt value
146146
message: 'expected 3 to be 1 // Object.is equality',

‎test/reporters/src/data.ts

+10
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ passedFile.tasks.push({
4949
suite,
5050
meta: {},
5151
file: passedFile,
52+
timeout: 0,
5253
result: {
5354
state: 'pass',
5455
duration: 1.4422860145568848,
@@ -95,6 +96,7 @@ const tasks: Task[] = [
9596
column: 32,
9697
line: 8,
9798
},
99+
timeout: 0,
98100
context: null as any,
99101
},
100102
{
@@ -104,6 +106,7 @@ const tasks: Task[] = [
104106
mode: 'run',
105107
suite,
106108
fails: undefined,
109+
timeout: 0,
107110
meta: {},
108111
file,
109112
result: { state: 'pass', duration: 1.0237109661102295 },
@@ -117,6 +120,7 @@ const tasks: Task[] = [
117120
suite,
118121
fails: undefined,
119122
meta: {},
123+
timeout: 0,
120124
file,
121125
result: undefined,
122126
context: null as any,
@@ -129,6 +133,7 @@ const tasks: Task[] = [
129133
suite,
130134
fails: undefined,
131135
meta: {},
136+
timeout: 0,
132137
file,
133138
result: { state: 'pass', duration: 100.50598406791687 },
134139
context: null as any,
@@ -141,6 +146,7 @@ const tasks: Task[] = [
141146
suite,
142147
fails: undefined,
143148
meta: {},
149+
timeout: 0,
144150
file,
145151
result: { state: 'pass', duration: 20.184875011444092 },
146152
context: null as any,
@@ -153,6 +159,7 @@ const tasks: Task[] = [
153159
suite,
154160
fails: undefined,
155161
meta: {},
162+
timeout: 0,
156163
file,
157164
result: { state: 'pass', duration: 0.33245420455932617 },
158165
context: null as any,
@@ -165,6 +172,7 @@ const tasks: Task[] = [
165172
suite,
166173
fails: undefined,
167174
meta: {},
175+
timeout: 0,
168176
file,
169177
result: { state: 'pass', duration: 19.738605976104736 },
170178
context: null as any,
@@ -177,6 +185,7 @@ const tasks: Task[] = [
177185
suite,
178186
fails: undefined,
179187
meta: {},
188+
timeout: 0,
180189
file,
181190
result: { state: 'pass', duration: 0.1923508644104004 },
182191
context: null as any,
@@ -195,6 +204,7 @@ const tasks: Task[] = [
195204
name: 'todo test',
196205
mode: 'todo',
197206
suite,
207+
timeout: 0,
198208
fails: undefined,
199209
meta: {},
200210
file,

‎test/reporters/tests/__snapshots__/html.test.ts.snap

+3
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
6868
"startTime": 0,
6969
"state": "fail",
7070
},
71+
"timeout": 5000,
7172
"type": "test",
7273
},
7374
],
@@ -148,6 +149,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
148149
"startTime": 0,
149150
"state": "pass",
150151
},
152+
"timeout": 5000,
151153
"type": "test",
152154
},
153155
{
@@ -160,6 +162,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
160162
"meta": {},
161163
"mode": "skip",
162164
"name": "3 + 3 = 6",
165+
"timeout": 5000,
163166
"type": "test",
164167
},
165168
],

‎test/reporters/tests/junit.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ test('calc the duration used by junit', () => {
3636
mode: 'run',
3737
result,
3838
file,
39+
timeout: 0,
3940
context: null as any,
4041
suite,
4142
meta: {},

0 commit comments

Comments
 (0)
Please sign in to comment.