Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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