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

Refactor runner #3166

Merged
merged 8 commits into from
Mar 17, 2017
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 86 additions & 174 deletions packages/jest-cli/src/TestRunner.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import type {
SerializableError as TestError,
TestResult,
} from 'types/TestResult';
import type {Config, Path} from 'types/Config';
import type {Config} from 'types/Config';
import type {HasteContext, HasteFS} from 'types/HasteMap';
import type {RunnerContext} from 'types/Reporters';
import type {Test, Tests} from 'types/TestRunner';
import type BaseReporter from './reporters/BaseReporter';

const {formatExecError} = require('jest-message-util');
const fs = require('graceful-fs');
const getCacheFilePath = require('jest-haste-map').getCacheFilePath;

const DefaultReporter = require('./reporters/DefaultReporter');
const NotifyReporter = require('./reporters/NotifyReporter');
const SummaryReporter = require('./reporters/SummaryReporter');
Expand All @@ -33,9 +33,7 @@ const throat = require('throat');
const workerFarm = require('worker-farm');
const TestWatcher = require('./TestWatcher');

const FAIL = 0;
const SLOW_TEST_TIME = 3000;
const SUCCESS = 1;

class CancelRun extends Error {
constructor(message: ?string) {
Expand All @@ -49,9 +47,8 @@ type Options = {|
getTestSummary: () => string,
|};

type OnRunFailure = (path: string, err: TestError) => void;

type OnTestResult = (path: string, result: TestResult) => void;
type OnTestFailure = (test: Test, err: TestError) => void;
type OnTestSuccess = (test: Test, result: TestResult) => void;

const TEST_WORKER_PATH = require.resolve('./TestWorker');

Expand All @@ -61,7 +58,6 @@ class TestRunner {
_options: Options;
_startRun: () => *;
_dispatcher: ReporterDispatcher;
_testPerformanceCache: Object;

constructor(
hasteContext: HasteContext,
Expand All @@ -78,10 +74,6 @@ class TestRunner {
this._options = options;
this._startRun = startRun;
this._setupReporters();

// Map from testFilePath -> time it takes to run the test. Used to
// optimally schedule bigger test runs.
this._testPerformanceCache = {};
}

addReporter(reporter: BaseReporter) {
Expand All @@ -92,95 +84,36 @@ class TestRunner {
this._dispatcher.unregister(ReporterClass);
}

_getTestPerformanceCachePath() {
const config = this._config;
return getCacheFilePath(config.cacheDirectory, 'perf-cache-' + config.name);
}

_sortTests(testPaths: Array<string>) {
// When running more tests than we have workers available, sort the tests
// by size - big test files usually take longer to complete, so we run
// them first in an effort to minimize worker idle time at the end of a
// long test run.
//
// After a test run we store the time it took to run a test and on
// subsequent runs we use that to run the slowest tests first, yielding the
// fastest results.
try {
if (this._config.cache) {
this._testPerformanceCache = JSON.parse(
fs.readFileSync(this._getTestPerformanceCachePath(), 'utf8'),
);
} else {
this._testPerformanceCache = {};
}
} catch (e) {
this._testPerformanceCache = {};
}

const cache = this._testPerformanceCache;
async runTests(tests: Tests, watcher: TestWatcher) {
const timings = [];
const stats = {};
const getFileSize = filePath =>
stats[filePath] || (stats[filePath] = fs.statSync(filePath).size);
const getTestRunTime = filePath => {
if (cache[filePath]) {
return cache[filePath][0] === FAIL ? Infinity : cache[filePath][1];
}
return null;
};

testPaths = testPaths.sort((pathA, pathB) => {
const timeA = getTestRunTime(pathA);
const timeB = getTestRunTime(pathB);
if (timeA != null && timeB != null) {
return timeA < timeB ? 1 : -1;
}
return getFileSize(pathA) < getFileSize(pathB) ? 1 : -1;
});

testPaths.forEach(filePath => {
const timing = cache[filePath] && cache[filePath][1];
if (timing) {
timings.push(timing);
tests.forEach(test => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a minor nit, but I think we can change this for

const timings = tests.map(test => test.duration).filter(duration => !!duration)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glad you are bringing this up! I actually can't simplify because flow isn't smart enough to realize that the filter call filters out falsy values. It types timings as Array<?number> instead of Array<number> :(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, that is interesting

if (test.duration) {
timings.push(test.duration);
}
});

return {testPaths, timings};
}

_cacheTestResults(aggregatedResults: AggregatedResult) {
const cache = this._testPerformanceCache;
aggregatedResults.testResults.forEach(test => {
if (test && !test.skipped) {
const perf = test.perfStats;
cache[test.testFilePath] = [
test.numFailingTests ? FAIL : SUCCESS,
perf.end - perf.start || 0,
];
}
});
fs.writeFileSync(
this._getTestPerformanceCachePath(),
JSON.stringify(cache),
);
}

runTests(paths: Array<string>, watcher: TestWatcher) {
const config = this._config;
const {testPaths, timings} = this._sortTests(paths);
const aggregatedResults = createAggregatedResults(testPaths.length);
const aggregatedResults = createAggregatedResults(tests.length);
const estimatedTime = Math.ceil(
getEstimatedTime(timings, this._options.maxWorkers) / 1000,
);

const onResult = (testPath: Path, testResult: TestResult) => {
// Run in band if we only have one test or one worker available.
// If we are confident from previous runs that the tests will finish quickly
// we also run in band to reduce the overhead of spawning workers.
const runInBand = this._options.maxWorkers <= 1 ||
tests.length <= 1 ||
(tests.length <= 20 &&
timings.length > 0 &&
timings.every(timing => timing < SLOW_TEST_TIME));

const onResult = (test: Test, testResult: TestResult) => {
if (watcher.isInterrupted()) {
return;
}
if (testResult.testResults.length === 0) {
const message = 'Your test suite must contain at least one test.';
onFailure(testPath, {
onFailure(test, {
message,
stack: new Error(message).stack,
});
Expand All @@ -191,26 +124,20 @@ class TestRunner {
this._bailIfNeeded(aggregatedResults, watcher);
};

const onFailure = (testPath: Path, error: TestError) => {
const onFailure = (test: Test, error: TestError) => {
if (watcher.isInterrupted()) {
return;
}
const testResult = buildFailureTestResult(testPath, error);
testResult.failureMessage = formatExecError(testResult, config, testPath);
const testResult = buildFailureTestResult(test.path, error);
testResult.failureMessage = formatExecError(
testResult,
test.config,
test.path,
);
addResult(aggregatedResults, testResult);
this._dispatcher.onTestResult(config, testResult, aggregatedResults);
};

// Run in band if we only have one test or one worker available.
// If we are confident from previous runs that the tests will finish quickly
// we also run in band to reduce the overhead of spawning workers.
const shouldRunInBand = () =>
this._options.maxWorkers <= 1 ||
testPaths.length <= 1 ||
(testPaths.length <= 20 &&
timings.length > 0 &&
timings.every(timing => timing < SLOW_TEST_TIME));

const updateSnapshotState = () => {
const status = snapshot.cleanup(
this._hasteContext.hasteFS,
Expand All @@ -224,74 +151,66 @@ class TestRunner {
aggregatedResults.snapshot.filesRemoved));
};

const runInBand = shouldRunInBand();

this._dispatcher.onRunStart(config, aggregatedResults, {
estimatedTime,
showStatus: !runInBand,
});

const testRun = runInBand
? this._createInBandTestRun(testPaths, watcher, onResult, onFailure)
: this._createParallelTestRun(testPaths, watcher, onResult, onFailure);
try {
await (runInBand
? this._createInBandTestRun(tests, watcher, onResult, onFailure)
: this._createParallelTestRun(tests, watcher, onResult, onFailure));
} catch (error) {
if (!watcher.isInterrupted()) {
throw error;
}
}

return testRun
.catch(error => {
if (!watcher.isInterrupted()) {
throw error;
}
})
.then(() => {
updateSnapshotState();
aggregatedResults.wasInterrupted = watcher.isInterrupted();
updateSnapshotState();
aggregatedResults.wasInterrupted = watcher.isInterrupted();

this._dispatcher.onRunComplete(config, aggregatedResults);
this._dispatcher.onRunComplete(config, aggregatedResults);

const anyTestFailures = !(aggregatedResults.numFailedTests === 0 &&
aggregatedResults.numRuntimeErrorTestSuites === 0);
const anyReporterErrors = this._dispatcher.hasErrors();
const anyTestFailures = !(aggregatedResults.numFailedTests === 0 &&
aggregatedResults.numRuntimeErrorTestSuites === 0);
const anyReporterErrors = this._dispatcher.hasErrors();

aggregatedResults.success = !(anyTestFailures ||
aggregatedResults.snapshot.failure ||
anyReporterErrors);
aggregatedResults.success = !(anyTestFailures ||
aggregatedResults.snapshot.failure ||
anyReporterErrors);

this._cacheTestResults(aggregatedResults);
return aggregatedResults;
});
return aggregatedResults;
}

_createInBandTestRun(
testPaths: Array<Path>,
tests: Tests,
watcher: TestWatcher,
onResult: OnTestResult,
onFailure: OnRunFailure,
onResult: OnTestSuccess,
onFailure: OnTestFailure,
) {
const mutex = throat(1);
return testPaths.reduce(
(promise, path) =>
mutex(() =>
promise
.then(() => {
if (watcher.isInterrupted()) {
throw new CancelRun();
}

this._dispatcher.onTestStart(this._config, path);
return runTest(path, this._config, this._hasteContext.resolver);
})
.then(result => onResult(path, result))
.catch(err => onFailure(path, err))),
return tests.reduce(
(promise, test) => mutex(() => promise
.then(() => {
if (watcher.isInterrupted()) {
throw new CancelRun();
}

this._dispatcher.onTestStart(test.config, test.path);
return runTest(test.path, test.config, this._hasteContext.resolver);
})
.then(result => onResult(test, result))
.catch(err => onFailure(test, err))),
Promise.resolve(),
);
}

_createParallelTestRun(
testPaths: Array<Path>,
tests: Tests,
watcher: TestWatcher,
onResult: OnTestResult,
onFailure: OnRunFailure,
onResult: OnTestSuccess,
onFailure: OnTestFailure,
) {
const config = this._config;
const farm = workerFarm(
{
autoStart: true,
Expand All @@ -306,23 +225,22 @@ class TestRunner {

// Send test suites to workers continuously instead of all at once to track
// the start time of individual tests.
const runTestInWorker = ({config, path}) =>
mutex(() => {
if (watcher.isInterrupted()) {
return Promise.reject();
}
this._dispatcher.onTestStart(config, path);
return worker({
config,
path,
rawModuleMap: watcher.isWatchMode()
? this._hasteContext.moduleMap.getRawModuleMap()
: null,
});
const runTestInWorker = ({config, path}) => mutex(() => {
if (watcher.isInterrupted()) {
return Promise.reject();
}
this._dispatcher.onTestStart(config, path);
return worker({
config,
path,
rawModuleMap: watcher.isWatchMode()
? this._hasteContext.moduleMap.getRawModuleMap()
: null,
});
});

const onError = (err, path) => {
onFailure(path, err);
const onError = (err, test) => {
onFailure(test, err);
if (err.type === 'ProcessTerminatedError') {
console.error(
'A worker process has quit unexpectedly! ' +
Expand All @@ -341,15 +259,13 @@ class TestRunner {
});

const runAllTests = Promise.all(
testPaths.map(path => {
return runTestInWorker({config, path})
.then(testResult => onResult(path, testResult))
.catch(error => onError(error, path));
}),
tests.map(test =>
runTestInWorker(test)
.then(testResult => onResult(test, testResult))
.catch(error => onError(error, test))),
);

const cleanup = () => workerFarm.end(farm);

return Promise.race([runAllTests, onInterrupt]).then(cleanup, cleanup);
}

Expand Down Expand Up @@ -497,8 +413,6 @@ const buildFailureTestResult = (
};
};

// Proxy class that holds all reporter and dispatchers events to each
// of them.
class ReporterDispatcher {
_disabled: boolean;
_reporters: Array<BaseReporter>;
Expand Down Expand Up @@ -561,11 +475,9 @@ const getEstimatedTime = (timings, workers) => {
}

const max = Math.max.apply(null, timings);
if (timings.length <= workers) {
return max;
}

return Math.max(timings.reduce((sum, time) => sum + time) / workers, max);
return timings.length <= workers
? max
: Math.max(timings.reduce((sum, time) => sum + time) / workers, max);
};

module.exports = TestRunner;
Loading