mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore(test runner): simplify TimeoutManager and TimeoutRunner (#29863)
This commit is contained in:
parent
8f4c2f714d
commit
88e80cf948
@ -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<any>,
|
||||
};
|
||||
|
||||
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<T>(cb: () => Promise<T>): Promise<T> {
|
||||
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<T>(cb: () => Promise<T>, 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<T>(callback: () => Promise<{ continuePolling: boolean, result: T }>, deadline: number, pollIntervals: number[] = [100, 250, 500, 1000]): Promise<{ result?: T, timedOut: boolean }> {
|
||||
|
||||
@ -302,8 +302,8 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
|
||||
const contexts = new Map<BrowserContext, { pagesWithVideo: Page[] }>();
|
||||
|
||||
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<TestFixtures, WorkerFixtures> = ({
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@ -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 } = {}) {
|
||||
|
||||
@ -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<any>;
|
||||
};
|
||||
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<T>(runnable: RunnableDescription | undefined, cb: () => Promise<T>): Promise<T> {
|
||||
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.
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user