chore(test runner): make timeout a separate error in TestInfo.errors (#12315)

This way we control the timeout error message from the runner,
so that later on we can differentiate between test timeout, fixture
timeout and hook timeout.
This commit is contained in:
Dmitry Gozman 2022-02-23 12:32:12 -08:00 committed by GitHub
parent 5be9f68446
commit 4399623f9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 51 additions and 64 deletions

View File

@ -17,7 +17,7 @@
import child_process from 'child_process';
import path from 'path';
import { EventEmitter } from 'events';
import { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, WorkerInitParams, StepBeginPayload, StepEndPayload, SerializedLoaderData } from './ipc';
import { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, WorkerInitParams, StepBeginPayload, StepEndPayload, SerializedLoaderData, TeardownErrorsPayload } from './ipc';
import type { TestResult, Reporter, TestStep } from '../types/testReporter';
import { Suite, TestCase } from './test';
import { Loader } from './loader';
@ -290,7 +290,7 @@ export class Dispatcher {
// - there are no remaining
// - we are here not because something failed
// - no unrecoverable worker error
if (!remaining.length && !failedTestIds.size && !params.fatalError) {
if (!remaining.length && !failedTestIds.size && !params.fatalErrors.length) {
if (this._isWorkerRedundant(worker))
worker.stop();
doneWithJob();
@ -302,12 +302,12 @@ export class Dispatcher {
// In case of fatal error, report first remaining test as failing with this error,
// and all others as skipped.
if (params.fatalError) {
if (params.fatalErrors.length) {
// Perhaps we were running a hook - report it as failed.
if (runningHookId) {
const data = this._testById.get(runningHookId)!;
const { result } = data.resultByWorkerIndex.get(worker.workerIndex)!;
result.errors = [params.fatalError];
result.errors = [...params.fatalErrors];
result.error = result.errors[0];
result.status = 'failed';
this._reporter.onTestEnd?.(data.test, result);
@ -328,7 +328,7 @@ export class Dispatcher {
if (test._type === 'test')
this._reporter.onTestBegin?.(test, result);
}
result.errors = [params.fatalError];
result.errors = [...params.fatalErrors];
result.error = result.errors[0];
result.status = first ? 'failed' : 'skipped';
this._reportTestEnd(test, result);
@ -339,7 +339,8 @@ export class Dispatcher {
// We had a fatal error after all tests have passed - most likely in the afterAll hook.
// Let's just fail the test run.
this._hasWorkerErrors = true;
this._reporter.onError?.(params.fatalError);
for (const error of params.fatalErrors)
this._reporter.onError?.(error);
}
// Since we pretend that all remaining tests failed, there is nothing else to run,
// except for possible retries.
@ -407,7 +408,7 @@ export class Dispatcher {
worker.on('done', onDone);
const onExit = (expectedly: boolean) => {
onDone(expectedly ? {} : { fatalError: { value: 'Worker process exited unexpectedly' } });
onDone({ fatalErrors: expectedly ? [] : [{ value: 'Worker process exited unexpectedly' }] });
};
worker.on('exit', onExit);
@ -439,9 +440,10 @@ export class Dispatcher {
result?.stderr.push(chunk);
this._reporter.onStdErr?.(chunk, test, result);
});
worker.on('teardownError', ({ error }) => {
worker.on('teardownErrors', (params: TeardownErrorsPayload) => {
this._hasWorkerErrors = true;
this._reporter.onError?.(error);
for (const error of params.fatalErrors)
this._reporter.onError?.(error);
});
return worker;
}

View File

@ -21,7 +21,6 @@ import type { TestType, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWor
import { rootTestType } from './testType';
import { createGuid, removeFolders, debugMode } from 'playwright-core/lib/utils/utils';
import { GridClient } from 'playwright-core/lib/grid/gridClient';
import { prependToTestError } from './util';
export { expect } from './expect';
export const _baseTest: TestType<{}, {}> = rootTestType.test;
import * as outOfProcess from 'playwright-core/lib/outofprocess';
@ -429,7 +428,7 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
const pendingCalls = anyContext ? formatPendingCalls((anyContext as any)._connection.pendingProtocolCalls()) : '';
await Promise.all(leftoverContexts.filter(c => createdContexts.has(c)).map(c => c.close()));
if (pendingCalls)
testInfo.error = prependToTestError(testInfo.error, pendingCalls);
testInfo.errors.push({ message: pendingCalls });
}
}, { auto: true }],
@ -484,7 +483,7 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
}));
if (prependToError)
testInfo.error = prependToTestError(testInfo.error, prependToError);
testInfo.errors.push({ message: prependToError });
},
context: async ({ _contextFactory }, use) => {
@ -511,7 +510,7 @@ function formatPendingCalls(calls: ParsedStackTrace[]) {
return 'Pending operations:\n' + calls.map(call => {
const frame = call.frames && call.frames[0] ? ' at ' + formatStackFrame(call.frames[0]) : '';
return ` - ${call.apiName}${frame}\n`;
}).join('') + '\n';
}).join('');
}
function formatStackFrame(frame: StackFrame) {

View File

@ -75,7 +75,7 @@ export type RunPayload = {
};
export type DonePayload = {
fatalError?: TestError;
fatalErrors: TestError[];
};
export type TestOutputPayload = {
@ -83,3 +83,7 @@ export type TestOutputPayload = {
text?: string;
buffer?: string;
};
export type TeardownErrorsPayload = {
fatalErrors: TestError[];
};

View File

@ -303,11 +303,7 @@ export function formatFailure(config: FullConfig, test: TestCase, options: {inde
export function formatResultFailure(config: FullConfig, test: TestCase, result: TestResult, initialIndent: string, highlightCode: boolean): ErrorDetails[] {
const errorDetails: ErrorDetails[] = [];
if (result.status === 'timedOut') {
errorDetails.push({
message: indent(colors.red(`Timeout of ${test.timeout}ms exceeded.`), initialIndent),
});
} else if (result.status === 'passed' && test.expectedStatus === 'failed') {
if (result.status === 'passed' && test.expectedStatus === 'failed') {
errorDetails.push({
message: indent(colors.red(`Expected to fail, but passed.`), initialIndent),
});

View File

@ -14,6 +14,7 @@
* limitations under the License.
*/
import colors from 'colors/safe';
import fs from 'fs';
import * as mime from 'mime';
import path from 'path';
@ -25,7 +26,7 @@ import { Loader } from './loader';
import { ProjectImpl } from './project';
import { TestCase } from './test';
import { Annotations, TestStepInternal } from './types';
import { addSuffixToFilePath, getContainedPath, monotonicTime, sanitizeForFilePath, serializeError, trimLongString } from './util';
import { addSuffixToFilePath, formatLocation, getContainedPath, monotonicTime, sanitizeForFilePath, serializeError, trimLongString } from './util';
export class TestInfoImpl implements TestInfo {
private _projectImpl: ProjectImpl;
@ -167,8 +168,16 @@ export class TestInfoImpl implements TestInfo {
if (!(error instanceof TimeoutRunnerError))
throw error;
// Do not overwrite existing failure upon hook/teardown timeout.
if (this.status === 'passed')
if (this.status === 'passed') {
this.status = 'timedOut';
if (this._test._type === 'test') {
this.errors.push({ message: colors.red(`Timeout of ${this.timeout}ms exceeded.`) });
} else {
// Include location for the hook to distinguish between multiple hooks.
const message = colors.red(`Timeout of ${this.timeout}ms exceeded in ${this._test._type} hook.`);
this.errors.push({ message: message, stack: message + `\n at ${formatLocation(this._test.location)}.` });
}
}
}
this.duration = monotonicTime() - this._startTime;
}

View File

@ -205,24 +205,3 @@ export function getContainedPath(parentPath: string, subPath: string = ''): stri
}
export const debugTest = debug('pw:test');
export function prependToTestError(testError: TestError | undefined, message: string, location?: Location): TestError {
if (!testError) {
if (!location)
return { value: message };
let stack = ` at ${location.file}:${location.line}:${location.column}`;
if (!message.endsWith('\n'))
stack = '\n' + stack;
return { message: message, stack: message + stack };
}
if (testError.message) {
const stack = testError.stack ? message + testError.stack : testError.stack;
message = message + testError.message;
return {
value: testError.value,
message,
stack,
};
}
return testError;
}

View File

@ -16,7 +16,7 @@
import { Console } from 'console';
import * as util from 'util';
import { RunPayload, TestOutputPayload, WorkerInitParams } from './ipc';
import { RunPayload, TeardownErrorsPayload, TestOutputPayload, WorkerInitParams } from './ipc';
import { startProfiling, stopProfiling } from './profiler';
import { serializeError } from './util';
import { WorkerRunner } from './workerRunner';
@ -74,7 +74,7 @@ process.on('message', async message => {
workerIndex = initParams.workerIndex;
startProfiling();
workerRunner = new WorkerRunner(initParams);
for (const event of ['testBegin', 'testEnd', 'stepBegin', 'stepEnd', 'done', 'teardownError'])
for (const event of ['testBegin', 'testEnd', 'stepBegin', 'stepEnd', 'done', 'teardownErrors'])
workerRunner.on(event, sendMessageToParent.bind(null, event));
return;
}
@ -103,7 +103,8 @@ async function gracefullyCloseAndExit() {
if (workerIndex !== undefined)
await stopProfiling(workerIndex);
} catch (e) {
process.send!({ method: 'teardownError', params: { error: serializeError(e) } });
const payload: TeardownErrorsPayload = { fatalErrors: [serializeError(e)] };
process.send!({ method: 'teardownErrors', params: payload });
}
process.exit(0);
}

View File

@ -18,8 +18,8 @@ import rimraf from 'rimraf';
import util from 'util';
import colors from 'colors/safe';
import { EventEmitter } from 'events';
import { serializeError, prependToTestError, formatLocation } from './util';
import { TestBeginPayload, TestEndPayload, RunPayload, TestEntry, DonePayload, WorkerInitParams, StepBeginPayload, StepEndPayload } from './ipc';
import { serializeError, formatLocation } from './util';
import { TestBeginPayload, TestEndPayload, RunPayload, TestEntry, DonePayload, WorkerInitParams, StepBeginPayload, StepEndPayload, TeardownErrorsPayload } from './ipc';
import { setCurrentTestInfo } from './globals';
import { Loader } from './loader';
import { Modifier, Suite, TestCase } from './test';
@ -39,7 +39,7 @@ export class WorkerRunner extends EventEmitter {
private _fixtureRunner: FixtureRunner;
private _failedTest: TestInfoImpl | undefined;
private _fatalError: TestError | undefined;
private _fatalErrors: TestError[] = [];
private _entries = new Map<string, TestEntry>();
private _isStopped = false;
private _runFinished = Promise.resolve();
@ -69,8 +69,10 @@ export class WorkerRunner extends EventEmitter {
// We have to load the project to get the right deadline below.
await this._loadIfNeeded();
await this._teardownScopes();
if (this._fatalError)
this.emit('teardownError', { error: this._fatalError });
if (this._fatalErrors.length) {
const payload: TeardownErrorsPayload = { fatalErrors: this._fatalErrors };
this.emit('teardownErrors', payload);
}
}
private async _teardownScopes() {
@ -79,8 +81,8 @@ export class WorkerRunner extends EventEmitter {
await this._fixtureRunner.teardownScope('test');
await this._fixtureRunner.teardownScope('worker');
}, this._project.config.timeout);
if (result.timedOut && !this._fatalError)
this._fatalError = { message: colors.red(`Timeout of ${this._project.config.timeout}ms exceeded while shutting down environment`) };
if (result.timedOut)
this._fatalErrors.push({ message: colors.red(`Timeout of ${this._project.config.timeout}ms exceeded while shutting down environment`) });
}
unhandledError(error: Error | any) {
@ -102,8 +104,8 @@ export class WorkerRunner extends EventEmitter {
this._currentTest._failWithError(serializeError(error), true /* isHardError */);
} else {
// No current test - fatal error.
if (!this._fatalError)
this._fatalError = serializeError(error);
if (!this._fatalErrors.length)
this._fatalErrors.push(serializeError(error));
}
this.stop();
}
@ -180,8 +182,7 @@ export class WorkerRunner extends EventEmitter {
// TODO: separate timeout for beforeAll modifiers?
const result = await raceAgainstTimeout(() => this._fixtureRunner.resolveParametersAndRunFunction(beforeAllModifier.fn, this._workerInfo, undefined), this._project.config.timeout);
if (result.timedOut) {
if (!this._fatalError)
this._fatalError = serializeError(new Error(`Timeout of ${this._project.config.timeout}ms exceeded while running ${beforeAllModifier.type} modifier\n at ${formatLocation(beforeAllModifier.location)}`));
this._fatalErrors.push(serializeError(new Error(`Timeout of ${this._project.config.timeout}ms exceeded while running ${beforeAllModifier.type} modifier\n at ${formatLocation(beforeAllModifier.location)}`)));
this.stop();
} else if (!!result.result) {
annotations.push({ type: beforeAllModifier.type, description: beforeAllModifier.description });
@ -303,11 +304,7 @@ export class WorkerRunner extends EventEmitter {
this._failedTest = testInfo;
if (test._type !== 'test') {
// beforeAll/afterAll hook failure skips any remaining tests in the worker.
if (!this._fatalError)
this._fatalError = testInfo.error;
// Keep any error we have, and add "timeout" message.
if (testInfo.status === 'timedOut')
this._fatalError = prependToTestError(this._fatalError, colors.red(`Timeout of ${testInfo.timeout}ms exceeded in ${test._type} hook.\n`), test.location);
this._fatalErrors.push(...testInfo.errors);
}
this.stop();
} else {
@ -389,9 +386,9 @@ export class WorkerRunner extends EventEmitter {
}
private _reportDone() {
const donePayload: DonePayload = { fatalError: this._fatalError };
const donePayload: DonePayload = { fatalErrors: this._fatalErrors };
this.emit('done', donePayload);
this._fatalError = undefined;
this._fatalErrors = [];
this._failedTest = undefined;
}
}

View File

@ -499,7 +499,7 @@ test('afterAll timeout should be reported', async ({ runInlineTest }, testInfo)
'%%afterAll',
]);
expect(result.output).toContain('Timeout of 1000ms exceeded in afterAll hook.');
expect(result.output).toContain(`at ${testInfo.outputPath('a.test.js')}:6:12`);
expect(result.output).toContain(`at a.test.js:6:12`);
});
test('beforeAll and afterAll timeouts at the same time should be reported', async ({ runInlineTest }) => {