chore(test runner): create TestResult instances lazily in dispatcher (#10921)

This prepares for beforeAll/afterAll hooks to be handled in the same way.
Since we do not know in advance whether a hook will run, we must create
TestResults lazily.
This commit is contained in:
Dmitry Gozman 2021-12-14 14:10:56 -08:00 committed by GitHub
parent b310c6bd6c
commit 34b84841b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 86 additions and 54 deletions

View File

@ -31,13 +31,22 @@ export type TestGroup = {
tests: TestCase[]; tests: TestCase[];
}; };
type TestResultData = {
result: TestResult;
steps: Map<string, TestStep>;
stepStack: Set<TestStep>;
};
type TestData = {
test: TestCase;
resultByWorkerIndex: Map<number, TestResultData>;
};
export class Dispatcher { export class Dispatcher {
private _workerSlots: { busy: boolean, worker?: Worker }[] = []; private _workerSlots: { busy: boolean, worker?: Worker }[] = [];
private _queue: TestGroup[] = []; private _queue: TestGroup[] = [];
private _finished = new ManualPromise<void>(); private _finished = new ManualPromise<void>();
private _isStopped = false; private _isStopped = false;
private _testById = new Map<string, { test: TestCase, result: TestResult, steps: Map<string, TestStep>, stepStack: Set<TestStep> }>(); private _testById = new Map<string, TestData>();
private _loader: Loader; private _loader: Loader;
private _reporter: Reporter; private _reporter: Reporter;
private _hasWorkerErrors = false; private _hasWorkerErrors = false;
@ -48,11 +57,8 @@ export class Dispatcher {
this._reporter = reporter; this._reporter = reporter;
this._queue = testGroups; this._queue = testGroups;
for (const group of testGroups) { for (const group of testGroups) {
for (const test of group.tests) { for (const test of group.tests)
const result = test._appendTestResult(); this._testById.set(test._id, { test, resultByWorkerIndex: new Map() });
// When changing this line, change the one in retry too.
this._testById.set(test._id, { test, result, steps: new Map(), stepStack: new Set() });
}
} }
} }
@ -109,10 +115,23 @@ export class Dispatcher {
} }
private _checkFinished() { private _checkFinished() {
const hasMoreJobs = !!this._queue.length && !this._isStopped; if (this._finished.isDone())
const allWorkersFree = this._workerSlots.every(w => !w.busy); return;
if (!hasMoreJobs && allWorkersFree)
this._finished.resolve(); // Check that we have no more work to do.
if (this._queue.length && !this._isStopped)
return;
// Make sure all workers have finished the current job.
if (this._workerSlots.some(w => w.busy))
return;
for (const { test } of this._testById.values()) {
// Emulate skipped test run if we have stopped early.
if (!test.results.length)
test._appendTestResult().status = 'skipped';
}
this._finished.resolve();
} }
async run() { async run() {
@ -145,17 +164,17 @@ export class Dispatcher {
}; };
const remainingByTestId = new Map(testGroup.tests.map(e => [ e._id, e ])); const remainingByTestId = new Map(testGroup.tests.map(e => [ e._id, e ]));
let lastStartedTestId: string | undefined;
const failedTestIds = new Set<string>(); const failedTestIds = new Set<string>();
const onTestBegin = (params: TestBeginPayload) => { const onTestBegin = (params: TestBeginPayload) => {
lastStartedTestId = params.testId;
if (this._hasReachedMaxFailures()) if (this._hasReachedMaxFailures())
return; return;
const { test, result: testRun } = this._testById.get(params.testId)!; const data = this._testById.get(params.testId)!;
testRun.workerIndex = params.workerIndex; const result = data.test._appendTestResult();
testRun.startTime = new Date(params.startWallTime); data.resultByWorkerIndex.set(worker.workerIndex, { result, stepStack: new Set(), steps: new Map() });
this._reporter.onTestBegin?.(test, testRun); result.workerIndex = worker.workerIndex;
result.startTime = new Date(params.startWallTime);
this._reporter.onTestBegin?.(data.test, result);
}; };
worker.addListener('testBegin', onTestBegin); worker.addListener('testBegin', onTestBegin);
@ -163,7 +182,10 @@ export class Dispatcher {
remainingByTestId.delete(params.testId); remainingByTestId.delete(params.testId);
if (this._hasReachedMaxFailures()) if (this._hasReachedMaxFailures())
return; return;
const { test, result } = this._testById.get(params.testId)!; const data = this._testById.get(params.testId)!;
const test = data.test;
const { result } = data.resultByWorkerIndex.get(worker.workerIndex)!;
data.resultByWorkerIndex.delete(worker.workerIndex);
result.duration = params.duration; result.duration = params.duration;
result.error = params.error; result.error = params.error;
result.attachments = params.attachments.map(a => ({ result.attachments = params.attachments.map(a => ({
@ -184,7 +206,13 @@ export class Dispatcher {
worker.addListener('testEnd', onTestEnd); worker.addListener('testEnd', onTestEnd);
const onStepBegin = (params: StepBeginPayload) => { const onStepBegin = (params: StepBeginPayload) => {
const { test, result, steps, stepStack } = this._testById.get(params.testId)!; const data = this._testById.get(params.testId)!;
const runData = data.resultByWorkerIndex.get(worker.workerIndex);
if (!runData) {
// The test has finished, but steps are still coming. Just ignore them.
return;
}
const { result, steps, stepStack } = runData;
const parentStep = params.forceNoParent ? undefined : [...stepStack].pop(); const parentStep = params.forceNoParent ? undefined : [...stepStack].pop();
const step: TestStep = { const step: TestStep = {
title: params.title, title: params.title,
@ -204,15 +232,21 @@ export class Dispatcher {
(parentStep || result).steps.push(step); (parentStep || result).steps.push(step);
if (params.canHaveChildren) if (params.canHaveChildren)
stepStack.add(step); stepStack.add(step);
this._reporter.onStepBegin?.(test, result, step); this._reporter.onStepBegin?.(data.test, result, step);
}; };
worker.on('stepBegin', onStepBegin); worker.on('stepBegin', onStepBegin);
const onStepEnd = (params: StepEndPayload) => { const onStepEnd = (params: StepEndPayload) => {
const { test, result, steps, stepStack } = this._testById.get(params.testId)!; const data = this._testById.get(params.testId)!;
const runData = data.resultByWorkerIndex.get(worker.workerIndex);
if (!runData) {
// The test has finished, but steps are still coming. Just ignore them.
return;
}
const { result, steps, stepStack } = runData;
const step = steps.get(params.stepId); const step = steps.get(params.stepId);
if (!step) { if (!step) {
this._reporter.onStdErr?.('Internal error: step end without step begin: ' + params.stepId, test, result); this._reporter.onStdErr?.('Internal error: step end without step begin: ' + params.stepId, data.test, result);
return; return;
} }
step.duration = params.wallTime - step.startTime.getTime(); step.duration = params.wallTime - step.startTime.getTime();
@ -220,7 +254,7 @@ export class Dispatcher {
step.error = params.error; step.error = params.error;
stepStack.delete(step); stepStack.delete(step);
steps.delete(params.stepId); steps.delete(params.stepId);
this._reporter.onStepEnd?.(test, result, step); this._reporter.onStepEnd?.(data.test, result, step);
}; };
worker.on('stepEnd', onStepEnd); worker.on('stepEnd', onStepEnd);
@ -244,12 +278,18 @@ export class Dispatcher {
if (params.fatalError) { if (params.fatalError) {
let first = true; let first = true;
for (const test of remaining) { for (const test of remaining) {
const { result } = this._testById.get(test._id)!;
if (this._hasReachedMaxFailures()) if (this._hasReachedMaxFailures())
break; break;
const data = this._testById.get(test._id)!;
const runData = data.resultByWorkerIndex.get(worker.workerIndex);
// There might be a single test that has started but has not finished yet. // There might be a single test that has started but has not finished yet.
if (test._id !== lastStartedTestId) let result: TestResult;
if (runData) {
result = runData.result;
} else {
result = data.test._appendTestResult();
this._reporter.onTestBegin?.(test, result); this._reporter.onTestBegin?.(test, result);
}
result.error = params.fatalError; result.error = params.fatalError;
result.status = first ? 'failed' : 'skipped'; result.status = first ? 'failed' : 'skipped';
this._reportTestEnd(test, result); this._reportTestEnd(test, result);
@ -294,7 +334,8 @@ export class Dispatcher {
return true; return true;
// Emulate a "skipped" run, and drop this test from remaining. // Emulate a "skipped" run, and drop this test from remaining.
const { result } = this._testById.get(test._id)!; const data = this._testById.get(test._id)!;
const result = data.test._appendTestResult();
this._reporter.onTestBegin?.(test, result); this._reporter.onTestBegin?.(test, result);
result.status = 'skipped'; result.status = 'skipped';
this._reportTestEnd(test, result); this._reportTestEnd(test, result);
@ -310,12 +351,8 @@ export class Dispatcher {
for (const testId of retryCandidates) { for (const testId of retryCandidates) {
const pair = this._testById.get(testId)!; const pair = this._testById.get(testId)!;
if (!this._isStopped && pair.test.results.length < pair.test.retries + 1) { if (!this._isStopped && pair.test.results.length < pair.test.retries + 1)
pair.result = pair.test._appendTestResult();
pair.steps = new Map();
pair.stepStack = new Set();
remaining.push(pair.test); remaining.push(pair.test);
}
} }
if (remaining.length) { if (remaining.length) {
@ -339,33 +376,28 @@ export class Dispatcher {
_createWorker(hash: string, parallelIndex: number) { _createWorker(hash: string, parallelIndex: number) {
const worker = new Worker(hash, parallelIndex); const worker = new Worker(hash, parallelIndex);
worker.on('stdOut', (params: TestOutputPayload) => { const handleOutput = (params: TestOutputPayload) => {
const chunk = chunkFromParams(params); const chunk = chunkFromParams(params);
if (worker.didFail()) { if (worker.didFail()) {
// Note: we keep reading stdout from workers that are currently stopping after failure, // Note: we keep reading stdio from workers that are currently stopping after failure,
// to debug teardown issues. However, we avoid spoiling the test result from // to debug teardown issues. However, we avoid spoiling the test result from
// the next retry. // the next retry.
this._reporter.onStdOut?.(chunk); return { chunk };
return;
} }
const pair = params.testId ? this._testById.get(params.testId) : undefined; if (!params.testId)
if (pair) return { chunk };
pair.result.stdout.push(chunk); const data = this._testById.get(params.testId)!;
this._reporter.onStdOut?.(chunk, pair?.test, pair?.result); return { chunk, test: data.test, result: data.resultByWorkerIndex.get(worker.workerIndex)?.result };
};
worker.on('stdOut', (params: TestOutputPayload) => {
const { chunk, test, result } = handleOutput(params);
result?.stdout.push(chunk);
this._reporter.onStdOut?.(chunk, test, result);
}); });
worker.on('stdErr', (params: TestOutputPayload) => { worker.on('stdErr', (params: TestOutputPayload) => {
const chunk = chunkFromParams(params); const { chunk, test, result } = handleOutput(params);
if (worker.didFail()) { result?.stderr.push(chunk);
// Note: we keep reading stderr from workers that are currently stopping after failure, this._reporter.onStdErr?.(chunk, test, result);
// 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);
this._reporter.onStdErr?.(chunk, pair?.test, pair?.result);
}); });
worker.on('teardownError', ({ error }) => { worker.on('teardownError', ({ error }) => {
this._hasWorkerErrors = true; this._hasWorkerErrors = true;
@ -405,7 +437,7 @@ class Worker extends EventEmitter {
private process: child_process.ChildProcess; private process: child_process.ChildProcess;
private _hash: string; private _hash: string;
private parallelIndex: number; private parallelIndex: number;
private workerIndex: number; readonly workerIndex: number;
private _didSendStop = false; private _didSendStop = false;
private _didFail = false; private _didFail = false;
private didExit = false; private didExit = false;
@ -455,7 +487,7 @@ class Worker extends EventEmitter {
const runPayload: RunPayload = { const runPayload: RunPayload = {
file: testGroup.requireFile, file: testGroup.requireFile,
entries: testGroup.tests.map(test => { entries: testGroup.tests.map(test => {
return { testId: test._id, retry: test.results.length - 1 }; return { testId: test._id, retry: test.results.length };
}), }),
}; };
this.process.send({ method: 'run', params: runPayload }); this.process.send({ method: 'run', params: runPayload });

View File

@ -33,7 +33,6 @@ export type WorkerInitParams = {
export type TestBeginPayload = { export type TestBeginPayload = {
testId: string; testId: string;
startWallTime: number; // milliseconds since unix epoch startWallTime: number; // milliseconds since unix epoch
workerIndex: number;
}; };
export type TestEndPayload = { export type TestEndPayload = {

View File

@ -600,7 +600,6 @@ export class WorkerRunner extends EventEmitter {
function buildTestBeginPayload(testId: string, testInfo: TestInfo, startWallTime: number): TestBeginPayload { function buildTestBeginPayload(testId: string, testInfo: TestInfo, startWallTime: number): TestBeginPayload {
return { return {
testId, testId,
workerIndex: testInfo.workerIndex,
startWallTime, startWallTime,
}; };
} }

View File

@ -38,6 +38,8 @@ test('max-failures should work', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.failed).toBe(8); expect(result.failed).toBe(8);
expect(result.output.split('\n').filter(l => l.includes('expect(')).length).toBe(16); expect(result.output.split('\n').filter(l => l.includes('expect(')).length).toBe(16);
expect(result.report.suites[0].specs.map(spec => spec.tests[0].results.length)).toEqual(new Array(10).fill(1));
expect(result.report.suites[1].specs.map(spec => spec.tests[0].results.length)).toEqual(new Array(10).fill(1));
}); });
test('-x should work', async ({ runInlineTest }) => { test('-x should work', async ({ runInlineTest }) => {