blob: 8be0c6ad029b9a729bfad70c3aa57aafabecaa9a [file] [log] [blame]
#!/usr/bin/env node
'use strict'
const node = process.execPath
const fs = require('fs')
const spawn = require('child_process').spawn
const fg = require('foreground-child')
const opener = require('opener')
const colorSupport = require('color-support')
const nycBin = require.resolve('nyc/bin/nyc.js')
const glob = require('glob')
const isexe = require('isexe')
const osHomedir = require('os-homedir')
const yaml = require('js-yaml')
const path = require('path')
const exists = require('fs-exists-cached').sync
const os = require('os')
const isTTY = process.stdin.isTTY || process.env._TAP_IS_TTY === '1'
const coverageServiceTest = process.env.COVERAGE_SERVICE_TEST === 'true'
// NYC will not wrap a module in node_modules.
// So, we need to tell the child proc when it's been added.
// Of course, this can't reasonably be branch-covered, so ignore it.
/* istanbul ignore next */
if (process.env._TAP_COVERAGE_ === '1')
global.__coverage__ = global.__coverage__ || {}
else if (process.env._TAP_COVERAGE_ === '0') {
global.__coverage__ = null
Object.keys(process.env).filter(k => /NYC/.test(k)).forEach(k =>
process.env[k] = '')
}
/* istanbul ignore next */
if (coverageServiceTest)
console.log('COVERAGE_SERVICE_TEST')
// Add new coverage services here.
// it'll check for the environ named and pipe appropriately.
//
// Currently only Coveralls is supported, but the infrastructure
// is left in place in case some noble soul fixes the codecov
// module in the future. See https://github.com/tapjs/node-tap/issues/270
const coverageServices = [
{
env: 'COVERALLS_REPO_TOKEN',
bin: require.resolve('coveralls/bin/coveralls.js'),
name: 'Coveralls'
}
]
const main = _ => {
const args = process.argv.slice(2)
if (!args.length && isTTY) {
console.error(usage())
process.exit(1)
}
// set default args
const defaults = constructDefaultArgs()
// parse dotfile
const rcFile = process.env.TAP_RCFILE || (osHomedir() + '/.taprc')
const rcOptions = parseRcFile(rcFile)
// supplement defaults with parsed rc options
Object.keys(rcOptions).forEach(k =>
defaults[k] = rcOptions[k])
defaults.rcFile = rcFile
// parse args
const options = parseArgs(args, defaults)
if (!options)
return
process.stdout.on('error', er => {
/* istanbul ignore else */
if (er.code === 'EPIPE')
process.exit()
else
throw er
})
options.files = globFiles(options.files)
// this is only testable by escaping from the covered environment
/* istanbul ignore next */
if ((options.coverageReport || options.checkCoverage) &&
options.files.length === 0)
return runCoverageReport(options)
if (options.files.length === 0) {
console.error('Reading TAP data from stdin (use "-" argument to suppress)')
options.files.push('-')
}
if (options.files.length === 1 && options.files[0] === '-') {
if (options.coverage)
console.error('Coverage disabled because stdin cannot be instrumented')
setupTapEnv(options)
stdinOnly(options)
return
}
// By definition, the next block cannot be covered, because
// they are only relevant when coverage is turned off.
/* istanbul ignore next */
if (options.coverage && !global.__coverage__) {
return respawnWithCoverage(options)
}
setupTapEnv(options)
runTests(options)
}
const constructDefaultArgs = _ => {
/* istanbul ignore next */
const defaultTimeout = global.__coverage__ ? 240 : 30
const defaultArgs = {
nodeArgs: [],
nycArgs: [],
testArgs: [],
timeout: +process.env.TAP_TIMEOUT || defaultTimeout,
color: !!colorSupport.level,
reporter: null,
files: [],
grep: [],
grepInvert: false,
bail: false,
saveFile: null,
pipeToService: false,
coverageReport: null,
browser: true,
coverage: undefined,
checkCoverage: false,
branches: 0,
functions: 0,
lines: 0,
statements: 0,
jobs: 1,
outputFile: null
}
if (process.env.TAP_COLORS !== undefined)
defaultArgs.color = !!(+process.env.TAP_COLORS)
return defaultArgs
}
const parseArgs = (args, options) => {
const singleFlags = {
b: 'bail',
B: 'no-bail',
i: 'invert',
I: 'no-invert',
c: 'color',
C: 'no-color',
T: 'no-timeout',
J: 'jobs-auto',
O: 'only',
h: 'help',
'?': 'help',
v: 'version'
}
const singleOpts = {
j: 'jobs',
g: 'grep',
R: 'reporter',
t: 'timeout',
s: 'save',
o: 'output-file'
}
// If we're running under Travis-CI with a Coveralls.io token,
// then it's a safe bet that we ought to output coverage.
for (let i = 0; i < coverageServices.length && !options.pipeToService; i++) {
/* istanbul ignore next */
if (process.env[coverageServices[i].env])
options.pipeToService = true
}
let defaultCoverage = options.pipeToService
let dumpConfig = false
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg.charAt(0) !== '-' || arg === '-') {
options.files.push(arg)
continue
}
// short-flags
if (arg.charAt(1) !== '-' && arg !== '-gc') {
const expand = []
for (let f = 1; f < arg.length; f++) {
const fc = arg.charAt(f)
const sf = singleFlags[fc]
const so = singleOpts[fc]
if (sf)
expand.push('--' + sf)
else if (so) {
const soslice = arg.slice(f + 1)
const soval = soslice.charAt(0) === '=' ? soslice : '=' + soslice
expand.push('--' + so + soval)
f = arg.length
} else if (arg !== '-' + fc)
expand.push('-' + fc)
}
if (expand.length) {
args.splice.apply(args, [i, 1].concat(expand))
i--
continue
}
}
const argsplit = arg.split('=')
const key = argsplit.shift()
const val = argsplit.length ? argsplit.join('=') : null
switch (key) {
case '--help':
console.log(usage())
return null
case '--dump-config':
dumpConfig = true
continue
case '--nyc-help':
nycHelp()
return null
case '--nyc-version':
nycVersion()
return null
case '--version':
console.log(require('../package.json').version)
return null
case '--jobs':
options.jobs = +(val || args[++i])
continue
case '--jobs-auto':
options.jobs = +os.cpus().length
continue
case '--coverage-report':
options.coverageReport = val || args[++i]
if (options.coverageReport === 'html')
options.coverageReport = 'lcov'
defaultCoverage = true
continue
case '--no-browser':
options.browser = false
continue
case '--no-coverage-report':
options.coverageReport = false
continue
case '--no-cov': case '--no-coverage':
options.coverage = false
continue
case '--cov': case '--coverage':
options.coverage = true
continue
case '--save':
options.saveFile = val || args[++i]
continue
case '--reporter':
options.reporter = val || args[++i]
continue
case '--gc': case '-gc': case '--expose-gc':
options.nodeArgs.push('--expose-gc')
continue
case '--strict':
options.nodeArgs.push('--use_strict')
continue
case '--debug':
options.nodeArgs.push('--debug')
continue
case '--debug-brk':
options.nodeArgs.push('--debug-brk')
continue
case '--harmony':
options.nodeArgs.push('--harmony')
continue
case '--node-arg': {
const v = val || args[++i]
if (v !== undefined)
options.nodeArgs.push(v)
continue
}
case '--check-coverage':
defaultCoverage = true
options.checkCoverage = true
continue
case '--test-arg': {
const v = val || args[++i]
if (v !== undefined)
options.testArgs.push(v)
continue
}
case '--nyc-arg': {
const v = val || args[++i]
if (v !== undefined)
options.nycArgs.push(v)
continue
}
case '--100':
defaultCoverage = true
options.checkCoverage = true
options.branches = 100
options.functions = 100
options.lines = 100
options.statements = 100
continue
case '--branches':
case '--functions':
case '--lines':
case '--statements':
defaultCoverage = true
options.checkCoverage = true
options[key.slice(2)] = +(val || args[++i])
continue
case '--color':
options.color = true
continue
case '--no-color':
options.color = false
continue
case '--output-file': {
const v = val || args[++i]
if (v !== undefined)
options.outputFile = v
continue
}
case '--no-timeout':
options.timeout = 0
continue
case '--timeout':
options.timeout = +(val || args[++i])
continue
case '--invert':
options.grepInvert = true
continue
case '--no-invert':
options.grepInvert = false
continue
case '--grep': {
const v = val || args[++i]
if (v !== undefined)
options.grep.push(strToRegExp(v))
continue
}
case '--bail':
options.bail = true
continue
case '--no-bail':
options.bail = false
continue
case '--only':
options.only = true
continue
case '--':
options.files = options.files.concat(args.slice(i + 1))
i = args.length
continue
default:
throw new Error('Unknown argument: ' + arg)
}
}
if (options.coverage === undefined)
options.coverage = defaultCoverage
if (process.env.TAP === '1')
options.reporter = 'tap'
// default to tap for non-tty envs
if (!options.reporter)
options.reporter = options.color ? 'classic' : 'tap'
if (dumpConfig)
return console.log(JSON.stringify(options, null, 2))
return options
}
// Obviously, this bit isn't going to ever be covered, because
// it only runs when we DON'T have coverage enabled, to enable it.
/* istanbul ignore next */
const respawnWithCoverage = options => {
// Re-spawn with coverage
const args = [nycBin].concat(
'--silent',
'--cache=true',
options.nycArgs,
'--',
process.execArgv,
process.argv.slice(1)
)
process.env._TAP_COVERAGE_ = '1'
const child = fg(node, args)
child.removeAllListeners('close')
child.on('close', (code, signal) =>
runCoverageReport(options, code, signal))
}
/* istanbul ignore next */
const pipeToCoverageServices = (options, child) => {
let piped = false
for (let i = 0; i < coverageServices.length; i++) {
if (process.env[coverageServices[i].env]) {
pipeToCoverageService(coverageServices[i], options, child)
piped = true
}
}
if (!piped)
throw new Error('unknown service, internal error')
}
/* istanbul ignore next */
const pipeToCoverageService = (service, options, child) => {
let bin = service.bin
if (coverageServiceTest) {
// test scaffolding.
// don't actually send stuff to the service
bin = require.resolve('../test-legacy/fixtures/cat.js')
console.log('%s:%s', service.name, process.env[service.env])
}
const ca = spawn(node, [bin], {
stdio: [ 'pipe', 1, 2 ]
})
child.stdout.pipe(ca.stdin)
ca.on('close', (code, signal) =>
signal ? process.kill(process.pid, signal)
: code ? console.log('Error piping coverage to ' + service.name)
: console.log('Successfully piped to ' + service.name))
}
/* istanbul ignore next */
const runCoverageReport = (options, code, signal) => {
if (options.checkCoverage)
runCoverageCheck(options, code, signal)
else
runCoverageReportOnly(options, code, signal)
}
/* istanbul ignore next */
const runCoverageReportOnly = (options, code, signal) => {
const close = (s, c) => {
if (signal || s) {
setTimeout(() => {}, 200)
process.kill(process.pid, signal || s)
} else if (code || c)
process.exit(code || c)
}
if (options.coverageReport === false)
return close(code, signal)
if (!options.coverageReport) {
if (options.pipeToService || coverageServiceTest)
options.coverageReport = 'text-lcov'
else
options.coverageReport = 'text'
}
const args = [nycBin, 'report', '--reporter', options.coverageReport]
let child
// automatically hook into coveralls
if (options.coverageReport === 'text-lcov' && options.pipeToService) {
child = spawn(node, args, { stdio: [ 0, 'pipe', 2 ] })
pipeToCoverageServices(options, child)
} else {
// otherwise just run the reporter
child = fg(node, args)
if (options.coverageReport === 'lcov' && options.browser)
child.on('exit', () =>
opener('coverage/lcov-report/index.html'))
}
if (code || signal) {
child.removeAllListeners('close')
child.on('close', close)
}
}
/* istanbul ignore next */
const coverageCheckArgs = options => {
const args = []
if (options.branches)
args.push('--branches', options.branches)
if (options.functions)
args.push('--functions', options.functions)
if (options.lines)
args.push('--lines', options.lines)
if (options.statements)
args.push('--statements', options.statements)
return args
}
/* istanbul ignore next */
const runCoverageCheck = (options, code, signal) => {
const args = [nycBin, 'check-coverage'].concat(coverageCheckArgs(options))
const child = fg(node, args)
child.removeAllListeners('close')
child.on('close', (c, s) =>
runCoverageReportOnly(options, code || c, signal || s))
}
const usage = _ => fs.readFileSync(__dirname + '/usage.txt', 'utf8')
.split('@@REPORTERS@@')
.join(getReporters())
const nycHelp = _ => fg(node, [nycBin, '--help'])
const nycVersion = _ => console.log(require('nyc/package.json').version)
const getReporters = _ => {
const types = require('tap-mocha-reporter').types.reduce((str, t) => {
const ll = str.split('\n').pop().length + t.length
if (ll < 40)
return str + ' ' + t
else
return str + '\n' + t
}, '').trim()
const ind = ' '
return ind + types.split('\n').join('\n' + ind)
}
const setupTapEnv = options => {
process.env.TAP_TIMEOUT = options.timeout
if (options.color)
process.env.TAP_COLORS = '1'
else
process.env.TAP_COLORS = '0'
if (options.bail)
process.env.TAP_BAIL = '1'
if (options.grepInvert)
process.env.TAP_GREP_INVERT = '1'
if (options.grep.length)
process.env.TAP_GREP = options.grep.map(p => p.toString())
.join('\n')
if (options.only)
process.env.TAP_ONLY = '1'
}
const globFiles = files => files.reduce((acc, f) =>
acc.concat(f === '-' ? f : glob.sync(f, { nonull: true })), [])
const makeReporter = options =>
new (require('tap-mocha-reporter'))(options.reporter)
const stdinOnly = options => {
// if we didn't specify any files, then just passthrough
// to the reporter, so we don't get '/dev/stdin' in the suite list.
// We have to pause() before piping to switch streams2 into old-mode
process.stdin.pause()
const reporter = makeReporter(options)
process.stdin.pipe(reporter)
if (options.outputFile !== null)
process.stdin.pipe(fs.createWriteStream(options.outputFile))
process.stdin.resume()
}
const readSaveFile = options => {
if (options.saveFile)
try {
const s = fs.readFileSync(options.saveFile, 'utf8').trim()
if (s)
return s.split('\n')
} catch (er) {}
return null
}
const saveFails = (options, tap) => {
if (!options.saveFile)
return
let fails = []
const successes = []
tap.on('result', res => {
// we will continue to re-run todo tests, even though they're
// not technically "failures".
if (!res.ok && !res.extra.skip)
fails.push(res.extra.file)
else
successes.push(res.extra.file)
})
const save = () => {
fails = fails.reduce((set, f) => {
f = f.replace(/\\/g, '/')
if (set.indexOf(f) === -1)
set.push(f)
return set
}, [])
if (!fails.length)
try {
fs.unlinkSync(options.saveFile)
} catch (er) {}
else
try {
fs.writeFileSync(options.saveFile, fails.join('\n') + '\n')
} catch (er) {}
}
tap.on('bailout', reason => {
// add any pending test files to the fails list.
fails.push.apply(fails, options.files.filter(file =>
successes.indexOf(file) === -1))
save()
})
tap.on('end', save)
}
const filterFiles = (files, saved, parallelOk) =>
files.filter(file =>
path.basename(file) === 'tap-parallel-ok' ?
((parallelOk[path.resolve(path.dirname(file))] = true), false)
: path.basename(file) === 'tap-parallel-not-ok' ?
parallelOk[path.resolve(path.dirname(file))] = false
: onSavedList(saved, file)
)
// check if the file is on the list, or if it's a parent dir of
// any items that are on the list.
const onSavedList = (saved, file) =>
!saved || !saved.length ? true
: saved.indexOf(file) !== -1 ? true
: saved.some(f => f.indexOf(file + '/') === 0)
const isParallelOk = (parallelOk, file) => {
const dir = path.resolve(path.dirname(file))
return (dir in parallelOk) ? parallelOk[dir]
: exists(dir + '/tap-parallel-ok')
? parallelOk[dir] = true
: exists(dir + '/tap-parallel-not-ok')
? parallelOk[dir] = false
: dir.length >= process.cwd().length
? isParallelOk(parallelOk, dir)
: true
}
const runAllFiles = (options, saved, tap) => {
let doStdin = false
let parallelOk = Object.create(null)
options.files = filterFiles(options.files, saved, parallelOk)
for (let i = 0; i < options.files.length; i++) {
const opt = {}
const file = options.files[i]
// Pick up stdin after all the other files are handled.
if (file === '-') {
doStdin = true
continue
}
let st
try {
st = fs.statSync(file)
} catch (er) {
continue
}
if (options.timeout)
opt.timeout = options.timeout * 1000
opt.file = file
if (st.isDirectory()) {
const dir = filterFiles(fs.readdirSync(file).map(f =>
file + '/' + f), saved, parallelOk)
options.files.push.apply(options.files, dir)
} else {
if (options.jobs > 1)
opt.buffered = isParallelOk(parallelOk, file) !== false
if (file.match(/\.js$/)) {
const args = options.nodeArgs.concat(file).concat(options.testArgs)
tap.spawn(node, args, opt, file)
} else if (isexe.sync(options.files[i]))
tap.spawn(options.files[i], options.testArgs, opt, file)
}
}
if (doStdin)
tap.stdin()
}
const runTests = options => {
const saved = readSaveFile(options)
// At this point, we know we need to use the tap root,
// because there are 1 or more files to spawn.
const tap = require('../lib/tap.js')
tap.runOnly = false
// greps are passed to children, but not the runner itself
tap.grep = []
tap.jobs = options.jobs
tap.patchProcess()
// if not -Rtap, then output what the user wants.
// otherwise just dump to stdout
tap.pipe(options.reporter === 'tap' ? process.stdout: makeReporter(options))
// need to replay the first version line, because the previous
// line will have flushed it out to stdout or the reporter already.
if (options.outputFile !== null)
tap.pipe(fs.createWriteStream(options.outputFile)).write('TAP version 13\n')
saveFails(options, tap)
runAllFiles(options, saved, tap)
tap.end()
}
const parseRcFile = path => {
try {
const contents = fs.readFileSync(path, 'utf8')
return yaml.safeLoad(contents) || {}
} catch (er) {
// if no dotfile exists, or invalid yaml, fail gracefully
return {}
}
}
const strToRegExp = g => {
const p = g.match(/^\/(.*)\/([a-z]*)$/)
g = p ? p[1] : g
const flags = p ? p[2] : ''
return new RegExp(g, flags)
}
main()