Skip to content

Commit 93b67c2

Browse files
authoredNov 13, 2024··
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

+5-1
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

+2
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

+44-36
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

+4-2
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

+3-3
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

+10-10
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

+2-4
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

+11-2
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

+34
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[]

‎packages/expect/src/jest-expect.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
subsetEquality,
2323
typeEquality,
2424
} from './jest-utils'
25-
import { recordAsyncExpect, wrapAssertion } from './utils'
25+
import { createAssertionMessage, recordAsyncExpect, wrapAssertion } from './utils'
2626

2727
// polyfill globals because expect can be used in node environment
2828
declare class Node {
@@ -983,6 +983,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
983983
}
984984

985985
return (...args: any[]) => {
986+
utils.flag(this, '_name', key)
986987
const promise = obj.then(
987988
(value: any) => {
988989
utils.flag(this, 'object', value)
@@ -1004,7 +1005,12 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
10041005
},
10051006
)
10061007

1007-
return recordAsyncExpect(test, promise)
1008+
return recordAsyncExpect(
1009+
test,
1010+
promise,
1011+
createAssertionMessage(utils, this, !!args.length),
1012+
error,
1013+
)
10081014
}
10091015
},
10101016
})
@@ -1045,6 +1051,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
10451051
}
10461052

10471053
return (...args: any[]) => {
1054+
utils.flag(this, '_name', key)
10481055
const promise = wrapper.then(
10491056
(value: any) => {
10501057
const _error = new AssertionError(
@@ -1069,7 +1076,12 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
10691076
},
10701077
)
10711078

1072-
return recordAsyncExpect(test, promise)
1079+
return recordAsyncExpect(
1080+
test,
1081+
promise,
1082+
createAssertionMessage(utils, this, !!args.length),
1083+
error,
1084+
)
10731085
}
10741086
},
10751087
})

‎packages/expect/src/utils.ts

+53-3
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,32 @@ import type { Test } from '@vitest/runner/types'
22
import type { Assertion } from './types'
33
import { processError } from '@vitest/utils/error'
44

