diff --git a/docs/src/test-api/class-fixtures.md b/docs/src/test-api/class-fixtures.md index 7b5c45a4e2..24499cae4f 100644 --- a/docs/src/test-api/class-fixtures.md +++ b/docs/src/test-api/class-fixtures.md @@ -115,6 +115,13 @@ const config: PlaywrightTestConfig = { export default config; ``` +## property: Fixtures.actionTimeout +- type: <[int]> + +Timeout for each action and expect in milliseconds. Defaults to 0 (no timeout). + +This is a default timeout for all Playwright actions, same as configured via [`method: Page.setDefaultTimeout`]. + ## property: Fixtures.bypassCSP = %%-context-option-bypasscsp-%% ## property: Fixtures.channel = %%-browser-option-channel-%% @@ -220,6 +227,13 @@ Options used to launch the browser, as passed to [`method: BrowserType.launch`]. ## property: Fixtures.locale = %%-context-option-locale-%% +## property: Fixtures.navigationTimeout +- type: <[int]> + +Timeout for each navigation action in milliseconds. Defaults to 0 (no timeout). + +This is a default navigation timeout, same as configured via [`method: Page.setDefaultNavigationTimeout`]. + ## property: Fixtures.offline = %%-context-option-offline-%% ## property: Fixtures.page diff --git a/src/test/index.ts b/src/test/index.ts index 6668a35e4a..081d3a70b7 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import expectLibrary from 'expect'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -74,12 +75,42 @@ export const test = _baseTest.extend { await use(process.env.PLAYWRIGHT_TEST_BASE_URL); }, contextOptions: {}, - createContext: async ({ browser, screenshot, trace, video, acceptDownloads, bypassCSP, colorScheme, deviceScaleFactor, extraHTTPHeaders, hasTouch, geolocation, httpCredentials, ignoreHTTPSErrors, isMobile, javaScriptEnabled, locale, offline, permissions, proxy, storageState, viewport, timezoneId, userAgent, baseURL, contextOptions }, use, testInfo) => { + createContext: async ({ + browser, + screenshot, + trace, + video, + acceptDownloads, + bypassCSP, + colorScheme, + deviceScaleFactor, + extraHTTPHeaders, + hasTouch, + geolocation, + httpCredentials, + ignoreHTTPSErrors, + isMobile, + javaScriptEnabled, + locale, + offline, + permissions, + proxy, + storageState, + viewport, + timezoneId, + userAgent, + baseURL, + contextOptions, + actionTimeout, + navigationTimeout + }, use, testInfo) => { testInfo.snapshotSuffix = process.platform; if (process.env.PWDEBUG) testInfo.setTimeout(0); @@ -153,7 +184,9 @@ export const test = _baseTest.extend allPages.push(page)); if (captureTrace) { diff --git a/src/test/matchers/toBeTruthy.ts b/src/test/matchers/toBeTruthy.ts index 5d2240268f..f4270fc6e6 100644 --- a/src/test/matchers/toBeTruthy.ts +++ b/src/test/matchers/toBeTruthy.ts @@ -20,7 +20,7 @@ import { } from 'jest-matcher-utils'; import { currentTestInfo } from '../globals'; import type { Expect } from '../types'; -import { expectType, monotonicTime, pollUntilDeadline } from '../util'; +import { expectType, pollUntilDeadline } from '../util'; export async function toBeTruthy( this: ReturnType, @@ -42,16 +42,13 @@ export async function toBeTruthy( let received: T; let pass = false; - const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout; - const deadline = timeout ? monotonicTime() + timeout : 0; // TODO: interrupt on timeout for nice message. - await pollUntilDeadline(async () => { - const remainingTime = deadline ? deadline - monotonicTime() : 0; + await pollUntilDeadline(this, async remainingTime => { received = await query(remainingTime); pass = !!received; return pass === !matcherOptions.isNot; - }, deadline, 100); + }, options.timeout, 100); const message = () => { return matcherHint(matcherName, undefined, '', matcherOptions); diff --git a/src/test/matchers/toEqual.ts b/src/test/matchers/toEqual.ts index 799b44c653..07905ea2a2 100644 --- a/src/test/matchers/toEqual.ts +++ b/src/test/matchers/toEqual.ts @@ -27,7 +27,7 @@ import { } from 'jest-matcher-utils'; import { currentTestInfo } from '../globals'; import type { Expect } from '../types'; -import { expectType, monotonicTime, pollUntilDeadline } from '../util'; +import { expectType, pollUntilDeadline } from '../util'; // Omit colon and one or more spaces, so can call getLabelPrinter. const EXPECTED_LABEL = 'Expected'; @@ -58,16 +58,13 @@ export async function toEqual( let received: T | undefined = undefined; let pass = false; - const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout; - const deadline = timeout ? monotonicTime() + timeout : 0; // TODO: interrupt on timeout for nice message. - await pollUntilDeadline(async () => { - const remainingTime = deadline ? deadline - monotonicTime() : 0; + await pollUntilDeadline(this, async remainingTime => { received = await query(remainingTime); pass = equals(received, expected, [iterableEquality]); return pass === !matcherOptions.isNot; - }, deadline, 100); + }, options.timeout, 100); const message = pass ? () => diff --git a/src/test/matchers/toMatchText.ts b/src/test/matchers/toMatchText.ts index 515820fd7b..3601ad60d7 100644 --- a/src/test/matchers/toMatchText.ts +++ b/src/test/matchers/toMatchText.ts @@ -30,7 +30,7 @@ import { } from 'jest-matcher-utils'; import { currentTestInfo } from '../globals'; import type { Expect } from '../types'; -import { expectType, monotonicTime, pollUntilDeadline } from '../util'; +import { expectType, pollUntilDeadline } from '../util'; export async function toMatchText( this: ReturnType, @@ -68,12 +68,9 @@ export async function toMatchText( let received: string; let pass = false; - const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout; - const deadline = timeout ? monotonicTime() + timeout : 0; // TODO: interrupt on timeout for nice message. - await pollUntilDeadline(async () => { - const remainingTime = deadline ? deadline - monotonicTime() : 0; + await pollUntilDeadline(this, async remainingTime => { received = await query(remainingTime); if (options.matchSubstring) pass = received.includes(expected as string); @@ -83,7 +80,7 @@ export async function toMatchText( pass = expected.test(received); return pass === !matcherOptions.isNot; - }, deadline, 100); + }, options.timeout, 100); const stringSubstring = options.matchSubstring ? 'substring' : 'string'; const message = pass diff --git a/src/test/util.ts b/src/test/util.ts index f0bcd56dfe..c9c9be85ea 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { Expect } from './types'; import util from 'util'; import path from 'path'; import type { TestError, Location } from './types'; @@ -70,21 +71,25 @@ export async function raceAgainstDeadline(promise: Promise, deadline: numb return (new DeadlineRunner(promise, deadline)).result; } -export async function pollUntilDeadline(func: () => Promise, deadline: number, delay: number): Promise { +export async function pollUntilDeadline(state: ReturnType, func: (remainingTime: number) => Promise, pollTime: number | undefined, pollInterval: number): Promise { + const playwrightActionTimeout = (state as any).playwrightActionTimeout; + pollTime = pollTime === 0 ? 0 : pollTime || playwrightActionTimeout; + const deadline = pollTime ? monotonicTime() + pollTime : 0; + while (true) { - const timeUntilDeadline = deadline ? deadline - monotonicTime() : Number.MAX_VALUE; - if (timeUntilDeadline <= 0) + const remainingTime = deadline ? deadline - monotonicTime() : 1000 * 3600 * 24; + if (remainingTime <= 0) break; try { - if (await func()) + if (await func(remainingTime)) return; } catch (e) { if (e instanceof errors.TimeoutError) return; throw e; } - await new Promise(f => setTimeout(f, delay)); + await new Promise(f => setTimeout(f, pollInterval)); } } diff --git a/tests/playwright-test/playwright.expect.misc.spec.ts b/tests/playwright-test/playwright.expect.misc.spec.ts index e3c0dfbf8c..747d7a551f 100644 --- a/tests/playwright-test/playwright.expect.misc.spec.ts +++ b/tests/playwright-test/playwright.expect.misc.spec.ts @@ -158,3 +158,24 @@ test('should support toHaveURL', async ({ runInlineTest }) => { expect(result.failed).toBe(1); expect(result.exitCode).toBe(1); }); + +test('should support respect actionTimeout', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.js': `module.exports = { use: { actionTimeout: 1000 } }`, + 'a.test.ts': ` + const { test } = pwt; + + test('timeout', async ({ page }) => { + await page.goto('data:text/html,
A
'); + await Promise.all([ + expect(page).toHaveURL('data:text/html,
B
'), + new Promise(f => setTimeout(f, 2000)).then(() => expect(true).toBe(false)) + ]); + }); + `, + }, { workers: 1 }); + const output = stripAscii(result.output); + expect(output).toContain('expect(received).toHaveURL(expected)'); + expect(result.failed).toBe(1); + expect(result.exitCode).toBe(1); +}); diff --git a/types/test.d.ts b/types/test.d.ts index dff7639124..c3c5b96da3 100644 --- a/types/test.d.ts +++ b/types/test.d.ts @@ -2490,6 +2490,20 @@ export interface PlaywrightTestOptions { * like [fixtures.viewport](https://playwright.dev/docs/api/class-fixtures#fixtures-viewport) take priority over this. */ contextOptions: BrowserContextOptions; + /** + * Timeout for each action and expect in milliseconds. Defaults to 0 (no timeout). + * + * This is a default timeout for all Playwright actions, same as configured via + * [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout). + */ + actionTimeout: number | undefined; + /** + * Timeout for each navigation action in milliseconds. Defaults to 0 (no timeout). + * + * This is a default navigation timeout, same as configured via + * [page.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout). + */ + navigationTimeout: number | undefined; } diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 94d4d0c345..2b7a9c843e 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -308,6 +308,8 @@ export interface PlaywrightTestOptions { viewport: ViewportSize | null | undefined; baseURL: string | undefined; contextOptions: BrowserContextOptions; + actionTimeout: number | undefined; + navigationTimeout: number | undefined; }