diff --git a/packages/playwright-core/src/utils/timeoutRunner.ts b/packages/playwright-core/src/utils/timeoutRunner.ts index dd60551f62..622019565a 100644 --- a/packages/playwright-core/src/utils/timeoutRunner.ts +++ b/packages/playwright-core/src/utils/timeoutRunner.ts @@ -14,108 +14,22 @@ * limitations under the License. */ -import { ManualPromise } from './manualPromise'; import { monotonicTime } from './'; -export class TimeoutRunnerError extends Error {} - -type TimeoutRunnerData = { - lastElapsedSync: number, - timer: NodeJS.Timeout | undefined, - timeoutPromise: ManualPromise, -}; - -export const MaxTime = 2147483647; // 2^31-1 - -export class TimeoutRunner { - private _running: TimeoutRunnerData | undefined; - private _timeout: number; - private _elapsed: number; - private _deadline = MaxTime; - - constructor(timeout: number) { - this._timeout = timeout; - this._elapsed = 0; - } - - async run(cb: () => Promise): Promise { - const running = this._running = { - lastElapsedSync: monotonicTime(), - timer: undefined, - timeoutPromise: new ManualPromise(), - }; - try { - this._updateTimeout(running, this._timeout); - const resultPromise = Promise.race([ - cb(), - running.timeoutPromise - ]); - return await resultPromise; - } finally { - this._updateTimeout(running, 0); - if (this._running === running) - this._running = undefined; - } - } - - interrupt() { - if (this._running) - this._updateTimeout(this._running, -1); - } - - elapsed() { - this._syncElapsedAndStart(); - return this._elapsed; - } - - deadline(): number { - return this._deadline; - } - - updateTimeout(timeout: number, elapsed?: number) { - this._timeout = timeout; - if (elapsed !== undefined) { - this._syncElapsedAndStart(); - this._elapsed = elapsed; - } - if (this._running) - this._updateTimeout(this._running, timeout); - } - - private _syncElapsedAndStart() { - if (this._running) { - const now = monotonicTime(); - this._elapsed += now - this._running.lastElapsedSync; - this._running.lastElapsedSync = now; - } - } - - private _updateTimeout(running: TimeoutRunnerData, timeout: number) { - if (running.timer) { - clearTimeout(running.timer); - running.timer = undefined; - } - this._syncElapsedAndStart(); - this._deadline = timeout ? monotonicTime() + timeout : MaxTime; - if (timeout === 0) - return; - timeout = timeout - this._elapsed; - if (timeout <= 0) - running.timeoutPromise.reject(new TimeoutRunnerError()); - else - running.timer = setTimeout(() => running.timeoutPromise.reject(new TimeoutRunnerError()), timeout); - } -} - export async function raceAgainstDeadline(cb: () => Promise, deadline: number): Promise<{ result: T, timedOut: false } | { timedOut: true }> { - const runner = new TimeoutRunner((deadline || MaxTime) - monotonicTime()); - try { - return { result: await runner.run(cb), timedOut: false }; - } catch (e) { - if (e instanceof TimeoutRunnerError) - return { timedOut: true }; - throw e; - } + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + cb().then(result => { + return { result, timedOut: false }; + }), + new Promise<{ timedOut: true }>(resolve => { + const kMaxDeadline = 2147483647; // 2^31-1 + const timeout = (deadline || kMaxDeadline) - monotonicTime(); + timer = setTimeout(() => resolve({ timedOut: true }), timeout); + }), + ]).finally(() => { + clearTimeout(timer); + }); } export async function pollAgainstDeadline(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> { diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 59b70a1f62..2c105a2640 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -302,8 +302,8 @@ const playwrightFixtures: Fixtures = ({ const contexts = new Map(); await use(async options => { - const hook = hookType(testInfoImpl); - if (hook) { + const hook = testInfoImpl._currentHookType(); + if (hook === 'beforeAll' || hook === 'afterAll') { throw new Error([ `"context" and "page" fixtures are not supported in "${hook}" since they are created on a per-test basis.`, `If you would like to reuse a single page between tests, create context manually with browser.newContext(). See https://aka.ms/playwright/reuse-page for details.`, @@ -396,12 +396,6 @@ const playwrightFixtures: Fixtures = ({ }, }); -function hookType(testInfo: TestInfoImpl): 'beforeAll' | 'afterAll' | undefined { - const type = testInfo._timeoutManager.currentRunnableType(); - if (type === 'beforeAll' || type === 'afterAll') - return type; -} - type StackFrame = { file: string, line?: number, diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 372f01d93f..e3ec7a46c1 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -16,11 +16,11 @@ import fs from 'fs'; import path from 'path'; -import { MaxTime, captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; +import { captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; import type { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } from '../../types/test'; import type { AttachmentPayload, StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; -import { TimeoutManager, TimeoutManagerError } from './timeoutManager'; +import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager'; import type { RunnableDescription } from './timeoutManager'; import type { Annotation, FullConfigInternal, FullProjectInternal } from '../common/config'; import type { Location } from '../../types/testReporter'; @@ -113,7 +113,7 @@ export class TestInfoImpl implements TestInfo { } get timeout(): number { - return this._timeoutManager.defaultSlotTimings().timeout; + return this._timeoutManager.defaultSlot().timeout; } set timeout(timeout: number) { @@ -122,7 +122,7 @@ export class TestInfoImpl implements TestInfo { _deadlineForMatcher(timeout: number): { deadline: number, timeoutMessage: string } { const startTime = monotonicTime(); - const matcherDeadline = timeout ? startTime + timeout : MaxTime; + const matcherDeadline = timeout ? startTime + timeout : kMaxDeadline; const testDeadline = this._timeoutManager.currentSlotDeadline() - 250; const matcherMessage = `Timeout ${timeout}ms exceeded while waiting on the predicate`; const testMessage = `Test timeout of ${this.timeout}ms exceeded`; @@ -417,6 +417,14 @@ export class TestInfoImpl implements TestInfo { return this.status !== 'skipped' && this.status !== this.expectedStatus; } + _currentHookType() { + for (let i = this._stages.length - 1; i >= 0; i--) { + const type = this._stages[i].runnable?.type; + if (type && ['beforeAll', 'afterAll', 'beforeEach', 'afterEach'].includes(type)) + return type; + } + } + // ------------ TestInfo methods ------------ async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) { diff --git a/packages/playwright/src/worker/timeoutManager.ts b/packages/playwright/src/worker/timeoutManager.ts index f253e2e0a4..6287bc232c 100644 --- a/packages/playwright/src/worker/timeoutManager.ts +++ b/packages/playwright/src/worker/timeoutManager.ts @@ -15,7 +15,7 @@ */ import { colors } from 'playwright-core/lib/utilsBundle'; -import { TimeoutRunner, TimeoutRunnerError } from 'playwright-core/lib/utils'; +import { ManualPromise, monotonicTime } from 'playwright-core/lib/utils'; import type { Location } from '../../types/testReporter'; export type TimeSlot = { @@ -39,89 +39,109 @@ export type FixtureDescription = { slot?: TimeSlot; // Falls back to the runnable slot. }; +type Running = { + runnable: RunnableDescription; + slot: TimeSlot; + start: number; + deadline: number; + timer: NodeJS.Timeout | undefined; + timeoutPromise: ManualPromise; +}; +export const kMaxDeadline = 2147483647; // 2^31-1 + export class TimeoutManager { private _defaultSlot: TimeSlot; - private _runnable: RunnableDescription; - private _timeoutRunner: TimeoutRunner; + private _running?: Running; constructor(timeout: number) { this._defaultSlot = { timeout, elapsed: 0 }; - this._runnable = { type: 'test' }; - this._timeoutRunner = new TimeoutRunner(timeout); } interrupt() { - this._timeoutRunner.interrupt(); + if (this._running) + this._running.timeoutPromise.reject(this._createTimeoutError(this._running)); } async withRunnable(runnable: RunnableDescription | undefined, cb: () => Promise): Promise { if (!runnable) return await cb(); - this._updateRunnable(runnable); + if (this._running) + throw new Error(`Internal error: duplicate runnable`); + const running = this._running = { + runnable, + slot: runnable.fixture?.slot || runnable.slot || this._defaultSlot, + start: monotonicTime(), + deadline: kMaxDeadline, + timer: undefined, + timeoutPromise: new ManualPromise(), + }; try { - return await this._timeoutRunner.run(cb); - } catch (error) { - if (!(error instanceof TimeoutRunnerError)) - throw error; - throw this._createTimeoutError(); + this._updateTimeout(running); + return await Promise.race([ + cb(), + running.timeoutPromise, + ]); } finally { - this._updateRunnable({ type: 'test' }); + if (running.timer) + clearTimeout(running.timer); + running.timer = undefined; + running.slot.elapsed += monotonicTime() - running.start; + this._running = undefined; } } - defaultSlotTimings() { - const slot = this._currentSlot(); - slot.elapsed = this._timeoutRunner.elapsed(); + private _updateTimeout(running: Running) { + if (running.timer) + clearTimeout(running.timer); + running.timer = undefined; + if (!running.slot.timeout) { + running.deadline = kMaxDeadline; + return; + } + running.deadline = running.start + (running.slot.timeout - running.slot.elapsed); + const timeout = running.deadline - monotonicTime(); + if (timeout <= 0) + running.timeoutPromise.reject(this._createTimeoutError(running)); + else + running.timer = setTimeout(() => running.timeoutPromise.reject(this._createTimeoutError(running)), timeout); + } + + defaultSlot() { return this._defaultSlot; } slow() { - const slot = this._currentSlot(); + const slot = this._running ? this._running.slot : this._defaultSlot; slot.timeout = slot.timeout * 3; - this._timeoutRunner.updateTimeout(slot.timeout); + if (this._running) + this._updateTimeout(this._running); } setTimeout(timeout: number) { - const slot = this._currentSlot(); + const slot = this._running ? this._running.slot : this._defaultSlot; if (!slot.timeout) return; // Zero timeout means some debug mode - do not set a timeout. slot.timeout = timeout; - this._timeoutRunner.updateTimeout(timeout); - } - - currentRunnableType() { - return this._runnable?.type || 'test'; + if (this._running) + this._updateTimeout(this._running); } currentSlotDeadline() { - return this._timeoutRunner.deadline(); + return this._running ? this._running.deadline : kMaxDeadline; } - private _currentSlot() { - return this._runnable.fixture?.slot || this._runnable.slot || this._defaultSlot; - } - - private _updateRunnable(runnable: RunnableDescription) { - let slot = this._currentSlot(); - slot.elapsed = this._timeoutRunner.elapsed(); - - this._runnable = runnable; - - slot = this._currentSlot(); - this._timeoutRunner.updateTimeout(slot.timeout, slot.elapsed); - } - - private _createTimeoutError(): Error { + private _createTimeoutError(running: Running): Error { let message = ''; - const timeout = this._currentSlot().timeout; - switch (this._runnable.type || 'test') { + const timeout = running.slot.timeout; + const runnable = running.runnable; + switch (runnable.type) { case 'test': { - if (this._runnable.fixture) { - if (this._runnable.fixture.phase === 'setup') { - message = `Test timeout of ${timeout}ms exceeded while setting up "${this._runnable.fixture.title}".`; + if (runnable.fixture) { + if (runnable.fixture.phase === 'setup') { + message = `Test timeout of ${timeout}ms exceeded while setting up "${runnable.fixture.title}".`; } else { message = [ - `Test finished within timeout of ${timeout}ms, but tearing down "${this._runnable.fixture.title}" ran out of time.`, + `Test finished within timeout of ${timeout}ms, but tearing down "${runnable.fixture.title}" ran out of time.`, `Please allow more time for the test, since teardown is attributed towards the test timeout budget.`, ].join('\n'); } @@ -132,15 +152,15 @@ export class TimeoutManager { } case 'afterEach': case 'beforeEach': - message = `Test timeout of ${timeout}ms exceeded while running "${this._runnable.type}" hook.`; + message = `Test timeout of ${timeout}ms exceeded while running "${runnable.type}" hook.`; break; case 'beforeAll': case 'afterAll': - message = `"${this._runnable.type}" hook timeout of ${timeout}ms exceeded.`; + message = `"${runnable.type}" hook timeout of ${timeout}ms exceeded.`; break; case 'teardown': { - if (this._runnable.fixture) - message = `Worker teardown timeout of ${timeout}ms exceeded while ${this._runnable.fixture.phase === 'setup' ? 'setting up' : 'tearing down'} "${this._runnable.fixture.title}".`; + if (runnable.fixture) + message = `Worker teardown timeout of ${timeout}ms exceeded while ${runnable.fixture.phase === 'setup' ? 'setting up' : 'tearing down'} "${runnable.fixture.title}".`; else message = `Worker teardown timeout of ${timeout}ms exceeded.`; break; @@ -149,14 +169,14 @@ export class TimeoutManager { case 'slow': case 'fixme': case 'fail': - message = `"${this._runnable.type}" modifier timeout of ${timeout}ms exceeded.`; + message = `"${runnable.type}" modifier timeout of ${timeout}ms exceeded.`; break; } - const fixtureWithSlot = this._runnable.fixture?.slot ? this._runnable.fixture : undefined; + const fixtureWithSlot = runnable.fixture?.slot ? runnable.fixture : undefined; if (fixtureWithSlot) message = `Fixture "${fixtureWithSlot.title}" timeout of ${timeout}ms exceeded during ${fixtureWithSlot.phase}.`; message = colors.red(message); - const location = (fixtureWithSlot || this._runnable).location; + const location = (fixtureWithSlot || runnable).location; const error = new TimeoutManagerError(message); error.name = ''; // Include location for hooks, modifiers and fixtures to distinguish between them. diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 8865dd2120..44a2c451f2 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -358,7 +358,7 @@ export class WorkerMain extends ProcessRunner { }).catch(error => testInfo._handlePossibleTimeoutError(error)); // Update duration, so it is available in fixture teardown and afterEach hooks. - testInfo.duration = testInfo._timeoutManager.defaultSlotTimings().elapsed | 0; + testInfo.duration = testInfo._timeoutManager.defaultSlot().elapsed | 0; // No skips in after hooks. testInfo._allowSkips = true; @@ -463,7 +463,7 @@ export class WorkerMain extends ProcessRunner { await testInfo._tracing.stopIfNeeded(); }).catch(error => testInfo._handlePossibleTimeoutError(error)); - testInfo.duration = (testInfo._timeoutManager.defaultSlotTimings().elapsed + afterHooksSlot.elapsed) | 0; + testInfo.duration = (testInfo._timeoutManager.defaultSlot().elapsed + afterHooksSlot.elapsed) | 0; this._currentTest = null; setCurrentTestInfo(null);