feat(test): more information about timing out fixtures (#13546)

- Always show a fixture that was running during timeout.
- Give custom titles to built-in fixtures.
- Specify setup/teardown fixture phase in the message.
- Split connect vs launch browser fixtures for better naming.

Example timeout message:

```log
Timeout of 2000ms exceeded while running fixture "built-in playwright configuration" teardown.
```
This commit is contained in:
Dmitry Gozman 2022-04-13 15:13:31 -07:00 committed by GitHub
parent e2fff31848
commit 166675b9c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 149 additions and 116 deletions

View file

@ -31,6 +31,7 @@ type FixtureRegistration = {
fn: Function | any; // Either a fixture function, or a fixture value. fn: Function | any; // Either a fixture function, or a fixture value.
auto: boolean; auto: boolean;
option: boolean; option: boolean;
customTitle?: string;
timeout?: number; timeout?: number;
deps: string[]; // Names of the dependencies, ({ foo, bar }) => {...} deps: string[]; // Names of the dependencies, ({ foo, bar }) => {...}
id: string; // Unique id, to differentiate between fixtures with the same name. id: string; // Unique id, to differentiate between fixtures with the same name.
@ -54,7 +55,7 @@ class Fixture {
this.usages = new Set(); this.usages = new Set();
this.value = null; this.value = null;
this._runnableDescription = { this._runnableDescription = {
fixture: this.registration.name, title: `fixture "${this.registration.customTitle || this.registration.name}" setup`,
location: registration.location, location: registration.location,
slot: this.registration.timeout === undefined ? undefined : { slot: this.registration.timeout === undefined ? undefined : {
timeout: this.registration.timeout, timeout: this.registration.timeout,
@ -117,6 +118,7 @@ class Fixture {
this.usages.clear(); this.usages.clear();
if (this._useFuncFinished) { if (this._useFuncFinished) {
debugTest(`teardown ${this.registration.name}`); debugTest(`teardown ${this.registration.name}`);
this._runnableDescription.title = `fixture "${this.registration.customTitle || this.registration.name}" teardown`;
timeoutManager.setCurrentFixture(this._runnableDescription); timeoutManager.setCurrentFixture(this._runnableDescription);
this._useFuncFinished.resolve(); this._useFuncFinished.resolve();
await this._selfTeardownComplete; await this._selfTeardownComplete;
@ -147,13 +149,14 @@ export class FixturePool {
for (const entry of Object.entries(fixtures)) { for (const entry of Object.entries(fixtures)) {
const name = entry[0]; const name = entry[0];
let value = entry[1]; let value = entry[1];
let options: { auto: boolean, scope: FixtureScope, option: boolean, timeout: number | undefined } | undefined; let options: { auto: boolean, scope: FixtureScope, option: boolean, timeout: number | undefined, customTitle: string | undefined } | undefined;
if (isFixtureTuple(value)) { if (isFixtureTuple(value)) {
options = { options = {
auto: !!value[1].auto, auto: !!value[1].auto,
scope: value[1].scope || 'test', scope: value[1].scope || 'test',
option: !!value[1].option, option: !!value[1].option,
timeout: value[1].timeout, timeout: value[1].timeout,
customTitle: (value[1] as any)._title,
}; };
value = value[0]; value = value[0];
} }
@ -166,9 +169,9 @@ export class FixturePool {
if (previous.auto !== options.auto) if (previous.auto !== options.auto)
throw errorWithLocations(`Fixture "${name}" has already been registered as a { auto: '${previous.scope}' } fixture.`, { location, name }, previous); throw errorWithLocations(`Fixture "${name}" has already been registered as a { auto: '${previous.scope}' } fixture.`, { location, name }, previous);
} else if (previous) { } else if (previous) {
options = { auto: previous.auto, scope: previous.scope, option: previous.option, timeout: previous.timeout }; options = { auto: previous.auto, scope: previous.scope, option: previous.option, timeout: previous.timeout, customTitle: previous.customTitle };
} else if (!options) { } else if (!options) {
options = { auto: false, scope: 'test', option: false, timeout: undefined }; options = { auto: false, scope: 'test', option: false, timeout: undefined, customTitle: undefined };
} }
if (options.scope !== 'test' && options.scope !== 'worker') if (options.scope !== 'test' && options.scope !== 'worker')
@ -177,7 +180,7 @@ export class FixturePool {
throw errorWithLocations(`Cannot use({ ${name} }) in a describe group, because it forces a new worker.\nMake it top-level in the test file or put in the configuration file.`, { location, name }); throw errorWithLocations(`Cannot use({ ${name} }) in a describe group, because it forces a new worker.\nMake it top-level in the test file or put in the configuration file.`, { location, name });
const deps = fixtureParameterNames(fn, location); const deps = fixtureParameterNames(fn, location);
const registration: FixtureRegistration = { id: '', name, location, scope: options.scope, fn, auto: options.auto, option: options.option, timeout: options.timeout, deps, super: previous }; const registration: FixtureRegistration = { id: '', name, location, scope: options.scope, fn, auto: options.auto, option: options.option, timeout: options.timeout, customTitle: options.customTitle, deps, super: previous };
registrationId(registration); registrationId(registration);
this.registrations.set(name, registration); this.registrations.set(name, registration);
} }

View file

@ -16,7 +16,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import type { LaunchOptions, BrowserContextOptions, Page, BrowserContext, Video, APIRequestContext, Tracing } from 'playwright-core'; import type { LaunchOptions, BrowserContextOptions, Page, Browser, BrowserContext, Video, APIRequestContext, Tracing } from 'playwright-core';
import type { TestType, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestInfo } from '../types/test'; import type { TestType, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestInfo } from '../types/test';
import { rootTestType } from './testType'; import { rootTestType } from './testType';
import { createGuid, debugMode } from 'playwright-core/lib/utils'; import { createGuid, debugMode } from 'playwright-core/lib/utils';
@ -44,6 +44,7 @@ type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
_contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>; _contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>;
}; };
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
_connectedBrowser: Browser | undefined,
_browserOptions: LaunchOptions; _browserOptions: LaunchOptions;
_artifactsDir: () => string; _artifactsDir: () => string;
_snapshotSuffix: string; _snapshotSuffix: string;
@ -86,12 +87,12 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
}); });
if (dir) if (dir)
await removeFolders([dir]); await removeFolders([dir]);
}, { scope: 'worker' }], }, { scope: 'worker', _title: 'built-in playwright configuration' } as any],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => { _browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
const options: LaunchOptions = { const options: LaunchOptions = {
handleSIGINT: false, handleSIGINT: false,
timeout: 30000, // 30 seconds timeout: 0,
...launchOptions, ...launchOptions,
}; };
if (headless !== undefined) if (headless !== undefined)
@ -106,27 +107,37 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
(browserType as any)._defaultLaunchOptions = undefined; (browserType as any)._defaultLaunchOptions = undefined;
}, { scope: 'worker', auto: true }], }, { scope: 'worker', auto: true }],
browser: [async ({ playwright, browserName, channel, headless, connectOptions }, use) => { _connectedBrowser: [async ({ playwright, browserName, channel, headless, connectOptions }, use) => {
if (!connectOptions) {
await use(undefined);
return;
}
if (!['chromium', 'firefox', 'webkit'].includes(browserName)) if (!['chromium', 'firefox', 'webkit'].includes(browserName))
throw new Error(`Unexpected browserName "${browserName}", must be one of "chromium", "firefox" or "webkit"`); throw new Error(`Unexpected browserName "${browserName}", must be one of "chromium", "firefox" or "webkit"`);
if (connectOptions) { const browser = await playwright[browserName].connect(connectOptions.wsEndpoint, {
const browser = await playwright[browserName].connect(connectOptions.wsEndpoint, { headers: {
headers: { 'x-playwright-browser': channel || browserName,
'x-playwright-browser': channel || browserName, 'x-playwright-headless': headless ? '1' : '0',
'x-playwright-headless': headless ? '1' : '0', ...connectOptions.headers,
...connectOptions.headers, },
}, timeout: connectOptions.timeout ?? 3 * 60 * 1000, // 3 minutes
timeout: connectOptions.timeout ?? 3 * 60 * 1000, // 3 minutes });
}); await use(browser);
await use(browser); await browser.close();
await browser.close(); }, { scope: 'worker', timeout: 0, _title: 'remote connection' } as any],
browser: [async ({ playwright, browserName, _connectedBrowser }, use) => {
if (_connectedBrowser) {
await use(_connectedBrowser);
return; return;
} }
if (!['chromium', 'firefox', 'webkit'].includes(browserName))
throw new Error(`Unexpected browserName "${browserName}", must be one of "chromium", "firefox" or "webkit"`);
const browser = await playwright[browserName].launch(); const browser = await playwright[browserName].launch();
await use(browser); await use(browser);
await browser.close(); await browser.close();
}, { scope: 'worker', timeout: 0 } ], }, { scope: 'worker' } ],
acceptDownloads: [ undefined, { option: true } ], acceptDownloads: [ undefined, { option: true } ],
bypassCSP: [ undefined, { option: true } ], bypassCSP: [ undefined, { option: true } ],
@ -422,9 +433,9 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
else else
await fs.promises.unlink(file).catch(() => {}); await fs.promises.unlink(file).catch(() => {});
})); }));
}, { auto: true }], }, { auto: true, _title: 'built-in playwright configuration' } as any],
_contextFactory: async ({ browser, video, _artifactsDir }, use, testInfo) => { _contextFactory: [async ({ browser, video, _artifactsDir }, use, testInfo) => {
let videoMode = typeof video === 'string' ? video : video.mode; let videoMode = typeof video === 'string' ? video : video.mode;
if (videoMode === 'retry-with-video') if (videoMode === 'retry-with-video')
videoMode = 'on-first-retry'; videoMode = 'on-first-retry';
@ -476,7 +487,7 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
if (prependToError) if (prependToError)
testInfo.errors.push({ message: prependToError }); testInfo.errors.push({ message: prependToError });
}, }, { scope: 'test', _title: 'context' } as any],
context: async ({ _contextFactory }, use) => { context: async ({ _contextFactory }, use) => {
await use(await _contextFactory()); await use(await _contextFactory());

View file

@ -31,7 +31,7 @@ type RunnableDescription = {
}; };
export type FixtureDescription = { export type FixtureDescription = {
fixture: string; title: string;
location?: Location; location?: Location;
slot?: TimeSlot; // Falls back to current runnable slot. slot?: TimeSlot; // Falls back to current runnable slot.
}; };
@ -123,7 +123,9 @@ export class TimeoutManager {
} }
const fixtureWithSlot = this._fixture?.slot ? this._fixture : undefined; const fixtureWithSlot = this._fixture?.slot ? this._fixture : undefined;
if (fixtureWithSlot) if (fixtureWithSlot)
suffix = ` in fixture "${fixtureWithSlot.fixture}"`; suffix = ` by ${fixtureWithSlot.title}`;
else if (this._fixture)
suffix = ` while running ${this._fixture.title}`;
const message = colors.red(`Timeout of ${this._currentSlot().timeout}ms exceeded${suffix}.`); const message = colors.red(`Timeout of ${this._currentSlot().timeout}ms exceeded${suffix}.`);
const location = (fixtureWithSlot || this._runnable).location; const location = (fixtureWithSlot || this._runnable).location;
return { return {

View file

@ -471,10 +471,10 @@ test('should not report fixture teardown timeout twice', async ({ runInlineTest
}, { reporter: 'list', timeout: 1000 }); }, { reporter: 'list', timeout: 1000 });
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(result.output).toContain('in fixtures teardown'); expect(result.output).toContain('Timeout of 1000ms exceeded while running fixture "fixture" teardown.');
expect(stripAnsi(result.output)).not.toContain('pwt.test.extend'); // Should not point to the location. expect(stripAnsi(result.output)).not.toContain('pwt.test.extend'); // Should not point to the location.
// TODO: this should be "1" actually. // TODO: this should be "not.toContain" actually.
expect(countTimes(result.output, 'in fixtures teardown')).not.toBe(1); expect(result.output).toContain('in fixtures teardown');
}); });
test('should handle fixture teardown error after test timeout and continue', async ({ runInlineTest }) => { test('should handle fixture teardown error after test timeout and continue', async ({ runInlineTest }) => {
@ -529,6 +529,6 @@ test('should report worker fixture teardown with debug info', async ({ runInline
'a.spec.ts:12:9 good18', 'a.spec.ts:12:9 good18',
'a.spec.ts:12:9 good19', 'a.spec.ts:12:9 good19',
'', '',
'Timeout of 1000ms exceeded in fixtures teardown.', 'Timeout of 1000ms exceeded while running fixture "fixture" teardown.',
].join('\n')); ].join('\n'));
}); });

