Skip to content

Commit

Permalink
chore: use explicit matcher call context (#34620)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Feb 5, 2025
1 parent 25ef2f1 commit 4b64c47
Showing 1 changed file with 55 additions and 41 deletions.
96 changes: 55 additions & 41 deletions packages/playwright/src/matchers/expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Function>) {
function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Record<string, Function>) {
const expectInstance: Expect<{}> = new Proxy(expectLibrary, {
apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) {
const [actual, messageOrOptions] = argumentsList;
Expand All @@ -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;

Expand All @@ -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 });
};
}

Expand All @@ -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[] }) => {
Expand All @@ -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');
}
Expand Down Expand Up @@ -299,8 +330,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
}
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);

Expand Down Expand Up @@ -362,7 +392,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
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<Error|undefined>(async () => {
Expand Down Expand Up @@ -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')
Expand All @@ -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);
Expand Down

0 comments on commit 4b64c47

Please sign in to comment.