Skip to content

Commit 93b67c2

Browse files
authored
fix: throw an error and a warning if .poll, .element, .rejects/.resolves, and locator.* weren't awaited (#6877)
1 parent 9a0c93d commit 93b67c2

File tree

24 files changed

+417
-99
lines changed

24 files changed

+417
-99
lines changed

docs/api/expect.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ test('element exists', async () => {
8282
```
8383

8484
::: warning
85-
`expect.poll` makes every assertion asynchronous, so do not forget to await it otherwise you might get unhandled promise rejections.
85+
`expect.poll` makes every assertion asynchronous, so you need to await it. Since Vitest 2.2, if you forget to await it, the test will fail with a warning to do so.
8686

8787
`expect.poll` doesn't work with several matchers:
8888

@@ -1185,6 +1185,8 @@ test('buyApples returns new stock id', async () => {
11851185

11861186
:::warning
11871187
If the assertion is not awaited, then you will have a false-positive test that will pass every time. To make sure that assertions are actually called, you may use [`expect.assertions(number)`](#expect-assertions).
1188+
1189+
Since Vitest 2.2, if a method is not awaited, Vitest will show a warning at the end of the test. In Vitest 3, the test will be marked as "failed" if the assertion is not awaited.
11881190
:::
11891191

11901192
## rejects
@@ -1214,6 +1216,8 @@ test('buyApples throws an error when no id provided', async () => {
12141216

12151217
:::warning
12161218
If the assertion is not awaited, then you will have a false-positive test that will pass every time. To make sure that assertions were actually called, you can use [`expect.assertions(number)`](#expect-assertions).
1219+
1220+
Since Vitest 2.2, if a method is not awaited, Vitest will show a warning at the end of the test. In Vitest 3, the test will be marked as "failed" if the assertion is not awaited.
12171221
:::
12181222

12191223
## expect.assertions

docs/guide/browser/locators.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ It is recommended to use this only after the other locators don't work for your
389389

390390
## Methods
391391

392+
All methods are asynchronous and must be awaited. Since Vitest 2.2, tests will fail if a method is not awaited.
393+
392394
### click
393395

394396
```ts

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

Lines changed: 44 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
UserEventTabOptions,
1212
UserEventTypeOptions,
1313
} from '../../../context'
14-
import { convertElementToCssSelector, getBrowserState, getWorkerState } from '../utils'
14+
import { convertElementToCssSelector, ensureAwaited, getBrowserState, getWorkerState } from '../utils'
1515

1616
// this file should not import anything directly, only types and utils
1717

@@ -40,12 +40,14 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
4040
return createUserEvent(__tl_user_event_base__, options)
4141
},
4242
async cleanup() {
43-
if (typeof __tl_user_event_base__ !== 'undefined') {
44-
__tl_user_event__ = __tl_user_event_base__?.setup(options ?? {})
45-
return
46-
}
47-
await triggerCommand('__vitest_cleanup', keyboard)
48-
keyboard.unreleased = []
43+
return ensureAwaited(async () => {
44+
if (typeof __tl_user_event_base__ !== 'undefined') {
45+
__tl_user_event__ = __tl_user_event_base__?.setup(options ?? {})
46+
return
47+
}
48+
await triggerCommand('__vitest_cleanup', keyboard)
49+
keyboard.unreleased = []
50+
})
4951
},
5052
click(element: Element | Locator, options: UserEventClickOptions = {}) {
5153
return convertToLocator(element).click(processClickOptions(options))
@@ -84,39 +86,45 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent
8486

8587
// testing-library user-event
8688
async type(element: Element | Locator, text: string, options: UserEventTypeOptions = {}) {
87-
if (typeof __tl_user_event__ !== 'undefined') {
88-
return __tl_user_event__.type(
89-
element instanceof Element ? element : element.element(),
89+
return ensureAwaited(async () => {
90+
if (typeof __tl_user_event__ !== 'undefined') {
91+
return __tl_user_event__.type(
92+
element instanceof Element ? element : element.element(),
93+
text,
94+
options,
95+
)
96+
}
97+
98+
const selector = convertToSelector(element)
99+
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
100+
'__vitest_type',
101+
selector,
90102
text,
91-
options,
103+
{ ...options, unreleased: keyboard.unreleased },
92104
)
93-
}
94-
95-
const selector = convertToSelector(element)
96-
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
97-
'__vitest_type',
98-
selector,
99-
text,
100-
{ ...options, unreleased: keyboard.unreleased },
101-
)
102-
keyboard.unreleased = unreleased
105+
keyboard.unreleased = unreleased
106+
})
103107
},
104108
tab(options: UserEventTabOptions = {}) {
105-
if (typeof __tl_user_event__ !== 'undefined') {
106-
return __tl_user_event__.tab(options)
107-
}
108-
return triggerCommand('__vitest_tab', options)
109+
return ensureAwaited(() => {
110+
if (typeof __tl_user_event__ !== 'undefined') {
111+
return __tl_user_event__.tab(options)
112+
}
113+
return triggerCommand('__vitest_tab', options)
114+
})
109115
},
110116
async keyboard(text: string) {
111-
if (typeof __tl_user_event__ !== 'undefined') {
112-
return __tl_user_event__.keyboard(text)
113-
}
114-
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
115-
'__vitest_keyboard',
116-
text,
117-
keyboard,
118-
)
119-
keyboard.unreleased = unreleased
117+
return ensureAwaited(async () => {
118+
if (typeof __tl_user_event__ !== 'undefined') {
119+
return __tl_user_event__.keyboard(text)
120+
}
121+
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
122+
'__vitest_keyboard',
123+
text,
124+
keyboard,
125+
)
126+
keyboard.unreleased = unreleased
127+
})
120128
},
121129
}
122130
}
@@ -167,12 +175,12 @@ export const page: BrowserPage = {
167175
const name
168176
= options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png`
169177

170-
return triggerCommand('__vitest_screenshot', name, {
178+
return ensureAwaited(() => triggerCommand('__vitest_screenshot', name, {
171179
...options,
172180
element: options.element
173181
? convertToSelector(options.element)
174182
: undefined,
175-
})
183+
}))
176184
},
177185
getByRole() {
178186
throw new Error('Method "getByRole" is not implemented in the current provider.')

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ export async function setupExpectDom() {
1414
if (elementOrLocator instanceof Element || elementOrLocator == null) {
1515
return elementOrLocator
1616
}
17-
const isNot = chai.util.flag(this, 'negate')
18-
const name = chai.util.flag(this, '_name')
17+
chai.util.flag(this, '_poll.element', true)
18+
19+
const isNot = chai.util.flag(this, 'negate') as boolean
20+
const name = chai.util.flag(this, '_name') as string
1921
// special case for `toBeInTheDocument` matcher
2022
if (isNot && name === 'toBeInTheDocument') {
2123
return elementOrLocator.query()

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
Ivya,
2323
type ParsedSelector,
2424
} from 'ivya'
25-
import { getBrowserState, getWorkerState } from '../../utils'
25+
import { ensureAwaited, getBrowserState, getWorkerState } from '../../utils'
2626
import { getElementError } from '../public-utils'
2727

2828
// we prefer using playwright locators because they are more powerful and support Shadow DOM
@@ -202,11 +202,11 @@ export abstract class Locator {
202202
|| this.worker.current?.file?.filepath
203203
|| undefined
204204

205-
return this.rpc.triggerCommand<T>(
205+
return ensureAwaited(() => this.rpc.triggerCommand<T>(
206206
this.state.contextId,
207207
command,
208208
filepath,
209209
args,
210-
)
210+
))
211211
}
212212
}

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
getByTextSelector,
1010
getByTitleSelector,
1111
} from 'ivya'
12-
import { convertElementToCssSelector } from '../../utils'
12+
import { convertElementToCssSelector, ensureAwaited } from '../../utils'
1313
import { getElementError } from '../public-utils'
1414
import { Locator, selectorEngine } from './index'
1515

@@ -58,28 +58,28 @@ class PreviewLocator extends Locator {
5858
}
5959

6060
click(): Promise<void> {
61-
return userEvent.click(this.element())
61+
return ensureAwaited(() => userEvent.click(this.element()))
6262
}
6363

6464
dblClick(): Promise<void> {
65-
return userEvent.dblClick(this.element())
65+
return ensureAwaited(() => userEvent.dblClick(this.element()))
6666
}
6767

6868
tripleClick(): Promise<void> {
69-
return userEvent.tripleClick(this.element())
69+
return ensureAwaited(() => userEvent.tripleClick(this.element()))
7070
}
7171

7272
hover(): Promise<void> {
73-
return userEvent.hover(this.element())
73+
return ensureAwaited(() => userEvent.hover(this.element()))
7474
}
7575

7676
unhover(): Promise<void> {
77-
return userEvent.unhover(this.element())
77+
return ensureAwaited(() => userEvent.unhover(this.element()))
7878
}
7979

8080
async fill(text: string): Promise<void> {
8181
await this.clear()
82-
return userEvent.type(this.element(), text)
82+
return ensureAwaited(() => userEvent.type(this.element(), text))
8383
}
8484

8585
async upload(file: string | string[] | File | File[]): Promise<void> {
@@ -100,7 +100,7 @@ class PreviewLocator extends Locator {
100100
return fileInstance
101101
})
102102
const uploadFiles = await Promise.all(uploadPromise)
103-
return userEvent.upload(this.element() as HTMLElement, uploadFiles)
103+
return ensureAwaited(() => userEvent.upload(this.element() as HTMLElement, uploadFiles))
104104
}
105105

106106
selectOptions(options_: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise<void> {
@@ -110,15 +110,15 @@ class PreviewLocator extends Locator {
110110
}
111111
return option
112112
})
113-
return userEvent.selectOptions(this.element(), options as string[] | HTMLElement[])
113+
return ensureAwaited(() => userEvent.selectOptions(this.element(), options as string[] | HTMLElement[]))
114114
}
115115

116116
async dropTo(): Promise<void> {
117117
throw new Error('The "preview" provider doesn\'t support `dropTo` method.')
118118
}
119119

120120
clear(): Promise<void> {
121-
return userEvent.clear(this.element())
121+
return ensureAwaited(() => userEvent.clear(this.element()))
122122
}
123123

124124
async screenshot(): Promise<never> {

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,8 @@ export function setupConsoleLogSpy() {
4141
trace(...args)
4242
const content = processLog(args)
4343
const error = new Error('$$Trace')
44-
const stack = (error.stack || '')
45-
.split('\n')
46-
.slice(error.stack?.includes('$$Trace') ? 2 : 1)
47-
.join('\n')
44+
const processor = (globalThis as any).__vitest_worker__?.onFilterStackTrace || ((s: string) => s || '')
45+
const stack = processor(error.stack || '')
4846
sendLog('stderr', `${content}\n${stack}`, true)
4947
}
5048

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { page, userEvent } from '@vitest/browser/context'
77
import { loadDiffConfig, loadSnapshotSerializers, takeCoverageInsideWorker } from 'vitest/browser'
88
import { NodeBenchmarkRunner, VitestTestRunner } from 'vitest/runners'
99
import { originalPositionFor, TraceMap } from 'vitest/utils'
10-
import { executor } from '../utils'
10+
import { createStackString, parseStacktrace } from '../../../../utils/src/source-map'
11+
import { executor, getWorkerState } from '../utils'
1112
import { rpc } from './rpc'
1213
import { VitestBrowserSnapshotEnvironment } from './snapshot'
1314

@@ -29,7 +30,7 @@ export function createBrowserRunner(
2930
mocker: VitestBrowserClientMocker,
3031
state: WorkerGlobalState,
3132
coverageModule: CoverageHandler | null,
32-
): { new (options: BrowserRunnerOptions): VitestRunner } {
33+
): { new (options: BrowserRunnerOptions): VitestRunner & { sourceMapCache: Map<string, any> } } {
3334
return class BrowserTestRunner extends runnerClass implements VitestRunner {
3435
public config: SerializedConfig
3536
hashMap = browserHashMap
@@ -171,6 +172,14 @@ export async function initiateRunner(
171172
])
172173
runner.config.diffOptions = diffOptions
173174
cachedRunner = runner
175+
getWorkerState().onFilterStackTrace = (stack: string) => {
176+
const stacks = parseStacktrace(stack, {
177+
getSourceMap(file) {
178+
return runner.sourceMapCache.get(file)
179+
},
180+
})
181+
return createStackString(stacks)
182+
}
174183
return runner
175184
}
176185

packages/browser/src/client/utils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,40 @@ export function getConfig(): SerializedConfig {
2525
return getBrowserState().config
2626
}
2727

28+
export function ensureAwaited<T>(promise: () => Promise<T>): Promise<T> {
29+
const test = getWorkerState().current
30+
if (!test || test.type !== 'test') {
31+
return promise()
32+
}
33+
let awaited = false
34+
const sourceError = new Error('STACK_TRACE_ERROR')
35+
test.onFinished ??= []
36+
test.onFinished.push(() => {
37+
if (!awaited) {
38+
const error = new Error(
39+
`The call was not awaited. This method is asynchronous and must be awaited; otherwise, the call will not start to avoid unhandled rejections.`,
40+
)
41+
error.stack = sourceError.stack?.replace(sourceError.message, error.message)
42+
throw error
43+
}
44+
})
45+
// don't even start the promise if it's not awaited to not cause any unhanded promise rejections
46+
let promiseResult: Promise<T> | undefined
47+
return {
48+
then(onFulfilled, onRejected) {
49+
awaited = true
50+
return (promiseResult ||= promise()).then(onFulfilled, onRejected)
51+
},
52+
catch(onRejected) {
53+
return (promiseResult ||= promise()).catch(onRejected)
54+
},
55+
finally(onFinally) {
56+
return (promiseResult ||= promise()).finally(onFinally)
57+
},
58+
[Symbol.toStringTag]: 'Promise',
59+
} satisfies Promise<T>
60+
}
61+
2862
export interface BrowserRunnerState {
2963
files: string[]
3064
runningFiles: string[]

0 commit comments

Comments
 (0)