fix(test runner): ensure we run after hooks after failures (#8102)

This commit is contained in:
Dmitry Gozman 2021-08-10 10:54:05 -07:00 committed by GitHub
parent 24fde5b13e
commit 9d6c7cdf20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 164 additions and 93 deletions

View file

@ -29,6 +29,5 @@ export type CompleteStepCallback = (error?: Error | TestError) => void;
export interface TestInfoImpl extends TestInfo { export interface TestInfoImpl extends TestInfo {
_testFinished: Promise<void>; _testFinished: Promise<void>;
_type: 'test' | 'beforeAll' | 'afterAll';
_addStep: (category: string, title: string) => CompleteStepCallback; _addStep: (category: string, title: string) => CompleteStepCallback;
} }

View file

@ -97,7 +97,7 @@ async function gracefullyCloseAndExit() {
// Meanwhile, try to gracefully shutdown. // Meanwhile, try to gracefully shutdown.
try { try {
if (workerRunner) { if (workerRunner) {
workerRunner.stop(); await workerRunner.stop();
await workerRunner.cleanup(); await workerRunner.cleanup();
} }
if (workerIndex !== undefined) if (workerIndex !== undefined)

View file

@ -43,7 +43,9 @@ export class WorkerRunner extends EventEmitter {
private _fatalError: TestError | undefined; private _fatalError: TestError | undefined;
private _entries = new Map<string, TestEntry>(); private _entries = new Map<string, TestEntry>();
private _isStopped = false; private _isStopped = false;
_currentTest: { testId: string, testInfo: TestInfoImpl, testFinishedCallback: () => void } | null = null; private _runFinished = Promise.resolve();
private _currentDeadlineRunner: DeadlineRunner<any> | undefined;
_currentTest: { testId: string, testInfo: TestInfoImpl, type: 'test' | 'beforeAll' | 'afterAll' } | null = null;
constructor(params: WorkerInitParams) { constructor(params: WorkerInitParams) {
super(); super();
@ -51,13 +53,18 @@ export class WorkerRunner extends EventEmitter {
this._fixtureRunner = new FixtureRunner(); this._fixtureRunner = new FixtureRunner();
} }
stop() { stop(): Promise<void> {
// TODO: mark test as 'interrupted' instead. if (!this._isStopped) {
if (this._currentTest && this._currentTest.testInfo.status === 'passed') { this._isStopped = true;
this._currentTest.testInfo.status = 'skipped';
this._currentTest.testFinishedCallback(); // Interrupt current action.
this._currentDeadlineRunner?.setDeadline(0);
// TODO: mark test as 'interrupted' instead.
if (this._currentTest && this._currentTest.testInfo.status === 'passed')
this._currentTest.testInfo.status = 'skipped';
} }
this._isStopped = true; return this._runFinished;
} }
async cleanup() { async cleanup() {
@ -73,20 +80,17 @@ export class WorkerRunner extends EventEmitter {
} }
unhandledError(error: Error | any) { unhandledError(error: Error | any) {
if (this._isStopped) if (this._currentTest && this._currentTest.type === 'test') {
return; if (!this._currentTest.testInfo.error) {
if (this._currentTest && this._currentTest.testInfo._type === 'test') { this._currentTest.testInfo.status = 'failed';
if (this._currentTest.testInfo.error) this._currentTest.testInfo.error = serializeError(error);
return; }
this._currentTest.testInfo.status = 'failed';
this._currentTest.testInfo.error = serializeError(error);
this._failedTestId = this._currentTest.testId;
this.emit('testEnd', buildTestEndPayload(this._currentTest.testId, this._currentTest.testInfo));
} else { } else {
// No current test - fatal error. // No current test - fatal error.
this._fatalError = serializeError(error); if (!this._fatalError)
this._fatalError = serializeError(error);
} }
this._reportDoneAndStop(); this.stop();
} }
private _deadline() { private _deadline() {
@ -117,46 +121,49 @@ export class WorkerRunner extends EventEmitter {
} }
async run(runPayload: RunPayload) { async run(runPayload: RunPayload) {
this._entries = new Map(runPayload.entries.map(e => [ e.testId, e ])); let runFinishedCalback = () => {};
this._runFinished = new Promise(f => runFinishedCalback = f);
await this._loadIfNeeded(); try {
this._entries = new Map(runPayload.entries.map(e => [ e.testId, e ]));
const fileSuite = await this._loader.loadTestFile(runPayload.file); await this._loadIfNeeded();
let anyPool: FixturePool | undefined; const fileSuite = await this._loader.loadTestFile(runPayload.file);
const suite = this._project.cloneFileSuite(fileSuite, this._params.repeatEachIndex, test => { let anyPool: FixturePool | undefined;
if (!this._entries.has(test._id)) const suite = this._project.cloneFileSuite(fileSuite, this._params.repeatEachIndex, test => {
return false; if (!this._entries.has(test._id))
anyPool = test._pool; return false;
return true; anyPool = test._pool;
}); return true;
});
if (!suite || !anyPool) { if (suite && anyPool) {
this._fixtureRunner.setPool(anyPool);
await this._runSuite(suite, []);
}
} catch (e) {
// In theory, we should run above code without any errors.
// However, in the case we screwed up, or loadTestFile failed in the worker
// but not in the runner, let's do a fatal error.
this.unhandledError(e);
} finally {
this._reportDone(); this._reportDone();
return; runFinishedCalback();
} }
this._fixtureRunner.setPool(anyPool);
await this._runSuite(suite, []);
if (this._isStopped)
return;
this._reportDone();
} }
private async _runSuite(suite: Suite, annotations: Annotations) { private async _runSuite(suite: Suite, annotations: Annotations) {
// When stopped, do not run a suite. But if we have started running the suite with hooks,
// always finish the hooks.
if (this._isStopped) if (this._isStopped)
return; return;
annotations = annotations.concat(suite._annotations); annotations = annotations.concat(suite._annotations);
for (const beforeAllModifier of suite._modifiers) { for (const beforeAllModifier of suite._modifiers) {
if (this._isStopped)
return;
if (!this._fixtureRunner.dependsOnWorkerFixturesOnly(beforeAllModifier.fn, beforeAllModifier.location)) if (!this._fixtureRunner.dependsOnWorkerFixturesOnly(beforeAllModifier.fn, beforeAllModifier.location))
continue; continue;
// TODO: separate timeout for beforeAll modifiers? // TODO: separate timeout for beforeAll modifiers?
const result = await raceAgainstDeadline(this._fixtureRunner.resolveParametersAndRunHookOrTest(beforeAllModifier.fn, this._workerInfo, undefined), this._deadline()); const result = await raceAgainstDeadline(this._fixtureRunner.resolveParametersAndRunHookOrTest(beforeAllModifier.fn, this._workerInfo, undefined), this._deadline());
if (result.timedOut) { if (result.timedOut) {
this._fatalError = serializeError(new Error(`Timeout of ${this._project.config.timeout}ms exceeded while running ${beforeAllModifier.type} modifier`)); this._fatalError = serializeError(new Error(`Timeout of ${this._project.config.timeout}ms exceeded while running ${beforeAllModifier.type} modifier`));
this._reportDoneAndStop(); this.stop();
} }
if (!!result.result) if (!!result.result)
annotations.push({ type: beforeAllModifier.type, description: beforeAllModifier.description }); annotations.push({ type: beforeAllModifier.type, description: beforeAllModifier.description });
@ -165,8 +172,6 @@ export class WorkerRunner extends EventEmitter {
for (const hook of suite._allHooks) { for (const hook of suite._allHooks) {
if (hook._type !== 'beforeAll') if (hook._type !== 'beforeAll')
continue; continue;
if (this._isStopped)
return;
const firstTest = suite.allTests()[0]; const firstTest = suite.allTests()[0];
await this._runTestOrAllHook(hook, annotations, this._entries.get(firstTest._id)?.retry || 0); await this._runTestOrAllHook(hook, annotations, this._entries.get(firstTest._id)?.retry || 0);
} }
@ -175,23 +180,18 @@ export class WorkerRunner extends EventEmitter {
await this._runSuite(entry, annotations); await this._runSuite(entry, annotations);
} else { } else {
const runEntry = this._entries.get(entry._id); const runEntry = this._entries.get(entry._id);
if (runEntry) if (runEntry && !this._isStopped)
await this._runTestOrAllHook(entry, annotations, runEntry.retry); await this._runTestOrAllHook(entry, annotations, runEntry.retry);
} }
} }
for (const hook of suite._allHooks) { for (const hook of suite._allHooks) {
if (hook._type !== 'afterAll') if (hook._type !== 'afterAll')
continue; continue;
if (this._isStopped)
return;
await this._runTestOrAllHook(hook, annotations, 0); await this._runTestOrAllHook(hook, annotations, 0);
} }
} }
private async _runTestOrAllHook(test: TestCase, annotations: Annotations, retry: number) { private async _runTestOrAllHook(test: TestCase, annotations: Annotations, retry: number) {
if (this._isStopped)
return;
const reportEvents = test._type === 'test'; const reportEvents = test._type === 'test';
const startTime = monotonicTime(); const startTime = monotonicTime();
const startWallTime = Date.now(); const startWallTime = Date.now();
@ -285,7 +285,6 @@ export class WorkerRunner extends EventEmitter {
this.emit('stepEnd', payload); this.emit('stepEnd', payload);
}; };
}, },
_type: test._type,
}; };
// Inherit test.setTimeout() from parent suites. // Inherit test.setTimeout() from parent suites.
@ -314,7 +313,9 @@ export class WorkerRunner extends EventEmitter {
} }
} }
this._setCurrentTest({ testInfo, testId, testFinishedCallback }); this._currentTest = { testInfo, testId, type: test._type };
setCurrentTestInfo(testInfo);
const deadline = () => { const deadline = () => {
return testInfo.timeout ? startTime + testInfo.timeout : undefined; return testInfo.timeout ? startTime + testInfo.timeout : undefined;
}; };
@ -332,31 +333,28 @@ export class WorkerRunner extends EventEmitter {
// Update the fixture pool - it may differ between tests, but only in test-scoped fixtures. // Update the fixture pool - it may differ between tests, but only in test-scoped fixtures.
this._fixtureRunner.setPool(test._pool!); this._fixtureRunner.setPool(test._pool!);
deadlineRunner = new DeadlineRunner(this._runTestWithBeforeHooks(test, testInfo), deadline()); this._currentDeadlineRunner = deadlineRunner = new DeadlineRunner(this._runTestWithBeforeHooks(test, testInfo), deadline());
const result = await deadlineRunner.result; const result = await deadlineRunner.result;
// Do not overwrite test failure upon hook timeout. // Do not overwrite test failure upon hook timeout.
if (result.timedOut && testInfo.status === 'passed') if (result.timedOut && testInfo.status === 'passed')
testInfo.status = 'timedOut'; testInfo.status = 'timedOut';
testFinishedCallback();
if (!this._isStopped) { if (!result.timedOut) {
// When stopped during the test run (usually either a user interruption or an unhandled error), this._currentDeadlineRunner = deadlineRunner = new DeadlineRunner(this._runAfterHooks(test, testInfo), deadline());
// we do not run cleanup because the worker will cleanup() anyway. deadlineRunner.setDeadline(deadline());
testFinishedCallback(); const hooksResult = await deadlineRunner.result;
if (!result.timedOut) { // Do not overwrite test failure upon hook timeout.
deadlineRunner = new DeadlineRunner(this._runAfterHooks(test, testInfo), deadline()); if (hooksResult.timedOut && testInfo.status === 'passed')
deadlineRunner.setDeadline(deadline()); testInfo.status = 'timedOut';
const hooksResult = await deadlineRunner.result; } else {
// Do not overwrite test failure upon hook timeout. // A timed-out test gets a full additional timeout to run after hooks.
if (hooksResult.timedOut && testInfo.status === 'passed') const newDeadline = this._deadline();
testInfo.status = 'timedOut'; this._currentDeadlineRunner = deadlineRunner = new DeadlineRunner(this._runAfterHooks(test, testInfo), newDeadline);
} else { await deadlineRunner.result;
// A timed-out test gets a full additional timeout to run after hooks.
const newDeadline = this._deadline();
deadlineRunner = new DeadlineRunner(this._runAfterHooks(test, testInfo), newDeadline);
await deadlineRunner.result;
}
} }
this._currentDeadlineRunner = undefined;
testInfo.duration = monotonicTime() - startTime; testInfo.duration = monotonicTime() - startTime;
if (reportEvents) if (reportEvents)
this.emit('testEnd', buildTestEndPayload(testId, testInfo)); this.emit('testEnd', buildTestEndPayload(testId, testInfo));
@ -367,24 +365,18 @@ export class WorkerRunner extends EventEmitter {
if (!preserveOutput) if (!preserveOutput)
await removeFolderAsync(testInfo.outputDir).catch(e => {}); await removeFolderAsync(testInfo.outputDir).catch(e => {});
this._setCurrentTest(null); this._currentTest = null;
setCurrentTestInfo(null);
if (this._isStopped)
return;
if (testInfo.status !== 'passed' && testInfo.status !== 'skipped') { if (testInfo.status !== 'passed' && testInfo.status !== 'skipped') {
if (test._type === 'test') if (test._type === 'test')
this._failedTestId = testId; this._failedTestId = testId;
else else
this._fatalError = testInfo.error; this._fatalError = testInfo.error;
this._reportDoneAndStop(); this.stop();
} }
} }
private _setCurrentTest(currentTest: { testId: string, testInfo: TestInfoImpl, testFinishedCallback: () => void } | null) {
this._currentTest = currentTest;
setCurrentTestInfo(currentTest ? currentTest.testInfo : null);
}
private async _runBeforeHooks(test: TestCase, testInfo: TestInfoImpl) { private async _runBeforeHooks(test: TestCase, testInfo: TestInfoImpl) {
let completeStep: CompleteStepCallback | undefined; let completeStep: CompleteStepCallback | undefined;
try { try {
@ -395,8 +387,6 @@ export class WorkerRunner extends EventEmitter {
} }
beforeEachModifiers.reverse(); beforeEachModifiers.reverse();
for (const modifier of beforeEachModifiers) { for (const modifier of beforeEachModifiers) {
if (this._isStopped)
return;
const result = await this._fixtureRunner.resolveParametersAndRunHookOrTest(modifier.fn, this._workerInfo, testInfo); const result = await this._fixtureRunner.resolveParametersAndRunHookOrTest(modifier.fn, this._workerInfo, testInfo);
testInfo[modifier.type](!!result, modifier.description!); testInfo[modifier.type](!!result, modifier.description!);
} }
@ -420,7 +410,7 @@ export class WorkerRunner extends EventEmitter {
await this._runBeforeHooks(test, testInfo); await this._runBeforeHooks(test, testInfo);
// Do not run the test when beforeEach hook fails. // Do not run the test when beforeEach hook fails.
if (this._isStopped || testInfo.status === 'failed' || testInfo.status === 'skipped') if (testInfo.status === 'failed' || testInfo.status === 'skipped')
return; return;
try { try {
@ -473,8 +463,6 @@ export class WorkerRunner extends EventEmitter {
} }
private async _runHooks(suite: Suite, type: 'beforeEach' | 'afterEach', testInfo: TestInfo) { private async _runHooks(suite: Suite, type: 'beforeEach' | 'afterEach', testInfo: TestInfo) {
if (this._isStopped)
return;
const all = []; const all = [];
for (let s: Suite | undefined = suite; s; s = s.parent) { for (let s: Suite | undefined = suite; s; s = s.parent) {
const funcs = s._eachHooks.filter(e => e.type === type).map(e => e.fn); const funcs = s._eachHooks.filter(e => e.type === type).map(e => e.fn);
@ -502,13 +490,6 @@ export class WorkerRunner extends EventEmitter {
}; };
this.emit('done', donePayload); this.emit('done', donePayload);
} }
private _reportDoneAndStop() {
if (this._isStopped)
return;
this._reportDone();
this.stop();
}
} }
function buildTestBeginPayload(testId: string, testInfo: TestInfo, startWallTime: number): TestBeginPayload { function buildTestBeginPayload(testId: string, testInfo: TestInfo, startWallTime: number): TestBeginPayload {

View file

@ -220,6 +220,43 @@ test('beforeAll hooks are skipped when no tests in the suite are run', async ({
expect(result.output).not.toContain('%%beforeAll1'); expect(result.output).not.toContain('%%beforeAll1');
}); });
test('should run hooks after failure', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.js': `
const { test } = pwt;
test.describe('suite', () => {
test('faled', ({}) => {
console.log('\\n%%test');
expect(1).toBe(2);
});
test.afterEach(() => {
console.log('\\n%%afterEach-1');
});
test.afterAll(() => {
console.log('\\n%%afterAll-1');
});
});
test.afterEach(async () => {
await new Promise(f => setTimeout(f, 1000));
console.log('\\n%%afterEach-2');
});
test.afterAll(async () => {
await new Promise(f => setTimeout(f, 1000));
console.log('\\n%%afterAll-2');
});
`,
});
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.output.split('\n').filter(line => line.startsWith('%%'))).toEqual([
'%%test',
'%%afterEach-1',
'%%afterEach-2',
'%%afterAll-1',
'%%afterAll-2',
]);
});
test('beforeAll hook should get retry index of the first test', async ({ runInlineTest }) => { test('beforeAll hook should get retry index of the first test', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'a.test.js': ` 'a.test.js': `
@ -258,3 +295,57 @@ test('afterAll exception should fail the run', async ({ runInlineTest }) => {
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
expect(result.output).toContain('From the afterAll'); expect(result.output).toContain('From the afterAll');
}); });
test('max-failures should still run afterEach/afterAll', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.js': `
const { test } = pwt;
test.afterAll(() => {
console.log('\\n%%afterAll');
});
test.afterEach(() => {
console.log('\\n%%afterEach');
});
test('failed', async () => {
console.log('\\n%%test');
test.expect(1).toBe(2);
});
test('skipped', async () => {
console.log('\\n%%skipped');
});
`,
}, { 'max-failures': 1 });
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(0);
expect(result.failed).toBe(1);
expect(result.skipped).toBe(1);
expect(result.output.split('\n').filter(line => line.startsWith('%%'))).toEqual([
'%%test',
'%%afterEach',
'%%afterAll',
]);
});
test('beforeAll failure should prevent the test, but not afterAll', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.js': `
const { test } = pwt;
test.beforeAll(() => {
console.log('\\n%%beforeAll');
throw new Error('From a beforeAll');
});
test.afterAll(() => {
console.log('\\n%%afterAll');
});
test('skipped', () => {
console.log('\\n%%test');
});
`,
});
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.output.split('\n').filter(line => line.startsWith('%%'))).toEqual([
'%%beforeAll',
'%%afterAll',
]);
});