diff --git a/docs/src/test-assertions-js.md b/docs/src/test-assertions-js.md index 8a7742aa35..c28fd9d653 100644 --- a/docs/src/test-assertions-js.md +++ b/docs/src/test-assertions-js.md @@ -121,7 +121,17 @@ The same works with soft assertions: expect.soft(value, 'my soft assertion').toBe(56); ``` -## Polling +## expect.configurte + +You can create your own pre-configured `expect` instance to have its own +defaults such as `timeout`, `soft` and `poll`. + +```js +const slowExpect = expect.configure({ timeout: 10000 }); +await slowExpect(locator).toHaveText('Submit); +``` + +## expect.poll You can convert any synchronous `expect` to an asynchronous polling one using `expect.poll`. @@ -152,7 +162,7 @@ await expect.poll(async () => { }).toBe(200); ``` -## Retrying +## expect.toPass You can retry blocks of code until they are passing successfully. diff --git a/packages/playwright-test/src/common/globals.ts b/packages/playwright-test/src/common/globals.ts index 16a4673b1d..9443db25d3 100644 --- a/packages/playwright-test/src/common/globals.ts +++ b/packages/playwright-test/src/common/globals.ts @@ -34,10 +34,18 @@ export function currentlyLoadingFileSuite() { return currentFileSuite; } +let currentExpectConfigureTimeout: number | undefined; + +export function setCurrentExpectConfigureTimeout(timeout: number | undefined) { + currentExpectConfigureTimeout = timeout; +} + export function currentExpectTimeout(options: { timeout?: number }) { const testInfo = currentTestInfo(); if (options.timeout !== undefined) return options.timeout; + if (currentExpectConfigureTimeout !== undefined) + return currentExpectConfigureTimeout; let defaultExpectTimeout = testInfo?._projectInternal?.expect?.timeout; if (typeof defaultExpectTimeout === 'undefined') defaultExpectTimeout = 5000; diff --git a/packages/playwright-test/src/matchers/expect.ts b/packages/playwright-test/src/matchers/expect.ts index 22200ccd3e..16ad0c0279 100644 --- a/packages/playwright-test/src/matchers/expect.ts +++ b/packages/playwright-test/src/matchers/expect.ts @@ -18,6 +18,7 @@ import { captureRawStack, createAfterActionTraceEventForExpect, createBeforeActionTraceEventForExpect, + isString, pollAgainstTimeout } from 'playwright-core/lib/utils'; import type { ExpectZone } from 'playwright-core/lib/utils'; import { @@ -48,7 +49,7 @@ import { } from './matchers'; import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot'; import type { Expect } from '../../types/test'; -import { currentTestInfo, currentExpectTimeout } from '../common/globals'; +import { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals'; import { filteredStackTrace, serializeError, stringifyStackFrames, trimLongString } from '../util'; import { expect as expectLibrary, @@ -106,28 +107,58 @@ export const printReceivedStringContainExpectedResult = ( // #endregion -type ExpectMessageOrOptions = undefined | string | { message?: string, timeout?: number, intervals?: number[] }; +type ExpectMessage = string | { message?: string }; -function createExpect(actual: unknown, messageOrOptions: ExpectMessageOrOptions, isSoft: boolean, isPoll: boolean, generator?: Generator): any { - return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(messageOrOptions, isSoft, isPoll, generator)); +function createMatchers(actual: unknown, info: ExpectMetaInfo): any { + return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info)); } -export const expect: Expect = new Proxy(expectLibrary, { - apply: function(target: any, thisArg: any, argumentsList: [actual: unknown, messageOrOptions: ExpectMessageOrOptions]) { - const [actual, messageOrOptions] = argumentsList; - return createExpect(actual, messageOrOptions, false /* isSoft */, false /* isPoll */); - } -}); +function createExpect(info: ExpectMetaInfo) { + const expect: Expect = new Proxy(expectLibrary, { + apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { + const [actual, messageOrOptions] = argumentsList; + const message = isString(messageOrOptions) ? messageOrOptions : messageOrOptions?.message || info.message; + const newInfo = { ...info, message }; + if (newInfo.isPoll) { + if (typeof actual !== 'function') + throw new Error('`expect.poll()` accepts only function as a first argument'); + newInfo.generator = actual as any; + } + return createMatchers(actual, newInfo); + } + }); -expect.soft = (actual: unknown, messageOrOptions: ExpectMessageOrOptions) => { - return createExpect(actual, messageOrOptions, true /* isSoft */, false /* isPoll */); -}; + expect.soft = (actual: unknown, messageOrOptions?: ExpectMessage) => { + return expect.configure({ soft: true })(actual, messageOrOptions) as any; + }; -expect.poll = (actual: unknown, messageOrOptions: ExpectMessageOrOptions) => { - if (typeof actual !== 'function') - throw new Error('`expect.poll()` accepts only function as a first argument'); - return createExpect(actual, messageOrOptions, false /* isSoft */, true /* isPoll */, actual as any); -}; + expect.poll = (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { + const poll = isString(messageOrOptions) ? {} : messageOrOptions || {}; + return expect.configure({ poll })(actual, messageOrOptions) as any; + }; + + expect.configure = (configuration: { message?: string, timeout?: number, soft?: boolean, poll?: boolean | { timeout?: number, intervals?: number[] } }) => { + const newInfo = { ...info }; + if ('message' in configuration) + newInfo.message = configuration.message; + if ('timeout' in configuration) + newInfo.timeout = configuration.timeout; + if ('soft' in configuration) + newInfo.isSoft = configuration.soft; + if ('poll' in configuration) { + newInfo.isPoll = !!configuration.poll; + if (typeof configuration.poll === 'object') { + newInfo.pollTimeout = configuration.poll.timeout; + newInfo.pollIntervals = configuration.poll.intervals; + } + } + return createExpect(newInfo); + }; + + return expect; +} + +export const expect: Expect = createExpect({}); expectLibrary.setState({ expand: false }); @@ -168,10 +199,10 @@ type Generator = () => any; type ExpectMetaInfo = { message?: string; - isNot: boolean; - isSoft: boolean; - isPoll: boolean; - nameTokens: string[]; + isNot?: boolean; + isSoft?: boolean; + isPoll?: boolean; + timeout?: number; pollTimeout?: number; pollIntervals?: number[]; generator?: Generator; @@ -180,15 +211,8 @@ type ExpectMetaInfo = { class ExpectMetaInfoProxyHandler implements ProxyHandler { private _info: ExpectMetaInfo; - constructor(messageOrOptions: ExpectMessageOrOptions, isSoft: boolean, isPoll: boolean, generator?: Generator) { - this._info = { isSoft, isPoll, generator, isNot: false, nameTokens: [] }; - if (typeof messageOrOptions === 'string') { - this._info.message = messageOrOptions; - } else { - this._info.message = messageOrOptions?.message; - this._info.pollTimeout = messageOrOptions?.timeout; - this._info.pollIntervals = messageOrOptions?.intervals; - } + constructor(info: ExpectMetaInfo) { + this._info = { ...info }; } get(target: Object, matcherName: string | symbol, receiver: any): any { @@ -205,7 +229,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { if (this._info.isPoll) { if ((customAsyncMatchers as any)[matcherName] || matcherName === 'resolves' || matcherName === 'rejects') throw new Error(`\`expect.poll()\` does not support "${matcherName}" matcher.`); - matcher = (...args: any[]) => pollMatcher(matcherName, this._info.isNot, this._info.pollIntervals, currentExpectTimeout({ timeout: this._info.pollTimeout }), this._info.generator!, ...args); + matcher = (...args: any[]) => pollMatcher(matcherName, !!this._info.isNot, this._info.pollIntervals, currentExpectTimeout({ timeout: this._info.pollTimeout }), this._info.generator!, ...args); } return (...args: any[]) => { const testInfo = currentTestInfo(); @@ -278,6 +302,8 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { try { const expectZone: ExpectZone = { title: defaultTitle, wallTime }; await zones.run('expectZone', expectZone, async () => { + // We assume that the matcher will read the current expect timeout the first thing. + setCurrentExpectConfigureTimeout(this._info.timeout); await matcher.call(target, ...args); }); finalizer(); diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 59fbe4604d..0e7a2ae06c 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -4654,6 +4654,12 @@ export type Expect = { not: BaseMatchers, T>; }; extend(matchers: any): void; + configure: (configuration: { + message?: string, + timeout?: number, + soft?: boolean, + poll?: boolean | { timeout?: number, intervals?: number[] }, + }) => Expect; getState(): { expand?: boolean; isNot: boolean; diff --git a/tests/playwright-test/expect-configure.spec.ts b/tests/playwright-test/expect-configure.spec.ts new file mode 100644 index 0000000000..2463881da7 --- /dev/null +++ b/tests/playwright-test/expect-configure.spec.ts @@ -0,0 +1,160 @@ +/** + * 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 configure timeout', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + const fastExpect = expect.configure({ timeout: 1 }); + test('pass', async ({ page }) => { + const time = performance.now(); + try { + await fastExpect(page.locator('li')).toBeVisible(); + } catch (e) { + expect(performance.now() - time).toBeLessThan(5000); + } + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); +}); + +test('should configure message', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'expect-test.spec.ts': ` + import { test, expect } from '@playwright/test'; + const namedExpect = expect.configure({ message: 'x-foo must be visible' }); + test('custom expect message', async ({page}) => { + await namedExpect(page.locator('x-foo')).toBeVisible({timeout: 1}); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.output).toContain([ + ` Error: x-foo must be visible`, + ``, + ` Call log:`, + ].join('\n')); +}); + +test('should prefer local message', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'expect-test.spec.ts': ` + import { test, expect } from '@playwright/test'; + const namedExpect = expect.configure({ message: 'x-foo must be visible' }); + test('custom expect message', async ({page}) => { + await namedExpect(page.locator('x-foo'), { message: 'overridden' }).toBeVisible({timeout: 1}); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.output).toContain([ + ` Error: overridden`, + ``, + ` Call log:`, + ].join('\n')); +}); + +test('should configure soft', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + const softExpect = expect.configure({ soft: true }); + test('should work', () => { + softExpect(1+1).toBe(3); + console.log('woof-woof'); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('woof-woof'); +}); + +test('should configure poll', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + const pollingExpect = expect.configure({ poll: { timeout: 1000, intervals: [0, 10000] } }); + test('should fail', async () => { + let probes = 0; + const startTime = Date.now(); + await pollingExpect(() => ++probes).toBe(3).catch(() => {}); + // Probe at 0 and epsilon. + expect(probes).toBe(2); + expect(Date.now() - startTime).toBeLessThan(5000); + }); + ` + }); + expect(result.exitCode).toBe(0); +}); + +test('should chain configure', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'expect-test.spec.ts': ` + import { test, expect } from '@playwright/test'; + const slowExpect = expect.configure({ timeout: 1 }); + const slowAndSoftExpect = slowExpect.configure({ soft: true }); + test('custom expect message', async ({page}) => { + await slowAndSoftExpect(page.locator('x-foo')).toBeVisible({timeout: 1}); + console.log('%% woof-woof'); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.outputLines).toEqual(['woof-woof']); +}); + +test('should cancel effect', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + const softExpect = expect.configure({ soft: true }); + const normalExpect = expect.configure({ soft: false }); + test('should work', () => { + normalExpect(1+1).toBe(3); + console.log('%% woof-woof'); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.outputLines).toEqual([]); +}); + +test('should configure soft poll', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + const pollingExpect = expect.configure({ soft: true, poll: { timeout: 1000, intervals: [0, 10000] } }); + test('should fail', async () => { + let probes = 0; + const startTime = Date.now(); + await pollingExpect(() => ++probes).toBe(3); + // Probe at 0 and epsilon. + expect(probes).toBe(2); + expect(Date.now() - startTime).toBeLessThan(5000); + console.log('%% woof-woof'); + }); + ` + }); + expect(result.exitCode).toBe(1); + expect(result.outputLines).toEqual(['woof-woof']); +}); diff --git a/tests/playwright-test/expect-soft.spec.ts b/tests/playwright-test/expect-soft.spec.ts index 9742b1b725..ffb0252dab 100644 --- a/tests/playwright-test/expect-soft.spec.ts +++ b/tests/playwright-test/expect-soft.spec.ts @@ -36,12 +36,12 @@ test('soft expects should work', async ({ runInlineTest }) => { import { test, expect } from '@playwright/test'; test('should work', () => { test.expect.soft(1+1).toBe(3); - console.log('woof-woof'); + console.log('%% woof-woof'); }); ` }); expect(result.exitCode).toBe(1); - expect(result.output).toContain('woof-woof'); + expect(result.outputLines).toEqual(['woof-woof']); }); test('should report a mixture of soft and non-soft errors', async ({ runInlineTest }) => { diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index eca53d1be2..860b48aab5 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -342,6 +342,12 @@ export type Expect = { not: BaseMatchers, T>; }; extend(matchers: any): void; + configure: (configuration: { + message?: string, + timeout?: number, + soft?: boolean, + poll?: boolean | { timeout?: number, intervals?: number[] }, + }) => Expect; getState(): { expand?: boolean; isNot: boolean;