diff --git a/docs/.vitepress/components.d.ts b/docs/.vitepress/components.d.ts index 5805e44bf38f..b509b57215f9 100644 --- a/docs/.vitepress/components.d.ts +++ b/docs/.vitepress/components.d.ts @@ -13,8 +13,6 @@ declare module 'vue' { HomePage: typeof import('./components/HomePage.vue')['default'] ListItem: typeof import('./components/ListItem.vue')['default'] NonProjectOption: typeof import('./components/NonProjectOption.vue')['default'] - RouterLink: typeof import('vue-router')['RouterLink'] - RouterView: typeof import('vue-router')['RouterView'] Version: typeof import('./components/Version.vue')['default'] } } diff --git a/docs/api/expect.md b/docs/api/expect.md index 04c947dcadbb..ca467bbd7578 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -59,6 +59,47 @@ test('expect.soft test', () => { `expect.soft` can only be used inside the [`test`](/api/#test) function. ::: +## poll + +- **Type:** `ExpectStatic & (actual: () => any, options: { interval, timeout, message }) => Assertions` + +`expect.poll` reruns the _assertion_ until it is succeeded. You can configure how many times Vitest should rerun the `expect.poll` callback by setting `interval` and `timeout` options. + +If an error is thrown inside the `expect.poll` callback, Vitest will retry again until the timeout runs out. + +```ts twoslash +function asyncInjectElement() { + // example function +} + +// ---cut--- +import { expect, test } from 'vitest' + +test('element exists', async () => { + asyncInjectElement() + + await expect.poll(() => document.querySelector('.element')).toBeTruthy() +}) +``` + +::: warning +`expect.poll` makes every assertion asynchronous, so do not forget to await it otherwise you might get unhandled promise rejections. + +`expect.poll` doesn't work with several matchers: + +- Snapshot matchers are not supported because they will always succeed. If your condition is flaky, consider using [`vi.waitFor`](/api/vi#vi-waitfor) instead to resolve it first: + +```ts +import { expect, vi } from 'vitest' + +const flakyValue = await vi.waitFor(() => getFlakyValue()) +expect(flakyValue).toMatchSnapshot() +``` + +- `.resolves` and `.rejects` are not supported. `expect.poll` already awaits the condition if it's asynchronous. +- `toThrow` and its aliases are not supported because the `expect.poll` condition is always resolved before the matcher gets the value +::: + ## not Using `not` will negate the assertion. For example, this code asserts that an `input` value is not equal to `2`. If it's equal, the assertion will throw an error, and the test will fail. diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index cb377ead6bf8..59107879df7c 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -722,6 +722,13 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return this.be.satisfy(matcher, message) }) + // @ts-expect-error @internal + def('withContext', function (this: any, context: Record) { + for (const key in context) + utils.flag(this, key, context[key]) + return this + }) + utils.addProperty(chai.Assertion.prototype, 'resolves', function __VITEST_RESOLVES__(this: any) { const error = new Error('resolves') utils.flag(this, 'promise', 'resolves') @@ -729,6 +736,9 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const test: Test = utils.flag(this, 'vitest-test') const obj = utils.flag(this, 'object') + if (utils.flag(this, 'poll')) + throw new SyntaxError(`expect.poll() is not supported in combination with .resolves`) + if (typeof obj?.then !== 'function') throw new TypeError(`You must provide a Promise to expect() when using .resolves, not '${typeof obj}'.`) @@ -772,6 +782,9 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const obj = utils.flag(this, 'object') const wrapper = typeof obj === 'function' ? obj() : obj // for jest compat + if (utils.flag(this, 'poll')) + throw new SyntaxError(`expect.poll() is not supported in combination with .rejects`) + if (typeof wrapper?.then !== 'function') throw new TypeError(`You must provide a Promise to expect() when using .rejects, not '${typeof wrapper}'.`) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index bbe27df36ddd..3acb0ee4616e 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -40,6 +40,8 @@ function getMatcherState(assertion: Chai.AssertionStatic & Chai.Assertion, expec equals, // needed for built-in jest-snapshots, but we don't use it suppressedErrors: [], + soft: util.flag(assertion, 'soft') as boolean | undefined, + poll: util.flag(assertion, 'poll') as boolean | undefined, } return { diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 7b1ed307f0c1..5afc34018451 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -70,6 +70,7 @@ export interface MatcherState { subsetEquality: Tester } soft?: boolean + poll?: boolean } export interface SyncExpectationResult { @@ -91,12 +92,7 @@ export type MatchersObject = Record(actual: T, message?: string): Assertion - unreachable: (message?: string) => never - soft: (actual: T, message?: string) => Assertion extend: (expects: MatchersObject) => void - addEqualityTesters: (testers: Array) => void - assertions: (expected: number) => void - hasAssertions: () => void anything: () => any any: (constructor: unknown) => any getState: () => MatcherState @@ -175,13 +171,15 @@ type Promisify = { : O[K] } +export type PromisifyAssertion = Promisify> + export interface Assertion extends VitestAssertion, JestAssertion { toBeTypeOf: (expected: 'bigint' | 'boolean' | 'function' | 'number' | 'object' | 'string' | 'symbol' | 'undefined') => void toHaveBeenCalledOnce: () => void toSatisfy: (matcher: (value: E) => boolean, message?: string) => void - resolves: Promisify> - rejects: Promisify> + resolves: PromisifyAssertion + rejects: PromisifyAssertion } declare global { diff --git a/packages/expect/src/utils.ts b/packages/expect/src/utils.ts index aa1754d57f78..9b9c0a05e480 100644 --- a/packages/expect/src/utils.ts +++ b/packages/expect/src/utils.ts @@ -1,8 +1,6 @@ import { processError } from '@vitest/utils/error' import type { Test } from '@vitest/runner/types' -import { GLOBAL_EXPECT } from './constants' -import { getState } from './state' -import type { Assertion, MatcherState } from './types' +import type { Assertion } from './types' export function recordAsyncExpect(test: any, promise: Promise | PromiseLike) { // record promise for test, that resolves before test ends @@ -25,16 +23,11 @@ export function recordAsyncExpect(test: any, promise: Promise | PromiseLike export function wrapSoft(utils: Chai.ChaiUtils, fn: (this: Chai.AssertionStatic & Assertion, ...args: any[]) => void) { return function (this: Chai.AssertionStatic & Assertion, ...args: any[]) { - const test: Test = utils.flag(this, 'vitest-test') - - // @ts-expect-error local is untyped - const state: MatcherState = test?.context._local - ? test.context.expect.getState() - : getState((globalThis as any)[GLOBAL_EXPECT]) - - if (!state.soft) + if (!utils.flag(this, 'soft')) return fn.apply(this, args) + const test: Test = utils.flag(this, 'vitest-test') + if (!test) throw new Error('expect.soft() can only be used inside a test') diff --git a/packages/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index 9ed4c439202c..1127337d8fb3 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -9,11 +9,12 @@ import type { Assertion, ExpectStatic } from '@vitest/expect' import type { MatcherState } from '../../types/chai' import { getTestName } from '../../utils/tasks' import { getCurrentEnvironment, getWorkerState } from '../../utils/global' +import { createExpectPoll } from './poll' export function createExpect(test?: TaskPopulated) { const expect = ((value: any, message?: string): Assertion => { const { assertionCalls } = getState(expect) - setState({ assertionCalls: assertionCalls + 1, soft: false }, expect) + setState({ assertionCalls: assertionCalls + 1 }, expect) const assert = chai.expect(value, message) as unknown as Assertion const _test = test || getCurrentTest() if (_test) @@ -51,13 +52,12 @@ export function createExpect(test?: TaskPopulated) { addCustomEqualityTesters(customTesters) expect.soft = (...args) => { - const assert = expect(...args) - expect.setState({ - soft: true, - }) - return assert + // @ts-expect-error private soft access + return expect(...args).withContext({ soft: true }) as Assertion } + expect.poll = createExpectPoll(expect) + expect.unreachable = (message?: string) => { chai.assert.fail(`expected${message ? ` "${message}" ` : ' '}not to be reached`) } diff --git a/packages/vitest/src/integrations/chai/poll.ts b/packages/vitest/src/integrations/chai/poll.ts new file mode 100644 index 000000000000..8627e132dcdf --- /dev/null +++ b/packages/vitest/src/integrations/chai/poll.ts @@ -0,0 +1,80 @@ +import * as chai from 'chai' +import type { ExpectStatic } from '@vitest/expect' +import { getSafeTimers } from '@vitest/utils' + +// these matchers are not supported because they don't make sense with poll +const unsupported = [ + // .poll is meant to retry matchers until they succeed, and + // snapshots will always succeed as long as the poll method doesn't thow an error + // in this case using the `vi.waitFor` method is more appropriate + 'matchSnapshot', + 'toMatchSnapshot', + 'toMatchInlineSnapshot', + 'toThrowErrorMatchingSnapshot', + 'toThrowErrorMatchingInlineSnapshot', + // toThrow will never succeed because we call the poll callback until it doesn't throw + 'throws', + 'Throw', + 'throw', + 'toThrow', + 'toThrowError', + // these are not supported because you can call them without `.poll`, + // we throw an error inside the rejects/resolves methods to prevent this + // rejects, + // resolves +] + +export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] { + return function poll(fn, options = {}) { + const { interval = 50, timeout = 1000, message } = options + // @ts-expect-error private poll access + const assertion = expect(null, message).withContext({ poll: true }) as Assertion + const proxy: any = new Proxy(assertion, { + get(target, key, receiver) { + const result = Reflect.get(target, key, receiver) + + if (typeof result !== 'function') + return result instanceof chai.Assertion ? proxy : result + + if (key === 'assert') + return result + + if (typeof key === 'string' && unsupported.includes(key)) + throw new SyntaxError(`expect.poll() is not supported in combination with .${key}(). Use vi.waitFor() if your assertion condition is unstable.`) + + return function (this: any, ...args: any[]) { + const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR') + return new Promise((resolve, reject) => { + let intervalId: any + let lastError: any + const { setTimeout, clearTimeout } = getSafeTimers() + const timeoutId = setTimeout(() => { + clearTimeout(intervalId) + reject(copyStackTrace(new Error(`Matcher did not succeed in ${timeout}ms`, { cause: lastError }), STACK_TRACE_ERROR)) + }, timeout) + const check = async () => { + try { + chai.util.flag(this, 'object', await fn()) + resolve(await result.call(this, ...args)) + clearTimeout(intervalId) + clearTimeout(timeoutId) + } + catch (err) { + lastError = err + intervalId = setTimeout(check, interval) + } + } + check() + }) + } + }, + }) + return proxy + } +} + +function copyStackTrace(target: Error, source: Error) { + if (source.stack !== undefined) + target.stack = source.stack.replace(source.message, target.message) + return target +} diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index 8af1a1f2c515..64118194fb45 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -160,6 +160,8 @@ const skipErrorProperties = new Set([ 'stackStr', 'type', 'showDiff', + 'ok', + 'operator', 'diff', 'codeFrame', 'actual', diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index 66f3b6e26807..864530249a8d 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -1,6 +1,6 @@ import type { Plugin as PrettyFormatPlugin } from 'pretty-format' import type { SnapshotState } from '@vitest/snapshot' -import type { ExpectStatic } from '@vitest/expect' +import type { ExpectStatic, PromisifyAssertion, Tester } from '@vitest/expect' import type { UserConsoleLog } from './general' import type { VitestEnvironment } from './config' import type { BenchmarkResult } from './benchmark' @@ -33,7 +33,19 @@ declare module '@vitest/expect' { snapshotState: SnapshotState } + interface ExpectPollOptions { + interval?: number + timeout?: number + message?: string + } + interface ExpectStatic { + unreachable: (message?: string) => never + soft: (actual: T, message?: string) => Assertion + poll: (actual: () => T, options?: ExpectPollOptions) => PromisifyAssertion> + addEqualityTesters: (testers: Array) => void + assertions: (expected: number) => void + hasAssertions: () => void addSnapshotSerializer: (plugin: PrettyFormatPlugin) => void } diff --git a/test/core/test/expect-poll.test.ts b/test/core/test/expect-poll.test.ts new file mode 100644 index 000000000000..20fb6a81e9e8 --- /dev/null +++ b/test/core/test/expect-poll.test.ts @@ -0,0 +1,85 @@ +import { expect, test, vi } from 'vitest' + +test('simple usage', async () => { + await expect.poll(() => false).toBe(false) + await expect.poll(() => false).not.toBe(true) + // .resolves allowed after .poll + await expect(Promise.resolve(1)).resolves.toBe(1) + + await expect(async () => { + await expect.poll(() => Promise.resolve(1)).resolves.toBe(1) + }).rejects.toThrowError('expect.poll() is not supported in combination with .resolves') + await expect(async () => { + await expect.poll(() => Promise.reject(new Error('empty'))).rejects.toThrowError('empty') + }).rejects.toThrowError('expect.poll() is not supported in combination with .rejects') + + const unsupported = [ + 'matchSnapshot', + 'toMatchSnapshot', + 'toMatchInlineSnapshot', + 'throws', + 'Throw', + 'throw', + 'toThrow', + 'toThrowError', + 'toThrowErrorMatchingSnapshot', + 'toThrowErrorMatchingInlineSnapshot', + ] as const + + for (const key of unsupported) { + await expect(async () => { + await expect.poll(() => Promise.resolve(1))[key as 'matchSnapshot']() + }).rejects.toThrowError(`expect.poll() is not supported in combination with .${key}(). Use vi.waitFor() if your assertion condition is unstable.`) + } +}) + +test('timeout', async () => { + await expect(async () => { + await expect.poll(() => false, { timeout: 100, interval: 10 }).toBe(true) + }).rejects.toThrowError(expect.objectContaining({ + message: 'Matcher did not succeed in 100ms', + stack: expect.stringContaining('expect-poll.test.ts:38:68'), + cause: expect.objectContaining({ + message: 'expected false to be true // Object.is equality', + }), + })) +}) + +test('interval', async () => { + const fn = vi.fn(() => true) + await expect(async () => { + // using big values because CI can be slow + await expect.poll(fn, { interval: 100, timeout: 500 }).toBe(false) + }).rejects.toThrowError() + // CI can be unstable, but there should be always at least 5 calls + expect(fn.mock.calls.length >= 4).toBe(true) +}) + +test('fake timers don\'t break it', async () => { + const now = Date.now() + vi.useFakeTimers() + await expect(async () => { + await expect.poll(() => false, { timeout: 100 }).toBe(true) + }).rejects.toThrowError('Matcher did not succeed in 100ms') + vi.useRealTimers() + const diff = Date.now() - now + expect(diff >= 100).toBe(true) +}) + +test('custom matcher works correctly', async () => { + const fn = vi.fn() + let idx = 0 + expect.extend({ + toBeJestCompatible() { + idx++ + fn({ poll: this.poll }) + return { + pass: idx > 2, + message: () => 'ok', + } + }, + }) + await expect.poll(() => 1, { interval: 10 }).toBeJestCompatible() + expect(fn).toHaveBeenCalledTimes(3) + expect(fn).toHaveBeenCalledWith({ poll: true }) +}) diff --git a/test/core/vite.config.ts b/test/core/vite.config.ts index 8460d106a2ee..e9d52328914b 100644 --- a/test/core/vite.config.ts +++ b/test/core/vite.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ port: 3022, }, test: { - reporters: ['verbose'], + reporters: ['dot'], api: { port: 3023, },