351 lines
9.4 KiB
JavaScript
351 lines
9.4 KiB
JavaScript
|
'use strict';
|
||
|
const EventEmitter = require('events');
|
||
|
const path = require('path');
|
||
|
const fs = require('fs');
|
||
|
const commonPathPrefix = require('common-path-prefix');
|
||
|
const uniqueTempDir = require('unique-temp-dir');
|
||
|
const findCacheDir = require('find-cache-dir');
|
||
|
const resolveCwd = require('resolve-cwd');
|
||
|
const debounce = require('lodash.debounce');
|
||
|
const autoBind = require('auto-bind');
|
||
|
const Promise = require('bluebird');
|
||
|
const getPort = require('get-port');
|
||
|
const arrify = require('arrify');
|
||
|
const ms = require('ms');
|
||
|
const babelConfigHelper = require('./lib/babel-config');
|
||
|
const CachingPrecompiler = require('./lib/caching-precompiler');
|
||
|
const RunStatus = require('./lib/run-status');
|
||
|
const AvaError = require('./lib/ava-error');
|
||
|
const AvaFiles = require('./lib/ava-files');
|
||
|
const fork = require('./lib/fork');
|
||
|
|
||
|
function resolveModules(modules) {
|
||
|
return arrify(modules).map(name => {
|
||
|
const modulePath = resolveCwd(name);
|
||
|
|
||
|
if (modulePath === null) {
|
||
|
throw new Error(`Could not resolve required module '${name}'`);
|
||
|
}
|
||
|
|
||
|
return modulePath;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function getBlankResults() {
|
||
|
return {
|
||
|
stats: {
|
||
|
knownFailureCount: 0,
|
||
|
testCount: 0,
|
||
|
passCount: 0,
|
||
|
skipCount: 0,
|
||
|
todoCount: 0,
|
||
|
failCount: 0
|
||
|
},
|
||
|
tests: []
|
||
|
};
|
||
|
}
|
||
|
|
||
|
class Api extends EventEmitter {
|
||
|
constructor(options) {
|
||
|
super();
|
||
|
autoBind(this);
|
||
|
|
||
|
this.options = Object.assign({match: []}, options);
|
||
|
this.options.require = resolveModules(this.options.require);
|
||
|
}
|
||
|
_runFile(file, runStatus, execArgv) {
|
||
|
const hash = this.precompiler.precompileFile(file);
|
||
|
const precompiled = Object.assign({}, this._precompiledHelpers);
|
||
|
const resolvedfpath = fs.realpathSync(file);
|
||
|
precompiled[resolvedfpath] = hash;
|
||
|
|
||
|
const options = Object.assign({}, this.options, {precompiled});
|
||
|
const emitter = fork(file, options, execArgv);
|
||
|
runStatus.observeFork(emitter);
|
||
|
|
||
|
return emitter;
|
||
|
}
|
||
|
run(files, options) {
|
||
|
return new AvaFiles({cwd: this.options.resolveTestsFrom, files})
|
||
|
.findTestFiles()
|
||
|
.then(files => this._run(files, options));
|
||
|
}
|
||
|
_onTimeout(runStatus) {
|
||
|
const timeout = ms(this.options.timeout);
|
||
|
const err = new AvaError(`Exited because no new tests completed within the last ${timeout}ms of inactivity`);
|
||
|
this._handleError(runStatus, err);
|
||
|
runStatus.emit('timeout');
|
||
|
}
|
||
|
_setupTimeout(runStatus) {
|
||
|
const timeout = ms(this.options.timeout);
|
||
|
|
||
|
runStatus._restartTimer = debounce(() => {
|
||
|
this._onTimeout(runStatus);
|
||
|
}, timeout);
|
||
|
|
||
|
runStatus._restartTimer();
|
||
|
runStatus.on('test', runStatus._restartTimer);
|
||
|
}
|
||
|
_cancelTimeout(runStatus) {
|
||
|
runStatus._restartTimer.cancel();
|
||
|
}
|
||
|
_setupPrecompiler(files) {
|
||
|
const isCacheEnabled = this.options.cacheEnabled !== false;
|
||
|
let cacheDir = uniqueTempDir();
|
||
|
|
||
|
if (isCacheEnabled) {
|
||
|
const foundDir = findCacheDir({
|
||
|
name: 'ava',
|
||
|
files
|
||
|
});
|
||
|
if (foundDir !== null) {
|
||
|
cacheDir = foundDir;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.options.cacheDir = cacheDir;
|
||
|
|
||
|
const isPowerAssertEnabled = this.options.powerAssert !== false;
|
||
|
return babelConfigHelper.build(this.options.projectDir, cacheDir, this.options.babelConfig, isPowerAssertEnabled)
|
||
|
.then(result => {
|
||
|
this.precompiler = new CachingPrecompiler({
|
||
|
path: cacheDir,
|
||
|
getBabelOptions: result.getOptions,
|
||
|
babelCacheKeys: result.cacheKeys
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
_precompileHelpers() {
|
||
|
this._precompiledHelpers = {};
|
||
|
|
||
|
// Assumes the tests only load helpers from within the `resolveTestsFrom`
|
||
|
// directory. Without arguments this is the `projectDir`, else it's
|
||
|
// `process.cwd()` which may be nested too deeply. This will be solved
|
||
|
// as we implement RFC 001 and move helper compilation into the worker
|
||
|
// processes, avoiding the need for precompilation.
|
||
|
return new AvaFiles({cwd: this.options.resolveTestsFrom})
|
||
|
.findTestHelpers()
|
||
|
.map(file => { // eslint-disable-line array-callback-return
|
||
|
const hash = this.precompiler.precompileFile(file);
|
||
|
this._precompiledHelpers[file] = hash;
|
||
|
});
|
||
|
}
|
||
|
_run(files, options) {
|
||
|
options = options || {};
|
||
|
|
||
|
const runStatus = new RunStatus({
|
||
|
runOnlyExclusive: options.runOnlyExclusive,
|
||
|
prefixTitles: this.options.explicitTitles || files.length > 1,
|
||
|
base: path.relative(process.cwd(), commonPathPrefix(files)) + path.sep,
|
||
|
failFast: this.options.failFast
|
||
|
});
|
||
|
|
||
|
this.emit('test-run', runStatus, files);
|
||
|
|
||
|
if (files.length === 0) {
|
||
|
const err = new AvaError('Couldn\'t find any files to test');
|
||
|
this._handleError(runStatus, err);
|
||
|
return Promise.resolve(runStatus);
|
||
|
}
|
||
|
|
||
|
return this._setupPrecompiler(files)
|
||
|
.then(() => this._precompileHelpers())
|
||
|
.then(() => {
|
||
|
if (this.options.timeout) {
|
||
|
this._setupTimeout(runStatus);
|
||
|
}
|
||
|
|
||
|
let overwatch;
|
||
|
if (this.options.concurrency > 0) {
|
||
|
const concurrency = this.options.serial ? 1 : this.options.concurrency;
|
||
|
overwatch = this._runWithPool(files, runStatus, concurrency);
|
||
|
} else {
|
||
|
// _runWithoutPool exists to preserve legacy behavior, specifically around `.only`
|
||
|
overwatch = this._runWithoutPool(files, runStatus);
|
||
|
}
|
||
|
|
||
|
return overwatch;
|
||
|
});
|
||
|
}
|
||
|
_computeForkExecArgs(files) {
|
||
|
const execArgv = this.options.testOnlyExecArgv || process.execArgv;
|
||
|
let debugArgIndex = -1;
|
||
|
|
||
|
// --debug-brk is used in addition to --inspect to break on first line and wait
|
||
|
execArgv.some((arg, index) => {
|
||
|
const isDebugArg = arg === '--inspect' || arg.indexOf('--inspect=') === 0;
|
||
|
if (isDebugArg) {
|
||
|
debugArgIndex = index;
|
||
|
}
|
||
|
|
||
|
return isDebugArg;
|
||
|
});
|
||
|
|
||
|
const isInspect = debugArgIndex >= 0;
|
||
|
if (!isInspect) {
|
||
|
execArgv.some((arg, index) => {
|
||
|
const isDebugArg = arg === '--debug' || arg === '--debug-brk' || arg.indexOf('--debug-brk=') === 0 || arg.indexOf('--debug=') === 0;
|
||
|
if (isDebugArg) {
|
||
|
debugArgIndex = index;
|
||
|
}
|
||
|
|
||
|
return isDebugArg;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
if (debugArgIndex === -1) {
|
||
|
return Promise.resolve([]);
|
||
|
}
|
||
|
|
||
|
return Promise
|
||
|
.map(files, () => getPort())
|
||
|
.map(port => {
|
||
|
const forkExecArgv = execArgv.slice();
|
||
|
let flagName = isInspect ? '--inspect' : '--debug';
|
||
|
const oldValue = forkExecArgv[debugArgIndex];
|
||
|
if (oldValue.indexOf('brk') > 0) {
|
||
|
flagName += '-brk';
|
||
|
}
|
||
|
|
||
|
forkExecArgv[debugArgIndex] = `${flagName}=${port}`;
|
||
|
|
||
|
return forkExecArgv;
|
||
|
});
|
||
|
}
|
||
|
_handleError(runStatus, err) {
|
||
|
runStatus.handleExceptions({
|
||
|
exception: err,
|
||
|
file: err.file ? path.relative(process.cwd(), err.file) : undefined
|
||
|
});
|
||
|
}
|
||
|
_runWithoutPool(files, runStatus) {
|
||
|
const tests = [];
|
||
|
let execArgvList;
|
||
|
|
||
|
// TODO: This should be cleared at the end of the run
|
||
|
runStatus.on('timeout', () => {
|
||
|
tests.forEach(fork => {
|
||
|
fork.exit();
|
||
|
});
|
||
|
});
|
||
|
|
||
|
return this._computeForkExecArgs(files)
|
||
|
.then(argvList => {
|
||
|
execArgvList = argvList;
|
||
|
})
|
||
|
.return(files)
|
||
|
.each((file, index) => {
|
||
|
return new Promise(resolve => {
|
||
|
const forkArgs = execArgvList[index];
|
||
|
const test = this._runFile(file, runStatus, forkArgs);
|
||
|
tests.push(test);
|
||
|
test.on('stats', resolve);
|
||
|
test.catch(resolve);
|
||
|
}).catch(err => {
|
||
|
err.results = [];
|
||
|
err.file = file;
|
||
|
return Promise.reject(err);
|
||
|
});
|
||
|
})
|
||
|
.then(() => {
|
||
|
if (this.options.match.length > 0 && !runStatus.hasExclusive) {
|
||
|
const err = new AvaError('Couldn\'t find any matching tests');
|
||
|
err.file = undefined;
|
||
|
err.results = [];
|
||
|
return Promise.reject(err);
|
||
|
}
|
||
|
|
||
|
const method = this.options.serial ? 'mapSeries' : 'map';
|
||
|
const options = {
|
||
|
runOnlyExclusive: runStatus.hasExclusive
|
||
|
};
|
||
|
|
||
|
return Promise[method](files, (file, index) => {
|
||
|
return tests[index].run(options).catch(err => {
|
||
|
err.file = file;
|
||
|
this._handleError(runStatus, err);
|
||
|
return getBlankResults();
|
||
|
});
|
||
|
});
|
||
|
})
|
||
|
.catch(err => {
|
||
|
this._handleError(runStatus, err);
|
||
|
return err.results;
|
||
|
})
|
||
|
.tap(results => {
|
||
|
// If no tests ran, make sure to tear down the child processes
|
||
|
if (results.length === 0) {
|
||
|
tests.forEach(test => {
|
||
|
test.send('teardown');
|
||
|
});
|
||
|
}
|
||
|
})
|
||
|
.then(results => {
|
||
|
// Cancel debounced _onTimeout() from firing
|
||
|
if (this.options.timeout) {
|
||
|
this._cancelTimeout(runStatus);
|
||
|
}
|
||
|
|
||
|
runStatus.processResults(results);
|
||
|
|
||
|
return runStatus;
|
||
|
});
|
||
|
}
|
||
|
_runWithPool(files, runStatus, concurrency) {
|
||
|
const tests = [];
|
||
|
let execArgvList;
|
||
|
|
||
|
runStatus.on('timeout', () => {
|
||
|
tests.forEach(fork => {
|
||
|
fork.exit();
|
||
|
});
|
||
|
});
|
||
|
|
||
|
return this._computeForkExecArgs(files)
|
||
|
.then(argvList => {
|
||
|
execArgvList = argvList;
|
||
|
})
|
||
|
.return(files)
|
||
|
.map((file, index) => {
|
||
|
return new Promise(resolve => {
|
||
|
const forkArgs = execArgvList[index];
|
||
|
const test = this._runFile(file, runStatus, forkArgs);
|
||
|
tests.push(test);
|
||
|
|
||
|
// If we're looking for matches, run every single test process in exclusive-only mode
|
||
|
const options = {
|
||
|
runOnlyExclusive: this.options.match.length > 0
|
||
|
};
|
||
|
|
||
|
resolve(test.run(options));
|
||
|
}).catch(err => {
|
||
|
err.file = file;
|
||
|
this._handleError(runStatus, err);
|
||
|
return getBlankResults();
|
||
|
});
|
||
|
}, {concurrency})
|
||
|
.then(results => {
|
||
|
// Filter out undefined results (usually result of caught exceptions)
|
||
|
results = results.filter(Boolean);
|
||
|
|
||
|
// Cancel debounced _onTimeout() from firing
|
||
|
if (this.options.timeout) {
|
||
|
this._cancelTimeout(runStatus);
|
||
|
}
|
||
|
|
||
|
if (this.options.match.length > 0 && !runStatus.hasExclusive) {
|
||
|
results = [];
|
||
|
|
||
|
const err = new AvaError('Couldn\'t find any matching tests');
|
||
|
this._handleError(runStatus, err);
|
||
|
}
|
||
|
|
||
|
runStatus.processResults(results);
|
||
|
|
||
|
return runStatus;
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
module.exports = Api;
|