547 lines
16 KiB
JavaScript
547 lines
16 KiB
JavaScript
|
'use strict'
|
||
|
|
||
|
/* global __coverage__ */
|
||
|
|
||
|
const cachingTransform = require('caching-transform')
|
||
|
const findCacheDir = require('find-cache-dir')
|
||
|
const fs = require('./lib/fs-promises')
|
||
|
const os = require('os')
|
||
|
const { debuglog, promisify } = require('util')
|
||
|
const glob = promisify(require('glob'))
|
||
|
const Hash = require('./lib/hash')
|
||
|
const libCoverage = require('istanbul-lib-coverage')
|
||
|
const libHook = require('istanbul-lib-hook')
|
||
|
const { ProcessInfo, ProcessDB } = require('istanbul-lib-processinfo')
|
||
|
const mkdirp = require('make-dir')
|
||
|
const Module = require('module')
|
||
|
const onExit = require('signal-exit')
|
||
|
const path = require('path')
|
||
|
const rimraf = promisify(require('rimraf'))
|
||
|
const SourceMaps = require('./lib/source-maps')
|
||
|
const TestExclude = require('test-exclude')
|
||
|
const pMap = require('p-map')
|
||
|
const getPackageType = require('get-package-type')
|
||
|
|
||
|
const debugLog = debuglog('nyc')
|
||
|
|
||
|
let selfCoverageHelper
|
||
|
|
||
|
/* istanbul ignore next */
|
||
|
if (/self-coverage/.test(__dirname)) {
|
||
|
selfCoverageHelper = require('../self-coverage-helper')
|
||
|
} else {
|
||
|
// Avoid additional conditional code
|
||
|
selfCoverageHelper = {
|
||
|
onExit () {}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function coverageFinder () {
|
||
|
var coverage = global.__coverage__
|
||
|
if (typeof __coverage__ === 'object') coverage = __coverage__
|
||
|
if (!coverage) coverage = global.__coverage__ = {}
|
||
|
return coverage
|
||
|
}
|
||
|
|
||
|
class NYC {
|
||
|
constructor (config) {
|
||
|
this.config = { ...config }
|
||
|
|
||
|
this.subprocessBin = config.subprocessBin || path.resolve(__dirname, './bin/nyc.js')
|
||
|
this._tempDirectory = config.tempDirectory || config.tempDir || './.nyc_output'
|
||
|
this._instrumenterLib = require(config.instrumenter || './lib/instrumenters/istanbul')
|
||
|
this._reportDir = config.reportDir || 'coverage'
|
||
|
this._sourceMap = typeof config.sourceMap === 'boolean' ? config.sourceMap : true
|
||
|
this._showProcessTree = config.showProcessTree || false
|
||
|
this._eagerInstantiation = config.eager || false
|
||
|
this.cwd = config.cwd || process.cwd()
|
||
|
this.reporter = [].concat(config.reporter || 'text')
|
||
|
|
||
|
this.cacheDirectory = (config.cacheDir && path.resolve(config.cacheDir)) || findCacheDir({ name: 'nyc', cwd: this.cwd })
|
||
|
this.cache = Boolean(this.cacheDirectory && config.cache)
|
||
|
|
||
|
this.extensions = [].concat(config.extension || [])
|
||
|
.concat('.js')
|
||
|
.map(ext => ext.toLowerCase())
|
||
|
.filter((item, pos, arr) => arr.indexOf(item) === pos)
|
||
|
|
||
|
this.exclude = new TestExclude({
|
||
|
cwd: this.cwd,
|
||
|
include: config.include,
|
||
|
exclude: config.exclude,
|
||
|
excludeNodeModules: config.excludeNodeModules !== false,
|
||
|
extension: this.extensions
|
||
|
})
|
||
|
|
||
|
this.sourceMaps = new SourceMaps({
|
||
|
cache: this.cache,
|
||
|
cacheDirectory: this.cacheDirectory
|
||
|
})
|
||
|
|
||
|
// require extensions can be provided as config in package.json.
|
||
|
this.require = [].concat(config.require || [])
|
||
|
|
||
|
this.transforms = this.extensions.reduce((transforms, ext) => {
|
||
|
transforms[ext] = this._createTransform(ext)
|
||
|
return transforms
|
||
|
}, {})
|
||
|
|
||
|
this.hookRequire = config.hookRequire
|
||
|
this.hookRunInContext = config.hookRunInContext
|
||
|
this.hookRunInThisContext = config.hookRunInThisContext
|
||
|
this.fakeRequire = null
|
||
|
|
||
|
this.processInfo = new ProcessInfo(Object.assign({}, config._processInfo, {
|
||
|
directory: path.resolve(this.tempDirectory(), 'processinfo')
|
||
|
}))
|
||
|
|
||
|
this.hashCache = {}
|
||
|
}
|
||
|
|
||
|
_createTransform (ext) {
|
||
|
const opts = {
|
||
|
salt: Hash.salt(this.config),
|
||
|
hashData: (input, metadata) => [metadata.filename],
|
||
|
filenamePrefix: metadata => path.parse(metadata.filename).name + '-',
|
||
|
onHash: (input, metadata, hash) => {
|
||
|
this.hashCache[metadata.filename] = hash
|
||
|
},
|
||
|
cacheDir: this.cacheDirectory,
|
||
|
// when running --all we should not load source-file from
|
||
|
// cache, we want to instead return the fake source.
|
||
|
disableCache: this._disableCachingTransform(),
|
||
|
ext: ext
|
||
|
}
|
||
|
if (this._eagerInstantiation) {
|
||
|
opts.transform = this._transformFactory(this.cacheDirectory)
|
||
|
} else {
|
||
|
opts.factory = this._transformFactory.bind(this)
|
||
|
}
|
||
|
return cachingTransform(opts)
|
||
|
}
|
||
|
|
||
|
_disableCachingTransform () {
|
||
|
return !(this.cache && this.config.isChildProcess)
|
||
|
}
|
||
|
|
||
|
_loadAdditionalModules () {
|
||
|
if (!this.config.useSpawnWrap || this.require.length === 0) {
|
||
|
return
|
||
|
}
|
||
|
|
||
|
const resolveFrom = require('resolve-from')
|
||
|
this.require.forEach(requireModule => {
|
||
|
// Attempt to require the module relative to the directory being instrumented.
|
||
|
// Then try other locations, e.g. the nyc node_modules folder.
|
||
|
require(resolveFrom.silent(this.cwd, requireModule) || requireModule)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
instrumenter () {
|
||
|
return this._instrumenter || (this._instrumenter = this._createInstrumenter())
|
||
|
}
|
||
|
|
||
|
_createInstrumenter () {
|
||
|
return this._instrumenterLib({
|
||
|
ignoreClassMethods: [].concat(this.config.ignoreClassMethod).filter(a => a),
|
||
|
produceSourceMap: this.config.produceSourceMap,
|
||
|
compact: this.config.compact,
|
||
|
preserveComments: this.config.preserveComments,
|
||
|
esModules: this.config.esModules,
|
||
|
parserPlugins: this.config.parserPlugins
|
||
|
})
|
||
|
}
|
||
|
|
||
|
addFile (filename) {
|
||
|
const source = this._readTranspiledSource(filename)
|
||
|
this._maybeInstrumentSource(source, filename)
|
||
|
}
|
||
|
|
||
|
_readTranspiledSource (filePath) {
|
||
|
var source = null
|
||
|
var ext = path.extname(filePath)
|
||
|
if (typeof Module._extensions[ext] === 'undefined') {
|
||
|
ext = '.js'
|
||
|
}
|
||
|
Module._extensions[ext]({
|
||
|
_compile: function (content, filename) {
|
||
|
source = content
|
||
|
}
|
||
|
}, filePath)
|
||
|
return source
|
||
|
}
|
||
|
|
||
|
_getSourceMap (code, filename, hash) {
|
||
|
const sourceMap = {}
|
||
|
if (this._sourceMap) {
|
||
|
sourceMap.sourceMap = this.sourceMaps.extract(code, filename)
|
||
|
sourceMap.registerMap = () => this.sourceMaps.registerMap(filename, hash, sourceMap.sourceMap)
|
||
|
} else {
|
||
|
sourceMap.registerMap = () => {}
|
||
|
}
|
||
|
|
||
|
return sourceMap
|
||
|
}
|
||
|
|
||
|
async addAllFiles () {
|
||
|
this._loadAdditionalModules()
|
||
|
|
||
|
this.fakeRequire = true
|
||
|
const files = await this.exclude.glob(this.cwd)
|
||
|
for (const relFile of files) {
|
||
|
const filename = path.resolve(this.cwd, relFile)
|
||
|
const ext = path.extname(filename)
|
||
|
if (ext === '.mjs' || (ext === '.js' && await getPackageType(filename) === 'module')) {
|
||
|
const source = await fs.readFile(filename, 'utf8')
|
||
|
this.instrumenter().instrumentSync(
|
||
|
source,
|
||
|
filename,
|
||
|
this._getSourceMap(source, filename)
|
||
|
)
|
||
|
} else {
|
||
|
this.addFile(filename)
|
||
|
}
|
||
|
const coverage = coverageFinder()
|
||
|
const lastCoverage = this.instrumenter().lastFileCoverage()
|
||
|
if (lastCoverage) {
|
||
|
coverage[lastCoverage.path] = {
|
||
|
...lastCoverage,
|
||
|
// Only use this data if we don't have it without `all: true`
|
||
|
all: true
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
this.fakeRequire = false
|
||
|
|
||
|
this.writeCoverageFile()
|
||
|
}
|
||
|
|
||
|
async instrumentAllFiles (input, output) {
|
||
|
let inputDir = '.' + path.sep
|
||
|
const visitor = async relFile => {
|
||
|
const inFile = path.resolve(inputDir, relFile)
|
||
|
const inCode = await fs.readFile(inFile, 'utf-8')
|
||
|
const outCode = this._transform(inCode, inFile) || inCode
|
||
|
|
||
|
if (output) {
|
||
|
const { mode } = await fs.stat(inFile)
|
||
|
const outFile = path.resolve(output, relFile)
|
||
|
|
||
|
await mkdirp(path.dirname(outFile))
|
||
|
await fs.writeFile(outFile, outCode)
|
||
|
await fs.chmod(outFile, mode)
|
||
|
} else {
|
||
|
console.log(outCode)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this._loadAdditionalModules()
|
||
|
|
||
|
const stats = await fs.lstat(input)
|
||
|
if (stats.isDirectory()) {
|
||
|
inputDir = input
|
||
|
|
||
|
const filesToInstrument = await this.exclude.glob(input)
|
||
|
|
||
|
const concurrency = output ? os.cpus().length : 1
|
||
|
if (this.config.completeCopy && output) {
|
||
|
const files = await glob(path.resolve(input, '**'), {
|
||
|
dot: true,
|
||
|
nodir: true,
|
||
|
ignore: ['**/.git', '**/.git/**', path.join(output, '**')]
|
||
|
})
|
||
|
const destDirs = new Set(
|
||
|
files.map(src => path.dirname(path.join(output, path.relative(input, src))))
|
||
|
)
|
||
|
|
||
|
await pMap(
|
||
|
destDirs,
|
||
|
dir => mkdirp(dir),
|
||
|
{ concurrency }
|
||
|
)
|
||
|
await pMap(
|
||
|
files,
|
||
|
src => fs.copyFile(src, path.join(output, path.relative(input, src))),
|
||
|
{ concurrency }
|
||
|
)
|
||
|
}
|
||
|
|
||
|
await pMap(filesToInstrument, visitor, { concurrency })
|
||
|
} else {
|
||
|
await visitor(input)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_transform (code, filename) {
|
||
|
const extname = path.extname(filename).toLowerCase()
|
||
|
const transform = this.transforms[extname] || (() => null)
|
||
|
|
||
|
return transform(code, { filename })
|
||
|
}
|
||
|
|
||
|
_maybeInstrumentSource (code, filename) {
|
||
|
if (!this.exclude.shouldInstrument(filename)) {
|
||
|
return null
|
||
|
}
|
||
|
|
||
|
return this._transform(code, filename)
|
||
|
}
|
||
|
|
||
|
maybePurgeSourceMapCache () {
|
||
|
if (!this.cache) {
|
||
|
this.sourceMaps.purgeCache()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_transformFactory (cacheDir) {
|
||
|
const instrumenter = this.instrumenter()
|
||
|
let instrumented
|
||
|
|
||
|
return (code, metadata, hash) => {
|
||
|
const filename = metadata.filename
|
||
|
const sourceMap = this._getSourceMap(code, filename, hash)
|
||
|
|
||
|
try {
|
||
|
instrumented = instrumenter.instrumentSync(code, filename, sourceMap)
|
||
|
} catch (e) {
|
||
|
debugLog('failed to instrument ' + filename + ' with error: ' + e.stack)
|
||
|
if (this.config.exitOnError) {
|
||
|
console.error('Failed to instrument ' + filename)
|
||
|
process.exit(1)
|
||
|
} else {
|
||
|
instrumented = code
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (this.fakeRequire) {
|
||
|
return 'function x () {}'
|
||
|
} else {
|
||
|
return instrumented
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_handleJs (code, options) {
|
||
|
// ensure the path has correct casing (see istanbuljs/nyc#269 and nodejs/node#6624)
|
||
|
const filename = path.resolve(this.cwd, options.filename)
|
||
|
return this._maybeInstrumentSource(code, filename) || code
|
||
|
}
|
||
|
|
||
|
_addHook (type) {
|
||
|
const handleJs = this._handleJs.bind(this)
|
||
|
const dummyMatcher = () => true // we do all processing in transformer
|
||
|
libHook['hook' + type](dummyMatcher, handleJs, { extensions: this.extensions })
|
||
|
}
|
||
|
|
||
|
_addRequireHooks () {
|
||
|
if (this.hookRequire) {
|
||
|
this._addHook('Require')
|
||
|
}
|
||
|
if (this.hookRunInContext) {
|
||
|
this._addHook('RunInContext')
|
||
|
}
|
||
|
if (this.hookRunInThisContext) {
|
||
|
this._addHook('RunInThisContext')
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async createTempDirectory () {
|
||
|
await mkdirp(this.tempDirectory())
|
||
|
if (this.cache) {
|
||
|
await mkdirp(this.cacheDirectory)
|
||
|
}
|
||
|
|
||
|
await mkdirp(this.processInfo.directory)
|
||
|
}
|
||
|
|
||
|
async reset () {
|
||
|
if (!process.env.NYC_CWD) {
|
||
|
await rimraf(this.tempDirectory())
|
||
|
}
|
||
|
|
||
|
await this.createTempDirectory()
|
||
|
}
|
||
|
|
||
|
_wrapExit () {
|
||
|
selfCoverageHelper.registered = true
|
||
|
|
||
|
// we always want to write coverage
|
||
|
// regardless of how the process exits.
|
||
|
onExit(
|
||
|
() => {
|
||
|
this.writeCoverageFile()
|
||
|
selfCoverageHelper.onExit()
|
||
|
},
|
||
|
{ alwaysLast: true }
|
||
|
)
|
||
|
}
|
||
|
|
||
|
wrap (bin) {
|
||
|
process.env.NYC_PROCESS_ID = this.processInfo.uuid
|
||
|
// This is a bug with the spawn-wrap method where
|
||
|
// we cannot force propagation of NYC_PROCESS_ID.
|
||
|
if (!this.config.useSpawnWrap) {
|
||
|
const updateVariable = require('./lib/register-env.js')
|
||
|
updateVariable('NYC_PROCESS_ID')
|
||
|
}
|
||
|
this._addRequireHooks()
|
||
|
this._wrapExit()
|
||
|
this._loadAdditionalModules()
|
||
|
return this
|
||
|
}
|
||
|
|
||
|
writeCoverageFile () {
|
||
|
var coverage = coverageFinder()
|
||
|
|
||
|
// Remove any files that should be excluded but snuck into the coverage
|
||
|
Object.keys(coverage).forEach(function (absFile) {
|
||
|
if (!this.exclude.shouldInstrument(absFile)) {
|
||
|
delete coverage[absFile]
|
||
|
}
|
||
|
}, this)
|
||
|
|
||
|
if (this.cache) {
|
||
|
Object.keys(coverage).forEach(function (absFile) {
|
||
|
if (this.hashCache[absFile] && coverage[absFile]) {
|
||
|
coverage[absFile].contentHash = this.hashCache[absFile]
|
||
|
}
|
||
|
}, this)
|
||
|
}
|
||
|
|
||
|
var id = this.processInfo.uuid
|
||
|
var coverageFilename = path.resolve(this.tempDirectory(), id + '.json')
|
||
|
|
||
|
fs.writeFileSync(
|
||
|
coverageFilename,
|
||
|
JSON.stringify(coverage),
|
||
|
'utf-8'
|
||
|
)
|
||
|
|
||
|
this.processInfo.coverageFilename = coverageFilename
|
||
|
this.processInfo.files = Object.keys(coverage)
|
||
|
this.processInfo.saveSync()
|
||
|
}
|
||
|
|
||
|
async getCoverageMapFromAllCoverageFiles (baseDirectory) {
|
||
|
const map = libCoverage.createCoverageMap({})
|
||
|
const files = await this.coverageFiles(baseDirectory)
|
||
|
|
||
|
await pMap(
|
||
|
files,
|
||
|
async f => {
|
||
|
const report = await this.coverageFileLoad(f, baseDirectory)
|
||
|
map.merge(report)
|
||
|
},
|
||
|
{ concurrency: os.cpus().length }
|
||
|
)
|
||
|
|
||
|
map.data = await this.sourceMaps.remapCoverage(map.data)
|
||
|
|
||
|
// depending on whether source-code is pre-instrumented
|
||
|
// or instrumented using a JIT plugin like @babel/require
|
||
|
// you may opt to exclude files after applying
|
||
|
// source-map remapping logic.
|
||
|
if (this.config.excludeAfterRemap) {
|
||
|
map.filter(filename => this.exclude.shouldInstrument(filename))
|
||
|
}
|
||
|
|
||
|
return map
|
||
|
}
|
||
|
|
||
|
async report () {
|
||
|
const libReport = require('istanbul-lib-report')
|
||
|
const reports = require('istanbul-reports')
|
||
|
|
||
|
const context = libReport.createContext({
|
||
|
dir: this.reportDirectory(),
|
||
|
watermarks: this.config.watermarks,
|
||
|
coverageMap: await this.getCoverageMapFromAllCoverageFiles()
|
||
|
})
|
||
|
|
||
|
this.reporter.forEach((_reporter) => {
|
||
|
reports.create(_reporter, {
|
||
|
skipEmpty: this.config.skipEmpty,
|
||
|
skipFull: this.config.skipFull,
|
||
|
projectRoot: this.cwd,
|
||
|
maxCols: process.stdout.columns || 100
|
||
|
}).execute(context)
|
||
|
})
|
||
|
|
||
|
if (this._showProcessTree) {
|
||
|
await this.showProcessTree()
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async writeProcessIndex () {
|
||
|
const db = new ProcessDB(this.processInfo.directory)
|
||
|
await db.writeIndex()
|
||
|
}
|
||
|
|
||
|
async showProcessTree () {
|
||
|
const db = new ProcessDB(this.processInfo.directory)
|
||
|
console.log(await db.renderTree(this))
|
||
|
}
|
||
|
|
||
|
async checkCoverage (thresholds, perFile) {
|
||
|
const map = await this.getCoverageMapFromAllCoverageFiles()
|
||
|
|
||
|
if (perFile) {
|
||
|
map.files().forEach(file => {
|
||
|
// ERROR: Coverage for lines (90.12%) does not meet threshold (120%) for index.js
|
||
|
this._checkCoverage(map.fileCoverageFor(file).toSummary(), thresholds, file)
|
||
|
})
|
||
|
} else {
|
||
|
// ERROR: Coverage for lines (90.12%) does not meet global threshold (120%)
|
||
|
this._checkCoverage(map.getCoverageSummary(), thresholds)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_checkCoverage (summary, thresholds, file) {
|
||
|
Object.keys(thresholds).forEach(function (key) {
|
||
|
var coverage = summary[key].pct
|
||
|
if (coverage < thresholds[key]) {
|
||
|
process.exitCode = 1
|
||
|
if (file) {
|
||
|
console.error('ERROR: Coverage for ' + key + ' (' + coverage + '%) does not meet threshold (' + thresholds[key] + '%) for ' + file)
|
||
|
} else {
|
||
|
console.error('ERROR: Coverage for ' + key + ' (' + coverage + '%) does not meet global threshold (' + thresholds[key] + '%)')
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
coverageFiles (baseDirectory = this.tempDirectory()) {
|
||
|
return fs.readdir(baseDirectory)
|
||
|
}
|
||
|
|
||
|
async coverageFileLoad (filename, baseDirectory = this.tempDirectory()) {
|
||
|
try {
|
||
|
const report = JSON.parse(await fs.readFile(path.resolve(baseDirectory, filename)), 'utf8')
|
||
|
await this.sourceMaps.reloadCachedSourceMaps(report)
|
||
|
return report
|
||
|
} catch (error) {
|
||
|
return {}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// TODO: Remove from nyc v16
|
||
|
async coverageData (baseDirectory) {
|
||
|
const files = await this.coverageFiles(baseDirectory)
|
||
|
return pMap(
|
||
|
files,
|
||
|
f => this.coverageFileLoad(f, baseDirectory),
|
||
|
{ concurrency: os.cpus().length }
|
||
|
)
|
||
|
}
|
||
|
|
||
|
tempDirectory () {
|
||
|
return path.resolve(this.cwd, this._tempDirectory)
|
||
|
}
|
||
|
|
||
|
reportDirectory () {
|
||
|
return path.resolve(this.cwd, this._reportDir)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
module.exports = NYC
|