fix(test runner): avoid internal error for step end without begin (#8327)

fix(test runner): avoid internal error for step end without begin

Consider the following scenario:
- Test finishes and starts tearing down fixtures.
- Fixture teardown starts a step S and then times out.
- We declare the test finished (with timeout).
- Dispatcher shuts down the worker and spins a new one for a retry.
  Additionally, it clears steps information for the test to be
  ready for the new retry. Step S information is lost.
- Meanwhile, during worker teardown, the step S does
  actually finish (usually with an error), and we send stepEnd for S.
- Dispatcher does not know what to do with step S end and
  prints an internal error.

The fix is to ignore certain messages from the shutting down worker that failed.
This commit is contained in:
Dmitry Gozman 2021-08-19 17:04:09 -07:00 committed by GitHub
parent cad7fc2ef7
commit 70eca0401d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 59 additions and 1 deletions

View file

@ -124,6 +124,7 @@ export class Dispatcher {
// When worker encounters error, we will stop it and create a new one.
worker.stop();
worker.didFail = true;
const retryCandidates = new Set<string>();
@ -255,6 +256,11 @@ export class Dispatcher {
_createWorker(testGroup: TestGroup) {
const worker = new Worker(this);
worker.on('testBegin', (params: TestBeginPayload) => {
if (worker.didFail) {
// Ignore test-related messages from failed workers, because timed out tests/fixtures
// may be triggering unexpected messages.
return;
}
if (this._hasReachedMaxFailures())
return;
const { test, result: testRun } = this._testById.get(params.testId)!;
@ -263,6 +269,11 @@ export class Dispatcher {
this._reporter.onTestBegin?.(test, testRun);
});
worker.on('testEnd', (params: TestEndPayload) => {
if (worker.didFail) {
// Ignore test-related messages from failed workers, because timed out tests/fixtures
// may be triggering unexpected messages.
return;
}
if (this._hasReachedMaxFailures())
return;
const { test, result } = this._testById.get(params.testId)!;
@ -281,6 +292,11 @@ export class Dispatcher {
this._reportTestEnd(test, result);
});
worker.on('stepBegin', (params: StepBeginPayload) => {
if (worker.didFail) {
// Ignore test-related messages from failed workers, because timed out tests/fixtures
// may be triggering unexpected messages.
return;
}
const { test, result, steps, stepStack } = this._testById.get(params.testId)!;
const parentStep = [...stepStack].pop();
const step: TestStep = {
@ -301,6 +317,11 @@ export class Dispatcher {
this._reporter.onStepBegin?.(test, result, step);
});
worker.on('stepEnd', (params: StepEndPayload) => {
if (worker.didFail) {
// Ignore test-related messages from failed workers, because timed out tests/fixtures
// may be triggering unexpected messages.
return;
}
const { test, result, steps, stepStack } = this._testById.get(params.testId)!;
const step = steps.get(params.stepId);
if (!step) {
@ -316,6 +337,13 @@ export class Dispatcher {
});
worker.on('stdOut', (params: TestOutputPayload) => {
const chunk = chunkFromParams(params);
if (worker.didFail) {
// Note: we keep reading stdout from workers that are currently stopping after failure,
// to debug teardown issues. However, we avoid spoiling the test result from
// the next retry.
this._reporter.onStdOut?.(chunk);
return;
}
const pair = params.testId ? this._testById.get(params.testId) : undefined;
if (pair)
pair.result.stdout.push(chunk);
@ -323,6 +351,13 @@ export class Dispatcher {
});
worker.on('stdErr', (params: TestOutputPayload) => {
const chunk = chunkFromParams(params);
if (worker.didFail) {
// Note: we keep reading stderr from workers that are currently stopping after failure,
// to debug teardown issues. However, we avoid spoiling the test result from
// the next retry.
this._reporter.onStdErr?.(chunk);
return;
}
const pair = params.testId ? this._testById.get(params.testId) : undefined;
if (pair)
pair.result.stderr.push(chunk);
@ -381,6 +416,7 @@ class Worker extends EventEmitter {
hash = '';
index: number;
didSendStop = false;
didFail = false;
constructor(runner: Dispatcher) {
super();

View file

@ -373,6 +373,28 @@ test('should report test.step', async ({ runInlineTest }) => {
]);
});
test('should not have internal error when steps are finished after timeout', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const test = pwt.test.extend({
page: async ({ page }, use) => {
await use(page);
// Timeout in fixture teardown that will resolve on browser.close.
await page.waitForNavigation();
},
});
test('pass', async ({ page }) => {
// Timeout in the test.
await page.click('foo');
});
`
}, { workers: 1, timeout: 1000, reporter: 'dot', retries: 1 });
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
expect(result.output).not.toContain('Internal error');
});
test('should report api step hierarchy', async ({ runInlineTest }) => {
const expectReporterJS = `
class Reporter {
@ -389,7 +411,7 @@ test('should report api step hierarchy', async ({ runInlineTest }) => {
steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined,
};
}
async onEnd() {
const processSuite = (suite: Suite) => {
for (const child of suite.suites)