diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index a7e060cc4d..bc8c9ad0e4 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -110,13 +110,13 @@ function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]) return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info, prefix)); } -const getCustomMatchersSymbol = Symbol('get custom matchers'); +const userMatchersSymbol = Symbol('userMatchers'); function qualifiedMatcherName(qualifier: string[], matcherName: string) { return qualifier.join(':') + '$' + matcherName; } -function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record) { +function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Record) { const expectInstance: Expect<{}> = new Proxy(expectLibrary, { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { const [actual, messageOrOptions] = argumentsList; @@ -130,7 +130,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re return createMatchers(actual, newInfo, prefix); }, - get: function(target: any, property: string | typeof getCustomMatchersSymbol) { + get: function(target: any, property: string | typeof userMatchersSymbol) { if (property === 'configure') return configure; @@ -139,27 +139,14 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re const qualifier = [...prefix, createGuid()]; const wrappedMatchers: any = {}; - const extendedMatchers: any = { ...customMatchers }; for (const [name, matcher] of Object.entries(matchers)) { - wrappedMatchers[name] = function(...args: any[]) { - const { isNot, promise, utils } = this; - const newThis: ExpectMatcherState = { - isNot, - promise, - utils, - timeout: currentExpectTimeout() - }; - (newThis as any).equals = throwUnsupportedExpectMatcherError; - return (matcher as any).call(newThis, ...args); - }; + wrappedMatchers[name] = wrapPlaywrightMatcherToPassNiceThis(matcher); const key = qualifiedMatcherName(qualifier, name); wrappedMatchers[key] = wrappedMatchers[name]; Object.defineProperty(wrappedMatchers[key], 'name', { value: name }); - extendedMatchers[name] = wrappedMatchers[key]; } expectLibrary.extend(wrappedMatchers); - - return createExpect(info, qualifier, extendedMatchers); + return createExpect(info, qualifier, { ...userMatchers, ...matchers }); }; } @@ -169,8 +156,8 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re }; } - if (property === getCustomMatchersSymbol) - return customMatchers; + if (property === userMatchersSymbol) + return userMatchers; if (property === 'poll') { return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { @@ -197,12 +184,56 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals; } } - return createExpect(newInfo, prefix, customMatchers); + return createExpect(newInfo, prefix, userMatchers); }; return expectInstance; } +// Expect wraps matchers, so there is no way to pass this information to the raw Playwright matcher. +// Rely on sync call sequence to seed each matcher call with the context. +type MatcherCallContext = { + expectInfo: ExpectMetaInfo; + testInfo: TestInfoImpl | null; +}; + +let matcherCallContext: MatcherCallContext | undefined; + +function setMatcherCallContext(context: MatcherCallContext) { + matcherCallContext = context; +} + +function takeMatcherCallContext(): MatcherCallContext { + try { + return matcherCallContext!; + } finally { + matcherCallContext = undefined; + } +} + +type ExpectMatcherStateInternal = ExpectMatcherState & { + _context: MatcherCallContext | undefined; +}; + +const defaultExpectTimeout = 5000; + +function wrapPlaywrightMatcherToPassNiceThis(matcher: any) { + return function(this: any, ...args: any[]) { + const { isNot, promise, utils } = this; + const context = takeMatcherCallContext(); + const timeout = context.expectInfo.timeout ?? context.testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout; + const newThis: ExpectMatcherStateInternal = { + isNot, + promise, + utils, + timeout, + _context: context, + }; + (newThis as any).equals = throwUnsupportedExpectMatcherError; + return matcher.call(newThis, ...args); + }; +} + function throwUnsupportedExpectMatcherError() { throw new Error('It looks like you are using custom expect matchers that are not compatible with Playwright. See https://aka.ms/playwright/expect-compatibility'); } @@ -299,8 +330,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } return (...args: any[]) => { const testInfo = currentTestInfo(); - // We assume that the matcher will read the current expect timeout the first thing. - setCurrentExpectConfigureTimeout(this._info.timeout); + setMatcherCallContext({ expectInfo: this._info, testInfo }); if (!testInfo) return matcher.call(target, ...args); @@ -362,7 +392,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) { const testInfo = currentTestInfo(); const poll = info.poll!; - const timeout = poll.timeout ?? currentExpectTimeout(); + const timeout = poll.timeout ?? info.timeout ?? testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout; const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); const result = await pollAgainstDeadline(async () => { @@ -398,22 +428,6 @@ async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, p } } -let currentExpectConfigureTimeout: number | undefined; - -function setCurrentExpectConfigureTimeout(timeout: number | undefined) { - currentExpectConfigureTimeout = timeout; -} - -function currentExpectTimeout() { - if (currentExpectConfigureTimeout !== undefined) - return currentExpectConfigureTimeout; - const testInfo = currentTestInfo(); - let defaultExpectTimeout = testInfo?._projectInternal?.expect?.timeout; - if (typeof defaultExpectTimeout === 'undefined') - defaultExpectTimeout = 5000; - return defaultExpectTimeout; -} - function computeArgsSuffix(matcherName: string, args: any[]) { let value = ''; if (matcherName === 'toHaveScreenshot') @@ -426,7 +440,7 @@ export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers export function mergeExpects(...expects: any[]) { let merged = expect; for (const e of expects) { - const internals = e[getCustomMatchersSymbol]; + const internals = e[userMatchersSymbol]; if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special continue; merged = merged.extend(internals);