exam
Version:
Clustered JavaScript test runner with builtin assertions.
460 lines (418 loc) • 14.2 kB
JavaScript
// TODO: Bundle as exam.min.js for speed.
// TODO: Bundle a karma-compatible version for front end testing.
var dir = __dirname
var fs = require('fs')
var spawn = require('child_process').spawn
var tree = require(dir + '/lib/tree')
var mkdirp = require(dir + '/common/fs/mkdirp')
var deepWatch = require(dir + '/common/fs/deep-watch')
var cli = require(dir + '/common/process/cli')
var stringify = require('lighter-json').stringify
// Exam exposes a function that runs a test suite.
var exam = module.exports = function (options) {
// Save the state of a test run.
var waits, testFiles, data, isRunning, finished
// Use a manifest to store run times.
var cwd = process.cwd()
var manifest = {}
var cacheDir = cwd + '/.cache/exam'
var manifestPath = cacheDir + '/manifest.json'
// Load the chosen reporter.
var reporter = require(dir + '/lib/reporters/' + options.reporter)
var stream = reporter.stream = options.stream || process.stdout
var statusTimer
// Start the first run.
start()
readManifest()
// Optionally watch for changes, and restart.
if (options.watch) {
var watcher = deepWatch(cwd)
watcher.on('change', function (file) {
if (reporter.status) {
reporter.status('Change found in "' + file + '".')
}
if (!isRunning) {
start()
}
})
}
/**
* Kick off a test run (or re-run).
*/
function start () {
waits = 0
testFiles = []
isRunning = true
initResults()
if (reporter.start) {
reporter.start(options)
}
findTests()
}
/**
* Initialize (or re-initialize) the result set.
*/
function initResults (startTime) {
data = {
started: startTime || Date.now(),
outputs: [],
hasOnly: false,
passed: 0,
failed: 0,
skipped: 0,
stubbed: 0,
times: {}
}
isRunning = true
}
/**
* Decrement the count of async events we're waiting for.
*/
function unwait () {
if (!--waits) {
assignTests()
}
}
/**
* Read the manifest file (if possible), and ignore errors.
*/
function readManifest () {
waits++
fs.readFile(manifestPath, function (e, content) {
try {
manifest = JSON.parse(content)
} catch (e) {
manifest = {testFiles: {}}
}
unwait()
})
}
/**
* Add errors to an array.
*/
function handleError (error) {
var list = data.errors = data.errors || []
list.push(error)
}
/**
* Read or recurse the file path or directory.
*/
function findTests () {
var optionsPattern = /\/\.exam\.js(on)?$/
// TODO: Support CoffeeScript, etc.
var testPattern = /\.js$/
var testExtensions = ['.js']
var ignorePattern = options.ignore
options.paths.forEach(read)
// Add a test file.
function add (path) {
if (optionsPattern.test(path)) {
var config = require(cwd + '/' + path)
for (var key in config) {
options[key] = config[key]
}
} else if (testPattern.test(path) && !(ignorePattern && ignorePattern.test(path))) {
testFiles.push(path)
}
}
// Read a directory.
function read (path, index) {
if (testPattern.test(path)) {
add(path)
} else {
waits++
fs.readdir(path, function (err, list) {
if (err) {
var found = false
// Account for (e.g.) "test/apiTest" meaning "test/apiTest.js".
if ((err.code === 'ENOENT') && (typeof index === 'number')) {
testExtensions.forEach(function (extension) {
if (!found && fs.existsSync(path + extension)) {
found = true
add(path + extension)
}
})
}
if (!found) {
handleError(err)
}
} else {
list.forEach(function (file) {
// Assume a file with no dots is a directory.
if (file.indexOf('.') < 0) {
if (options.recursive) {
read(path)
}
// Otherwise, try to add it as a file.
} else {
add(path + '/' + file)
}
})
}
unwait()
})
}
}
}
/**
* If there are test files, assign them to cores for running.
*/
function assignTests () {
if (!testFiles.length) {
return finish()
}
// If running in a single process, start a test run tree.
if (!options.multiProcess) {
waits = 1
options.files = testFiles
options.finish = receiveResult
tree(options)
// Otherwise, spawn child processes to run test files.
} else {
var cpus = require('os').cpus()
// Prepare to spawn at most once per CPU, and at most once per file.
var spawnCount = Math.min(testFiles.length, cpus.length)
var assignments = []
for (var i = 0; i < spawnCount; i++) {
assignments[i] = []
}
// Create a dictionary of file execution times and sort by it.
var fileTimes = {}
if (!manifest.files || (manifest.files instanceof Array)) {
manifest.files = {}
}
testFiles.forEach(function (path) {
var entry = manifest.files[path] || 0
var time = entry.avg1 || 0
fileTimes[path] = time || 0
})
testFiles.sort(function (a, b) {
return fileTimes[a] > fileTimes[b] ? -1 : 1
})
// Zig-zag over the test files so that the slowest and fastest will be
// assigned to the same core in the case where there are 2 passes.
// TODO: Assign more optimally by summing runtimes and juggling buckets.
var reverse = true
testFiles.forEach(function (path, index) {
var mod = index % spawnCount
if (!mod) reverse = !reverse
index = reverse ? spawnCount - 1 - mod : mod
assignments[index].push(path)
})
// Spawn children, and wait for them to return results.
waits = spawnCount
testFiles = []
var execPath = __filename
var args = process.argv.slice(2)
if (options.debug) {
execPath = process.execPath
args.unshift(__filename)
args.unshift('--debug')
}
args.push('--files')
assignments.forEach(function (testFiles) {
args.push(testFiles.join(','))
var child = spawn(execPath, args)
var input = JSON.reader(child.stdout)
input.on('string', function (text) {
stream.write(text)
})
input.on('object', receiveResult)
input.on('error', function (error) {
// Errors should not get through the stream, but just in case...
console.log(error.stack)
})
args.pop()
})
}
}
/**
* Total all status counts.
*/
function total (o) {
return o.skipped + o.passed + o.failed + o.stubbed
}
/**
* Receive the result of running one or more test files.
*/
function receiveResult (result) {
// If another process put us in "only" mode, add counts to "skipped".
if (data.hasOnly && !result.hasOnly) {
data.skipped += total(result)
} else {
// If entering "only" mode now, add previous counts to "skipped".
if (result.hasOnly && !data.hasOnly) {
var skip = total(data)
initResults(data.started)
data.skipped = skip
data.hasOnly = true
}
if (result.output) {
data.outputs.push(result.output)
}
// Increment all counts based on the current data.
data.skipped += result.skipped
data.passed += result.passed
data.failed += result.failed
data.stubbed += result.stubbed
// Add to errors and/or log messages (where applicable).
var keys = ['errors', 'logs']
keys.forEach(function (key) {
if (result[key]) {
result[key].forEach(function (item) {
data[key] = data[key] || []
data[key].push(item)
})
}
})
}
var times = result.times
for (var path in times) {
data.times[path] = times[path]
}
if (!--waits) {
data.time = Date.now() - data.started
finish()
}
}
/**
* Upon receiving results from all runners, write the report and manifest.
*/
function finish () {
isRunning = false
finished = Date.now()
reporter.finishExam(data)
process.emit('exam:finished', data)
// If tests were screened by `grep` or `ignore`, exit.
if (options.grep || options.ignore) {
end()
// Otherwise, write the manifest.
} else {
var times = data.times
var files = manifest.files = manifest.files || {}
for (var path in times) {
var newValue = times[path]
var entry = files[path] = files[path] || {}
entry.runs = (entry.runs || 0) + 1
for (var exponent = 0; exponent < 4; exponent++) {
var key = 'avg' + exponent
var denominator = Math.min(entry.runs, Math.pow(10, exponent))
var newPortion = 1 / denominator
var oldPortion = 1 - newPortion
var oldValue = entry[key] || newValue
entry[key] = newValue * newPortion + oldValue * oldPortion
}
}
mkdirp(cacheDir, function (e) {
if (e) {
stream.write('Failed to create exam cache directory: "' + cacheDir + '".\n' + e.stack)
end()
} else {
var content = stringify(manifest, null, ' ')
fs.writeFile(manifestPath, content, function (e) {
if (e) {
stream.write('Failed to write manifest: "' + manifestPath + '".\n' + e.stack)
}
end()
})
}
})
}
}
/**
* End an `exam` run, exiting if necessary.
*/
function end () {
// If there's a function to run when it's done, run it.
if (options.done) {
options.done()
}
// If `exam` ran via CLI and we're not watching for changes, exit.
if ((process.mainModule === module) && !options.watch) {
process.exit(data.errors ? 1 : 0)
}
// Show how long ago it ran.
if (reporter.status) {
clearTimeout(statusTimer)
var word = {s: 'second', m: 'minute', h: 'hour', d: 'day'}
var ms = {s: 1e3, m: 6e4, h: 36e5, d: 864e5}
ago()
function ago () {
var delay = 1e3
if (finished && !isRunning) {
var now = Date.now()
var t = now - finished
var unit = t < ms.m ? 's' : t < ms.h ? 'm' : t < ms.d ? 'h' : 'd'
var elapsed = Math.floor(t / ms[unit])
var text = elapsed + ' ' + word[unit] + (elapsed === 1 ? '' : 's') + ' ago'
delay = ms[unit] - (t % ms[unit]) + 1
reporter.status(text)
}
statusTimer = setTimeout(ago, delay)
}
}
}
}
// Expose the module version.
exam.version = require(dir + '/package.json').version
// If Node loaded this file directly, get options and run tests.
if (process.mainModule === module) {
process.nextTick(function () {
// Get command line interface options.
var options = cli({
options: [
'-h, --help Show usage information',
'-w, --watch When changes are made, re-run tests',
'-V, --version Show the version number',
'-r, --require <modules> Require a comma-delimited list of modules (Array)',
'-R, --reporter <name> Result reporter ("console", "tap", "xunit" or "counts") [console]',
'-G, --no-globals Do not add "it", "describe", etc. to global scope',
'-v, --recursive Load test files recursively',
'-p, --parser <parser> EcmaScript parser ("acorn", "esprima", or "none") (String) [acorn]',
'-b, --bail Exit after the first test failure',
'-a, --assertive Stop a test after one failed assertion',
'-g, --grep <regexp> Only run files/tests that match a regular expression (RegExp)',
'-i, --ignore <regexp> Exclude files/tests that match a regular expression (RegExp)',
'-d, --debug Run `node` with the --debug flag',
'-m, --multi-process Spawn child processes, creating a cluster of test runners.',
'-t, --timeout <millis> Test case timeout in milliseconds (Number) [1000]',
'-s, --slow <millis> Slow test (yellow warning) threshold in milliseconds (Number) [10]',
'-y, --very-slow <millis> Very slow (red warning) threshold in milliseconds (Number) [100]',
'-A, --hide-ascii Do not show ASCII art before the run',
'-P, --hide-progress Do not show dots as tests run',
'-C, --no-color Turn off color console logging',
'-B, --bench Run benchmarks along with the test suite',
'-T, --bench-time <millis> Milliseconds allocated to each benchmark (Number) [60000]',
'-f, --files <files> HIDDEN (Array)',
'--no-colors HIDDEN'
],
extras: 'paths',
version: exam.version
})
// When watching, each run should spawn clean child processes.
if (options.watch) {
options.multiProcess = true
}
// Use "test" as the default path.
options.paths[0] = options.paths[0] || 'test'
options.parser = options.parser.replace(/^no.*/, '')
if (!/^(acorn|esprima|no.*|)$/.test(options.parser)) {
console.error('Unknown parser: "' + options.parser + '".')
console.error(' Expected "acorn", "esprima", or "none".')
process.exit()
}
// Force numbers.
options.timeout *= 1
options.slow *= 1
options.verySlow *= 1
options.benchTime *= 1
// If the benchmark time is not the default, run benchmarks.
if (options.benchTime !== 6e4) {
options.bench = true
}
// If testFiles have been assigned, run on one CPU, otherwise get assigments.
var treeLib = options.files ? require(dir + '/lib/tree') : exam
treeLib(options)
})
}