chore: introduce expect.configure (#22533)

This commit is contained in:
Pavel Feldman 2023-04-25 10:29:56 -07:00 committed by GitHub
parent 76a2afc836
commit a1007bbe2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 252 additions and 36 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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<any> {
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<any> {
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<any> {
try {
const expectZone: ExpectZone = { title: defaultTitle, wallTime };
await zones.run<ExpectZone, any>('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();

View File

@ -4654,6 +4654,12 @@ export type Expect = {
not: BaseMatchers<Promise<void>, 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;

View File

@ -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']);
});

View File

@ -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 }) => {

View File

@ -342,6 +342,12 @@ export type Expect = {
not: BaseMatchers<Promise<void>, 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;