View file

@ -0,0 +1,97 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 work with connectOptions', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.js': `
module.exports = {
globalSetup: './global-setup',
use: {
connectOptions: {
wsEndpoint: process.env.CONNECT_WS_ENDPOINT,
},
},
};
`,
'global-setup.ts': `
module.exports = async () => {
const server = await pwt.chromium.launchServer();
process.env.CONNECT_WS_ENDPOINT = server.wsEndpoint();
return () => server.close();
};
`,
'a.test.ts': `
const { test } = pwt;
test.use({ locale: 'fr-CH' });
test('pass', async ({ page }) => {
await page.setContent('<div>PASS</div>');
await expect(page.locator('div')).toHaveText('PASS');
expect(await page.evaluate(() => navigator.language)).toBe('fr-CH');
});
`,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should throw with bad connectOptions', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.js': `
module.exports = {
use: {
connectOptions: {
wsEndpoint: 'http://does-not-exist-bad-domain.oh-no-should-not-work',
},
},
};
`,
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div>PASS</div>');
await expect(page.locator('div')).toHaveText('PASS');
});
`,
});
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(0);
expect(result.output).toContain('browserType.connect:');
});
test('should respect connectOptions.timeout', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.js': `
module.exports = {
use: {
connectOptions: {
wsEndpoint: 'wss://locahost:5678',
timeout: 1,
},
},
};
`,
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
});
`,
});
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(0);
expect(result.output).toContain('browserType.connect: Timeout 1ms exceeded.');
});

