Skip to content

Commit

Permalink
fix TestScheduler dispatch upon exec error (#13203)
Browse files Browse the repository at this point in the history
  • Loading branch information
connectdotz committed Sep 4, 2022
1 parent 0cfc2ad commit dbda13f
Show file tree
Hide file tree
Showing 4 changed files with 239 additions and 72 deletions.
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', () => {
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

0 comments on commit dbda13f

Please sign in to comment.