Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix TestScheduler dispatch upon exec error #13203

Merged
merged 10 commits into from Sep 4, 2022
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -6,6 +6,8 @@

### Fixes

- `[jest-core]` Capture execError during `TestScheduler.scheduleTests` and dispatch to reporters ([#13203](https://github.com/facebook/jest/pull/13203))

### Chore & Maintenance

### Performance
Expand Down
170 changes: 100 additions & 70 deletions packages/jest-core/src/TestScheduler.ts
Expand Up @@ -31,13 +31,13 @@ import {
} from '@jest/test-result';
import {createScriptTransformer} from '@jest/transform';
import type {Config} from '@jest/types';
import {formatExecError} from 'jest-message-util';
import {formatExecError, separateMessageFromStack} from 'jest-message-util';
import type {JestTestRunner, TestRunnerContext} from 'jest-runner';
import {
buildSnapshotResolver,
cleanup as cleanupSnapshots,
} from 'jest-snapshot';
import {requireOrImportModule} from 'jest-util';
import {ErrorWithStack, requireOrImportModule} from 'jest-util';
import type {TestWatcher} from 'jest-watcher';
import ReporterDispatcher from './ReporterDispatcher';
import {shouldRunInBand} from './testSchedulerHelper';
Expand Down Expand Up @@ -209,79 +209,86 @@ class TestScheduler {

const testRunners: Record<string, JestTestRunner> = Object.create(null);
const contextsByTestRunner = new WeakMap<JestTestRunner, TestContext>();
await Promise.all(
Array.from(testContexts).map(async context => {
const {config} = context;
if (!testRunners[config.runner]) {
const transformer = await createScriptTransformer(config);
const Runner: TestRunnerConstructor =
await transformer.requireAndTranspileModule(config.runner);
const runner = new Runner(this._globalConfig, {
changedFiles: this._context.changedFiles,
sourcesRelatedToTestsInChangedFiles:
this._context.sourcesRelatedToTestsInChangedFiles,
});
testRunners[config.runner] = runner;
contextsByTestRunner.set(runner, context);
}
}),
);

const testsByRunner = this._partitionTests(testRunners, tests);
try {
await Promise.all(
Array.from(testContexts).map(async context => {
const {config} = context;
if (!testRunners[config.runner]) {
const transformer = await createScriptTransformer(config);
const Runner: TestRunnerConstructor =
await transformer.requireAndTranspileModule(config.runner);
const runner = new Runner(this._globalConfig, {
changedFiles: this._context.changedFiles,
sourcesRelatedToTestsInChangedFiles:
this._context.sourcesRelatedToTestsInChangedFiles,
});
testRunners[config.runner] = runner;
contextsByTestRunner.set(runner, context);
}
}),
);

if (testsByRunner) {
try {
for (const runner of Object.keys(testRunners)) {
const testRunner = testRunners[runner];
const context = contextsByTestRunner.get(testRunner);

invariant(context);

const tests = testsByRunner[runner];

const testRunnerOptions = {
serial: runInBand || Boolean(testRunner.isSerial),
};

if (testRunner.supportsEventEmitters) {
const unsubscribes = [
testRunner.on('test-file-start', ([test]) =>
onTestFileStart(test),
),
testRunner.on('test-file-success', ([test, testResult]) =>
onResult(test, testResult),
),
testRunner.on('test-file-failure', ([test, error]) =>
onFailure(test, error),
),
testRunner.on(
'test-case-result',
([testPath, testCaseResult]) => {
const test: Test = {context, path: testPath};
this._dispatcher.onTestCaseResult(test, testCaseResult);
},
),
];

await testRunner.runTests(tests, watcher, testRunnerOptions);

unsubscribes.forEach(sub => sub());
} else {
await testRunner.runTests(
tests,
watcher,
onTestFileStart,
onResult,
onFailure,
testRunnerOptions,
);
const testsByRunner = this._partitionTests(testRunners, tests);

if (testsByRunner) {
try {
for (const runner of Object.keys(testRunners)) {
const testRunner = testRunners[runner];
const context = contextsByTestRunner.get(testRunner);

invariant(context);

const tests = testsByRunner[runner];

const testRunnerOptions = {
serial: runInBand || Boolean(testRunner.isSerial),
};

if (testRunner.supportsEventEmitters) {
const unsubscribes = [
testRunner.on('test-file-start', ([test]) =>
onTestFileStart(test),
),
testRunner.on('test-file-success', ([test, testResult]) =>
onResult(test, testResult),
),
testRunner.on('test-file-failure', ([test, error]) =>
onFailure(test, error),
),
testRunner.on(
'test-case-result',
([testPath, testCaseResult]) => {
const test: Test = {context, path: testPath};
this._dispatcher.onTestCaseResult(test, testCaseResult);
},
),
];

await testRunner.runTests(tests, watcher, testRunnerOptions);

unsubscribes.forEach(sub => sub());
} else {
await testRunner.runTests(
tests,
watcher,
onTestFileStart,
onResult,
onFailure,
testRunnerOptions,
);
}
}
} catch (error) {
if (!watcher.isInterrupted()) {
throw error;
}
}
} catch (error) {
if (!watcher.isInterrupted()) {
throw error;
}
}
} catch (error) {
aggregatedResults.runExecError = buildExecError(error);
await this._dispatcher.onRunComplete(testContexts, aggregatedResults);
throw error;
}

await updateSnapshotState();
Expand Down Expand Up @@ -431,3 +438,26 @@ const getEstimatedTime = (timings: Array<number>, workers: number) => {
? max
: Math.max(timings.reduce((sum, time) => sum + time) / workers, max);
};

const strToError = (errString: string): SerializableError => {
const {message, stack} = separateMessageFromStack(errString);
if (stack.length > 0) {
return {message, stack};
}
const error = new ErrorWithStack(message, buildExecError);
return {message, stack: error.stack || ''};
};

const buildExecError = (err: unknown): SerializableError => {
if (typeof err === 'string' || err == null) {
return strToError(err || 'Error');
}
const anyErr = err as any;
if (typeof anyErr.message === 'string') {
if (typeof anyErr.stack === 'string' && anyErr.stack.length > 0) {
return anyErr;
}
return strToError(anyErr.message);
}
return strToError(JSON.stringify(err));
};
138 changes: 136 additions & 2 deletions packages/jest-core/src/__tests__/TestScheduler.test.js
Expand Up @@ -15,6 +15,7 @@ import {
VerboseReporter,
} from '@jest/reporters';
import {makeGlobalConfig, makeProjectConfig} from '@jest/test-utils';
import * as transform from '@jest/transform';
import {createTestScheduler} from '../TestScheduler';
import * as testSchedulerHelper from '../testSchedulerHelper';

Expand All @@ -28,8 +29,13 @@ jest
onTestStart() {},
})),
{virtual: true},
);

)
.mock('@jest/transform', () => {
SimenB marked this conversation as resolved.
Show resolved Hide resolved
return {
__esModule: true,
...jest.requireActual('@jest/transform'),
};
});
const mockSerialRunner = {
isSerial: true,
runTests: jest.fn(),
Expand Down Expand Up @@ -233,6 +239,134 @@ test('.addReporter() .removeReporter()', async () => {
expect(scheduler._dispatcher._reporters).not.toContain(reporter);
});

describe('scheduleTests should always dispatch runStart and runComplete events', () => {
const mockReporter = {
onRunComplete: jest.fn(),
onRunStart: jest.fn(),
};

const errorMsg = 'runtime-error';
let scheduler, t;

beforeEach(async () => {
mockReporter.onRunStart.mockClear();
mockReporter.onRunComplete.mockClear();

t = {
context: {
config: makeProjectConfig({
moduleFileExtensions: ['.js'],
rootDir: './',
runner: 'jest-runner-serial',
transform: [],
}),
hasteFS: {
matchFiles: jest.fn(() => []),
},
},
path: './test/path.js',
};

scheduler = await createTestScheduler(makeGlobalConfig(), {}, {});
scheduler.addReporter(mockReporter);
});

test('during normal run', async () => {
expect.hasAssertions();
const result = await scheduler.scheduleTests([t], {
isInterrupted: jest.fn(),
isWatchMode: () => true,
setState: jest.fn(),
});

expect(result.numTotalTestSuites).toEqual(1);

expect(mockReporter.onRunStart).toBeCalledTimes(1);
expect(mockReporter.onRunComplete).toBeCalledTimes(1);
const aggregatedResult = mockReporter.onRunComplete.mock.calls[0][1];
expect(aggregatedResult.runExecError).toBeUndefined();

expect(aggregatedResult).toEqual(result);
});
test.each`
runtimeError | message
${errorMsg} | ${errorMsg}
${123} | ${'123'}
${new Error(errorMsg)} | ${errorMsg}
${{message: errorMsg}} | ${errorMsg}
${{message: errorMsg, stack: 'stack-string'}} | ${errorMsg}
${`${errorMsg}\n Require stack:xxxx`} | ${errorMsg}
`('with runtime error: $runtimeError', async ({runtimeError, message}) => {
expect.hasAssertions();

const spyCreateScriptTransformer = jest.spyOn(
transform,
'createScriptTransformer',
);
spyCreateScriptTransformer.mockImplementation(async () => {
throw runtimeError;
});

await expect(
scheduler.scheduleTests([t], {
isInterrupted: jest.fn(),
isWatchMode: () => true,
setState: jest.fn(),
}),
).rejects.toEqual(runtimeError);

expect(mockReporter.onRunStart).toBeCalledTimes(1);
expect(mockReporter.onRunComplete).toBeCalledTimes(1);
const aggregatedResult = mockReporter.onRunComplete.mock.calls[0][1];
expect(aggregatedResult.runExecError.message).toEqual(message);
expect(aggregatedResult.runExecError.stack.length).toBeGreaterThan(0);

spyCreateScriptTransformer.mockRestore();
});
test.each`
watchMode | isInterrupted | hasExecError
${false} | ${false} | ${true}
${true} | ${false} | ${true}
${true} | ${true} | ${false}
`(
'with runner exception: watchMode=$watchMode, isInterrupted=$isInterrupted',
async ({watchMode, isInterrupted, hasExecError}) => {
expect.hasAssertions();

mockSerialRunner.runTests.mockImplementation(() => {
throw errorMsg;
});

try {
const result = await scheduler.scheduleTests([t], {
isInterrupted: () => isInterrupted,
isWatchMode: () => watchMode,
setState: jest.fn(),
});
if (hasExecError) {
throw new Error('should throw exception');
}
expect(result.runExecError).toBeUndefined();
} catch (e) {
expect(e).toEqual(errorMsg);
}

expect(mockReporter.onRunStart).toBeCalledTimes(1);
expect(mockReporter.onRunComplete).toBeCalledTimes(1);

const aggregatedResult = mockReporter.onRunComplete.mock.calls[0][1];
if (hasExecError) {
expect(aggregatedResult.runExecError.message).toEqual(errorMsg);
expect(aggregatedResult.runExecError.stack.length).toBeGreaterThan(0);
} else {
expect(aggregatedResult.runExecError).toBeUndefined();
}

mockSerialRunner.runTests.mockReset();
},
);
});

test('schedule tests run in parallel per default', async () => {
const scheduler = await createTestScheduler(makeGlobalConfig(), {}, {});
const test = {
Expand Down
1 change: 1 addition & 0 deletions packages/jest-test-result/src/types.ts
Expand Up @@ -68,6 +68,7 @@ export type AggregatedResultWithoutCoverage = {
success: boolean;
testResults: Array<TestResult>;
wasInterrupted: boolean;
runExecError?: SerializableError;
};

export type AggregatedResult = AggregatedResultWithoutCoverage & {
Expand Down