View file

@ -563,83 +563,3 @@ test('should work with video.path() throwing', async ({ runInlineTest }, testInf
const video = fs.readdirSync(dir).find(file => file.endsWith('webm')); const video = fs.readdirSync(dir).find(file => file.endsWith('webm'));
expect(video).toBeTruthy(); expect(video).toBeTruthy();
}); });
test('should work with connectOptions', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.js': `
module.exports = {
globalSetup: './global-setup',
use: {
connectOptions: {
wsEndpoint: process.env.CONNECT_WS_ENDPOINT,
},
},
};
`,
'global-setup.ts': `
module.exports = async () => {
const server = await pwt.chromium.launchServer();
process.env.CONNECT_WS_ENDPOINT = server.wsEndpoint();
return () => server.close();
};
`,
'a.test.ts': `
const { test } = pwt;
test.use({ locale: 'fr-CH' });
test('pass', async ({ page }) => {
await page.setContent('<div>PASS</div>');
await expect(page.locator('div')).toHaveText('PASS');
expect(await page.evaluate(() => navigator.language)).toBe('fr-CH');
});
`,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
});
test('should throw with bad connectOptions', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.js': `
module.exports = {
use: {
connectOptions: {
wsEndpoint: 'http://does-not-exist-bad-domain.oh-no-should-not-work',
},
},
};
`,
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div>PASS</div>');
await expect(page.locator('div')).toHaveText('PASS');
});
`,
});
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(0);
expect(result.output).toContain('browserType.connect:');
});
test('should respect connectOptions.timeout', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.js': `
module.exports = {
use: {
connectOptions: {
wsEndpoint: 'wss://locahost:5678',
timeout: 1,
},
},
};
`,
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
});
`,
});
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(0);
expect(result.output).toContain('browserType.connect: Timeout 1ms exceeded.');
});

