chore(test runner): simplify TimeoutManager and TimeoutRunner (#29863)

This commit is contained in:
Dmitry Gozman 2024-03-11 15:43:50 -07:00 committed by GitHub
parent 8f4c2f714d
commit 88e80cf948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 102 additions and 166 deletions

View File

@ -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 }> {

View File

@ -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,

View File

@ -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 } = {}) {

View File

@ -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.

View File

@ -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);