5+
export function createAssertionMessage(
6+
util: Chai.ChaiUtils,
7+
assertion: Assertion,
8+
hasArgs: boolean,
9+
) {
10+
const not = util.flag(assertion, 'negate') ? 'not.' : ''
11+
const name = `${util.flag(assertion, '_name')}(${hasArgs ? 'expected' : ''})`
12+
const promiseName = util.flag(assertion, 'promise')
13+
const promise = promiseName ? `.${promiseName}` : ''
14+
return `expect(actual)${promise}.${not}${name}`
15+
}
16+
517
export function recordAsyncExpect(
6-
test: any,
7-
promise: Promise<any> | PromiseLike<any>,
18+
_test: any,
19+
promise: Promise<any>,
20+
assertion: string,
21+
error: Error,
822
) {
23+
const test = _test as Test | undefined
924
// record promise for test, that resolves before test ends
1025
if (test && promise instanceof Promise) {
1126
// if promise is explicitly awaited, remove it from the list
1227
promise = promise.finally(() => {
28+
if (!test.promises) {
29+
return
30+
}
1331
const index = test.promises.indexOf(promise)
1432
if (index !== -1) {
1533
test.promises.splice(index, 1)
@@ -21,6 +39,35 @@ export function recordAsyncExpect(
2139
test.promises = []
2240
}
2341
test.promises.push(promise)
42+
43+
let resolved = false
44+
test.onFinished ??= []
45+
test.onFinished.push(() => {
46+
if (!resolved) {
47+
const processor = (globalThis as any).__vitest_worker__?.onFilterStackTrace || ((s: string) => s || '')
48+
const stack = processor(error.stack)
49+
console.warn([
50+
`Promise returned by \`${assertion}\` was not awaited. `,
51+
'Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in Vitest 3. ',
52+
'Please remember to await the assertion.\n',
53+
stack,
54+
].join(''))
55+
}
56+
})
57+
58+
return {
59+
then(onFullfilled, onRejected) {
60+
resolved = true
61+
return promise.then(onFullfilled, onRejected)
62+
},
63+
catch(onRejected) {
64+
return promise.catch(onRejected)
65+
},
66+
finally(onFinally) {
67+
return promise.finally(onFinally)
68+
},
69+
[Symbol.toStringTag]: 'Promise',
70+
} satisfies Promise<any>
2471
}
2572

2673
return promise
@@ -32,7 +79,10 @@ export function wrapAssertion(
3279
fn: (this: Chai.AssertionStatic & Assertion, ...args: any[]) => void,
3380
) {
3481
return function (this: Chai.AssertionStatic & Assertion, ...args: any[]) {
35-
utils.flag(this, '_name', name)
82+
// private
83+
if (name !== 'withTest') {
84+
utils.flag(this, '_name', name)
85+
}
3686

3787
if (!utils.flag(this, 'soft')) {
3888
return fn.apply(this, args)

‎packages/runner/src/run.ts

+22-20
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ function getSuiteHooks(
6161
return hooks
6262
}
6363

64-
async function callTaskHooks(
64+
async function callTestHooks(
65+
runner: VitestRunner,
6566
task: Task,
6667
hooks: ((result: TaskResult) => Awaitable<void>)[],
6768
sequence: SequenceHooks,
@@ -71,11 +72,21 @@ async function callTaskHooks(
7172
}
7273

7374
if (sequence === 'parallel') {
74-
await Promise.all(hooks.map(fn => fn(task.result!)))
75+
try {
76+
await Promise.all(hooks.map(fn => fn(task.result!)))
77+
}
78+
catch (e) {
79+
failTask(task.result!, e, runner.config.diffOptions)
80+
}
7581
}
7682
else {
7783
for (const fn of hooks) {
78-
await fn(task.result!)
84+
try {
85+
await fn(task.result!)
86+
}
87+
catch (e) {
88+
failTask(task.result!, e, runner.config.diffOptions)
89+
}
7990
}
8091
}
8192
}
@@ -271,24 +282,15 @@ export async function runTest(test: Test | Custom, runner: VitestRunner): Promis
271282
failTask(test.result, e, runner.config.diffOptions)
272283
}
273284

274-
try {
275-
await callTaskHooks(test, test.onFinished || [], 'stack')
276-
}
277-
catch (e) {
278-
failTask(test.result, e, runner.config.diffOptions)
279-
}
285+
await callTestHooks(runner, test, test.onFinished || [], 'stack')
280286

281287
if (test.result.state === 'fail') {
282-
try {
283-
await callTaskHooks(
284-
test,
285-
test.onFailed || [],
286-
runner.config.sequence.hooks,
287-
)
288-
}
289-
catch (e) {
290-
failTask(test.result, e, runner.config.diffOptions)
291-
}
288+
await callTestHooks(
289+
runner,
290+
test,
291+
test.onFailed || [],
292+
runner.config.sequence.hooks,
293+
)
292294
}
293295

294296
delete test.onFailed
@@ -331,7 +333,7 @@ export async function runTest(test: Test | Custom, runner: VitestRunner): Promis
331333
updateTask(test, runner)
332334
}
333335

334-
function failTask(result: TaskResult, err: unknown, diffOptions?: DiffOptions) {
336+
function failTask(result: TaskResult, err: unknown, diffOptions: DiffOptions | undefined) {
335337
if (err instanceof PendingError) {
336338
result.state = 'skip'
337339
return

‎packages/utils/src/source-map.ts

+10
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,16 @@ export function parseSingleV8Stack(raw: string): ParsedStack | null {
179179
}
180180
}
181181

182+
export function createStackString(stacks: ParsedStack[]): string {
183+
return stacks.map((stack) => {
184+
const line = `${stack.file}:${stack.line}:${stack.column}`
185+
if (stack.method) {
186+
return ` at ${stack.method}(${line})`
187+
}
188+
return ` at ${line}`
189+
}).join('\n')
190+
}
191+
182192
export function parseStacktrace(
183193
stack: string,
184194
options: StackTraceParserOptions = {},

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

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Assertion, ExpectStatic } from '@vitest/expect'
2+
import type { Test } from '@vitest/runner'
23
import { getSafeTimers } from '@vitest/utils'
34
import * as chai from 'chai'
45
import { getWorkerState } from '../../runtime/utils'
@@ -39,6 +40,10 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
3940
poll: true,
4041
}) as Assertion
4142
fn = fn.bind(assertion)
43+
const test = chai.util.flag(assertion, 'vitest-test') as Test | undefined
44+
if (!test) {
45+
throw new Error('expect.poll() must be called inside a test')
46+
}
4247
const proxy: any = new Proxy(assertion, {
4348
get(target, key, receiver) {
4449
const assertionFunction = Reflect.get(target, key, receiver)
@@ -59,7 +64,7 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
5964

6065
return function (this: any, ...args: any[]) {
6166
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
62-
return new Promise((resolve, reject) => {
67+
const promise = () => new Promise<void>((resolve, reject) => {
6368
let intervalId: any
6469
let lastError: any
6570
const { setTimeout, clearTimeout } = getSafeTimers()
@@ -90,6 +95,35 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
9095
}
9196
check()
9297
})
98+
let awaited = false
99+
test.onFinished ??= []
100+
test.onFinished.push(() => {
101+
if (!awaited) {
102+
const negated = chai.util.flag(assertion, 'negate') ? 'not.' : ''
103+
const name = chai.util.flag(assertion, '_poll.element') ? 'element(locator)' : 'poll(assertion)'
104+
const assertionString = `expect.${name}.${negated}${String(key)}()`
105+
const error = new Error(
106+
`${assertionString} was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not executed to avoid unhandled rejections:\n\nawait ${assertionString}\n`,
107+
)
108+
throw copyStackTrace(error, STACK_TRACE_ERROR)
109+
}
110+
})
111+
let resultPromise: Promise<void> | undefined
112+
// only .then is enough to check awaited, but we type this as `Promise<void>` in global types
113+
// so let's follow it
114+
return {
115+
then(onFulfilled, onRejected) {
116+
awaited = true
117+
return (resultPromise ||= promise()).then(onFulfilled, onRejected)
118+
},
119+
catch(onRejected) {
120+
return (resultPromise ||= promise()).catch(onRejected)
121+
},
122+
finally(onFinally) {
123+
return (resultPromise ||= promise()).finally(onFinally)
124+
},
125+
[Symbol.toStringTag]: 'Promise',
126+
} satisfies Promise<void>
93127
}
94128
},
95129
})

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

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ChaiPlugin } from '@vitest/expect'
1+
import type { Assertion, ChaiPlugin } from '@vitest/expect'
22
import type { Test } from '@vitest/runner'
33
import { equals, iterableEquality, subsetEquality } from '@vitest/expect'
44
import { getNames } from '@vitest/runner/utils'
@@ -7,7 +7,7 @@ import {
77
SnapshotClient,
88
stripSnapshotIndentation,
99
} from '@vitest/snapshot'
10-
import { recordAsyncExpect } from '../../../../expect/src/utils'
10+
import { createAssertionMessage, recordAsyncExpect } from '../../../../expect/src/utils'
1111

1212
let _client: SnapshotClient
1313

@@ -64,6 +64,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
6464
properties?: object,
6565
message?: string,
6666
) {
67+
utils.flag(this, '_name', key)
6768
const isNot = utils.flag(this, 'negate')
6869
if (isNot) {
6970
throw new Error(`${key} cannot be used with "not"`)
@@ -90,11 +91,13 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
9091
utils.addMethod(
9192
chai.Assertion.prototype,
9293
'toMatchFileSnapshot',
93-
function (this: Record<string, unknown>, file: string, message?: string) {
94+
function (this: Assertion, file: string, message?: string) {
95+
utils.flag(this, '_name', 'toMatchFileSnapshot')
9496
const isNot = utils.flag(this, 'negate')
9597
if (isNot) {
9698
throw new Error('toMatchFileSnapshot cannot be used with "not"')
9799
}
100+
const error = new Error('resolves')
98101
const expected = utils.flag(this, 'object')
99102
const test = utils.flag(this, 'vitest-test') as Test
100103
const errorMessage = utils.flag(this, 'message')
@@ -110,7 +113,12 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
110113
...getTestNames(test),
111114
})
112115

113-
return recordAsyncExpect(test, promise)
116+
return recordAsyncExpect(
117+
test,
118+
promise,
119+
createAssertionMessage(utils, this, true),
120+
error,
121+
)
114122
},
115123
)
116124

@@ -123,6 +131,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
123131
inlineSnapshot?: string,
124132
message?: string,
125133
) {
134+
utils.flag(this, '_name', 'toMatchInlineSnapshot')
126135
const isNot = utils.flag(this, 'negate')
127136
if (isNot) {
128137
throw new Error('toMatchInlineSnapshot cannot be used with "not"')
@@ -162,6 +171,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
162171
chai.Assertion.prototype,
163172
'toThrowErrorMatchingSnapshot',
164173
function (this: Record<string, unknown>, message?: string) {
174+
utils.flag(this, '_name', 'toThrowErrorMatchingSnapshot')
165175
const isNot = utils.flag(this, 'negate')
166176
if (isNot) {
167177
throw new Error(

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

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ContextRPC, WorkerGlobalState } from '../types/worker'
22
import type { VitestWorker } from './workers/types'
33
import { pathToFileURL } from 'node:url'
4+
import { createStackString, parseStacktrace } from '@vitest/utils/source-map'
45
import { workerId as poolId } from 'tinypool'
56
import { ModuleCacheMap } from 'vite-node/client'
67
import { loadEnvironment } from '../integrations/env/loader'
@@ -90,6 +91,9 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC) {
9091
},
9192
rpc,
9293
providedContext: ctx.providedContext,
94+
onFilterStackTrace(stack) {
95+
return createStackString(parseStacktrace(stack))
96+
},
9397
} satisfies WorkerGlobalState
9498

9599
const methodName = method === 'collect' ? 'collectTests' : 'runTests'

‎packages/vitest/src/types/worker.ts

+1
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,5 @@ export interface WorkerGlobalState {
4747
environment: number
4848
prepare: number
4949
}
50+
onFilterStackTrace?: (trace: string) => string
5051
}

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

+10-5
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ log with a stack
101101
error with a stack
102102
❯ test/logs.test.ts:59:10
103103
`.trim())
104-
// console.trace doens't add additional stack trace
105-
expect(stderr).not.toMatch('test/logs.test.ts:60:10')
104+
// console.trace processes the stack trace correctly
105+
expect(stderr).toMatch('test/logs.test.ts:60:10')
106106
})
107107

108108
test.runIf(browser === 'webkit')(`logs have stack traces in safari`, () => {
@@ -115,16 +115,21 @@ log with a stack
115115
error with a stack
116116
❯ test/logs.test.ts:59:16
117117
`.trim())
118-
// console.trace doens't add additional stack trace
119-
expect(stderr).not.toMatch('test/logs.test.ts:60:16')
118+
// console.trace processes the stack trace correctly
119+
expect(stderr).toMatch('test/logs.test.ts:60:16')
120120
})
121121

122122
test(`stack trace points to correct file in every browser`, () => {
123123
// dependeing on the browser it references either `.toBe()` or `expect()`
124-
expect(stderr).toMatch(/test\/failing.test.ts:5:(12|17)/)
124+
expect(stderr).toMatch(/test\/failing.test.ts:10:(12|17)/)
125125

126126
// column is 18 in safari, 8 in others
127127
expect(stderr).toMatch(/throwError src\/error.ts:8:(18|8)/)
128+
129+
expect(stderr).toContain('The call was not awaited. This method is asynchronous and must be awaited; otherwise, the call will not start to avoid unhandled rejections.')
130+
expect(stderr).toMatch(/test\/failing.test.ts:18:(27|36)/)
131+
expect(stderr).toMatch(/test\/failing.test.ts:19:(27|33)/)
132+
expect(stderr).toMatch(/test\/failing.test.ts:20:(27|39)/)
128133
})
129134

130135
test('popup apis should log a warning', () => {

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

+11
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
import { page } from '@vitest/browser/context'
12
import { expect, it } from 'vitest'
23
import { throwError } from '../src/error'
34

5+
document.body.innerHTML = `
6+
<button>Click me!</button>
7+
`
8+
49
it('correctly fails and prints a diff', () => {
510
expect(1).toBe(2)
611
})
712

813
it('correctly print error in another file', () => {
914
throwError()
1015
})
16+
17+
it('several locator methods are not awaited', () => {
18+
page.getByRole('button').dblClick()
19+
page.getByRole('button').click()
20+
page.getByRole('button').tripleClick()
21+
})

‎test/browser/test/userEvent.test.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ const userEvent = _uE.setup()
1111

1212
describe('userEvent.click', () => {
1313
test('correctly clicks a button', async () => {
14+
const wrapper = document.createElement('div')
15+
wrapper.style.height = '100px'
16+
wrapper.style.width = '200px'
17+
wrapper.style.backgroundColor = 'red'
18+
wrapper.style.display = 'flex'
19+
wrapper.style.justifyContent = 'center'
20+
wrapper.style.alignItems = 'center'
1421
const button = document.createElement('button')
15-
button.style.height = '100px'
16-
button.style.width = '200px'
1722
button.textContent = 'Click me'
1823
document.body.appendChild(button)
1924
const onClick = vi.fn()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect, test } from 'vitest';
2+
3+
test('poll is not awaited once', () => {
4+
expect.poll(() => 2).toBe(2)
5+
})
6+
7+
test('poll is not awaited several times', () => {
8+
expect.poll(() => 3).toBe(3)
9+
expect.poll(() => 'string').not.toBe('correct')
10+
})
11+
12+
test('poll is not awaited but there is an async assertion afterwards', async () => {
13+
expect.poll(() => 4).toBe(4)
14+
await expect(new Promise((r) => setTimeout(() => r(3), 50))).resolves.toBe(3)
15+
})
16+
17+
test('poll is not awaited but there is an error afterwards', async () => {
18+
expect.poll(() => 4).toBe(4)
19+
expect(3).toBe(4)
20+
})

‎test/cli/test/__snapshots__/fails.test.ts.snap

+9
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ exports[`should fail no-assertions.test.ts > no-assertions.test.ts 1`] = `"Error
6060
6161
exports[`should fail node-browser-context.test.ts > node-browser-context.test.ts 1`] = `"Error: @vitest/browser/context can be imported only inside the Browser Mode. Your test is running in forks pool. Make sure your regular tests are excluded from the "test.include" glob pattern."`;
6262
63+
exports[`should fail poll-no-awaited.test.ts > poll-no-awaited.test.ts 1`] = `
64+
"Error: expect.poll(assertion).toBe() was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not executed to avoid unhandled rejections:
65+
AssertionError: expected 3 to be 4 // Object.is equality
66+
Error: expect.poll(assertion).toBe() was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not executed to avoid unhandled rejections:
67+
Error: expect.poll(assertion).toBe() was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not executed to avoid unhandled rejections:
68+
Error: expect.poll(assertion).not.toBe() was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not executed to avoid unhandled rejections:
69+
Error: expect.poll(assertion).toBe() was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not executed to avoid unhandled rejections:"
70+
`;
71+
6372
exports[`should fail primitive-error.test.ts > primitive-error.test.ts 1`] = `"Unknown Error: 42"`;
6473
6574
exports[`should fail snapshot-with-not.test.ts > snapshot-with-not.test.ts 1`] = `

‎test/cli/test/fails.test.ts

+84-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import type { TestCase } from 'vitest/node'
12
import { resolve } from 'pathe'
3+
24
import { glob } from 'tinyglobby'
35
import { expect, it } from 'vitest'
4-
5-
import { runVitest } from '../../test-utils'
6+
import { runInlineTests, runVitest, ts } from '../../test-utils'
67

78
const root = resolve(__dirname, '../fixtures/fails')
89
const files = await glob(['**/*.test.ts'], { cwd: root, dot: true, expandDirectories: false })
@@ -50,3 +51,84 @@ it('should not report coverage when "coverag.reportOnFailure" has default value
5051

5152
expect(stdout).not.toMatch('Coverage report from istanbul')
5253
})
54+
55+
it('prints a warning if the assertion is not awaited', async () => {
56+
const { stderr, results, root } = await runInlineTests({
57+
'base.test.js': ts`
58+
import { expect, test } from 'vitest';
59+
60+
test('single not awaited', () => {
61+
expect(Promise.resolve(1)).resolves.toBe(1)
62+
})
63+
64+
test('several not awaited', () => {
65+
expect(Promise.resolve(1)).resolves.toBe(1)
66+
expect(Promise.reject(1)).rejects.toBe(1)
67+
})
68+
69+
test('not awaited and failed', () => {
70+
expect(Promise.resolve(1)).resolves.toBe(1)
71+
expect(1).toBe(2)
72+
})
73+
74+
test('toMatchSnapshot not awaited', () => {
75+
expect(1).toMatchFileSnapshot('./snapshot.txt')
76+
})
77+
`,
78+
})
79+
expect(results[0].children.size).toEqual(4)
80+
const failedTest = results[0].children.at(2) as TestCase
81+
expect(failedTest.result()).toEqual({
82+
state: 'failed',
83+
errors: [
84+
expect.objectContaining({
85+
message: expect.stringContaining('expected 1 to be 2'),
86+
}),
87+
],
88+
})
89+
const warnings: string[] = []
90+
const lines = stderr.split('\n')
91+
lines.forEach((line, index) => {
92+
if (line.includes('Promise returned by')) {
93+
warnings.push(lines.slice(index, index + 2).join('\n').replace(`${root}/`, '<rootDir>/'))
94+
}
95+
})
96+
expect(warnings).toMatchInlineSnapshot(`
97+
[
98+
"Promise returned by \`expect(actual).resolves.toBe(expected)\` was not awaited. Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in Vitest 3. Please remember to await the assertion.
99+
at <rootDir>/base.test.js:5:33",
100+
"Promise returned by \`expect(actual).rejects.toBe(expected)\` was not awaited. Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in Vitest 3. Please remember to await the assertion.
101+
at <rootDir>/base.test.js:10:32",
102+
"Promise returned by \`expect(actual).resolves.toBe(expected)\` was not awaited. Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in Vitest 3. Please remember to await the assertion.
103+
at <rootDir>/base.test.js:9:33",
104+
"Promise returned by \`expect(actual).resolves.toBe(expected)\` was not awaited. Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in Vitest 3. Please remember to await the assertion.
105+
at <rootDir>/base.test.js:14:33",
106+
"Promise returned by \`expect(actual).toMatchFileSnapshot(expected)\` was not awaited. Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in Vitest 3. Please remember to await the assertion.
107+
at <rootDir>/base.test.js:19:17",
108+
]
109+
`)
110+
})
111+
112+
it('prints a warning if the assertion is not awaited in the browser mode', async () => {
113+
const { stderr } = await runInlineTests({
114+
'./vitest.config.js': {
115+
test: {
116+
browser: {
117+
enabled: true,
118+
name: 'chromium',
119+
provider: 'playwright',
120+
headless: true,
121+
},
122+
},
123+
},
124+
'base.test.js': ts`
125+
import { expect, test } from 'vitest';
126+
127+
test('single not awaited', () => {
128+
expect(Promise.resolve(1)).resolves.toBe(1)
129+
})
130+
`,
131+
})
132+
expect(stderr).toContain('Promise returned by \`expect(actual).resolves.toBe(expected)\` was not awaited')
133+
expect(stderr).toContain('base.test.js:5:33')
134+
})

‎test/test-utils/index.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Options } from 'tinyexec'
22
import type { UserConfig as ViteUserConfig } from 'vite'
33
import type { WorkspaceProjectConfiguration } from 'vitest/config'
4-
import type { UserConfig, Vitest, VitestRunMode } from 'vitest/node'
4+
import type { TestModule, UserConfig, Vitest, VitestRunMode } from 'vitest/node'
55
import { webcrypto as crypto } from 'node:crypto'
66
import fs from 'node:fs'
77
import { Readable, Writable } from 'node:stream'
@@ -291,6 +291,12 @@ export async function runInlineTests(
291291
})
292292
return {
293293
fs,
294+
root,
294295
...vitest,
296+
get results() {
297+
return (vitest.ctx?.state.getFiles() || []).map(file => vitest.ctx?.state.getReportedEntity(file) as TestModule)
298+
},
295299
}
296300
}
301+
302+
export const ts = String.raw

0 commit comments

Comments
 (0)
Please sign in to comment.