View file

@ -178,7 +178,7 @@ test('should respect fixture timeout', async ({ runInlineTest }) => {
slowSetup: [async ({}, use) => { slowSetup: [async ({}, use) => {
await new Promise(f => setTimeout(f, 2000)); await new Promise(f => setTimeout(f, 2000));
await use('hey'); await use('hey');
}, { timeout: 500 }], }, { timeout: 500, _title: 'custom title' }],
slowTeardown: [async ({}, use) => { slowTeardown: [async ({}, use) => {
await use('hey'); await use('hey');
await new Promise(f => setTimeout(f, 2000)); await new Promise(f => setTimeout(f, 2000));
@ -196,8 +196,8 @@ test('should respect fixture timeout', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
expect(result.failed).toBe(2); expect(result.failed).toBe(2);
expect(result.output).toContain('Timeout of 500ms exceeded in fixture "slowSetup"'); expect(result.output).toContain('Timeout of 500ms exceeded by fixture "custom title" setup.');
expect(result.output).toContain('Timeout of 400ms exceeded in fixture "slowTeardown"'); expect(result.output).toContain('Timeout of 400ms exceeded by fixture "slowTeardown" teardown.');
expect(stripAnsi(result.output)).toContain('> 5 | const test = pwt.test.extend({'); expect(stripAnsi(result.output)).toContain('> 5 | const test = pwt.test.extend({');
}); });
@ -222,7 +222,7 @@ test('should respect test.setTimeout in the worker fixture', async ({ runInlineT
slowTeardown: [async ({}, use) => { slowTeardown: [async ({}, use) => {
await use('hey'); await use('hey');
await new Promise(f => setTimeout(f, 2000)); await new Promise(f => setTimeout(f, 2000));
}, { scope: 'worker', timeout: 400 }], }, { scope: 'worker', timeout: 400, _title: 'custom title' }],
}); });
test('test ok', async ({ fixture, noTimeout }) => { test('test ok', async ({ fixture, noTimeout }) => {
await new Promise(f => setTimeout(f, 1000)); await new Promise(f => setTimeout(f, 1000));
@ -236,8 +236,8 @@ test('should respect test.setTimeout in the worker fixture', async ({ runInlineT
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.passed).toBe(2); expect(result.passed).toBe(2);
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(result.output).toContain('Timeout of 500ms exceeded in fixture "slowSetup"'); expect(result.output).toContain('Timeout of 500ms exceeded by fixture "slowSetup" setup.');
expect(result.output).toContain('Timeout of 400ms exceeded in fixture "slowTeardown"'); expect(result.output).toContain('Timeout of 400ms exceeded by fixture "custom title" teardown.');
}); });
test('fixture time in beforeAll hook should not affect test', async ({ runInlineTest }) => { test('fixture time in beforeAll hook should not affect test', async ({ runInlineTest }) => {