diff --git a/docs/api/expect.md b/docs/api/expect.md index 1103ee6a15cb..0e12270e177b 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -82,7 +82,7 @@ test('element exists', async () => { ``` ::: warning -`expect.poll` makes every assertion asynchronous, so do not forget to await it otherwise you might get unhandled promise rejections. +`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. `expect.poll` doesn't work with several matchers: @@ -1185,6 +1185,8 @@ test('buyApples returns new stock id', async () => { :::warning 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). + +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. ::: ## rejects @@ -1214,6 +1216,8 @@ test('buyApples throws an error when no id provided', async () => { :::warning 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). + +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. ::: ## expect.assertions diff --git a/docs/guide/browser/locators.md b/docs/guide/browser/locators.md index 9c6e50f8d191..eaf3a59be5b5 100644 --- a/docs/guide/browser/locators.md +++ b/docs/guide/browser/locators.md @@ -389,6 +389,8 @@ It is recommended to use this only after the other locators don't work for your ## Methods +All methods are asynchronous and must be awaited. Since Vitest 2.2, tests will fail if a method is not awaited. + ### click ```ts diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 5cf0e294f2d9..d648f50aa7ea 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -11,7 +11,7 @@ import type { UserEventTabOptions, UserEventTypeOptions, } from '../../../context' -import { convertElementToCssSelector, getBrowserState, getWorkerState } from '../utils' +import { convertElementToCssSelector, ensureAwaited, getBrowserState, getWorkerState } from '../utils' // this file should not import anything directly, only types and utils @@ -40,12 +40,14 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent return createUserEvent(__tl_user_event_base__, options) }, async cleanup() { - if (typeof __tl_user_event_base__ !== 'undefined') { - __tl_user_event__ = __tl_user_event_base__?.setup(options ?? {}) - return - } - await triggerCommand('__vitest_cleanup', keyboard) - keyboard.unreleased = [] + return ensureAwaited(async () => { + if (typeof __tl_user_event_base__ !== 'undefined') { + __tl_user_event__ = __tl_user_event_base__?.setup(options ?? {}) + return + } + await triggerCommand('__vitest_cleanup', keyboard) + keyboard.unreleased = [] + }) }, click(element: Element | Locator, options: UserEventClickOptions = {}) { return convertToLocator(element).click(processClickOptions(options)) @@ -84,39 +86,45 @@ export function createUserEvent(__tl_user_event_base__?: TestingLibraryUserEvent // testing-library user-event async type(element: Element | Locator, text: string, options: UserEventTypeOptions = {}) { - if (typeof __tl_user_event__ !== 'undefined') { - return __tl_user_event__.type( - element instanceof Element ? element : element.element(), + return ensureAwaited(async () => { + if (typeof __tl_user_event__ !== 'undefined') { + return __tl_user_event__.type( + element instanceof Element ? element : element.element(), + text, + options, + ) + } + + const selector = convertToSelector(element) + const { unreleased } = await triggerCommand<{ unreleased: string[] }>( + '__vitest_type', + selector, text, - options, + { ...options, unreleased: keyboard.unreleased }, ) - } - - const selector = convertToSelector(element) - const { unreleased } = await triggerCommand<{ unreleased: string[] }>( - '__vitest_type', - selector, - text, - { ...options, unreleased: keyboard.unreleased }, - ) - keyboard.unreleased = unreleased + keyboard.unreleased = unreleased + }) }, tab(options: UserEventTabOptions = {}) { - if (typeof __tl_user_event__ !== 'undefined') { - return __tl_user_event__.tab(options) - } - return triggerCommand('__vitest_tab', options) + return ensureAwaited(() => { + if (typeof __tl_user_event__ !== 'undefined') { + return __tl_user_event__.tab(options) + } + return triggerCommand('__vitest_tab', options) + }) }, async keyboard(text: string) { - if (typeof __tl_user_event__ !== 'undefined') { - return __tl_user_event__.keyboard(text) - } - const { unreleased } = await triggerCommand<{ unreleased: string[] }>( - '__vitest_keyboard', - text, - keyboard, - ) - keyboard.unreleased = unreleased + return ensureAwaited(async () => { + if (typeof __tl_user_event__ !== 'undefined') { + return __tl_user_event__.keyboard(text) + } + const { unreleased } = await triggerCommand<{ unreleased: string[] }>( + '__vitest_keyboard', + text, + keyboard, + ) + keyboard.unreleased = unreleased + }) }, } } @@ -167,12 +175,12 @@ export const page: BrowserPage = { const name = options.path || `${taskName.replace(/[^a-z0-9]/gi, '-')}-${number}.png` - return triggerCommand('__vitest_screenshot', name, { + return ensureAwaited(() => triggerCommand('__vitest_screenshot', name, { ...options, element: options.element ? convertToSelector(options.element) : undefined, - }) + })) }, getByRole() { throw new Error('Method "getByRole" is not implemented in the current provider.') diff --git a/packages/browser/src/client/tester/expect-element.ts b/packages/browser/src/client/tester/expect-element.ts index 58a2a5a6cffb..7782853142d7 100644 --- a/packages/browser/src/client/tester/expect-element.ts +++ b/packages/browser/src/client/tester/expect-element.ts @@ -14,8 +14,10 @@ export async function setupExpectDom() { if (elementOrLocator instanceof Element || elementOrLocator == null) { return elementOrLocator } - const isNot = chai.util.flag(this, 'negate') - const name = chai.util.flag(this, '_name') + chai.util.flag(this, '_poll.element', true) + + const isNot = chai.util.flag(this, 'negate') as boolean + const name = chai.util.flag(this, '_name') as string // special case for `toBeInTheDocument` matcher if (isNot && name === 'toBeInTheDocument') { return elementOrLocator.query() diff --git a/packages/browser/src/client/tester/locators/index.ts b/packages/browser/src/client/tester/locators/index.ts index dd3cdea61ff1..f87a94fc945b 100644 --- a/packages/browser/src/client/tester/locators/index.ts +++ b/packages/browser/src/client/tester/locators/index.ts @@ -22,7 +22,7 @@ import { Ivya, type ParsedSelector, } from 'ivya' -import { getBrowserState, getWorkerState } from '../../utils' +import { ensureAwaited, getBrowserState, getWorkerState } from '../../utils' import { getElementError } from '../public-utils' // we prefer using playwright locators because they are more powerful and support Shadow DOM @@ -202,11 +202,11 @@ export abstract class Locator { || this.worker.current?.file?.filepath || undefined - return this.rpc.triggerCommand( + return ensureAwaited(() => this.rpc.triggerCommand( this.state.contextId, command, filepath, args, - ) + )) } } diff --git a/packages/browser/src/client/tester/locators/preview.ts b/packages/browser/src/client/tester/locators/preview.ts index beb03c178b7f..0e966a8557c7 100644 --- a/packages/browser/src/client/tester/locators/preview.ts +++ b/packages/browser/src/client/tester/locators/preview.ts @@ -9,7 +9,7 @@ import { getByTextSelector, getByTitleSelector, } from 'ivya' -import { convertElementToCssSelector } from '../../utils' +import { convertElementToCssSelector, ensureAwaited } from '../../utils' import { getElementError } from '../public-utils' import { Locator, selectorEngine } from './index' @@ -58,28 +58,28 @@ class PreviewLocator extends Locator { } click(): Promise { - return userEvent.click(this.element()) + return ensureAwaited(() => userEvent.click(this.element())) } dblClick(): Promise { - return userEvent.dblClick(this.element()) + return ensureAwaited(() => userEvent.dblClick(this.element())) } tripleClick(): Promise { - return userEvent.tripleClick(this.element()) + return ensureAwaited(() => userEvent.tripleClick(this.element())) } hover(): Promise { - return userEvent.hover(this.element()) + return ensureAwaited(() => userEvent.hover(this.element())) } unhover(): Promise { - return userEvent.unhover(this.element()) + return ensureAwaited(() => userEvent.unhover(this.element())) } async fill(text: string): Promise { await this.clear() - return userEvent.type(this.element(), text) + return ensureAwaited(() => userEvent.type(this.element(), text)) } async upload(file: string | string[] | File | File[]): Promise { @@ -100,7 +100,7 @@ class PreviewLocator extends Locator { return fileInstance }) const uploadFiles = await Promise.all(uploadPromise) - return userEvent.upload(this.element() as HTMLElement, uploadFiles) + return ensureAwaited(() => userEvent.upload(this.element() as HTMLElement, uploadFiles)) } selectOptions(options_: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise { @@ -110,7 +110,7 @@ class PreviewLocator extends Locator { } return option }) - return userEvent.selectOptions(this.element(), options as string[] | HTMLElement[]) + return ensureAwaited(() => userEvent.selectOptions(this.element(), options as string[] | HTMLElement[])) } async dropTo(): Promise { @@ -118,7 +118,7 @@ class PreviewLocator extends Locator { } clear(): Promise { - return userEvent.clear(this.element()) + return ensureAwaited(() => userEvent.clear(this.element())) } async screenshot(): Promise { diff --git a/packages/browser/src/client/tester/logger.ts b/packages/browser/src/client/tester/logger.ts index 30a5149cfd1d..e2e5219cc204 100644 --- a/packages/browser/src/client/tester/logger.ts +++ b/packages/browser/src/client/tester/logger.ts @@ -41,10 +41,8 @@ export function setupConsoleLogSpy() { trace(...args) const content = processLog(args) const error = new Error('$$Trace') - const stack = (error.stack || '') - .split('\n') - .slice(error.stack?.includes('$$Trace') ? 2 : 1) - .join('\n') + const processor = (globalThis as any).__vitest_worker__?.onFilterStackTrace || ((s: string) => s || '') + const stack = processor(error.stack || '') sendLog('stderr', `${content}\n${stack}`, true) } diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index bf1505ced25c..40ff4501b2c5 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -7,7 +7,8 @@ import { page, userEvent } from '@vitest/browser/context' import { loadDiffConfig, loadSnapshotSerializers, takeCoverageInsideWorker } from 'vitest/browser' import { NodeBenchmarkRunner, VitestTestRunner } from 'vitest/runners' import { originalPositionFor, TraceMap } from 'vitest/utils' -import { executor } from '../utils' +import { createStackString, parseStacktrace } from '../../../../utils/src/source-map' +import { executor, getWorkerState } from '../utils' import { rpc } from './rpc' import { VitestBrowserSnapshotEnvironment } from './snapshot' @@ -29,7 +30,7 @@ export function createBrowserRunner( mocker: VitestBrowserClientMocker, state: WorkerGlobalState, coverageModule: CoverageHandler | null, -): { new (options: BrowserRunnerOptions): VitestRunner } { +): { new (options: BrowserRunnerOptions): VitestRunner & { sourceMapCache: Map } } { return class BrowserTestRunner extends runnerClass implements VitestRunner { public config: SerializedConfig hashMap = browserHashMap @@ -171,6 +172,14 @@ export async function initiateRunner( ]) runner.config.diffOptions = diffOptions cachedRunner = runner + getWorkerState().onFilterStackTrace = (stack: string) => { + const stacks = parseStacktrace(stack, { + getSourceMap(file) { + return runner.sourceMapCache.get(file) + }, + }) + return createStackString(stacks) + } return runner } diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 168704878cb3..147b7c3dc344 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -25,6 +25,40 @@ export function getConfig(): SerializedConfig { return getBrowserState().config } +export function ensureAwaited(promise: () => Promise): Promise { + const test = getWorkerState().current + if (!test || test.type !== 'test') { + return promise() + } + let awaited = false + const sourceError = new Error('STACK_TRACE_ERROR') + test.onFinished ??= [] + test.onFinished.push(() => { + if (!awaited) { + const error = new Error( + `The call was not awaited. This method is asynchronous and must be awaited; otherwise, the call will not start to avoid unhandled rejections.`, + ) + error.stack = sourceError.stack?.replace(sourceError.message, error.message) + throw error + } + }) + // don't even start the promise if it's not awaited to not cause any unhanded promise rejections + let promiseResult: Promise | undefined + return { + then(onFulfilled, onRejected) { + awaited = true + return (promiseResult ||= promise()).then(onFulfilled, onRejected) + }, + catch(onRejected) { + return (promiseResult ||= promise()).catch(onRejected) + }, + finally(onFinally) { + return (promiseResult ||= promise()).finally(onFinally) + }, + [Symbol.toStringTag]: 'Promise', + } satisfies Promise +} + export interface BrowserRunnerState { files: string[] runningFiles: string[] diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index c8cb304ce193..72615f364de1 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -22,7 +22,7 @@ import { subsetEquality, typeEquality, } from './jest-utils' -import { recordAsyncExpect, wrapAssertion } from './utils' +import { createAssertionMessage, recordAsyncExpect, wrapAssertion } from './utils' // polyfill globals because expect can be used in node environment declare class Node { @@ -983,6 +983,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { } return (...args: any[]) => { + utils.flag(this, '_name', key) const promise = obj.then( (value: any) => { utils.flag(this, 'object', value) @@ -1004,7 +1005,12 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { }, ) - return recordAsyncExpect(test, promise) + return recordAsyncExpect( + test, + promise, + createAssertionMessage(utils, this, !!args.length), + error, + ) } }, }) @@ -1045,6 +1051,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { } return (...args: any[]) => { + utils.flag(this, '_name', key) const promise = wrapper.then( (value: any) => { const _error = new AssertionError( @@ -1069,7 +1076,12 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { }, ) - return recordAsyncExpect(test, promise) + return recordAsyncExpect( + test, + promise, + createAssertionMessage(utils, this, !!args.length), + error, + ) } }, }) diff --git a/packages/expect/src/utils.ts b/packages/expect/src/utils.ts index a043cd9cd9cb..9d5c44be9173 100644 --- a/packages/expect/src/utils.ts +++ b/packages/expect/src/utils.ts @@ -2,14 +2,32 @@ import type { Test } from '@vitest/runner/types' import type { Assertion } from './types' import { processError } from '@vitest/utils/error' +export function createAssertionMessage( + util: Chai.ChaiUtils, + assertion: Assertion, + hasArgs: boolean, +) { + const not = util.flag(assertion, 'negate') ? 'not.' : '' + const name = `${util.flag(assertion, '_name')}(${hasArgs ? 'expected' : ''})` + const promiseName = util.flag(assertion, 'promise') + const promise = promiseName ? `.${promiseName}` : '' + return `expect(actual)${promise}.${not}${name}` +} + export function recordAsyncExpect( - test: any, - promise: Promise | PromiseLike, + _test: any, + promise: Promise, + assertion: string, + error: Error, ) { + const test = _test as Test | undefined // record promise for test, that resolves before test ends if (test && promise instanceof Promise) { // if promise is explicitly awaited, remove it from the list promise = promise.finally(() => { + if (!test.promises) { + return + } const index = test.promises.indexOf(promise) if (index !== -1) { test.promises.splice(index, 1) @@ -21,6 +39,35 @@ export function recordAsyncExpect( test.promises = [] } test.promises.push(promise) + + let resolved = false + test.onFinished ??= [] + test.onFinished.push(() => { + if (!resolved) { + const processor = (globalThis as any).__vitest_worker__?.onFilterStackTrace || ((s: string) => s || '') + const stack = processor(error.stack) + console.warn([ + `Promise returned by \`${assertion}\` 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.\n', + stack, + ].join('')) + } + }) + + return { + then(onFullfilled, onRejected) { + resolved = true + return promise.then(onFullfilled, onRejected) + }, + catch(onRejected) { + return promise.catch(onRejected) + }, + finally(onFinally) { + return promise.finally(onFinally) + }, + [Symbol.toStringTag]: 'Promise', + } satisfies Promise } return promise @@ -32,7 +79,10 @@ export function wrapAssertion( fn: (this: Chai.AssertionStatic & Assertion, ...args: any[]) => void, ) { return function (this: Chai.AssertionStatic & Assertion, ...args: any[]) { - utils.flag(this, '_name', name) + // private + if (name !== 'withTest') { + utils.flag(this, '_name', name) + } if (!utils.flag(this, 'soft')) { return fn.apply(this, args) diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index bb3c73105255..0d9cbeeb14b9 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -61,7 +61,8 @@ function getSuiteHooks( return hooks } -async function callTaskHooks( +async function callTestHooks( + runner: VitestRunner, task: Task, hooks: ((result: TaskResult) => Awaitable)[], sequence: SequenceHooks, @@ -71,11 +72,21 @@ async function callTaskHooks( } if (sequence === 'parallel') { - await Promise.all(hooks.map(fn => fn(task.result!))) + try { + await Promise.all(hooks.map(fn => fn(task.result!))) + } + catch (e) { + failTask(task.result!, e, runner.config.diffOptions) + } } else { for (const fn of hooks) { - await fn(task.result!) + try { + await fn(task.result!) + } + catch (e) { + failTask(task.result!, e, runner.config.diffOptions) + } } } } @@ -271,24 +282,15 @@ export async function runTest(test: Test | Custom, runner: VitestRunner): Promis failTask(test.result, e, runner.config.diffOptions) } - try { - await callTaskHooks(test, test.onFinished || [], 'stack') - } - catch (e) { - failTask(test.result, e, runner.config.diffOptions) - } + await callTestHooks(runner, test, test.onFinished || [], 'stack') if (test.result.state === 'fail') { - try { - await callTaskHooks( - test, - test.onFailed || [], - runner.config.sequence.hooks, - ) - } - catch (e) { - failTask(test.result, e, runner.config.diffOptions) - } + await callTestHooks( + runner, + test, + test.onFailed || [], + runner.config.sequence.hooks, + ) } delete test.onFailed @@ -331,7 +333,7 @@ export async function runTest(test: Test | Custom, runner: VitestRunner): Promis updateTask(test, runner) } -function failTask(result: TaskResult, err: unknown, diffOptions?: DiffOptions) { +function failTask(result: TaskResult, err: unknown, diffOptions: DiffOptions | undefined) { if (err instanceof PendingError) { result.state = 'skip' return diff --git a/packages/utils/src/source-map.ts b/packages/utils/src/source-map.ts index 21b20a1605a8..0ca0382536ce 100644 --- a/packages/utils/src/source-map.ts +++ b/packages/utils/src/source-map.ts @@ -179,6 +179,16 @@ export function parseSingleV8Stack(raw: string): ParsedStack | null { } } +export function createStackString(stacks: ParsedStack[]): string { + return stacks.map((stack) => { + const line = `${stack.file}:${stack.line}:${stack.column}` + if (stack.method) { + return ` at ${stack.method}(${line})` + } + return ` at ${line}` + }).join('\n') +} + export function parseStacktrace( stack: string, options: StackTraceParserOptions = {}, diff --git a/packages/vitest/src/integrations/chai/poll.ts b/packages/vitest/src/integrations/chai/poll.ts index 1a7176021f60..4ee87549758a 100644 --- a/packages/vitest/src/integrations/chai/poll.ts +++ b/packages/vitest/src/integrations/chai/poll.ts @@ -1,4 +1,5 @@ import type { Assertion, ExpectStatic } from '@vitest/expect' +import type { Test } from '@vitest/runner' import { getSafeTimers } from '@vitest/utils' import * as chai from 'chai' import { getWorkerState } from '../../runtime/utils' @@ -39,6 +40,10 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] { poll: true, }) as Assertion fn = fn.bind(assertion) + const test = chai.util.flag(assertion, 'vitest-test') as Test | undefined + if (!test) { + throw new Error('expect.poll() must be called inside a test') + } const proxy: any = new Proxy(assertion, { get(target, key, receiver) { const assertionFunction = Reflect.get(target, key, receiver) @@ -59,7 +64,7 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] { return function (this: any, ...args: any[]) { const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR') - return new Promise((resolve, reject) => { + const promise = () => new Promise((resolve, reject) => { let intervalId: any let lastError: any const { setTimeout, clearTimeout } = getSafeTimers() @@ -90,6 +95,35 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] { } check() }) + let awaited = false + test.onFinished ??= [] + test.onFinished.push(() => { + if (!awaited) { + const negated = chai.util.flag(assertion, 'negate') ? 'not.' : '' + const name = chai.util.flag(assertion, '_poll.element') ? 'element(locator)' : 'poll(assertion)' + const assertionString = `expect.${name}.${negated}${String(key)}()` + const error = new Error( + `${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`, + ) + throw copyStackTrace(error, STACK_TRACE_ERROR) + } + }) + let resultPromise: Promise | undefined + // only .then is enough to check awaited, but we type this as `Promise` in global types + // so let's follow it + return { + then(onFulfilled, onRejected) { + awaited = true + return (resultPromise ||= promise()).then(onFulfilled, onRejected) + }, + catch(onRejected) { + return (resultPromise ||= promise()).catch(onRejected) + }, + finally(onFinally) { + return (resultPromise ||= promise()).finally(onFinally) + }, + [Symbol.toStringTag]: 'Promise', + } satisfies Promise } }, }) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 3837d2b88d20..f0eff3d94adc 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,4 +1,4 @@ -import type { ChaiPlugin } from '@vitest/expect' +import type { Assertion, ChaiPlugin } from '@vitest/expect' import type { Test } from '@vitest/runner' import { equals, iterableEquality, subsetEquality } from '@vitest/expect' import { getNames } from '@vitest/runner/utils' @@ -7,7 +7,7 @@ import { SnapshotClient, stripSnapshotIndentation, } from '@vitest/snapshot' -import { recordAsyncExpect } from '../../../../expect/src/utils' +import { createAssertionMessage, recordAsyncExpect } from '../../../../expect/src/utils' let _client: SnapshotClient @@ -64,6 +64,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { properties?: object, message?: string, ) { + utils.flag(this, '_name', key) const isNot = utils.flag(this, 'negate') if (isNot) { throw new Error(`${key} cannot be used with "not"`) @@ -90,11 +91,13 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { utils.addMethod( chai.Assertion.prototype, 'toMatchFileSnapshot', - function (this: Record, file: string, message?: string) { + function (this: Assertion, file: string, message?: string) { + utils.flag(this, '_name', 'toMatchFileSnapshot') const isNot = utils.flag(this, 'negate') if (isNot) { throw new Error('toMatchFileSnapshot cannot be used with "not"') } + const error = new Error('resolves') const expected = utils.flag(this, 'object') const test = utils.flag(this, 'vitest-test') as Test const errorMessage = utils.flag(this, 'message') @@ -110,7 +113,12 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { ...getTestNames(test), }) - return recordAsyncExpect(test, promise) + return recordAsyncExpect( + test, + promise, + createAssertionMessage(utils, this, true), + error, + ) }, ) @@ -123,6 +131,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { inlineSnapshot?: string, message?: string, ) { + utils.flag(this, '_name', 'toMatchInlineSnapshot') const isNot = utils.flag(this, 'negate') if (isNot) { throw new Error('toMatchInlineSnapshot cannot be used with "not"') @@ -162,6 +171,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { chai.Assertion.prototype, 'toThrowErrorMatchingSnapshot', function (this: Record, message?: string) { + utils.flag(this, '_name', 'toThrowErrorMatchingSnapshot') const isNot = utils.flag(this, 'negate') if (isNot) { throw new Error( diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 00897b4f0a53..4ccfa155e408 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -1,6 +1,7 @@ import type { ContextRPC, WorkerGlobalState } from '../types/worker' import type { VitestWorker } from './workers/types' import { pathToFileURL } from 'node:url' +import { createStackString, parseStacktrace } from '@vitest/utils/source-map' import { workerId as poolId } from 'tinypool' import { ModuleCacheMap } from 'vite-node/client' import { loadEnvironment } from '../integrations/env/loader' @@ -90,6 +91,9 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC) { }, rpc, providedContext: ctx.providedContext, + onFilterStackTrace(stack) { + return createStackString(parseStacktrace(stack)) + }, } satisfies WorkerGlobalState const methodName = method === 'collect' ? 'collectTests' : 'runTests' diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index b6dbf9c18032..2c8b9a0fc0c5 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -47,4 +47,5 @@ export interface WorkerGlobalState { environment: number prepare: number } + onFilterStackTrace?: (trace: string) => string } diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 15820327d6b2..64b8a94fc632 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -101,8 +101,8 @@ log with a stack error with a stack ❯ test/logs.test.ts:59:10 `.trim()) - // console.trace doens't add additional stack trace - expect(stderr).not.toMatch('test/logs.test.ts:60:10') + // console.trace processes the stack trace correctly + expect(stderr).toMatch('test/logs.test.ts:60:10') }) test.runIf(browser === 'webkit')(`logs have stack traces in safari`, () => { @@ -115,16 +115,21 @@ log with a stack error with a stack ❯ test/logs.test.ts:59:16 `.trim()) - // console.trace doens't add additional stack trace - expect(stderr).not.toMatch('test/logs.test.ts:60:16') + // console.trace processes the stack trace correctly + expect(stderr).toMatch('test/logs.test.ts:60:16') }) test(`stack trace points to correct file in every browser`, () => { // dependeing on the browser it references either `.toBe()` or `expect()` - expect(stderr).toMatch(/test\/failing.test.ts:5:(12|17)/) + expect(stderr).toMatch(/test\/failing.test.ts:10:(12|17)/) // column is 18 in safari, 8 in others expect(stderr).toMatch(/throwError src\/error.ts:8:(18|8)/) + + 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.') + expect(stderr).toMatch(/test\/failing.test.ts:18:(27|36)/) + expect(stderr).toMatch(/test\/failing.test.ts:19:(27|33)/) + expect(stderr).toMatch(/test\/failing.test.ts:20:(27|39)/) }) test('popup apis should log a warning', () => { diff --git a/test/browser/test/failing.test.ts b/test/browser/test/failing.test.ts index 14cb207fe897..96495127452e 100644 --- a/test/browser/test/failing.test.ts +++ b/test/browser/test/failing.test.ts @@ -1,6 +1,11 @@ +import { page } from '@vitest/browser/context' import { expect, it } from 'vitest' import { throwError } from '../src/error' +document.body.innerHTML = ` + +` + it('correctly fails and prints a diff', () => { expect(1).toBe(2) }) @@ -8,3 +13,9 @@ it('correctly fails and prints a diff', () => { it('correctly print error in another file', () => { throwError() }) + +it('several locator methods are not awaited', () => { + page.getByRole('button').dblClick() + page.getByRole('button').click() + page.getByRole('button').tripleClick() +}) diff --git a/test/browser/test/userEvent.test.ts b/test/browser/test/userEvent.test.ts index 10900dd1a3cf..2b81643bfcf8 100644 --- a/test/browser/test/userEvent.test.ts +++ b/test/browser/test/userEvent.test.ts @@ -11,9 +11,14 @@ const userEvent = _uE.setup() describe('userEvent.click', () => { test('correctly clicks a button', async () => { + const wrapper = document.createElement('div') + wrapper.style.height = '100px' + wrapper.style.width = '200px' + wrapper.style.backgroundColor = 'red' + wrapper.style.display = 'flex' + wrapper.style.justifyContent = 'center' + wrapper.style.alignItems = 'center' const button = document.createElement('button') - button.style.height = '100px' - button.style.width = '200px' button.textContent = 'Click me' document.body.appendChild(button) const onClick = vi.fn() diff --git a/test/cli/fixtures/fails/poll-no-awaited.test.ts b/test/cli/fixtures/fails/poll-no-awaited.test.ts new file mode 100644 index 000000000000..b6eeab4f8d3d --- /dev/null +++ b/test/cli/fixtures/fails/poll-no-awaited.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from 'vitest'; + +test('poll is not awaited once', () => { + expect.poll(() => 2).toBe(2) +}) + +test('poll is not awaited several times', () => { + expect.poll(() => 3).toBe(3) + expect.poll(() => 'string').not.toBe('correct') +}) + +test('poll is not awaited but there is an async assertion afterwards', async () => { + expect.poll(() => 4).toBe(4) + await expect(new Promise((r) => setTimeout(() => r(3), 50))).resolves.toBe(3) +}) + +test('poll is not awaited but there is an error afterwards', async () => { + expect.poll(() => 4).toBe(4) + expect(3).toBe(4) +}) diff --git a/test/cli/test/__snapshots__/fails.test.ts.snap b/test/cli/test/__snapshots__/fails.test.ts.snap index 2b27922890ed..8b6c55f29a16 100644 --- a/test/cli/test/__snapshots__/fails.test.ts.snap +++ b/test/cli/test/__snapshots__/fails.test.ts.snap @@ -60,6 +60,15 @@ exports[`should fail no-assertions.test.ts > no-assertions.test.ts 1`] = `"Error 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."`; +exports[`should fail poll-no-awaited.test.ts > poll-no-awaited.test.ts 1`] = ` +"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: +AssertionError: expected 3 to be 4 // Object.is equality +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: +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: +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: +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:" +`; + exports[`should fail primitive-error.test.ts > primitive-error.test.ts 1`] = `"Unknown Error: 42"`; exports[`should fail snapshot-with-not.test.ts > snapshot-with-not.test.ts 1`] = ` diff --git a/test/cli/test/fails.test.ts b/test/cli/test/fails.test.ts index b9e7e3343bea..037fc6c3c86f 100644 --- a/test/cli/test/fails.test.ts +++ b/test/cli/test/fails.test.ts @@ -1,8 +1,9 @@ +import type { TestCase } from 'vitest/node' import { resolve } from 'pathe' + import { glob } from 'tinyglobby' import { expect, it } from 'vitest' - -import { runVitest } from '../../test-utils' +import { runInlineTests, runVitest, ts } from '../../test-utils' const root = resolve(__dirname, '../fixtures/fails') 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 expect(stdout).not.toMatch('Coverage report from istanbul') }) + +it('prints a warning if the assertion is not awaited', async () => { + const { stderr, results, root } = await runInlineTests({ + 'base.test.js': ts` + import { expect, test } from 'vitest'; + + test('single not awaited', () => { + expect(Promise.resolve(1)).resolves.toBe(1) + }) + + test('several not awaited', () => { + expect(Promise.resolve(1)).resolves.toBe(1) + expect(Promise.reject(1)).rejects.toBe(1) + }) + + test('not awaited and failed', () => { + expect(Promise.resolve(1)).resolves.toBe(1) + expect(1).toBe(2) + }) + + test('toMatchSnapshot not awaited', () => { + expect(1).toMatchFileSnapshot('./snapshot.txt') + }) + `, + }) + expect(results[0].children.size).toEqual(4) + const failedTest = results[0].children.at(2) as TestCase + expect(failedTest.result()).toEqual({ + state: 'failed', + errors: [ + expect.objectContaining({ + message: expect.stringContaining('expected 1 to be 2'), + }), + ], + }) + const warnings: string[] = [] + const lines = stderr.split('\n') + lines.forEach((line, index) => { + if (line.includes('Promise returned by')) { + warnings.push(lines.slice(index, index + 2).join('\n').replace(`${root}/`, '/')) + } + }) + expect(warnings).toMatchInlineSnapshot(` + [ + "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. + at /base.test.js:5:33", + "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. + at /base.test.js:10:32", + "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. + at /base.test.js:9:33", + "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. + at /base.test.js:14:33", + "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. + at /base.test.js:19:17", + ] + `) +}) + +it('prints a warning if the assertion is not awaited in the browser mode', async () => { + const { stderr } = await runInlineTests({ + './vitest.config.js': { + test: { + browser: { + enabled: true, + name: 'chromium', + provider: 'playwright', + headless: true, + }, + }, + }, + 'base.test.js': ts` + import { expect, test } from 'vitest'; + + test('single not awaited', () => { + expect(Promise.resolve(1)).resolves.toBe(1) + }) + `, + }) + expect(stderr).toContain('Promise returned by \`expect(actual).resolves.toBe(expected)\` was not awaited') + expect(stderr).toContain('base.test.js:5:33') +}) diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index eebe9535e8fe..d63bb4429b12 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -1,7 +1,7 @@ import type { Options } from 'tinyexec' import type { UserConfig as ViteUserConfig } from 'vite' import type { WorkspaceProjectConfiguration } from 'vitest/config' -import type { UserConfig, Vitest, VitestRunMode } from 'vitest/node' +import type { TestModule, UserConfig, Vitest, VitestRunMode } from 'vitest/node' import { webcrypto as crypto } from 'node:crypto' import fs from 'node:fs' import { Readable, Writable } from 'node:stream' @@ -291,6 +291,12 @@ export async function runInlineTests( }) return { fs, + root, ...vitest, + get results() { + return (vitest.ctx?.state.getFiles() || []).map(file => vitest.ctx?.state.getReportedEntity(file) as TestModule) + }, } } + +export const ts = String.raw