diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 9c426fd86e..3a254ae7bf 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -121,10 +121,10 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re const [actual, messageOrOptions] = argumentsList; const message = isString(messageOrOptions) ? messageOrOptions : messageOrOptions?.message || info.message; const newInfo = { ...info, message }; - if (newInfo.isPoll) { + if (newInfo.poll) { if (typeof actual !== 'function') throw new Error('`expect.poll()` accepts only function as a first argument'); - newInfo.generator = actual as any; + newInfo.poll.generator = actual as any; } return createMatchers(actual, newInfo, prefix); }, @@ -189,10 +189,10 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re if ('soft' in configuration) newInfo.isSoft = configuration.soft; if ('_poll' in configuration) { - newInfo.isPoll = !!configuration._poll; + newInfo.poll = configuration._poll ? { ...info.poll, generator: () => {} } : undefined; if (typeof configuration._poll === 'object') { - newInfo.pollTimeout = configuration._poll.timeout; - newInfo.pollIntervals = configuration._poll.intervals; + newInfo.poll!.timeout = configuration._poll.timeout ?? newInfo.poll!.timeout; + newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals; } } return createExpect(newInfo, prefix, customMatchers); @@ -249,11 +249,12 @@ type ExpectMetaInfo = { message?: string; isNot?: boolean; isSoft?: boolean; - isPoll?: boolean; + poll?: { + timeout?: number; + intervals?: number[]; + generator: Generator; + }; timeout?: number; - pollTimeout?: number; - pollIntervals?: number[]; - generator?: Generator; }; class ExpectMetaInfoProxyHandler implements ProxyHandler { @@ -287,10 +288,10 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { this._info.isNot = !this._info.isNot; return new Proxy(matcher, this); } - if (this._info.isPoll) { + if (this._info.poll) { if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects') throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`); - matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, !!this._info.isNot, this._info.pollIntervals, this._info.pollTimeout ?? currentExpectTimeout(), this._info.generator!, ...args); + matcher = (...args: any[]) => pollMatcher(resolvedMatcherName, this._info, this._prefix, ...args); } return (...args: any[]) => { const testInfo = currentTestInfo(); @@ -302,7 +303,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const customMessage = this._info.message || ''; const argsSuffix = computeArgsSuffix(matcherName, args); - const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; + const defaultTitle = `expect${this._info.poll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; const title = customMessage || defaultTitle; // This looks like it is unnecessary, but it isn't - we need to filter @@ -336,7 +337,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const callback = () => matcher.call(target, ...args); // toPass and poll matchers can contain other steps, expects and API calls, // so they behave like a retriable step. - const result = (matcherName === 'toPass' || this._info.isPoll) ? + const result = (matcherName === 'toPass' || this._info.poll) ? zones.run('stepZone', step, callback) : zones.run('expectZone', { title, stepId: step.stepId }, callback); if (result instanceof Promise) @@ -350,25 +351,32 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } } -async function pollMatcher(qualifiedMatcherName: any, isNot: boolean, pollIntervals: number[] | undefined, timeout: number, generator: () => any, ...args: 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 { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); const result = await pollAgainstDeadline(async () => { if (testInfo && currentTestInfo() !== testInfo) return { continuePolling: false, result: undefined }; - const value = await generator(); - let expectInstance = expectLibrary(value) as any; - if (isNot) - expectInstance = expectInstance.not; + const innerInfo: ExpectMetaInfo = { + ...info, + isSoft: false, // soft is outside of poll, not inside + poll: undefined, + }; + const value = await poll.generator(); try { - expectInstance[qualifiedMatcherName].call(expectInstance, ...args); + let matchers = createMatchers(value, innerInfo, prefix); + if (info.isNot) + matchers = matchers.not; + matchers[qualifiedMatcherName](...args); return { continuePolling: false, result: undefined }; } catch (error) { return { continuePolling: true, result: error }; } - }, deadline, pollIntervals ?? [100, 250, 500, 1000]); + }, deadline, poll.intervals ?? [100, 250, 500, 1000]); if (result.timedOut) { const message = result.result ? [ diff --git a/tests/playwright-test/expect-poll.spec.ts b/tests/playwright-test/expect-poll.spec.ts index 344fdccdee..208ac4ccce 100644 --- a/tests/playwright-test/expect-poll.spec.ts +++ b/tests/playwright-test/expect-poll.spec.ts @@ -262,4 +262,20 @@ test('should propagate string exception from async arrow function', { annotation }); expect(result.output).toContain('some error'); -}); \ No newline at end of file +}); + +test('should show custom message', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32582' } +}, async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('should fail', async () => { + await expect.poll(() => 1, { message: 'custom message', timeout: 500 }).toBe(2); + }); + `, + }); + expect(result.output).toContain('Error: custom message'); + expect(result.output).toContain('Expected: 2'); + expect(result.output).toContain('Received: 1'); +}); diff --git a/tests/playwright-test/test-step.spec.ts b/tests/playwright-test/test-step.spec.ts index 96539a4c88..d14bccd98b 100644 --- a/tests/playwright-test/test-step.spec.ts +++ b/tests/playwright-test/test-step.spec.ts @@ -987,9 +987,12 @@ expect |expect.poll.toHaveLength @ a.test.ts:14 pw:api | page.goto(about:blank) @ a.test.ts:7 test.step | inner step attempt: 0 @ a.test.ts:8 expect | expect.toBe @ a.test.ts:10 +expect | expect.toHaveLength @ a.test.ts:6 +expect | ↪ error: Error: expect(received).toHaveLength(expected) pw:api | page.goto(about:blank) @ a.test.ts:7 test.step | inner step attempt: 1 @ a.test.ts:8 expect | expect.toBe @ a.test.ts:10 +expect | expect.toHaveLength @ a.test.ts:6 hook |After Hooks fixture | fixture: page fixture | fixture: context @@ -1036,9 +1039,12 @@ expect |expect.poll.toBe @ a.test.ts:13 expect | expect.toHaveText @ a.test.ts:7 test.step | iteration 1 @ a.test.ts:9 expect | expect.toBeVisible @ a.test.ts:10 +expect | expect.toBe @ a.test.ts:6 +expect | ↪ error: Error: expect(received).toBe(expected) // Object.is equality expect | expect.toHaveText @ a.test.ts:7 test.step | iteration 2 @ a.test.ts:9 expect | expect.toBeVisible @ a.test.ts:10 +expect | expect.toBe @ a.test.ts:6 hook |After Hooks fixture | fixture: page fixture | fixture: context