diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md
index 59ae8bcd97..bf4d608ae8 100644
--- a/docs/src/test-api/class-test.md
+++ b/docs/src/test-api/class-test.md
@@ -880,7 +880,7 @@ Test function that takes one or two arguments: an object with fixtures and optio
## method: Test.setTimeout
-Changes the timeout for the test. Learn more about [various timeouts](./test-timeouts.md).
+Changes the timeout for the test. Zero means no timeout. Learn more about [various timeouts](./test-timeouts.md).
```js js-flavor=js
const { test, expect } = require('@playwright/test');
diff --git a/docs/src/test-fixtures-js.md b/docs/src/test-fixtures-js.md
index ab401fd49f..7719eb8e1a 100644
--- a/docs/src/test-fixtures-js.md
+++ b/docs/src/test-fixtures-js.md
@@ -466,6 +466,41 @@ export const test = base.extend<{ saveLogs: void }>({
export { expect } from '@playwright/test';
```
+## Fixture timeout
+
+By default, fixture shares timeout with the test. However, for slow fixtures, especially [worker-scoped](#worker-scoped-fixtures) ones, it is convenient to have a separate timeout. This way you can keep the overall test timeout small, and give the slow fixture more time.
+
+```js js-flavor=js
+const { test: base, expect } = require('@playwright/test');
+
+const test = base.extend({
+ slowFixture: [async ({}, use) => {
+ // ... perform a slow operation ...
+ await use('hello');
+ }, { timeout: 60000 }]
+});
+
+test('example test', async ({ slowFixture }) => {
+ // ...
+});
+```
+
+```js js-flavor=ts
+import { test as base, expect } from '@playwright/test';
+
+const test = base.extend<{ slowFixture: string }>({
+ slowFixture: [async ({}, use) => {
+ // ... perform a slow operation ...
+ await use('hello');
+ }, { timeout: 60000 }]
+});
+
+test('example test', async ({ slowFixture }) => {
+ // ...
+});
+```
+
+
## Fixtures-options
:::note
diff --git a/docs/src/test-timeouts-js.md b/docs/src/test-timeouts-js.md
index 4a397a1ca3..a9bd5429b7 100644
--- a/docs/src/test-timeouts-js.md
+++ b/docs/src/test-timeouts-js.md
@@ -16,6 +16,7 @@ Playwright Test has multiple configurable timeouts for various tasks.
|Action timeout| no timeout |Timeout for each action:
Set default
{`config = { use: { actionTimeout: 10000 } }`}
Override
{`locator.click({ timeout: 10000 })`}|
|Navigation timeout| no timeout |Timeout for each navigation action:
Set default
{`config = { use: { navigationTimeout: 30000 } }`}
Override
{`page.goto('/', { timeout: 30000 })`}|
|Global timeout|no timeout |Global timeout for the whole test run:
Set in config
{`config = { globalTimeout: 60*60*1000 }`}
|
+|Fixture timeout|no timeout |Timeout for an individual fixture:
Set in fixture
{`{ scope: 'test', timeout: 30000 }`}
|
## Test timeout
@@ -89,7 +90,7 @@ test('very slow test', async ({ page }) => {
API reference: [`method: Test.setTimeout`] and [`method: Test.slow`].
-### Change timeout from a hook or fixture
+### Change timeout from a hook
```js js-flavor=js
const { test, expect } = require('@playwright/test');
@@ -279,3 +280,39 @@ export default config;
```
API reference: [`property: TestConfig.globalTimeout`].
+
+## Fixture timeout
+
+By default, [fixture](./test-fixtures) shares timeout with the test. However, for slow fixtures, especially [worker-scoped](./test-fixtures#worker-scoped-fixtures) ones, it is convenient to have a separate timeout. This way you can keep the overall test timeout small, and give the slow fixture more time.
+
+```js js-flavor=js
+const { test: base, expect } = require('@playwright/test');
+
+const test = base.extend({
+ slowFixture: [async ({}, use) => {
+ // ... perform a slow operation ...
+ await use('hello');
+ }, { timeout: 60000 }]
+});
+
+test('example test', async ({ slowFixture }) => {
+ // ...
+});
+```
+
+```js js-flavor=ts
+import { test as base, expect } from '@playwright/test';
+
+const test = base.extend<{ slowFixture: string }>({
+ slowFixture: [async ({}, use) => {
+ // ... perform a slow operation ...
+ await use('hello');
+ }, { timeout: 60000 }]
+});
+
+test('example test', async ({ slowFixture }) => {
+ // ...
+});
+```
+
+API reference: [`method: Test.extend`].
diff --git a/packages/playwright-test/src/fixtures.ts b/packages/playwright-test/src/fixtures.ts
index 2bb6462711..2d621f2d85 100644
--- a/packages/playwright-test/src/fixtures.ts
+++ b/packages/playwright-test/src/fixtures.ts
@@ -16,11 +16,13 @@
import { formatLocation, debugTest } from './util';
import * as crypto from 'crypto';
-import { FixturesWithLocation, Location, WorkerInfo, TestInfo } from './types';
+import { FixturesWithLocation, Location, WorkerInfo } from './types';
import { ManualPromise } from 'playwright-core/lib/utils/async';
+import { TestInfoImpl } from './testInfo';
+import { FixtureDescription, TimeoutManager } from './timeoutManager';
type FixtureScope = 'test' | 'worker';
-type FixtureOptions = { auto?: boolean, scope?: FixtureScope, option?: boolean };
+type FixtureOptions = { auto?: boolean, scope?: FixtureScope, option?: boolean, timeout?: number | undefined };
type FixtureTuple = [ value: any, options: FixtureOptions ];
type FixtureRegistration = {
location: Location; // Fixutre registration location.
@@ -29,6 +31,7 @@ type FixtureRegistration = {
fn: Function | any; // Either a fixture function, or a fixture value.
auto: boolean;
option: boolean;
+ timeout?: number;
deps: string[]; // Names of the dependencies, ({ foo, bar }) => {...}
id: string; // Unique id, to differentiate between fixtures with the same name.
super?: FixtureRegistration; // A fixture override can use the previous version of the fixture.
@@ -43,15 +46,24 @@ class Fixture {
_useFuncFinished: ManualPromise | undefined;
_selfTeardownComplete: Promise | undefined;
_teardownWithDepsComplete: Promise | undefined;
+ _runnableDescription: FixtureDescription;
constructor(runner: FixtureRunner, registration: FixtureRegistration) {
this.runner = runner;
this.registration = registration;
this.usages = new Set();
this.value = null;
+ this._runnableDescription = {
+ fixture: this.registration.name,
+ location: registration.location,
+ slot: this.registration.timeout === undefined ? undefined : {
+ timeout: this.registration.timeout,
+ elapsed: 0,
+ }
+ };
}
- async setup(testInfo: TestInfo) {
+ async setup(testInfo: TestInfoImpl) {
if (typeof this.registration.fn !== 'function') {
this.value = this.registration.fn;
return;
@@ -79,6 +91,7 @@ class Fixture {
};
const workerInfo: WorkerInfo = { config: testInfo.config, parallelIndex: testInfo.parallelIndex, workerIndex: testInfo.workerIndex, project: testInfo.project };
const info = this.registration.scope === 'worker' ? workerInfo : testInfo;
+ testInfo._timeoutManager.setCurrentFixture(this._runnableDescription);
this._selfTeardownComplete = Promise.resolve().then(() => this.registration.fn(params, useFunc, info)).catch((e: any) => {
if (!useFuncStarted.isDone())
useFuncStarted.reject(e);
@@ -86,25 +99,28 @@ class Fixture {
throw e;
});
await useFuncStarted;
+ testInfo._timeoutManager.setCurrentFixture(undefined);
}
- async teardown() {
+ async teardown(timeoutManager: TimeoutManager) {
if (!this._teardownWithDepsComplete)
- this._teardownWithDepsComplete = this._teardownInternal();
+ this._teardownWithDepsComplete = this._teardownInternal(timeoutManager);
await this._teardownWithDepsComplete;
}
- private async _teardownInternal() {
+ private async _teardownInternal(timeoutManager: TimeoutManager) {
if (typeof this.registration.fn !== 'function')
return;
try {
for (const fixture of this.usages)
- await fixture.teardown();
+ await fixture.teardown(timeoutManager);
this.usages.clear();
if (this._useFuncFinished) {
debugTest(`teardown ${this.registration.name}`);
+ timeoutManager.setCurrentFixture(this._runnableDescription);
this._useFuncFinished.resolve();
await this._selfTeardownComplete;
+ timeoutManager.setCurrentFixture(undefined);
}
} finally {
this.runner.instanceForId.delete(this.registration.id);
@@ -113,7 +129,7 @@ class Fixture {
}
function isFixtureTuple(value: any): value is FixtureTuple {
- return Array.isArray(value) && typeof value[1] === 'object' && ('scope' in value[1] || 'auto' in value[1] || 'option' in value[1]);
+ return Array.isArray(value) && typeof value[1] === 'object' && ('scope' in value[1] || 'auto' in value[1] || 'option' in value[1] || 'timeout' in value[1]);
}
export function isFixtureOption(value: any): value is FixtureTuple {
@@ -131,12 +147,13 @@ export class FixturePool {
for (const entry of Object.entries(fixtures)) {
const name = entry[0];
let value = entry[1];
- let options: Required | undefined;
+ let options: { auto: boolean, scope: FixtureScope, option: boolean, timeout: number | undefined } | undefined;
if (isFixtureTuple(value)) {
options = {
auto: !!value[1].auto,
scope: value[1].scope || 'test',
option: !!value[1].option,
+ timeout: value[1].timeout,
};
value = value[0];
}
@@ -149,9 +166,9 @@ export class FixturePool {
if (previous.auto !== options.auto)
throw errorWithLocations(`Fixture "${name}" has already been registered as a { auto: '${previous.scope}' } fixture.`, { location, name }, previous);
} else if (previous) {
- options = { auto: previous.auto, scope: previous.scope, option: previous.option };
+ options = { auto: previous.auto, scope: previous.scope, option: previous.option, timeout: previous.timeout };
} else if (!options) {
- options = { auto: false, scope: 'test', option: false };
+ options = { auto: false, scope: 'test', option: false, timeout: undefined };
}
if (options.scope !== 'test' && options.scope !== 'worker')
@@ -160,7 +177,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 });
const deps = fixtureParameterNames(fn, location);
- const registration: FixtureRegistration = { id: '', name, location, scope: options.scope, fn, auto: options.auto, option: options.option, deps, super: previous };
+ const registration: FixtureRegistration = { id: '', name, location, scope: options.scope, fn, auto: options.auto, option: options.option, timeout: options.timeout, deps, super: previous };
registrationId(registration);
this.registrations.set(name, registration);
}
@@ -242,14 +259,14 @@ export class FixtureRunner {
this.pool = pool;
}
- async teardownScope(scope: FixtureScope) {
+ async teardownScope(scope: FixtureScope, timeoutManager: TimeoutManager) {
let error: Error | undefined;
// Teardown fixtures in the reverse order.
const fixtures = Array.from(this.instanceForId.values()).reverse();
for (const fixture of fixtures) {
if (fixture.registration.scope === scope) {
try {
- await fixture.teardown();
+ await fixture.teardown(timeoutManager);
} catch (e) {
if (error === undefined)
error = e;
@@ -262,7 +279,7 @@ export class FixtureRunner {
throw error;
}
- async resolveParametersForFunction(fn: Function, testInfo: TestInfo): Promise