Skip to content

Commit dc10f5f

Browse files
authored
fix(browser): report the action error when a task times out (#11101)
1 parent 602d215 commit dc10f5f

14 files changed

Lines changed: 526 additions & 184 deletions

File tree

packages/browser-playwright/src/locators.ts

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import type {
2-
UserEventClearOptions,
32
UserEventClickOptions,
43
UserEventDragAndDropOptions,
5-
UserEventFillOptions,
64
UserEventHoverOptions,
7-
UserEventSelectOptions,
8-
UserEventUploadOptions,
95
} from 'vitest/browser'
106
import {
117
getByAltTextSelector,
@@ -17,7 +13,6 @@ import {
1713
getByTitleSelector,
1814
getIframeScale,
1915
Locator,
20-
processTimeoutOptions,
2116
selectorEngine,
2217
} from '@vitest/browser/locators'
2318
import { page, server } from 'vitest/browser'
@@ -29,47 +24,23 @@ class PlaywrightLocator extends Locator {
2924
}
3025

3126
public override click(options?: UserEventClickOptions) {
32-
return super.click(processTimeoutOptions(processClickOptions(options)))
27+
return super.click(processClickOptions(options))
3328
}
3429

3530
public override dblClick(options?: UserEventClickOptions): Promise<void> {
36-
return super.dblClick(processTimeoutOptions(processClickOptions(options)))
31+
return super.dblClick(processClickOptions(options))
3732
}
3833

3934
public override tripleClick(options?: UserEventClickOptions): Promise<void> {
40-
return super.tripleClick(processTimeoutOptions(processClickOptions(options)))
41-
}
42-
43-
public override selectOptions(
44-
value: HTMLElement | HTMLElement[] | Locator | Locator[] | string | string[],
45-
options?: UserEventSelectOptions,
46-
): Promise<void> {
47-
return super.selectOptions(value, processTimeoutOptions(options))
48-
}
49-
50-
public override clear(options?: UserEventClearOptions): Promise<void> {
51-
return super.clear(processTimeoutOptions(options))
35+
return super.tripleClick(processClickOptions(options))
5236
}
5337

5438
public override hover(options?: UserEventHoverOptions): Promise<void> {
55-
return super.hover(processTimeoutOptions(processHoverOptions(options)))
56-
}
57-
58-
public override upload(
59-
files: string | string[] | File | File[],
60-
options?: UserEventUploadOptions,
61-
): Promise<void> {
62-
return super.upload(files, processTimeoutOptions(options))
63-
}
64-
65-
public override fill(text: string, options?: UserEventFillOptions): Promise<void> {
66-
return super.fill(text, processTimeoutOptions(options))
39+
return super.hover(processHoverOptions(options))
6740
}
6841

6942
public override dropTo(target: Locator, options?: UserEventDragAndDropOptions): Promise<void> {
70-
return super.dropTo(target, processTimeoutOptions(
71-
processDragAndDropOptions(options),
72-
))
43+
return super.dropTo(target, processDragAndDropOptions(options))
7344
}
7445

7546
protected locator(selector: string) {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type { SerializedLocator } from './locators'
2+
import { getBrowserState, getWorkerState } from '../utils'
3+
4+
export interface ActionOptions {
5+
timeout?: number
6+
}
7+
8+
// an array gets the options appended; a factory receives them and builds the full argument list
9+
type ActionArguments = unknown[] | ((options: ActionOptions | undefined) => Promise<unknown[]>)
10+
11+
/** explicit option, then the provider default, then the remaining task time */
12+
export function resolveActionTimeout(options?: ActionOptions): number | undefined {
13+
if (options?.timeout != null) {
14+
return options.timeout
15+
}
16+
if (getWorkerState().config.browser.providerOptions.actionTimeout != null) {
17+
return undefined
18+
}
19+
return getBrowserState().runner._deadline?.derive()
20+
}
21+
22+
/**
23+
* @deprecated the timeout is derived by the action itself; pass the options through unchanged
24+
*/
25+
export function processTimeoutOptions<T extends { timeout?: number }>(options?: T): T | undefined {
26+
const timeout = resolveActionTimeout(options)
27+
if (timeout == null) {
28+
return options
29+
}
30+
return { ...options, timeout } as T
31+
}
32+
33+
/**
34+
* A browser command that the test awaits. The action derives its own timeout
35+
* from the running task, so it fails with a descriptive error before the task does.
36+
*/
37+
class Action<T = void> implements Promise<T> {
38+
public readonly [Symbol.toStringTag] = 'Action'
39+
readonly #command: string
40+
readonly #args: ActionArguments
41+
readonly #options: ActionOptions | undefined
42+
readonly #errorSource: Error
43+
#promise: Promise<T> | undefined
44+
#awaited = false
45+
46+
constructor(
47+
command: string,
48+
args: ActionArguments,
49+
options: ActionOptions | undefined,
50+
errorSource?: Error,
51+
) {
52+
this.#command = command
53+
this.#args = args
54+
this.#options = options
55+
this.#errorSource = errorSource ?? new Error('STACK_TRACE_ERROR')
56+
const test = getWorkerState().current
57+
if (errorSource || !test || test.type !== 'test') {
58+
this.#promise = this.#run()
59+
return
60+
}
61+
test.onFinished ??= []
62+
test.onFinished.push(() => {
63+
if (!this.#awaited) {
64+
const error = new Error(
65+
`The call was not awaited. This method is asynchronous and must be awaited; otherwise, the call will not start to avoid unhandled rejections.`,
66+
)
67+
error.stack = this.#errorSource.stack?.replace(this.#errorSource.message, error.message)
68+
throw error
69+
}
70+
})
71+
}
72+
73+
async #run(): Promise<T> {
74+
const timeout = resolveActionTimeout(this.#options)
75+
const options = timeout == null ? this.#options : { ...this.#options, timeout }
76+
const args = typeof this.#args === 'function'
77+
? await this.#args(options)
78+
: [...this.#args, options]
79+
const promise = getBrowserState().commands.triggerCommand<T>(
80+
this.#command,
81+
args,
82+
this.#errorSource,
83+
)
84+
const deadline = getBrowserState().runner._deadline
85+
return deadline && timeout != null
86+
? deadline.track(this.#command.slice('__vitest_'.length), promise, timeout, this.#errorSource)
87+
: promise
88+
}
89+
90+
// the command starts only when awaited, so an unawaited action cannot reject unhandled
91+
#start(): Promise<T> {
92+
this.#awaited = true
93+
return this.#promise ??= this.#run()
94+
}
95+
96+
then<R1 = T, R2 = never>(
97+
onFulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null,
98+
onRejected?: ((reason: any) => R2 | PromiseLike<R2>) | null,
99+
): Promise<R1 | R2> {
100+
return this.#start().then(onFulfilled, onRejected)
101+
}
102+
103+
catch<R = never>(onRejected?: ((reason: any) => R | PromiseLike<R>) | null): Promise<T | R> {
104+
return this.#start().catch(onRejected)
105+
}
106+
107+
finally(onFinally?: (() => void) | null): Promise<T> {
108+
return this.#start().finally(onFinally)
109+
}
110+
}
111+
112+
export class LocatorAction<T = void> extends Action<T> {
113+
constructor(
114+
target: SerializedLocator,
115+
command: string,
116+
args: unknown[],
117+
options?: ActionOptions,
118+
errorSource?: Error,
119+
) {
120+
super(command, [target, ...args], options, errorSource)
121+
}
122+
}
123+
124+
export class UploadAction extends Action {
125+
constructor(
126+
target: SerializedLocator,
127+
files: string | string[] | File | File[],
128+
options?: ActionOptions,
129+
errorSource?: Error,
130+
) {
131+
super('__vitest_upload', async options => [target, await readFiles(files), options], options, errorSource)
132+
}
133+
}
134+
135+
export class ScreenshotAction<T> extends Action<T> {
136+
constructor(
137+
name: string,
138+
options: ActionOptions,
139+
serialize: () => Promise<Record<string, unknown>>,
140+
) {
141+
super('__vitest_screenshot', async options => [name, { ...options, ...await serialize() }], options)
142+
}
143+
}
144+
145+
function readFiles(files: string | string[] | File | File[]): Promise<(string | { name: string; mimeType: string; base64: string })[]> {
146+
return Promise.all((Array.isArray(files) ? files : [files]).map(async (file) => {
147+
if (typeof file === 'string') {
148+
return file
149+
}
150+
const bas64String = await new Promise<string>((resolve, reject) => {
151+
const reader = new FileReader()
152+
reader.onload = () => resolve(reader.result as string)
153+
reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`))
154+
reader.readAsDataURL(file)
155+
})
156+
157+
return {
158+
name: file.name,
159+
mimeType: file.type,
160+
// strip prefix `data:[<media-type>][;base64],`
161+
base64: bas64String.slice(bas64String.indexOf(',') + 1),
162+
}
163+
}))
164+
}

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

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ import type { BrowserTraceEntryStatus } from './trace'
2020
import { vi } from 'vitest'
2121
import { __INTERNAL, stringify } from 'vitest/internal/browser'
2222
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
23-
import { isLocator, processTimeoutOptions, resolveUserEventWheelOptions, serializeElement } from './tester-utils'
23+
import { ScreenshotAction } from './action'
24+
import { isLocator, resolveUserEventWheelOptions, serializeElement } from './tester-utils'
2425
import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace'
2526

2627
// this file should not import anything directly, only types and utils
@@ -309,7 +310,7 @@ export const page: BrowserPage = {
309310
})
310311
})
311312
},
312-
async screenshot(options = {}) {
313+
screenshot(options = {}) {
313314
const currentTest = getWorkerState().current
314315
if (!currentTest) {
315316
throw new Error('Cannot take a screenshot outside of a test.')
@@ -333,28 +334,15 @@ export const page: BrowserPage = {
333334
const name
334335
= options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png`
335336

336-
const [element, ...mask] = await Promise.all([
337-
options.element ? serializeElement(options.element, options) : undefined,
338-
...('mask' in options
339-
? (options.mask as Array<Element | Locator>).map(el => serializeElement(el, options))
340-
: []),
341-
])
342-
343-
const normalizedOptions = 'mask' in options
344-
? { ...options, mask }
345-
: options
346-
347-
return ensureAwaited(error => triggerCommand(
348-
'__vitest_screenshot',
349-
[
350-
name,
351-
processTimeoutOptions({
352-
...normalizedOptions,
353-
element,
354-
} as any /** TODO */),
355-
],
356-
error,
357-
))
337+
return new ScreenshotAction(name, options, async () => {
338+
const [element, ...mask] = await Promise.all([
339+
options.element ? serializeElement(options.element, options) : undefined,
340+
...('mask' in options
341+
? (options.mask as Array<Element | Locator>).map(el => serializeElement(el, options))
342+
: []),
343+
])
344+
return 'mask' in options ? { element, mask } : { element }
345+
}) as any /** TODO */
358346
},
359347
mark<T>(
360348
name: string,

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import type { BrowserTraceEntryStatus } from './trace'
44
import { chai, expect } from 'vitest'
55
import { getType } from 'vitest/internal/browser'
66
import { getBrowserState, getWorkerState, now } from '../utils'
7+
import { resolveActionTimeout } from './action'
78
import { ariaMatchers } from './aria'
89
import { matchers } from './expect'
9-
import { processTimeoutOptions } from './tester-utils'
1010
import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace'
1111

1212
const kLocator = Symbol.for('$$vitest:locator')
@@ -16,7 +16,8 @@ function element<T extends HTMLElement | SVGElement | null | Locator>(elementOrL
1616
throw new Error(`Invalid element or locator: ${elementOrLocator}. Expected an instance of HTMLElement, SVGElement or Locator, received ${getType(elementOrLocator)}`)
1717
}
1818

19-
const pollOptions = processTimeoutOptions(options)
19+
const timeout = resolveActionTimeout(options)
20+
const pollOptions = timeout == null ? options : { ...options, timeout }
2021
const deadline = pollOptions?.timeout ? now() + pollOptions.timeout : undefined
2122
const expectElement = expect.poll(async function element(this: object): Promise<HTMLElement | SVGElement | null> {
2223
if (elementOrLocator instanceof Element || elementOrLocator == null) {
@@ -42,6 +43,12 @@ function element<T extends HTMLElement | SVGElement | null | Locator>(elementOrL
4243
}, pollOptions)
4344

4445
chai.util.flag(expectElement, '_poll.element', true)
46+
if (timeout != null) {
47+
chai.util.flag(expectElement, '_poll.wrap', (promise: Promise<void>, source: Error) => {
48+
const name = `expect.element().${chai.util.flag(expectElement, '_name')}()`
49+
return getBrowserState().runner._deadline?.track(name, promise, timeout, source) ?? promise
50+
})
51+
}
4552

4653
// ask `expect.poll` to invoke trace after the assertion
4754
const currentTest = getWorkerState().current

0 commit comments

Comments
 (0)
Sponsor
SponsoredKunjungi sekarang
Promo