From 1d3d5f7022e23bc2acfbd096c9abd937d5eed537 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 20 Jul 2026 16:31:52 -0700 Subject: [PATCH] feat(expect): add expect.fn() mock functions with awaited assertions expect.fn() creates a mock function that records calls, results and settled results. Mock assertions like toHaveBeenCalledWith() and toHaveResolvedWith() are asynchronous and retried until they pass or the expect timeout is reached, so mocks can be handed to concurrent code, e.g. exposed into the page via page.evaluate(). Assertions whose outcome can no longer change fail fast instead of waiting. Implementations are async-only because they run in the test process. Values set with mockReturnValue() are serialized along with the function and consumed synchronously by the page-side callback stub. --- docs/src/api/class-fnassertions.md | 326 ++++++++++ packages/isomorphic/index.ts | 1 + .../isomorphic/utilityScriptSerializers.ts | 35 +- .../playwright-core/src/client/jsHandle.ts | 15 +- packages/playwright/src/matchers/expect.ts | 26 +- .../playwright/src/matchers/expectLibrary.ts | 2 + packages/playwright/src/matchers/matchers.ts | 11 +- .../playwright/src/matchers/mockFunctions.ts | 373 ++++++++++++ packages/playwright/types/test.d.ts | 396 ++++++++++++- packages/protocol/spec/serialized.yml | 8 + packages/protocol/src/serializers.ts | 9 +- packages/protocol/src/structs.d.ts | 1 + packages/protocol/src/validator.ts | 1 + tests/page/page-evaluate-callback.spec.ts | 121 ++++ tests/playwright-test/expect-fn.spec.ts | 555 ++++++++++++++++++ utils/doclint/linkUtils.js | 1 + utils/generate_types/index.js | 2 + utils/generate_types/overrides-test.d.ts | 133 ++++- 18 files changed, 1979 insertions(+), 37 deletions(-) create mode 100644 docs/src/api/class-fnassertions.md create mode 100644 packages/playwright/src/matchers/mockFunctions.ts create mode 100644 tests/playwright-test/expect-fn.spec.ts diff --git a/docs/src/api/class-fnassertions.md b/docs/src/api/class-fnassertions.md new file mode 100644 index 0000000000000..3ebe0f9847e12 --- /dev/null +++ b/docs/src/api/class-fnassertions.md @@ -0,0 +1,326 @@ +# class: FnAssertions +* since: v1.62 +* langs: js + +The [FnAssertions] class provides assertion methods that can be used to make assertions about mock functions created with `expect.fn()`. Mock functions record their calls and results, so that the test can assert on how they were used, for example when handed to concurrent code as callbacks. + +Assertions over a mock function are asynchronous and retried until they pass or the expect timeout is reached, similarly to the web-first assertions. Await them to get reliable results: + +```js +import { test, expect } from '@playwright/test'; + +test('records a click', async ({ page }) => { + const callback = expect.fn(); + await page.evaluate(({ callback }) => { + document.addEventListener('click', () => callback('clicked')); + }, { callback }, { exposeFunctions: true }); + await page.locator('body').click(); + await expect(callback).toHaveBeenCalledWith('clicked'); +}); +``` + +## property: FnAssertions.not +* since: v1.62 +- returns: <[FnAssertions]> + +Makes the assertion check for the opposite condition. + +**Usage** + +```js +const callback = expect.fn(); +await expect(callback).not.toHaveBeenCalled(); +``` + +## async method: FnAssertions.toHaveBeenCalled +* since: v1.62 + +Ensures the mock function has been called at least once. + +**Usage** + +```js +const callback = expect.fn(); +callback('hello'); +await expect(callback).toHaveBeenCalled(); +``` + +## async method: FnAssertions.toHaveBeenCalledTimes +* since: v1.62 + +Ensures the mock function has been called exactly the expected number of times. Note that the assertion fails right away when the mock function has been called more times than expected, because the call count can only grow. + +**Usage** + +```js +const callback = expect.fn(); +callback('one'); +callback('two'); +await expect(callback).toHaveBeenCalledTimes(2); +``` + +### param: FnAssertions.toHaveBeenCalledTimes.count +* since: v1.62 +- `count` <[int]> + +Expected number of calls. + +## async method: FnAssertions.toHaveBeenCalledWith +* since: v1.62 + +Ensures the mock function has been called at least once with the specified arguments. Arguments are compared with the same algorithm as [`method: GenericAssertions.toEqual`], so asymmetric matchers like `expect.objectContaining()` are supported. + +**Usage** + +```js +const callback = expect.fn(); +callback({ title: 'Hello', id: 17 }); +await expect(callback).toHaveBeenCalledWith(expect.objectContaining({ title: 'Hello' })); +``` + +### param: FnAssertions.toHaveBeenCalledWith.args +* since: v1.62 +- `args` <[Array]<[any]>> + +Expected arguments. + +## async method: FnAssertions.toHaveBeenLastCalledWith +* since: v1.62 + +Ensures the last call of the mock function was made with the specified arguments. + +**Usage** + +```js +const callback = expect.fn(); +callback('first'); +callback('last'); +await expect(callback).toHaveBeenLastCalledWith('last'); +``` + +### param: FnAssertions.toHaveBeenLastCalledWith.args +* since: v1.62 +- `args` <[Array]<[any]>> + +Expected arguments. + +## async method: FnAssertions.toHaveBeenNthCalledWith +* since: v1.62 + +Ensures the n-th call of the mock function was made with the specified arguments. + +**Usage** + +```js +const callback = expect.fn(); +callback('first'); +callback('second'); +await expect(callback).toHaveBeenNthCalledWith(2, 'second'); +``` + +### param: FnAssertions.toHaveBeenNthCalledWith.n +* since: v1.62 +- `n` <[int]> + +One-based call index. + +### param: FnAssertions.toHaveBeenNthCalledWith.args +* since: v1.62 +- `args` <[Array]<[any]>> + +Expected arguments. + +## async method: FnAssertions.toHaveLastResolvedWith +* since: v1.62 + +Ensures the last call of the mock function resolved with the specified value. The assertion waits for the pending result of the call to settle. + +**Usage** + +```js +const load = expect.fn(async () => 'loaded'); +await load(); +await expect(load).toHaveLastResolvedWith('loaded'); +``` + +### param: FnAssertions.toHaveLastResolvedWith.value +* since: v1.62 +- `value` <[any]> + +Expected resolved value. + +## async method: FnAssertions.toHaveLastReturnedWith +* since: v1.62 + +Ensures the last call of the mock function returned the specified value. For mock functions with an async implementation, the returned value is a promise, see [`method: FnAssertions.toHaveLastResolvedWith`] instead. + +**Usage** + +```js +const callback = expect.fn().mockReturnValue('value'); +callback(); +await expect(callback).toHaveLastReturnedWith('value'); +``` + +### param: FnAssertions.toHaveLastReturnedWith.value +* since: v1.62 +- `value` <[any]> + +Expected return value. + +## async method: FnAssertions.toHaveNthResolvedWith +* since: v1.62 + +Ensures the n-th call of the mock function resolved with the specified value. The assertion waits for the pending result of the call to settle. + +**Usage** + +```js +const load = expect.fn(async id => `loaded ${id}`); +await load(1); +await load(2); +await expect(load).toHaveNthResolvedWith(2, 'loaded 2'); +``` + +### param: FnAssertions.toHaveNthResolvedWith.n +* since: v1.62 +- `n` <[int]> + +One-based call index. + +### param: FnAssertions.toHaveNthResolvedWith.value +* since: v1.62 +- `value` <[any]> + +Expected resolved value. + +## async method: FnAssertions.toHaveNthReturnedWith +* since: v1.62 + +Ensures the n-th call of the mock function returned the specified value. For mock functions with an async implementation, the returned value is a promise, see [`method: FnAssertions.toHaveNthResolvedWith`] instead. + +**Usage** + +```js +const callback = expect.fn().mockReturnValueOnce('first').mockReturnValue('rest'); +callback(); +callback(); +await expect(callback).toHaveNthReturnedWith(1, 'first'); +await expect(callback).toHaveNthReturnedWith(2, 'rest'); +``` + +### param: FnAssertions.toHaveNthReturnedWith.n +* since: v1.62 +- `n` <[int]> + +One-based call index. + +### param: FnAssertions.toHaveNthReturnedWith.value +* since: v1.62 +- `value` <[any]> + +Expected return value. + +## async method: FnAssertions.toHaveResolved +* since: v1.62 + +Ensures the mock function has resolved successfully at least once. A call counts as resolved when its returned promise has been fulfilled, or when it returned a non-promise value. + +**Usage** + +```js +const load = expect.fn(async () => 'loaded'); +await load(); +await expect(load).toHaveResolved(); +``` + +## async method: FnAssertions.toHaveResolvedTimes +* since: v1.62 + +Ensures the mock function has resolved successfully exactly the expected number of times. Calls that threw, returned a rejected promise, or are still pending do not count. + +**Usage** + +```js +const load = expect.fn(async () => 'loaded'); +await load(); +await load(); +await expect(load).toHaveResolvedTimes(2); +``` + +### param: FnAssertions.toHaveResolvedTimes.count +* since: v1.62 +- `count` <[int]> + +Expected number of resolved calls. + +## async method: FnAssertions.toHaveResolvedWith +* since: v1.62 + +Ensures the mock function has resolved with the specified value at least once. Values are compared with the same algorithm as [`method: GenericAssertions.toEqual`], so asymmetric matchers like `expect.objectContaining()` are supported. + +**Usage** + +```js +const load = expect.fn(async id => ({ id, title: 'Hello' })); +await load(17); +await expect(load).toHaveResolvedWith(expect.objectContaining({ id: 17 })); +``` + +### param: FnAssertions.toHaveResolvedWith.value +* since: v1.62 +- `value` <[any]> + +Expected resolved value. + +## async method: FnAssertions.toHaveReturned +* since: v1.62 + +Ensures the mock function has returned at least once, i.e. has been called and did not throw. Note that a mock function with an async implementation returns a promise and therefore counts as returned even if that promise is later rejected. + +**Usage** + +```js +const callback = expect.fn().mockReturnValue('value'); +callback(); +await expect(callback).toHaveReturned(); +``` + +## async method: FnAssertions.toHaveReturnedTimes +* since: v1.62 + +Ensures the mock function has returned exactly the expected number of times. Calls that threw do not count. + +**Usage** + +```js +const callback = expect.fn().mockReturnValue('value'); +callback(); +callback(); +await expect(callback).toHaveReturnedTimes(2); +``` + +### param: FnAssertions.toHaveReturnedTimes.count +* since: v1.62 +- `count` <[int]> + +Expected number of returns. + +## async method: FnAssertions.toHaveReturnedWith +* since: v1.62 + +Ensures the mock function has returned the specified value at least once. Values are compared with the same algorithm as [`method: GenericAssertions.toEqual`], so asymmetric matchers like `expect.objectContaining()` are supported. For mock functions with an async implementation, the returned value is a promise, see [`method: FnAssertions.toHaveResolvedWith`] instead. + +**Usage** + +```js +const callback = expect.fn().mockReturnValue({ title: 'Hello' }); +callback(); +await expect(callback).toHaveReturnedWith(expect.objectContaining({ title: 'Hello' })); +``` + +### param: FnAssertions.toHaveReturnedWith.value +* since: v1.62 +- `value` <[any]> + +Expected return value. diff --git a/packages/isomorphic/index.ts b/packages/isomorphic/index.ts index 4a623a16c3e12..1b06bcea11dc2 100644 --- a/packages/isomorphic/index.ts +++ b/packages/isomorphic/index.ts @@ -35,6 +35,7 @@ export * from './time'; export * from './timeoutRunner'; export * from './trace/snapshotServer'; export * from './urlMatch'; +export * from './utilityScriptSerializers'; export * from './cssParser'; export * from './locatorParser'; export * from './selectorParser'; diff --git a/packages/isomorphic/utilityScriptSerializers.ts b/packages/isomorphic/utilityScriptSerializers.ts index 012fa00e14e61..490a41ac484d7 100644 --- a/packages/isomorphic/utilityScriptSerializers.ts +++ b/packages/isomorphic/utilityScriptSerializers.ts @@ -16,11 +16,6 @@ type TypedArrayKind = 'i8' | 'ui8' | 'ui8c' | 'i16' | 'ui16' | 'i32' | 'ui32' | 'f32' | 'f64' | 'bi64' | 'bui64'; -// Name prefix of the page bindings backing the functions passed to evaluate() -// as arguments. Only functions carrying this prefix serialize as { fn }, -// arbitrary functions are dropped as before. -export const kFunctionBindingPrefix = '__pw_fn_'; - export const kBindingsControllerProperty = '__playwright__binding__controller__'; export type SerializedValue = @@ -35,7 +30,7 @@ export type SerializedValue = { o: { k: string, v: SerializedValue }[], id: number } | { ref: number } | { h: number } | - { fn: string } | + { fn: string, fn_rv?: SerializedValue[] } | { ta: { b: string, k: TypedArrayKind } } | { ab: { b: string } }; @@ -187,8 +182,20 @@ export function parseEvaluationResultValue(value: SerializedValue, handles: any[ return handles[value.h]; if ('fn' in value) { const name = value.fn; - // eslint-disable-next-line no-restricted-globals - return (...args: any[]) => (globalThis as any)[kBindingsControllerProperty].callBinding(name, ...args); + if (!value.fn_rv) { + // eslint-disable-next-line no-restricted-globals + return (...args: any[]) => (globalThis as any)[kBindingsControllerProperty].callBinding(name, ...args); + } + // Values are consumed in order, the last one repeats for all remaining calls. + const values = value.fn_rv.map(v => parseEvaluationResultValue(v, handles, refs)); + return (...args: any[]) => { + // Still route every call to the client so that it is recorded. + // eslint-disable-next-line no-restricted-globals + const promise = (globalThis as any)[kBindingsControllerProperty].callBinding(name, ...args); + // The client records the result, the caller consumes the local value. + promise.catch(() => {}); + return values.length > 1 ? values.shift() : values[0]; + }; } if ('ta' in value) return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]); @@ -314,6 +321,16 @@ function innerSerialize(value: any, handleSerializer: (value: any) => HandleOrVa return { o, id }; } - if (typeof value === 'function' && value.name.startsWith(kFunctionBindingPrefix)) + if (typeof value === 'function' && value.name.startsWith(kCallbackPrefix)) { + const returnValues = (value as any)[kCallbackReturnValuesProperty] as CallbackReturnValues | undefined; + if (returnValues) + return { fn: value.name, fn_rv: returnValues.map(v => serialize(v, handleSerializer, visitorInfo)) }; return { fn: value.name }; + } } + +// Never empty; the last value is the default that repeats for all remaining calls. +export type CallbackReturnValues = any[]; +export const kCallbackPrefix = '__pw_fn_'; +export const kCallbackReturnValuesProperty = '__pw_callback_rv__'; +export const kCallbackReturnValuesSymbol = Symbol.for('playwright.callbackReturnValues'); diff --git a/packages/playwright-core/src/client/jsHandle.ts b/packages/playwright-core/src/client/jsHandle.ts index c0f20cc97965e..1df77dddf3bcf 100644 --- a/packages/playwright-core/src/client/jsHandle.ts +++ b/packages/playwright-core/src/client/jsHandle.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { kFunctionBindingPrefix } from '@isomorphic/utilityScriptSerializers'; -import { parseSerializedValue, serializeValue } from '@protocol/serializers'; +import { kCallbackPrefix, kCallbackReturnValuesSymbol } from '@isomorphic/utilityScriptSerializers'; +import { parseSerializedValue, serializePlainValue, serializeValue } from '@protocol/serializers'; import { createGuid } from '@utils/crypto'; import { ChannelOwner } from './channelOwner'; import { isTargetClosedError } from './errors'; @@ -104,8 +104,13 @@ export function serializeArgument(arg: any, registerCallback?: (callback: Functi const value = serializeValue(arg, value => { if (value instanceof JSHandle) return { h: pushHandle(value._channel) }; - if (typeof value === 'function' && registerCallback) - return { fn: registerCallback(value as Function) }; + if (typeof value === 'function' && registerCallback) { + const fn = registerCallback(value as Function); + const returnValues = (value as any)[kCallbackReturnValuesSymbol]?.(); + if (returnValues) + return { fn, fn_rv: returnValues.map((v: any) => serializePlainValue(v)) }; + return { fn }; + } return { fallThrough: value }; }); return { value, handles }; @@ -119,7 +124,7 @@ export async function serializeArgumentWithCallbacks(owner: ChannelOwner, p const serialized = serializeArgument(arg, callback => { if (!page) throw new Error('Passing a function is not supported as an argument here'); - const name = kFunctionBindingPrefix + createGuid(); + const name = kCallbackPrefix + createGuid(); exposePromises.push(page._exposeEvaluateCallback(name, callback)); return name; }); diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index a027b71bc0949..833e587565218 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -77,8 +77,10 @@ import { toHaveURL, toHaveValue, toHaveValues, - toPass + toPass, + timeoutFailureMessage } from './matchers'; +import { createMockFunction, mockMatchers } from './mockFunctions'; import { toMatchAriaSnapshot } from './toMatchAriaSnapshot'; import { toHaveScreenshot, toMatchSnapshot } from './toMatchSnapshot'; @@ -211,11 +213,16 @@ const customAsyncMatchers = { toPass, }; +const retryingMatchers = { + ...customAsyncMatchers, + ...mockMatchers, +}; + const allBuiltinMatchers: MatchersObject = { ...expectMatchers, toThrow: createThrowMatcher('toThrow'), toThrowError: createThrowMatcher('toThrowError'), - ...customAsyncMatchers, + ...retryingMatchers, toMatchSnapshot, } as any; @@ -253,6 +260,8 @@ function createExpect(info: ExpectMetaInfo): Expect<{}> { notAsymmetric[name] = inverse; } + expectFn.fn = createMockFunction; + expectFn.getState = () => ({}); expectFn.configure = (configuration: { message?: string, timeout?: number, soft?: boolean }) => { @@ -429,7 +438,7 @@ async function invokePollMatcher( ): Promise { if (typeof actual !== 'function') throw new Error('`expect.poll()` accepts only function as a first argument'); - if (promise || (customAsyncMatchers as any)[matcherName]) + if (promise || (retryingMatchers as any)[matcherName]) throw new Error(`\`expect.poll()\` does not support "${promise ?? matcherName}" matcher.`); const testInfo = expectConfig().testInfo; @@ -448,15 +457,8 @@ async function invokePollMatcher( return { continuePolling: true, result: error }; } }, deadline, poll.intervals ?? [100, 250, 500, 1000]); - if (result.timedOut) { - const message = result.result ? [ - result.result.message, - '', - `Call Log:`, - `- ${timeoutMessage}`, - ].join('\n') : timeoutMessage; - return { pass: !!info.isNot, message: () => message }; - } + if (result.timedOut) + return { pass: !!info.isNot, message: () => timeoutFailureMessage(result.result?.message, timeoutMessage) }; return { pass: !info.isNot, message: () => '' }; } diff --git a/packages/playwright/src/matchers/expectLibrary.ts b/packages/playwright/src/matchers/expectLibrary.ts index 03af2f597560d..8925b5595eaa4 100644 --- a/packages/playwright/src/matchers/expectLibrary.ts +++ b/packages/playwright/src/matchers/expectLibrary.ts @@ -164,6 +164,8 @@ export const utils = Object.freeze({ subsetEquality, }); +export { equals }; + function hasProperty(obj: object | null, property: string | symbol): boolean { if (!obj) return false; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 0bdf1b3ac7710..e7b91adf54d01 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -517,12 +517,7 @@ export async function toPass( }, deadline, intervals); if (result.timedOut) { - const message = result.result ? [ - result.result.message, - '', - `Call Log:`, - `- ${timeoutMessage}`, - ].join('\n') : timeoutMessage; + const message = timeoutFailureMessage(result.result?.message, timeoutMessage); return { message: () => message, pass: !!this.isNot }; } return { pass: !this.isNot, message: () => '' }; @@ -542,6 +537,10 @@ export function computeMatcherTitleSuffix(matcherName: string, receiver: any, ar return {}; } +export function timeoutFailureMessage(message: string | undefined, timeoutMessage: string): string { + return message ? [message, '', `Call Log:`, `- ${timeoutMessage}`].join('\n') : timeoutMessage; +} + export function deadlineForMatcher(testInfo: ExpectTestInfo | null, timeout: number): { deadline: number; timeoutMessage: string } { const startTime = monotonicTime(); const matcherDeadline = timeout ? startTime + timeout : 0; diff --git a/packages/playwright/src/matchers/mockFunctions.ts b/packages/playwright/src/matchers/mockFunctions.ts new file mode 100644 index 0000000000000..44bbf32eea4bb --- /dev/null +++ b/packages/playwright/src/matchers/mockFunctions.ts @@ -0,0 +1,373 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { pollAgainstDeadline } from '@isomorphic/timeoutRunner'; +import { kCallbackReturnValuesSymbol } from '@isomorphic/utilityScriptSerializers'; + +import { expectConfig } from './expect'; +import { equals, isPromise, utils } from './expectLibrary'; +import { deadlineForMatcher, timeoutFailureMessage } from './matchers'; + +import type { CallbackReturnValues } from '@isomorphic/utilityScriptSerializers'; +import type { SyncExpectationResult } from './expectLibrary'; +import type { ExpectMatcherState } from '../../types/test'; + +type MockResult = { type: 'return' | 'throw', value: any }; +type MockSettledResult = { type: 'incomplete' | 'fulfilled' | 'rejected', value: any }; + +// Plain values are distinguished from implementations so that they can be +// serialized and consumed synchronously by the page, see kCallbackReturnValuesSymbol. +type MockBehavior = { impl: (...args: any[]) => any } | { value: any }; + +const kMockState = Symbol('mockFunctionState'); + +class MockFunctionState { + calls: any[][] = []; + results: MockResult[] = []; + settledResults: MockSettledResult[] = []; + defaultBehavior: MockBehavior | undefined; + onceBehaviors: MockBehavior[] = []; + name = 'expect.fn()'; + private _originalBehavior: MockBehavior | undefined; + + constructor(implementation?: (...args: any[]) => any) { + this.defaultBehavior = implementation ? { impl: implementation } : undefined; + this._originalBehavior = this.defaultBehavior; + } + + clear() { + this.calls = []; + this.results = []; + this.settledResults = []; + } + + reset() { + this.clear(); + // Following vitest, reset to the implementation originally passed to expect.fn(). + this.defaultBehavior = this._originalBehavior; + this.onceBehaviors = []; + } + + // Values that can be shipped to the page for synchronous consumption, with + // the last value repeating for all remaining calls. Only available when the + // entire behavior is made of values - any implementation in the mix makes + // all page-side calls go through the asynchronous roundtrip instead. + callbackReturnValues(): CallbackReturnValues | undefined { + if (!this.defaultBehavior && !this.onceBehaviors.length) + return undefined; + if (this.defaultBehavior && !('value' in this.defaultBehavior)) + return undefined; + if (this.onceBehaviors.some(behavior => !('value' in behavior))) + return undefined; + const values = this.onceBehaviors.map(behavior => (behavior as { value: any }).value); + values.push(this.defaultBehavior ? (this.defaultBehavior as { value: any }).value : undefined); + return values; + } +} + +export function createMockFunction(implementation?: (...args: any[]) => any): any { + const state = new MockFunctionState(implementation); + const fn: any = function(this: any, ...args: any[]) { + state.calls.push(args); + const behavior = state.onceBehaviors.shift() ?? state.defaultBehavior; + const settled: MockSettledResult = { type: 'incomplete', value: undefined }; + state.settledResults.push(settled); + try { + const value = !behavior ? undefined : 'value' in behavior ? behavior.value : behavior.impl.apply(this, args); + state.results.push({ type: 'return', value }); + if (isPromise(value)) { + value.then( + (resolved: any) => { settled.type = 'fulfilled'; settled.value = resolved; }, + (error: any) => { settled.type = 'rejected'; settled.value = error; }); + } else { + settled.type = 'fulfilled'; + settled.value = value; + } + return value; + } catch (error) { + state.results.push({ type: 'throw', value: error }); + settled.type = 'rejected'; + settled.value = error; + throw error; + } + }; + fn[kMockState] = state; + fn[kCallbackReturnValuesSymbol] = () => state.callbackReturnValues(); + fn.mock = { + get calls() { return state.calls; }, + get results() { return state.results; }, + get settledResults() { return state.settledResults; }, + get lastCall() { return state.calls.length ? state.calls[state.calls.length - 1] : undefined; }, + }; + fn.mockClear = () => { state.clear(); return fn; }; + fn.mockReset = () => { state.reset(); return fn; }; + fn.mockImplementation = (impl: (...args: any[]) => any) => { state.defaultBehavior = { impl }; return fn; }; + fn.mockImplementationOnce = (impl: (...args: any[]) => any) => { state.onceBehaviors.push({ impl }); return fn; }; + fn.mockReturnValue = (value: any) => { state.defaultBehavior = { value }; return fn; }; + fn.mockReturnValueOnce = (value: any) => { state.onceBehaviors.push({ value }); return fn; }; + fn.mockResolvedValue = (value: any) => { state.defaultBehavior = { impl: () => Promise.resolve(value) }; return fn; }; + fn.mockResolvedValueOnce = (value: any) => { state.onceBehaviors.push({ impl: () => Promise.resolve(value) }); return fn; }; + fn.mockRejectedValue = (error: any) => { state.defaultBehavior = { impl: () => Promise.reject(error) }; return fn; }; + fn.mockRejectedValueOnce = (error: any) => { state.onceBehaviors.push({ impl: () => Promise.reject(error) }); return fn; }; + fn.mockName = (name: string) => { state.name = name; return fn; }; + fn.getMockName = () => state.name; + return fn; +} + +type MockCheckResult = SyncExpectationResult & { + // Set when the outcome can never change with more calls, e.g. the call count + // already exceeded the expectation. Stops polling early. + terminal?: boolean; +}; + +function mockStateFor(matcherName: string, receiver: any): MockFunctionState { + const state = receiver?.[kMockState] as MockFunctionState | undefined; + if (!state) + throw new Error(`${matcherName}() can only be used with a mock function created by expect.fn()`); + return state; +} + +function createMockMatcher(matcherName: string, check: (state: MockFunctionState, context: ExpectMatcherState, args: any[]) => MockCheckResult) { + return async function(this: ExpectMatcherState, receiver: any, ...args: any[]): Promise { + const state = mockStateFor(matcherName, receiver); + const isNot = !!this.isNot; + const first = check(state, this, args); + if (first.pass !== isNot || first.terminal) + return first; + const { deadline, timeoutMessage } = deadlineForMatcher(expectConfig().testInfo, this.timeout); + const result = await pollAgainstDeadline(async () => { + const checkResult = check(state, this, args); + return { continuePolling: checkResult.pass === isNot && !checkResult.terminal, result: checkResult }; + }, deadline); + const last = result.result ?? first; + if (result.timedOut) + return { pass: last.pass, message: () => timeoutFailureMessage(last.message(), timeoutMessage) }; + return last; + }; +} + +function matchesExpected(actual: any, expected: any): boolean { + return equals(actual, expected, [utils.iterableEquality]); +} + +function printArgs(args: any[], print: (value: unknown) => string): string { + return args.length ? args.map(arg => print(arg)).join(', ') : 'called with 0 arguments'; +} + +function ensureInteger(matcherName: string, argName: string, value: any, min: 0 | 1) { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min) + throw new Error(`${matcherName}: ${argName} must be a ${min === 1 ? 'positive' : 'non-negative'} integer, received ${utils.stringify(value)}`); +} + +// Each matcher family observes one list of per-call items: the arguments for +// toHaveBeenCalled*, the returned values for toHaveReturned*, and the settled +// results for toHaveResolved*. The five matcher shapes below are shared +// between the families. +type MockItemFamily = { + items(state: MockFunctionState): Item[]; + // Whether the item counts towards the family total, e.g. a call that threw + // is not a "return". + counts(item: Item): boolean; + // `expected` are the matcher arguments, without the `n` for the nth shape. + matches(item: Item, expected: any[]): boolean; + printExpected(expected: any[]): string; + printItem(item: Item): string; + // Rendering of the item for the "Received:" line; undefined omits the line. + printItemInline(item: Item): string | undefined; + // Whether the item can still change, e.g. a pending promise can settle later. + isFinal(item: Item): boolean; + countNoun: string; + itemNoun: string; + receivedLabel: string; + expectedHint: string; +}; + +const calledFamily: MockItemFamily = { + items: state => state.calls, + counts: () => true, + matches: (call, expected) => matchesExpected(call, expected), + printExpected: expected => printArgs(expected, utils.printExpected), + printItem: call => printArgs(call, utils.printReceived), + printItemInline: call => printArgs(call, utils.printReceived), + isFinal: () => true, + countNoun: 'calls', + itemNoun: 'call', + receivedLabel: 'Received calls:', + expectedHint: '...expected', +}; + +const returnedFamily: MockItemFamily = { + items: state => state.results, + counts: result => result.type === 'return', + matches: (result, [expected]) => result.type === 'return' && matchesExpected(result.value, expected), + printExpected: ([expected]) => utils.printExpected(expected), + printItem: result => `${result.type === 'throw' ? 'threw ' : ''}${utils.printReceived(result.value)}`, + printItemInline: result => `${result.type === 'throw' ? 'threw ' : ''}${utils.printReceived(result.value)}`, + isFinal: () => true, + countNoun: 'returns', + itemNoun: 'result', + receivedLabel: 'Received returns:', + expectedHint: 'expected', +}; + +const resolvedFamily: MockItemFamily = { + items: state => state.settledResults, + counts: result => result.type === 'fulfilled', + matches: (result, [expected]) => result.type === 'fulfilled' && matchesExpected(result.value, expected), + printExpected: ([expected]) => utils.printExpected(expected), + printItem: result => result.type === 'incomplete' ? 'incomplete' : `${result.type === 'rejected' ? 'rejected ' : ''}${utils.printReceived(result.value)}`, + printItemInline: result => result.type === 'incomplete' ? undefined : `${result.type === 'rejected' ? 'rejected ' : ''}${utils.printReceived(result.value)}`, + isFinal: result => result.type !== 'incomplete', + countNoun: 'resolved values', + itemNoun: 'result', + receivedLabel: 'Received results:', + expectedHint: 'expected', +}; + +function matcherHintFor(matcherName: string, state: MockFunctionState, context: ExpectMatcherState, expectedArg: string): string { + return utils.matcherHint(matcherName, state.name, expectedArg, { isNot: context.isNot, promise: context.promise }); +} + +function printItemsList(items: Item[], family: MockItemFamily): string[] { + const limit = 5; + const lines: string[] = []; + const start = Math.max(0, items.length - limit); + if (start > 0) + lines.push(` ... ${start} earlier ${family.itemNoun}${start === 1 ? '' : 's'} ...`); + for (let i = start; i < items.length; i++) + lines.push(` ${i + 1}: ${family.printItem(items[i])}`); + return lines; +} + +function countItems(items: Item[], family: MockItemFamily): number { + let count = 0; + for (const item of items) { + if (family.counts(item)) + count++; + } + return count; +} + +function atLeastOnceMatcher(matcherName: string, family: MockItemFamily) { + return createMockMatcher(matcherName, (state, context) => { + const items = family.items(state); + const count = countItems(items, family); + const pass = count > 0; + const message = () => [ + matcherHintFor(matcherName, state, context, ''), + '', + `Expected number of ${family.countNoun}: ${context.isNot ? utils.printExpected(0) : `>= ${utils.printExpected(1)}`}`, + `Received number of ${family.countNoun}: ${utils.printReceived(count)}`, + ...(items.length ? ['', ...printItemsList(items, family)] : []), + ].join('\n'); + // The count only grows, so a failing `.not` can never recover. + return { pass, message, terminal: pass && !!context.isNot }; + }); +} + +function exactTimesMatcher(matcherName: string, family: MockItemFamily) { + return createMockMatcher(matcherName, (state, context, [expected]) => { + ensureInteger(matcherName, 'expected', expected, 0); + const count = countItems(family.items(state), family); + const pass = count === expected; + const message = () => [ + matcherHintFor(matcherName, state, context, 'expected'), + '', + `Expected number of ${family.countNoun}: ${context.isNot ? 'not ' : ''}${utils.printExpected(expected)}`, + ...(pass ? [] : [`Received number of ${family.countNoun}: ${utils.printReceived(count)}`]), + ].join('\n'); + // The count only grows, so once it exceeds the expectation it can never match again. + return { pass, message, terminal: !context.isNot && count > expected }; + }); +} + +function withMatcher(matcherName: string, family: MockItemFamily) { + return createMockMatcher(matcherName, (state, context, expected) => { + const items = family.items(state); + const pass = items.some(item => family.matches(item, expected)); + const message = () => [ + matcherHintFor(matcherName, state, context, family.expectedHint), + '', + `Expected: ${context.isNot ? 'not ' : ''}${family.printExpected(expected)}`, + ...(items.length ? [family.receivedLabel, ...printItemsList(items, family)] : []), + '', + `Number of calls: ${utils.printReceived(state.calls.length)}`, + ].join('\n'); + // A matching item cannot be undone, so a failing `.not` can never recover. + return { pass, message, terminal: pass && !!context.isNot }; + }); +} + +function lastWithMatcher(matcherName: string, family: MockItemFamily) { + return createMockMatcher(matcherName, (state, context, expected) => { + const items = family.items(state); + const last = items.length ? items[items.length - 1] : undefined; + const pass = !!last && family.matches(last, expected); + const message = () => { + const received = last === undefined ? undefined : family.printItemInline(last); + return [ + matcherHintFor(matcherName, state, context, family.expectedHint), + '', + `Expected: ${context.isNot ? 'not ' : ''}${family.printExpected(expected)}`, + ...(received === undefined ? [] : [`Received: ${received}`]), + '', + `Number of calls: ${utils.printReceived(state.calls.length)}`, + ].join('\n'); + }; + // Not terminal - a later call becomes the new last item. + return { pass, message }; + }); +} + +function nthWithMatcher(matcherName: string, family: MockItemFamily) { + return createMockMatcher(matcherName, (state, context, [n, ...expected]) => { + ensureInteger(matcherName, 'n', n, 1); + const item = family.items(state)[n - 1]; + const pass = !!item && family.matches(item, expected); + const message = () => { + const received = item === undefined ? undefined : family.printItemInline(item); + return [ + matcherHintFor(matcherName, state, context, `n, ${family.expectedHint}`), + '', + `n: ${n}`, + `Expected: ${context.isNot ? 'not ' : ''}${family.printExpected(expected)}`, + ...(received === undefined ? [] : [`Received: ${received}`]), + '', + `Number of calls: ${utils.printReceived(state.calls.length)}`, + ].join('\n'); + }; + // Once made and settled, the n-th item never changes. + return { pass, message, terminal: !!item && family.isFinal(item) && pass === !!context.isNot }; + }); +} + +export const mockMatchers = { + toHaveBeenCalled: atLeastOnceMatcher('toHaveBeenCalled', calledFamily), + toHaveBeenCalledTimes: exactTimesMatcher('toHaveBeenCalledTimes', calledFamily), + toHaveBeenCalledWith: withMatcher('toHaveBeenCalledWith', calledFamily), + toHaveBeenLastCalledWith: lastWithMatcher('toHaveBeenLastCalledWith', calledFamily), + toHaveBeenNthCalledWith: nthWithMatcher('toHaveBeenNthCalledWith', calledFamily), + toHaveReturned: atLeastOnceMatcher('toHaveReturned', returnedFamily), + toHaveReturnedTimes: exactTimesMatcher('toHaveReturnedTimes', returnedFamily), + toHaveReturnedWith: withMatcher('toHaveReturnedWith', returnedFamily), + toHaveLastReturnedWith: lastWithMatcher('toHaveLastReturnedWith', returnedFamily), + toHaveNthReturnedWith: nthWithMatcher('toHaveNthReturnedWith', returnedFamily), + toHaveResolved: atLeastOnceMatcher('toHaveResolved', resolvedFamily), + toHaveResolvedTimes: exactTimesMatcher('toHaveResolvedTimes', resolvedFamily), + toHaveResolvedWith: withMatcher('toHaveResolvedWith', resolvedFamily), + toHaveLastResolvedWith: lastWithMatcher('toHaveLastResolvedWith', resolvedFamily), + toHaveNthResolvedWith: nthWithMatcher('toHaveNthResolvedWith', resolvedFamily), +}; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 472c9e021ac9b..4d1db9c233753 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -8575,6 +8575,376 @@ type FunctionAssertions = { toPass(options?: { timeout?: number, intervals?: number[] }): Promise; }; +/** + * The [FnAssertions](https://playwright.dev/docs/api/class-fnassertions) class provides assertion methods that can be + * used to make assertions about mock functions created with `expect.fn()`. Mock functions record their calls and + * results, so that the test can assert on how they were used, for example when handed to concurrent code as + * callbacks. + * + * Assertions over a mock function are asynchronous and retried until they pass or the expect timeout is reached, + * similarly to the web-first assertions. Await them to get reliable results: + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('records a click', async ({ page }) => { + * const callback = expect.fn(); + * await page.evaluate(({ callback }) => { + * document.addEventListener('click', () => callback('clicked')); + * }, { callback }, { exposeFunctions: true }); + * await page.locator('body').click(); + * await expect(callback).toHaveBeenCalledWith('clicked'); + * }); + * ``` + * + */ +interface FnAssertions { + /** + * Makes the assertion check for the opposite condition. + * + * **Usage** + * + * ```js + * const callback = expect.fn(); + * await expect(callback).not.toHaveBeenCalled(); + * ``` + * + */ + not: FnAssertions; + /** + * Ensures the mock function has been called at least once. + * + * **Usage** + * + * ```js + * const callback = expect.fn(); + * callback('hello'); + * await expect(callback).toHaveBeenCalled(); + * ``` + * + */ + toHaveBeenCalled(): Promise; + /** + * Ensures the mock function has been called exactly the expected number of times. Note that the assertion fails right + * away when the mock function has been called more times than expected, because the call count can only grow. + * + * **Usage** + * + * ```js + * const callback = expect.fn(); + * callback('one'); + * callback('two'); + * await expect(callback).toHaveBeenCalledTimes(2); + * ``` + * + * @param count Expected number of calls. + */ + toHaveBeenCalledTimes(count: number): Promise; + /** + * Ensures the mock function has been called at least once with the specified arguments. Arguments are compared with + * the same algorithm as + * [expect(value).toEqual(expected)](https://playwright.dev/docs/api/class-genericassertions#generic-assertions-to-equal), + * so asymmetric matchers like `expect.objectContaining()` are supported. + * + * **Usage** + * + * ```js + * const callback = expect.fn(); + * callback({ title: 'Hello', id: 17 }); + * await expect(callback).toHaveBeenCalledWith(expect.objectContaining({ title: 'Hello' })); + * ``` + * + * @param args Expected arguments. + */ + toHaveBeenCalledWith(...args: Array): Promise; + /** + * Ensures the last call of the mock function was made with the specified arguments. + * + * **Usage** + * + * ```js + * const callback = expect.fn(); + * callback('first'); + * callback('last'); + * await expect(callback).toHaveBeenLastCalledWith('last'); + * ``` + * + * @param args Expected arguments. + */ + toHaveBeenLastCalledWith(...args: Array): Promise; + /** + * Ensures the n-th call of the mock function was made with the specified arguments. + * + * **Usage** + * + * ```js + * const callback = expect.fn(); + * callback('first'); + * callback('second'); + * await expect(callback).toHaveBeenNthCalledWith(2, 'second'); + * ``` + * + * @param n One-based call index. + * @param args Expected arguments. + */ + toHaveBeenNthCalledWith(n: number, ...args: Array): Promise; + /** + * Ensures the last call of the mock function resolved with the specified value. The assertion waits for the pending + * result of the call to settle. + * + * **Usage** + * + * ```js + * const load = expect.fn(async () => 'loaded'); + * await load(); + * await expect(load).toHaveLastResolvedWith('loaded'); + * ``` + * + * @param value Expected resolved value. + */ + toHaveLastResolvedWith(value: unknown): Promise; + /** + * Ensures the last call of the mock function returned the specified value. For mock functions with an async + * implementation, the returned value is a promise, see + * [expect(mockFunction).toHaveLastResolvedWith(value)](https://playwright.dev/docs/api/class-fnassertions#fn-assertions-to-have-last-resolved-with) + * instead. + * + * **Usage** + * + * ```js + * const callback = expect.fn().mockReturnValue('value'); + * callback(); + * await expect(callback).toHaveLastReturnedWith('value'); + * ``` + * + * @param value Expected return value. + */ + toHaveLastReturnedWith(value: unknown): Promise; + /** + * Ensures the n-th call of the mock function resolved with the specified value. The assertion waits for the pending + * result of the call to settle. + * + * **Usage** + * + * ```js + * const load = expect.fn(async id => `loaded ${id}`); + * await load(1); + * await load(2); + * await expect(load).toHaveNthResolvedWith(2, 'loaded 2'); + * ``` + * + * @param n One-based call index. + * @param value Expected resolved value. + */ + toHaveNthResolvedWith(n: number, value: unknown): Promise; + /** + * Ensures the n-th call of the mock function returned the specified value. For mock functions with an async + * implementation, the returned value is a promise, see + * [expect(mockFunction).toHaveNthResolvedWith(n, value)](https://playwright.dev/docs/api/class-fnassertions#fn-assertions-to-have-nth-resolved-with) + * instead. + * + * **Usage** + * + * ```js + * const callback = expect.fn().mockReturnValueOnce('first').mockReturnValue('rest'); + * callback(); + * callback(); + * await expect(callback).toHaveNthReturnedWith(1, 'first'); + * await expect(callback).toHaveNthReturnedWith(2, 'rest'); + * ``` + * + * @param n One-based call index. + * @param value Expected return value. + */ + toHaveNthReturnedWith(n: number, value: unknown): Promise; + /** + * Ensures the mock function has resolved successfully at least once. A call counts as resolved when its returned + * promise has been fulfilled, or when it returned a non-promise value. + * + * **Usage** + * + * ```js + * const load = expect.fn(async () => 'loaded'); + * await load(); + * await expect(load).toHaveResolved(); + * ``` + * + */ + toHaveResolved(): Promise; + /** + * Ensures the mock function has resolved successfully exactly the expected number of times. Calls that threw, + * returned a rejected promise, or are still pending do not count. + * + * **Usage** + * + * ```js + * const load = expect.fn(async () => 'loaded'); + * await load(); + * await load(); + * await expect(load).toHaveResolvedTimes(2); + * ``` + * + * @param count Expected number of resolved calls. + */ + toHaveResolvedTimes(count: number): Promise; + /** + * Ensures the mock function has resolved with the specified value at least once. Values are compared with the same + * algorithm as + * [expect(value).toEqual(expected)](https://playwright.dev/docs/api/class-genericassertions#generic-assertions-to-equal), + * so asymmetric matchers like `expect.objectContaining()` are supported. + * + * **Usage** + * + * ```js + * const load = expect.fn(async id => ({ id, title: 'Hello' })); + * await load(17); + * await expect(load).toHaveResolvedWith(expect.objectContaining({ id: 17 })); + * ``` + * + * @param value Expected resolved value. + */ + toHaveResolvedWith(value: unknown): Promise; + /** + * Ensures the mock function has returned at least once, i.e. has been called and did not throw. Note that a mock + * function with an async implementation returns a promise and therefore counts as returned even if that promise is + * later rejected. + * + * **Usage** + * + * ```js + * const callback = expect.fn().mockReturnValue('value'); + * callback(); + * await expect(callback).toHaveReturned(); + * ``` + * + */ + toHaveReturned(): Promise; + /** + * Ensures the mock function has returned exactly the expected number of times. Calls that threw do not count. + * + * **Usage** + * + * ```js + * const callback = expect.fn().mockReturnValue('value'); + * callback(); + * callback(); + * await expect(callback).toHaveReturnedTimes(2); + * ``` + * + * @param count Expected number of returns. + */ + toHaveReturnedTimes(count: number): Promise; + /** + * Ensures the mock function has returned the specified value at least once. Values are compared with the same + * algorithm as + * [expect(value).toEqual(expected)](https://playwright.dev/docs/api/class-genericassertions#generic-assertions-to-equal), + * so asymmetric matchers like `expect.objectContaining()` are supported. For mock functions with an async + * implementation, the returned value is a promise, see + * [expect(mockFunction).toHaveResolvedWith(value)](https://playwright.dev/docs/api/class-fnassertions#fn-assertions-to-have-resolved-with) + * instead. + * + * **Usage** + * + * ```js + * const callback = expect.fn().mockReturnValue({ title: 'Hello' }); + * callback(); + * await expect(callback).toHaveReturnedWith(expect.objectContaining({ title: 'Hello' })); + * ``` + * + * @param value Expected return value. + */ + toHaveReturnedWith(value: unknown): Promise; + +} + +/** + * A mock function created by [`expect.fn()`](https://playwright.dev/docs/test-assertions). Records all calls and + * their results, to be asserted with `await expect(mockFunction).toHaveBeenCalledWith(...)` and similar assertions. + */ +export interface MockFunction { + (...args: Args): ReturnValue | Promise; + /** + * Recorded calls and results. + */ + mock: { + /** + * Arguments of each recorded call. + */ + calls: Args[]; + /** + * Result of each recorded call, either a returned value or a thrown error. + */ + results: { type: 'return' | 'throw', value: any }[]; + /** + * Settled result of each recorded call: `'fulfilled'` once the returned promise resolves (immediately for + * non-promise values), `'rejected'` when the call throws or the promise rejects, `'incomplete'` while pending. + */ + settledResults: { type: 'incomplete' | 'fulfilled' | 'rejected', value: any }[]; + /** + * Arguments of the last recorded call, if any. + */ + lastCall: Args | undefined; + }; + /** + * Removes recorded calls and results, keeps the implementation. + */ + mockClear(): this; + /** + * Removes recorded calls and results, removes "once" implementations and values, and resets the implementation to + * the one originally passed to `expect.fn()`, if any. + */ + mockReset(): this; + /** + * Replaces the implementation of the mock function. The implementation runs in the test process, so when the mock + * function is passed into the page, for example to `page.evaluate()`, the page-side caller always receives a + * promise and must await it. That is why the implementation must be an async function. Use + * [mockFunction.mockReturnValue(value)](https://playwright.dev/docs/test-assertions) for values that should be + * available to the page synchronously. + */ + mockImplementation(implementation: (...args: Args) => Promise): this; + /** + * Adds an implementation to be used for a single call, in the order of registration. See + * [mockFunction.mockImplementation(implementation)](https://playwright.dev/docs/test-assertions) for why the + * implementation must be an async function. + */ + mockImplementationOnce(implementation: (...args: Args) => Promise): this; + /** + * Makes the mock function return the given value. When the mock function is passed into the page, for example to + * `page.evaluate()`, the value is serialized along with it and the page-side caller receives it synchronously. + */ + mockReturnValue(value: ReturnValue): this; + /** + * Makes the mock function return the given value for a single call, in the order of registration. Like + * [mockFunction.mockReturnValue(value)](https://playwright.dev/docs/test-assertions), the values are serialized + * into the page and consumed synchronously. + */ + mockReturnValueOnce(value: ReturnValue): this; + /** + * Makes the mock function return a promise resolved with the given value. + */ + mockResolvedValue(value: ReturnValue): this; + /** + * Makes the mock function return a promise resolved with the given value for a single call, in the order of registration. + */ + mockResolvedValueOnce(value: ReturnValue): this; + /** + * Makes the mock function return a promise rejected with the given error. + */ + mockRejectedValue(error: unknown): this; + /** + * Makes the mock function return a promise rejected with the given error for a single call, in the order of registration. + */ + mockRejectedValueOnce(error: unknown): this; + /** + * Sets the name of the mock function, used in the assertion error messages. + */ + mockName(name: string): this; + /** + * Returns the name of the mock function. + */ + getMockName(): string; +} + type BaseMatchers = GenericAssertions & PlaywrightTest.Matchers & SnapshotAssertions; type AllowedGenericMatchers = PlaywrightTest.Matchers & Pick, 'toBe' | 'toBeDefined' | 'toBeFalsy' | 'toBeNull' | 'toBeTruthy' | 'toBeUndefined'>; @@ -8582,8 +8952,8 @@ type SpecificMatchers = T extends Page ? PageAssertions & AllowedGenericMatchers : T extends Locator ? LocatorAssertions & AllowedGenericMatchers : T extends APIResponse ? APIResponseAssertions & AllowedGenericMatchers : - BaseMatchers & (T extends Function ? FunctionAssertions : {}); -type AllMatchers = PageAssertions & LocatorAssertions & APIResponseAssertions & FunctionAssertions & BaseMatchers; + BaseMatchers & (T extends Function ? FunctionAssertions & FnAssertions : {}); +type AllMatchers = PageAssertions & LocatorAssertions & APIResponseAssertions & FunctionAssertions & FnAssertions & BaseMatchers; type IfAny = 0 extends (1 & T) ? Y : N; type Awaited = T extends PromiseLike ? U : T; @@ -8669,6 +9039,28 @@ type PollMatchers = { export type Expect = { (actual: T, messageOrOptions?: string | { message?: string }): MakeMatchers; + /** + * Creates a {@link MockFunction} that records its calls and returned values, to be asserted with + * `await expect(mockFunction).toHaveBeenCalledWith(...)` and similar assertions. These assertions are asynchronous + * and retried until they pass or the expect timeout is reached, so the mock function can be handed to concurrent + * code, for example exposed into the page with + * [page.evaluate(pageFunction, arg, options)](https://playwright.dev/docs/api/class-page#page-evaluate) or + * [page.exposeBinding(name, callback, options)](https://playwright.dev/docs/api/class-page#page-expose-binding). + * + * **Usage** + * + * ```js + * const callback = expect.fn(); + * await page.evaluate(({ callback }) => { + * document.addEventListener('click', () => callback('clicked')); + * }, { callback }, { exposeFunctions: true }); + * await page.locator('body').click(); + * await expect(callback).toHaveBeenCalledWith('clicked'); + * ``` + * + * @param implementation Optional implementation to be invoked by the mock function. Must be an async function: the implementation runs in the test process, so a page-side caller always receives a promise. By default, the mock function returns `undefined`. + */ + fn(implementation?: (...args: Args) => Promise): MockFunction; soft: Expect; poll: (actual: () => T | Promise, messageOrOptions?: string | { message?: string, timeout?: number, intervals?: number[] }) => PollMatchers, T, ExtendedMatchers>; extend MatcherReturnType | Promise>>(matchers: MoreMatchers): Expect; diff --git a/packages/protocol/spec/serialized.yml b/packages/protocol/spec/serialized.yml index 5a1ddb22d0431..95d031c407ee1 100644 --- a/packages/protocol/spec/serialized.yml +++ b/packages/protocol/spec/serialized.yml @@ -82,6 +82,14 @@ SerializedValue: # Name of a page-side callback that routes back to a client-side function. # Used when passing a function as (part of) an evaluate argument. fn: string? + # Return values that accompany `fn` when the function has configured return + # values, e.g. a mock function created with expect.fn(). Never empty. + # Values are consumed in order, the last one repeats for all remaining + # calls. The page-side stub returns them synchronously, while still routing + # each call to the client for recording. + fn_rv: + type: array? + items: SerializedValue # Index of the object in value-type for circular reference resolution. id: int? # Ref to the object in value-type for circular reference resolution. diff --git a/packages/protocol/src/serializers.ts b/packages/protocol/src/serializers.ts index 399de41e35575..14a76db541e50 100644 --- a/packages/protocol/src/serializers.ts +++ b/packages/protocol/src/serializers.ts @@ -84,12 +84,19 @@ function innerParseSerializedValue(value: SerializedValue, handles: any[] | unde if (value.fn !== undefined) { const dummy = () => {}; Object.defineProperty(dummy, 'name', { value: value.fn }); + if (value.fn_rv !== undefined) { + // Keep the parsed return values on the dummy so that the page-bound + // serializer can pass them along. Each value was serialized separately, + // so each one is parsed with a fresh refs map. The property name must + // match kCallbackReturnValuesProperty in utilityScriptSerializers.ts. + (dummy as any)['__pw_callback_rv__'] = value.fn_rv.map(v => parseSerializedValue(v, undefined)); + } return dummy; } throw new Error(`Attempting to deserialize unexpected value${accessChainToDisplayString(accessChain)}: ${value}`); } -export type HandleOrValue = { h: number } | { fn: string } | { fallThrough: any }; +export type HandleOrValue = { h: number } | { fn: string, fn_rv?: SerializedValue[] } | { fallThrough: any }; type VisitorInfo = { visited: Map; lastId: number; diff --git a/packages/protocol/src/structs.d.ts b/packages/protocol/src/structs.d.ts index 30ee9c10c1149..701bf4dc8529c 100644 --- a/packages/protocol/src/structs.d.ts +++ b/packages/protocol/src/structs.d.ts @@ -285,6 +285,7 @@ export type SerializedValue = { }[], h?: number, fn?: string, + fn_rv?: SerializedValue[], id?: number, ref?: number, }; diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 5c080081f7e09..8e4c44e363bcf 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -2987,6 +2987,7 @@ scheme.SerializedValue = tObject({ }))), h: tOptional(tInt), fn: tOptional(tString), + fn_rv: tOptional(tArray(tType('SerializedValue'))), id: tOptional(tInt), ref: tOptional(tInt), }); diff --git a/tests/page/page-evaluate-callback.spec.ts b/tests/page/page-evaluate-callback.spec.ts index d7157f62cd1e9..6da67f6b281eb 100644 --- a/tests/page/page-evaluate-callback.spec.ts +++ b/tests/page/page-evaluate-callback.spec.ts @@ -211,3 +211,124 @@ it('should scope the page-side callback to the execution context', async ({ page await page.goto(server.EMPTY_PAGE); expect(await page.evaluate(() => typeof (window as any).__cb)).toBe('undefined'); }); + +it('should record calls to a mock function created with expect.fn()', async ({ page }) => { + const fn = expect.fn(); + await page.evaluate(async ({ cb }) => { + await cb('hello', 42); + // Fire-and-forget: the call is dispatched, but its recording may only + // arrive after the evaluation returns, so the assertions below must retry. + void cb('later'); + }, { cb: fn }, { exposeFunctions: true }); + await expect(fn).toHaveBeenCalledWith('hello', 42); + await expect(fn).toHaveBeenCalledWith('later'); + await expect(fn).toHaveBeenCalledTimes(2); + await expect(fn).toHaveBeenNthCalledWith(1, 'hello', 42); + await expect(fn).not.toHaveBeenCalledWith('never'); + expect(fn.mock.calls[0]).toEqual(['hello', 42]); +}); + +it('should return the expect.fn() implementation result to the page', async ({ page }) => { + const fn = expect.fn(async (n: number) => n * 2); + const result = await page.evaluate(async ({ double }) => await double(21), { double: fn }, { exposeFunctions: true }); + expect(result).toBe(42); + await expect(fn).toHaveBeenCalledWith(21); + await expect(fn).toHaveResolvedWith(42); +}); + +it('should return mock return values synchronously in the page', async ({ page }) => { + const fn = expect.fn().mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue({ deep: ['value'] }); + const values = await page.evaluate(({ cb }) => { + // Note: no await, the values are consumed synchronously. + return [cb('a'), cb('b'), cb('c'), cb('d')]; + }, { cb: fn }, { exposeFunctions: true }); + expect(values).toEqual([1, 2, { deep: ['value'] }, { deep: ['value'] }]); + await expect(fn).toHaveBeenCalledTimes(4); + await expect(fn).toHaveBeenNthCalledWith(1, 'a'); + await expect(fn).toHaveReturnedWith(1); +}); + +it('should deliver values asynchronously when mixed with an implementation', async ({ page }) => { + const fn = expect.fn(async () => 'from-node').mockReturnValueOnce('once'); + const values = await page.evaluate(async ({ cb }) => { + // A default implementation makes all page-side calls asynchronous. + return [await cb(), await cb()]; + }, { cb: fn }, { exposeFunctions: true }); + expect(values).toEqual(['once', 'from-node']); + await expect(fn).toHaveBeenCalledTimes(2); +}); + +it('should return undefined synchronously when once values run out', async ({ page }) => { + const fn = expect.fn().mockReturnValueOnce('only'); + const values = await page.evaluate(({ cb }) => [cb(), cb(), cb()], { cb: fn }, { exposeFunctions: true }); + expect(values).toEqual(['only', undefined, undefined]); + await expect(fn).toHaveBeenCalledTimes(3); +}); + +it('should populate mock.calls and mock.lastCall from page-side calls', async ({ page }) => { + const fn = expect.fn(); + await page.evaluate(async ({ cb }) => { + await cb('first', { n: 1 }); + await cb('second', [1, 2]); + await cb(); + }, { cb: fn }, { exposeFunctions: true }); + await expect(fn).toHaveBeenCalledTimes(3); + expect(fn.mock.calls).toEqual([['first', { n: 1 }], ['second', [1, 2]], []]); + expect(fn.mock.lastCall).toEqual([]); +}); + +it('should record serialized copies of page-side arguments', async ({ page }) => { + const fn = expect.fn(); + await page.evaluate(async ({ cb }) => { + const payload = { status: 'pending' }; + await cb(payload); + payload.status = 'done'; + }, { cb: fn }, { exposeFunctions: true }); + await expect(fn).toHaveBeenCalledTimes(1); + // Recorded arguments are serialized copies, so the page-side mutation + // after the call is not visible, unlike with in-process calls. + expect(fn.mock.calls[0]).toEqual([{ status: 'pending' }]); +}); + +it('should populate mock.results and mock.settledResults for implementations', async ({ page }) => { + const fn = expect.fn(async (n: number) => { + if (n < 0) + throw new Error('negative'); + return n * 2; + }); + const result = await page.evaluate(async ({ cb }) => { + const ok = await cb(21); + let error = 'none'; + try { + await cb(-1); + } catch (e) { + error = (e as Error).message; + } + return { ok, error }; + }, { cb: fn }, { exposeFunctions: true }); + expect(result.ok).toBe(42); + expect(result.error).toContain('negative'); + await expect(fn).toHaveResolvedTimes(1); + await expect(fn).toHaveResolvedWith(42); + await expect(fn).not.toHaveResolvedWith(-2); + expect(fn.mock.results).toEqual([ + { type: 'return', value: expect.any(Promise) }, + { type: 'return', value: expect.any(Promise) }, + ]); + expect(fn.mock.settledResults).toEqual([ + { type: 'fulfilled', value: 42 }, + { type: 'rejected', value: expect.any(Error) }, + ]); +}); + +it('should populate mock.results for synchronously consumed return values', async ({ page }) => { + const fn = expect.fn().mockReturnValueOnce(1).mockReturnValue(2); + await page.evaluate(({ cb }) => { + void cb('a'); + void cb('b'); + }, { cb: fn }, { exposeFunctions: true }); + await expect(fn).toHaveReturnedTimes(2); + expect(fn.mock.results).toEqual([{ type: 'return', value: 1 }, { type: 'return', value: 2 }]); + expect(fn.mock.settledResults).toEqual([{ type: 'fulfilled', value: 1 }, { type: 'fulfilled', value: 2 }]); + expect(fn.mock.lastCall).toEqual(['b']); +}); diff --git a/tests/playwright-test/expect-fn.spec.ts b/tests/playwright-test/expect-fn.spec.ts new file mode 100644 index 0000000000000..2ad36cfe109a7 --- /dev/null +++ b/tests/playwright-test/expect-fn.spec.ts @@ -0,0 +1,555 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './playwright-test-fixtures'; + +test('should record calls and support call assertions', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('mock function calls', async () => { + const fn = expect.fn(); + fn('a', 1); + fn('b'); + fn(); + await expect(fn).toHaveBeenCalled(); + await expect(fn).toHaveBeenCalledTimes(3); + await expect(fn).toHaveBeenCalledWith('a', 1); + await expect(fn).toHaveBeenCalledWith('b'); + await expect(fn).toHaveBeenLastCalledWith(); + await expect(fn).toHaveBeenNthCalledWith(1, 'a', 1); + await expect(fn).toHaveBeenNthCalledWith(2, 'b'); + await expect(fn).not.toHaveBeenCalledWith('c'); + await expect(fn).not.toHaveBeenCalledTimes(2); + expect(fn.mock.calls).toEqual([['a', 1], ['b'], []]); + expect(fn.mock.lastCall).toEqual([]); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should retry until the mock function is called', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('async callback', async () => { + const fn = expect.fn(); + setTimeout(() => fn('first'), 300); + setTimeout(() => fn('second'), 600); + await expect(fn).toHaveBeenCalledWith('first'); + await expect(fn).toHaveBeenCalledWith('second'); + await expect(fn).toHaveBeenCalledTimes(2); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should time out with a helpful message when never called', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('never called', async () => { + const fn = expect.fn(); + await expect.configure({ timeout: 500 })(fn).toHaveBeenCalled(); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('expect(expect.fn()).toHaveBeenCalled()'); + expect(result.output).toContain('Expected number of calls: >= 1'); + expect(result.output).toContain('Received number of calls: 0'); + expect(result.output).toContain('Timeout 500ms exceeded while waiting on the predicate'); +}); + +test('should respect mock name in error messages', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('named mock', async () => { + const fn = expect.fn().mockName('onChange'); + await expect.configure({ timeout: 100 })(fn).toHaveBeenCalled(); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('expect(onChange).toHaveBeenCalled()'); +}); + +test('should fail fast when the call count exceeds the expectation', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('too many calls', async () => { + test.setTimeout(3000); + const fn = expect.fn(); + fn(); + fn(); + fn(); + // Default expect timeout is 5 seconds, but the assertion must fail + // immediately because the call count can only grow. + await expect(fn).toHaveBeenCalledTimes(2); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Expected number of calls: 2'); + expect(result.output).toContain('Received number of calls: 3'); + expect(result.output).not.toContain('Test timeout of 3000ms exceeded'); +}); + +test('should fail fast on not.toHaveBeenCalled when already called', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('not called', async () => { + test.setTimeout(3000); + const fn = expect.fn(); + fn('a'); + await expect(fn).not.toHaveBeenCalled(); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Expected number of calls: 0'); + expect(result.output).toContain('Received number of calls: 1'); + expect(result.output).not.toContain('Test timeout of 3000ms exceeded'); +}); + +test('should support asymmetric matchers in toHaveBeenCalledWith', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('asymmetric matchers', async () => { + const fn = expect.fn(); + fn({ email: 'ellen@example.com', id: 42 }, 'extra'); + await expect(fn).toHaveBeenCalledWith(expect.objectContaining({ email: 'ellen@example.com' }), expect.any(String)); + await expect(fn).not.toHaveBeenCalledWith(expect.objectContaining({ email: 'other@example.com' }), expect.any(String)); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should support implementations and return value assertions', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('async implementations resolve', async () => { + const fn = expect.fn(async (a: number, b: number) => a + b); + expect(await fn(1, 2)).toBe(3); + await expect(fn).toHaveResolved(); + await expect(fn).toHaveResolvedTimes(1); + await expect(fn).toHaveResolvedWith(3); + await expect(fn).toHaveLastResolvedWith(3); + await expect(fn).toHaveNthResolvedWith(1, 3); + }); + test('return values are synchronous', async () => { + const stub = expect.fn() + .mockReturnValue('default') + .mockReturnValueOnce('first'); + expect(stub()).toBe('first'); + expect(stub()).toBe('default'); + await expect(stub).toHaveReturned(); + await expect(stub).toHaveReturnedTimes(2); + await expect(stub).toHaveReturnedWith('first'); + await expect(stub).toHaveLastReturnedWith('default'); + await expect(stub).toHaveNthReturnedWith(1, 'first'); + await expect(stub).toHaveNthReturnedWith(2, 'default'); + }); + test('resolved and rejected values', async () => { + const resolved = expect.fn().mockResolvedValue('value'); + expect(await resolved()).toBe('value'); + await expect(resolved).toHaveResolvedWith('value'); + + const rejected = expect.fn().mockRejectedValue(new Error('nope')); + await expect(rejected()).rejects.toThrow('nope'); + await expect(rejected).not.toHaveResolved(); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(3); +}); + +test('should not count thrown calls as returns', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('throwing mock', async () => { + const fn = expect.fn(() => { throw new Error('boom'); }); + expect(() => fn()).toThrow('boom'); + await expect(fn).toHaveBeenCalledTimes(1); + await expect(fn).not.toHaveReturned(); + await expect(fn).not.toHaveResolved(); + expect(fn.mock.results).toEqual([{ type: 'throw', value: expect.any(Error) }]); + expect(fn.mock.settledResults).toEqual([{ type: 'rejected', value: expect.any(Error) }]); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should support mockClear and mockReset', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('clear keeps the implementation', async () => { + const fn = expect.fn(() => 'value'); + fn(); + await expect(fn).toHaveBeenCalledTimes(1); + fn.mockClear(); + await expect(fn).not.toHaveBeenCalled(); + expect(fn()).toBe('value'); + }); + test('reset restores the original implementation', async () => { + const fn = expect.fn(async () => 'original'); + fn.mockImplementation(async () => 'override'); + fn.mockReturnValueOnce('once'); + expect(fn()).toBe('once'); + expect(await fn()).toBe('override'); + fn.mockReset(); + await expect(fn).not.toHaveBeenCalled(); + expect(await fn()).toBe('original'); + }); + test('reset clears the implementation when none was passed', async () => { + const fn = expect.fn(); + fn.mockImplementation(async () => 'override'); + expect(await fn()).toBe('override'); + fn.mockReset(); + expect(fn()).toBe(undefined); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(3); +}); + +test('should require a mock function for mock assertions', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('not a mock', async () => { + await expect(() => {}).toHaveBeenCalled(); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('toHaveBeenCalled() can only be used with a mock function created by expect.fn()'); +}); + +test('should not support mock matchers in expect.poll', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('poll', async () => { + const fn = expect.fn(); + await expect.poll(() => fn).toHaveBeenCalled(); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('`expect.poll()` does not support "toHaveBeenCalled" matcher'); +}); + +test('should support expect.poll over mock state', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('poll over derived mock state', async () => { + const fn = expect.fn(); + setTimeout(() => { fn(1); fn(2); fn(3); }, 200); + await expect.poll(() => fn.mock.calls.length).toBeGreaterThan(2); + expect(fn.mock.lastCall?.[0]).toBe(3); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should store call arguments by reference', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('by-reference storage', async () => { + const fn = expect.fn(); + const payload = { status: 'pending' }; + fn(payload); + payload.status = 'done'; + // Arguments are recorded by reference, so the mutation is visible. + await expect(fn).toHaveBeenCalledWith({ status: 'done' }); + await expect(fn).not.toHaveBeenCalledWith({ status: 'pending' }); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should support asymmetric matchers in all argument matchers', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('asymmetric matchers across the calledWith family', async () => { + const fn = expect.fn(); + fn({ email: 'ellen@example.com' }, ['a', 'b']); + fn('code-123', 42.0001); + await expect(fn).toHaveBeenNthCalledWith(1, expect.objectContaining({ email: 'ellen@example.com' }), expect.arrayContaining(['b'])); + await expect(fn).toHaveBeenNthCalledWith(2, expect.stringMatching(/^code-\\d+$/), expect.closeTo(42, 2)); + await expect(fn).toHaveBeenLastCalledWith(expect.stringContaining('code'), expect.any(Number)); + await expect(fn).toHaveBeenCalledWith(expect.not.objectContaining({ email: 'other@example.com' }), expect.any(Array)); + await expect(fn).not.toHaveBeenLastCalledWith(expect.stringMatching(/nope/), expect.any(Number)); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should support asymmetric matchers in all return matchers', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('asymmetric matchers across the returnedWith family', async () => { + const fn = expect.fn() + .mockReturnValueOnce({ id: 1, tags: ['x', 'y'] }) + .mockReturnValue({ id: 2, tags: ['x', 'y'] }); + fn(); + fn(); + await expect(fn).toHaveReturnedWith(expect.objectContaining({ id: 1 })); + await expect(fn).toHaveLastReturnedWith(expect.objectContaining({ id: 2, tags: expect.arrayContaining(['y']) })); + await expect(fn).toHaveNthReturnedWith(1, expect.objectContaining({ tags: expect.any(Array) })); + await expect(fn).not.toHaveReturnedWith(expect.objectContaining({ id: 3 })); + }); + test('asymmetric matchers across the resolvedWith family', async () => { + const fn = expect.fn(async (id: number) => ({ id, tags: ['x', 'y'] })); + await fn(1); + await fn(2); + await expect(fn).toHaveResolvedWith(expect.objectContaining({ id: 1 })); + await expect(fn).toHaveLastResolvedWith(expect.objectContaining({ id: 2, tags: expect.arrayContaining(['y']) })); + await expect(fn).toHaveNthResolvedWith(1, expect.objectContaining({ tags: expect.any(Array) })); + await expect(fn).not.toHaveResolvedWith(expect.objectContaining({ id: 3 })); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); +}); + +test('should retry last and returned matchers until they pass', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('last call changes with later calls', async () => { + const fn = expect.fn(async (value: string) => 'resolved ' + value); + await fn('first'); + setTimeout(() => fn('second'), 300); + // Initially the last call is 'first'; the assertion retries until the + // later call arrives and becomes the last one. + await expect(fn).toHaveBeenLastCalledWith('second'); + await expect(fn).toHaveLastResolvedWith('resolved second'); + await expect(fn).toHaveResolvedTimes(2); + await expect(fn).toHaveNthResolvedWith(2, 'resolved second'); + }); + test('toHaveReturned retries', async () => { + const fn = expect.fn().mockReturnValue('ok'); + setTimeout(() => fn(), 300); + await expect(fn).toHaveReturned(); + await expect(fn).toHaveReturnedWith('ok'); + }); + test('resolved matchers await settlement', async () => { + const fn = expect.fn(() => new Promise(f => setTimeout(() => f('late'), 300))); + fn(); + // The settled result is 'incomplete' at first; the assertion retries + // until the promise is fulfilled. + await expect(fn).toHaveResolvedWith('late'); + await expect(fn).toHaveNthResolvedWith(1, 'late'); + await expect(fn).toHaveResolved(); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(3); +}); + +test('should fail fast when the return count exceeds the expectation', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('too many returns', async () => { + test.setTimeout(3000); + const fn = expect.fn(() => 'ok'); + fn(); + fn(); + await expect(fn).toHaveReturnedTimes(1); + }); + test('nth call mismatch is final', async () => { + test.setTimeout(3000); + const fn = expect.fn(); + fn('actual'); + // The first call is made and can never change, so this fails immediately. + await expect(fn).toHaveBeenNthCalledWith(1, 'expected'); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(2); + expect(result.output).toContain('Expected number of returns: 1'); + expect(result.output).toContain('Received number of returns: 2'); + expect(result.output).not.toContain('Test timeout of 3000ms exceeded'); +}); + +test('should validate matcher arguments', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('negative call count', async () => { + await expect(expect.fn()).toHaveBeenCalledTimes(-1); + }); + test('fractional return count', async () => { + await expect(expect.fn()).toHaveReturnedTimes(1.5); + }); + test('zero n in nth called', async () => { + await expect(expect.fn()).toHaveBeenNthCalledWith(0, 'a'); + }); + test('zero n in nth returned', async () => { + await expect(expect.fn()).toHaveNthReturnedWith(0, 'a'); + }); + test('negative resolved count', async () => { + await expect(expect.fn()).toHaveResolvedTimes(-1); + }); + test('zero n in nth resolved', async () => { + await expect(expect.fn()).toHaveNthResolvedWith(0, 'a'); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(6); + expect(result.output).toContain('toHaveBeenCalledTimes: expected must be a non-negative integer, received -1'); + expect(result.output).toContain('toHaveReturnedTimes: expected must be a non-negative integer, received 1.5'); + expect(result.output).toContain('toHaveBeenNthCalledWith: n must be a positive integer, received 0'); + expect(result.output).toContain('toHaveNthReturnedWith: n must be a positive integer, received 0'); + expect(result.output).toContain('toHaveResolvedTimes: expected must be a non-negative integer, received -1'); + expect(result.output).toContain('toHaveNthResolvedWith: n must be a positive integer, received 0'); +}); + +test('should support the full implementation API', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('once implementations fall back to the default', async () => { + const fn = expect.fn(async () => 'default') + .mockImplementationOnce(async () => 'first call') + .mockImplementationOnce(async () => 'second call'); + expect(await Promise.all([fn(), fn(), fn(), fn()])).toEqual(['first call', 'second call', 'default', 'default']); + }); + test('once values fall back to the default value', async () => { + const fn = expect.fn() + .mockReturnValue('default') + .mockReturnValueOnce('first call') + .mockReturnValueOnce('second call'); + expect([fn(), fn(), fn(), fn()]).toEqual(['first call', 'second call', 'default', 'default']); + }); + test('mockImplementation replaces the default', async () => { + const fn = expect.fn(async () => 'original'); + fn.mockImplementation(async () => 'replaced'); + expect(await fn()).toBe('replaced'); + }); + test('resolved and rejected once chains', async () => { + const fn = expect.fn() + .mockResolvedValueOnce('first') + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue('rest'); + expect(await fn()).toBe('first'); + await expect(fn()).rejects.toThrow('boom'); + expect(await fn()).toBe('rest'); + expect(await fn()).toBe('rest'); + await expect(fn).toHaveResolvedTimes(3); + }); + test('mock names', async () => { + const fn = expect.fn(); + expect(fn.getMockName()).toBe('expect.fn()'); + fn.mockName('onChange'); + expect(fn.getMockName()).toBe('onChange'); + }); + test('mock state', async () => { + const fn = expect.fn().mockReturnValue('v'); + expect(fn.mock.lastCall).toBe(undefined); + fn(1); + fn(2); + expect(fn.mock.calls).toEqual([[1], [2]]); + expect(fn.mock.results).toEqual([{ type: 'return', value: 'v' }, { type: 'return', value: 'v' }]); + expect(fn.mock.settledResults).toEqual([{ type: 'fulfilled', value: 'v' }, { type: 'fulfilled', value: 'v' }]); + expect(fn.mock.lastCall).toEqual([2]); + }); + test('settled results track promise state', async () => { + const fn = expect.fn(async (n: number) => n + 1); + const promise = fn(1); + expect(fn.mock.settledResults).toEqual([{ type: 'incomplete', value: undefined }]); + expect(fn.mock.results[0].value).toBeInstanceOf(Promise); + await promise; + await expect(fn).toHaveResolvedWith(2); + expect(fn.mock.settledResults).toEqual([{ type: 'fulfilled', value: 2 }]); + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(7); +}); + +test('should list received calls in the failure message', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('calledWith mismatch', async () => { + const fn = expect.fn(); + fn('actual', 1); + fn(); + await expect.configure({ timeout: 500 })(fn).toHaveBeenCalledWith('expected'); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('expect(expect.fn()).toHaveBeenCalledWith(...expected)'); + expect(result.output).toContain('Received calls:'); + expect(result.output).toContain('called with 0 arguments'); + expect(result.output).toContain('Number of calls:'); +}); + +test('should work with soft assertions', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('soft', async () => { + const fn = expect.fn(); + await expect.configure({ timeout: 100 }).soft(fn).toHaveBeenCalled(); + expect(test.info().errors.length).toBe(1); + fn(); + await expect.soft(fn).toHaveBeenCalled(); + expect(test.info().errors.length).toBe(1); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.output).toContain('Expected number of calls: >= 1'); +}); diff --git a/utils/doclint/linkUtils.js b/utils/doclint/linkUtils.js index 86372398b98f5..67d9327a23820 100644 --- a/utils/doclint/linkUtils.js +++ b/utils/doclint/linkUtils.js @@ -114,6 +114,7 @@ function assertionArgument(className) { case 'genericassertions': return 'value'; case 'snapshotassertions': return 'value'; case 'apiresponseassertions': return 'response'; + case 'fnassertions': return 'mockFunction'; } throw new Error(`Unexpected assertion class: ${className}`); } diff --git a/utils/generate_types/index.js b/utils/generate_types/index.js index f20b51e3b391d..dcdb758d07621 100644 --- a/utils/generate_types/index.js +++ b/utils/generate_types/index.js @@ -504,6 +504,7 @@ class TypesGenerator { const reporterDocumentation = parseApi(path.join(PROJECT_DIR, 'docs', 'src', 'test-reporter-api')); const assertionClasses = new Set([ 'APIResponseAssertions', + 'FnAssertions', 'GenericAssertions', 'LocatorAssertions', 'PageAssertions', @@ -579,6 +580,7 @@ class TypesGenerator { 'Config', 'ExpectMatcherUtils', 'Matchers', + 'MockFunction', 'PlaywrightTestArgs.mount', 'PlaywrightWorkerArgs.playwright', 'PlaywrightWorkerOptions.defaultBrowserType', diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 66f6dc360519d..c0ff4c080e406 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -392,6 +392,113 @@ type FunctionAssertions = { toPass(options?: { timeout?: number, intervals?: number[] }): Promise; }; +interface FnAssertions { + not: FnAssertions; + toHaveBeenCalled(): Promise; + toHaveBeenCalledTimes(count: number): Promise; + toHaveBeenCalledWith(...args: Array): Promise; + toHaveBeenLastCalledWith(...args: Array): Promise; + toHaveBeenNthCalledWith(n: number, ...args: Array): Promise; + toHaveLastResolvedWith(value: unknown): Promise; + toHaveLastReturnedWith(value: unknown): Promise; + toHaveNthResolvedWith(n: number, value: unknown): Promise; + toHaveNthReturnedWith(n: number, value: unknown): Promise; + toHaveResolved(): Promise; + toHaveResolvedTimes(count: number): Promise; + toHaveResolvedWith(value: unknown): Promise; + toHaveReturned(): Promise; + toHaveReturnedTimes(count: number): Promise; + toHaveReturnedWith(value: unknown): Promise; +} + +/** + * A mock function created by [`expect.fn()`](https://playwright.dev/docs/test-assertions). Records all calls and + * their results, to be asserted with `await expect(mockFunction).toHaveBeenCalledWith(...)` and similar assertions. + */ +export interface MockFunction { + (...args: Args): ReturnValue | Promise; + /** + * Recorded calls and results. + */ + mock: { + /** + * Arguments of each recorded call. + */ + calls: Args[]; + /** + * Result of each recorded call, either a returned value or a thrown error. + */ + results: { type: 'return' | 'throw', value: any }[]; + /** + * Settled result of each recorded call: `'fulfilled'` once the returned promise resolves (immediately for + * non-promise values), `'rejected'` when the call throws or the promise rejects, `'incomplete'` while pending. + */ + settledResults: { type: 'incomplete' | 'fulfilled' | 'rejected', value: any }[]; + /** + * Arguments of the last recorded call, if any. + */ + lastCall: Args | undefined; + }; + /** + * Removes recorded calls and results, keeps the implementation. + */ + mockClear(): this; + /** + * Removes recorded calls and results, removes "once" implementations and values, and resets the implementation to + * the one originally passed to `expect.fn()`, if any. + */ + mockReset(): this; + /** + * Replaces the implementation of the mock function. The implementation runs in the test process, so when the mock + * function is passed into the page, for example to `page.evaluate()`, the page-side caller always receives a + * promise and must await it. That is why the implementation must be an async function. Use + * [mockFunction.mockReturnValue(value)](https://playwright.dev/docs/test-assertions) for values that should be + * available to the page synchronously. + */ + mockImplementation(implementation: (...args: Args) => Promise): this; + /** + * Adds an implementation to be used for a single call, in the order of registration. See + * [mockFunction.mockImplementation(implementation)](https://playwright.dev/docs/test-assertions) for why the + * implementation must be an async function. + */ + mockImplementationOnce(implementation: (...args: Args) => Promise): this; + /** + * Makes the mock function return the given value. When the mock function is passed into the page, for example to + * `page.evaluate()`, the value is serialized along with it and the page-side caller receives it synchronously. + */ + mockReturnValue(value: ReturnValue): this; + /** + * Makes the mock function return the given value for a single call, in the order of registration. Like + * [mockFunction.mockReturnValue(value)](https://playwright.dev/docs/test-assertions), the values are serialized + * into the page and consumed synchronously. + */ + mockReturnValueOnce(value: ReturnValue): this; + /** + * Makes the mock function return a promise resolved with the given value. + */ + mockResolvedValue(value: ReturnValue): this; + /** + * Makes the mock function return a promise resolved with the given value for a single call, in the order of registration. + */ + mockResolvedValueOnce(value: ReturnValue): this; + /** + * Makes the mock function return a promise rejected with the given error. + */ + mockRejectedValue(error: unknown): this; + /** + * Makes the mock function return a promise rejected with the given error for a single call, in the order of registration. + */ + mockRejectedValueOnce(error: unknown): this; + /** + * Sets the name of the mock function, used in the assertion error messages. + */ + mockName(name: string): this; + /** + * Returns the name of the mock function. + */ + getMockName(): string; +} + type BaseMatchers = GenericAssertions & PlaywrightTest.Matchers & SnapshotAssertions; type AllowedGenericMatchers = PlaywrightTest.Matchers & Pick, 'toBe' | 'toBeDefined' | 'toBeFalsy' | 'toBeNull' | 'toBeTruthy' | 'toBeUndefined'>; @@ -399,8 +506,8 @@ type SpecificMatchers = T extends Page ? PageAssertions & AllowedGenericMatchers : T extends Locator ? LocatorAssertions & AllowedGenericMatchers : T extends APIResponse ? APIResponseAssertions & AllowedGenericMatchers : - BaseMatchers & (T extends Function ? FunctionAssertions : {}); -type AllMatchers = PageAssertions & LocatorAssertions & APIResponseAssertions & FunctionAssertions & BaseMatchers; + BaseMatchers & (T extends Function ? FunctionAssertions & FnAssertions : {}); +type AllMatchers = PageAssertions & LocatorAssertions & APIResponseAssertions & FunctionAssertions & FnAssertions & BaseMatchers; type IfAny = 0 extends (1 & T) ? Y : N; type Awaited = T extends PromiseLike ? U : T; @@ -486,6 +593,28 @@ type PollMatchers = { export type Expect = { (actual: T, messageOrOptions?: string | { message?: string }): MakeMatchers; + /** + * Creates a {@link MockFunction} that records its calls and returned values, to be asserted with + * `await expect(mockFunction).toHaveBeenCalledWith(...)` and similar assertions. These assertions are asynchronous + * and retried until they pass or the expect timeout is reached, so the mock function can be handed to concurrent + * code, for example exposed into the page with + * [page.evaluate(pageFunction, arg, options)](https://playwright.dev/docs/api/class-page#page-evaluate) or + * [page.exposeBinding(name, callback, options)](https://playwright.dev/docs/api/class-page#page-expose-binding). + * + * **Usage** + * + * ```js + * const callback = expect.fn(); + * await page.evaluate(({ callback }) => { + * document.addEventListener('click', () => callback('clicked')); + * }, { callback }, { exposeFunctions: true }); + * await page.locator('body').click(); + * await expect(callback).toHaveBeenCalledWith('clicked'); + * ``` + * + * @param implementation Optional implementation to be invoked by the mock function. Must be an async function: the implementation runs in the test process, so a page-side caller always receives a promise. By default, the mock function returns `undefined`. + */ + fn(implementation?: (...args: Args) => Promise): MockFunction; soft: Expect; poll: (actual: () => T | Promise, messageOrOptions?: string | { message?: string, timeout?: number, intervals?: number[] }) => PollMatchers, T, ExtendedMatchers>; extend MatcherReturnType | Promise>>(matchers: MoreMatchers): Expect;