UNPKG

17.6 kBJavaScriptView Raw
1'use strict'
2
3/* global __coverage__ */
4
5const cachingTransform = require('caching-transform')
6const cpFile = require('cp-file')
7const findCacheDir = require('find-cache-dir')
8const fs = require('fs')
9const glob = require('glob')
10const Hash = require('./lib/hash')
11const libCoverage = require('istanbul-lib-coverage')
12const libHook = require('istanbul-lib-hook')
13const libReport = require('istanbul-lib-report')
14const mkdirp = require('make-dir')
15const Module = require('module')
16const onExit = require('signal-exit')
17const path = require('path')
18const reports = require('istanbul-reports')
19const resolveFrom = require('resolve-from')
20const rimraf = require('rimraf')
21const SourceMaps = require('./lib/source-maps')
22const testExclude = require('test-exclude')
23const util = require('util')
24const uuid = require('uuid/v4')
25
26const debugLog = util.debuglog('nyc')
27
28const ProcessInfo = require('./lib/process.js')
29
30/* istanbul ignore next */
31if (/self-coverage/.test(__dirname)) {
32 require('../self-coverage-helper')
33}
34
35function coverageFinder () {
36 var coverage = global.__coverage__
37 if (typeof __coverage__ === 'object') coverage = __coverage__
38 if (!coverage) coverage = global['__coverage__'] = {}
39 return coverage
40}
41
42class NYC {
43 constructor (config) {
44 config = config || {}
45 this.config = config
46
47 this.subprocessBin = config.subprocessBin || path.resolve(__dirname, './bin/nyc.js')
48 this._tempDirectory = config.tempDirectory || config.tempDir || './.nyc_output'
49 this._instrumenterLib = require(config.instrumenter || './lib/instrumenters/istanbul')
50 this._reportDir = config.reportDir || 'coverage'
51 this._sourceMap = typeof config.sourceMap === 'boolean' ? config.sourceMap : true
52 this._showProcessTree = config.showProcessTree || false
53 this._eagerInstantiation = config.eager || false
54 this.cwd = config.cwd || process.cwd()
55 this.reporter = [].concat(config.reporter || 'text')
56
57 this.cacheDirectory = (config.cacheDir && path.resolve(config.cacheDir)) || findCacheDir({ name: 'nyc', cwd: this.cwd })
58 this.cache = Boolean(this.cacheDirectory && config.cache)
59
60 this.extensions = [].concat(config.extension || [])
61 .concat('.js')
62 .map(ext => ext.toLowerCase())
63 .filter((item, pos, arr) => arr.indexOf(item) === pos)
64
65 this.exclude = testExclude({
66 cwd: this.cwd,
67 include: config.include,
68 exclude: config.exclude,
69 excludeNodeModules: config.excludeNodeModules !== false,
70 extension: this.extensions
71 })
72
73 this.sourceMaps = new SourceMaps({
74 cache: this.cache,
75 cacheDirectory: this.cacheDirectory
76 })
77
78 // require extensions can be provided as config in package.json.
79 this.require = [].concat(config.require || [])
80
81 this.transforms = this.extensions.reduce((transforms, ext) => {
82 transforms[ext] = this._createTransform(ext)
83 return transforms
84 }, {})
85
86 this.hookRequire = config.hookRequire
87 this.hookRunInContext = config.hookRunInContext
88 this.hookRunInThisContext = config.hookRunInThisContext
89 this.fakeRequire = null
90
91 this.processInfo = new ProcessInfo(config && config._processInfo)
92 this.rootId = this.processInfo.root || uuid()
93
94 this.hashCache = {}
95 }
96
97 _createTransform (ext) {
98 var opts = {
99 salt: Hash.salt(this.config),
100 hashData: (input, metadata) => [metadata.filename],
101 onHash: (input, metadata, hash) => {
102 this.hashCache[metadata.filename] = hash
103 },
104 cacheDir: this.cacheDirectory,
105 // when running --all we should not load source-file from
106 // cache, we want to instead return the fake source.
107 disableCache: this._disableCachingTransform(),
108 ext: ext
109 }
110 if (this._eagerInstantiation) {
111 opts.transform = this._transformFactory(this.cacheDirectory)
112 } else {
113 opts.factory = this._transformFactory.bind(this)
114 }
115 return cachingTransform(opts)
116 }
117
118 _disableCachingTransform () {
119 return !(this.cache && this.config.isChildProcess)
120 }
121
122 _loadAdditionalModules () {
123 this.require.forEach(requireModule => {
124 // Attempt to require the module relative to the directory being instrumented.
125 // Then try other locations, e.g. the nyc node_modules folder.
126 require(resolveFrom.silent(this.cwd, requireModule) || requireModule)
127 })
128 }
129
130 instrumenter () {
131 return this._instrumenter || (this._instrumenter = this._createInstrumenter())
132 }
133
134 _createInstrumenter () {
135 return this._instrumenterLib({
136 ignoreClassMethods: [].concat(this.config.ignoreClassMethod).filter(a => a),
137 produceSourceMap: this.config.produceSourceMap,
138 compact: this.config.compact,
139 preserveComments: this.config.preserveComments,
140 esModules: this.config.esModules,
141 plugins: this.config.parserPlugins
142 })
143 }
144
145 addFile (filename) {
146 const source = this._readTranspiledSource(filename)
147 this._maybeInstrumentSource(source, filename)
148 }
149
150 _readTranspiledSource (filePath) {
151 var source = null
152 var ext = path.extname(filePath)
153 if (typeof Module._extensions[ext] === 'undefined') {
154 ext = '.js'
155 }
156 Module._extensions[ext]({
157 _compile: function (content, filename) {
158 source = content
159 }
160 }, filePath)
161 return source
162 }
163
164 addAllFiles () {
165 this._loadAdditionalModules()
166
167 this.fakeRequire = true
168 this.exclude.globSync(this.cwd).forEach(relFile => {
169 const filename = path.resolve(this.cwd, relFile)
170 this.addFile(filename)
171 const coverage = coverageFinder()
172 const lastCoverage = this.instrumenter().lastFileCoverage()
173 if (lastCoverage) {
174 coverage[lastCoverage.path] = lastCoverage
175 }
176 })
177 this.fakeRequire = false
178
179 this.writeCoverageFile()
180 }
181
182 instrumentAllFiles (input, output, cb) {
183 let inputDir = '.' + path.sep
184 const visitor = relFile => {
185 const inFile = path.resolve(inputDir, relFile)
186 const inCode = fs.readFileSync(inFile, 'utf-8')
187 const outCode = this._transform(inCode, inFile) || inCode
188
189 if (output) {
190 const mode = fs.statSync(inFile).mode
191 const outFile = path.resolve(output, relFile)
192 mkdirp.sync(path.dirname(outFile))
193 fs.writeFileSync(outFile, outCode)
194 fs.chmodSync(outFile, mode)
195 } else {
196 console.log(outCode)
197 }
198 }
199
200 this._loadAdditionalModules()
201
202 try {
203 const stats = fs.lstatSync(input)
204 if (stats.isDirectory()) {
205 inputDir = input
206
207 const filesToInstrument = this.exclude.globSync(input)
208
209 if (this.config.completeCopy && output) {
210 const globOptions = { dot: true, nodir: true, ignore: ['**/.git', '**/.git/**', path.join(output, '**')] }
211 glob.sync(path.resolve(input, '**'), globOptions)
212 .forEach(src => cpFile.sync(src, path.join(output, path.relative(input, src))))
213 }
214 filesToInstrument.forEach(visitor)
215 } else {
216 visitor(input)
217 }
218 } catch (err) {
219 return cb(err)
220 }
221 cb()
222 }
223
224 _transform (code, filename) {
225 const extname = path.extname(filename).toLowerCase()
226 const transform = this.transforms[extname] || (() => null)
227
228 return transform(code, { filename })
229 }
230
231 _maybeInstrumentSource (code, filename) {
232 if (!this.exclude.shouldInstrument(filename)) {
233 return null
234 }
235
236 return this._transform(code, filename)
237 }
238
239 maybePurgeSourceMapCache () {
240 if (!this.cache) {
241 this.sourceMaps.purgeCache()
242 }
243 }
244
245 _transformFactory (cacheDir) {
246 const instrumenter = this.instrumenter()
247 let instrumented
248
249 return (code, metadata, hash) => {
250 const filename = metadata.filename
251 let sourceMap = null
252
253 if (this._sourceMap) sourceMap = this.sourceMaps.extractAndRegister(code, filename, hash)
254
255 try {
256 instrumented = instrumenter.instrumentSync(code, filename, sourceMap)
257 } catch (e) {
258 debugLog('failed to instrument ' + filename + ' with error: ' + e.stack)
259 if (this.config.exitOnError) {
260 console.error('Failed to instrument ' + filename)
261 process.exit(1)
262 } else {
263 instrumented = code
264 }
265 }
266
267 if (this.fakeRequire) {
268 return 'function x () {}'
269 } else {
270 return instrumented
271 }
272 }
273 }
274
275 _handleJs (code, options) {
276 // ensure the path has correct casing (see istanbuljs/nyc#269 and nodejs/node#6624)
277 const filename = path.resolve(this.cwd, options.filename)
278 return this._maybeInstrumentSource(code, filename) || code
279 }
280
281 _addHook (type) {
282 const handleJs = this._handleJs.bind(this)
283 const dummyMatcher = () => true // we do all processing in transformer
284 libHook['hook' + type](dummyMatcher, handleJs, { extensions: this.extensions })
285 }
286
287 _addRequireHooks () {
288 if (this.hookRequire) {
289 this._addHook('Require')
290 }
291 if (this.hookRunInContext) {
292 this._addHook('RunInContext')
293 }
294 if (this.hookRunInThisContext) {
295 this._addHook('RunInThisContext')
296 }
297 }
298
299 cleanup () {
300 if (!process.env.NYC_CWD) rimraf.sync(this.tempDirectory())
301 }
302
303 clearCache () {
304 if (this.cache) {
305 rimraf.sync(this.cacheDirectory)
306 }
307 }
308
309 createTempDirectory () {
310 mkdirp.sync(this.tempDirectory())
311 if (this.cache) mkdirp.sync(this.cacheDirectory)
312
313 mkdirp.sync(this.processInfoDirectory())
314 }
315
316 reset () {
317 this.cleanup()
318 this.createTempDirectory()
319 }
320
321 _wrapExit () {
322 // we always want to write coverage
323 // regardless of how the process exits.
324 onExit(() => {
325 this.writeCoverageFile()
326 }, { alwaysLast: true })
327 }
328
329 wrap (bin) {
330 process.env.NYC_PROCESS_ID = this.processInfo.uuid
331 this._addRequireHooks()
332 this._wrapExit()
333 this._loadAdditionalModules()
334 return this
335 }
336
337 writeCoverageFile () {
338 var coverage = coverageFinder()
339 if (!coverage) return
340
341 // Remove any files that should be excluded but snuck into the coverage
342 Object.keys(coverage).forEach(function (absFile) {
343 if (!this.exclude.shouldInstrument(absFile)) {
344 delete coverage[absFile]
345 }
346 }, this)
347
348 if (this.cache) {
349 Object.keys(coverage).forEach(function (absFile) {
350 if (this.hashCache[absFile] && coverage[absFile]) {
351 coverage[absFile].contentHash = this.hashCache[absFile]
352 }
353 }, this)
354 } else {
355 coverage = this.sourceMaps.remapCoverage(coverage)
356 }
357
358 var id = this.processInfo.uuid
359 var coverageFilename = path.resolve(this.tempDirectory(), id + '.json')
360
361 fs.writeFileSync(
362 coverageFilename,
363 JSON.stringify(coverage),
364 'utf-8'
365 )
366
367 this.processInfo.coverageFilename = coverageFilename
368 this.processInfo.files = Object.keys(coverage)
369
370 fs.writeFileSync(
371 path.resolve(this.processInfoDirectory(), id + '.json'),
372 JSON.stringify(this.processInfo),
373 'utf-8'
374 )
375 }
376
377 getCoverageMapFromAllCoverageFiles (baseDirectory) {
378 const map = libCoverage.createCoverageMap({})
379
380 this.eachReport(undefined, (report) => {
381 map.merge(report)
382 }, baseDirectory)
383
384 map.data = this.sourceMaps.remapCoverage(map.data)
385
386 // depending on whether source-code is pre-instrumented
387 // or instrumented using a JIT plugin like @babel/require
388 // you may opt to exclude files after applying
389 // source-map remapping logic.
390 if (this.config.excludeAfterRemap) {
391 map.filter(filename => this.exclude.shouldInstrument(filename))
392 }
393
394 return map
395 }
396
397 report () {
398 var tree
399 var map = this.getCoverageMapFromAllCoverageFiles()
400 var context = libReport.createContext({
401 dir: this.reportDirectory(),
402 watermarks: this.config.watermarks
403 })
404
405 tree = libReport.summarizers.pkg(map)
406
407 this.reporter.forEach((_reporter) => {
408 tree.visit(reports.create(_reporter, {
409 skipEmpty: this.config.skipEmpty,
410 skipFull: this.config.skipFull
411 }), context)
412 })
413
414 if (this._showProcessTree) {
415 this.showProcessTree()
416 }
417 }
418
419 // XXX(@isaacs) Index generation should move to istanbul-lib-processinfo
420 writeProcessIndex () {
421 const dir = this.processInfoDirectory()
422 const pidToUid = new Map()
423 const infoByUid = new Map()
424 const eidToUid = new Map()
425 const infos = fs.readdirSync(dir).filter(f => f !== 'index.json').map(f => {
426 try {
427 const info = JSON.parse(fs.readFileSync(path.resolve(dir, f), 'utf-8'))
428 info.children = []
429 pidToUid.set(info.uuid, info.pid)
430 pidToUid.set(info.pid, info.uuid)
431 infoByUid.set(info.uuid, info)
432 if (info.externalId) {
433 eidToUid.set(info.externalId, info.uuid)
434 }
435 return info
436 } catch (er) {
437 return null
438 }
439 }).filter(Boolean)
440
441 // create all the parent-child links and write back the updated info
442 infos.forEach(info => {
443 if (info.parent) {
444 const parentInfo = infoByUid.get(info.parent)
445 if (parentInfo && !parentInfo.children.includes(info.uuid)) {
446 parentInfo.children.push(info.uuid)
447 }
448 }
449 })
450
451 // figure out which files were touched by each process.
452 const files = infos.reduce((files, info) => {
453 info.files.forEach(f => {
454 files[f] = files[f] || []
455 files[f].push(info.uuid)
456 })
457 return files
458 }, {})
459
460 // build the actual index!
461 const index = infos.reduce((index, info) => {
462 index.processes[info.uuid] = {}
463 index.processes[info.uuid].parent = info.parent
464 if (info.externalId) {
465 if (index.externalIds[info.externalId]) {
466 throw new Error(`External ID ${info.externalId} used by multiple processes`)
467 }
468 index.processes[info.uuid].externalId = info.externalId
469 index.externalIds[info.externalId] = {
470 root: info.uuid,
471 children: info.children
472 }
473 }
474 index.processes[info.uuid].children = Array.from(info.children)
475 return index
476 }, { processes: {}, files: files, externalIds: {} })
477
478 // flatten the descendant sets of all the externalId procs
479 Object.keys(index.externalIds).forEach(eid => {
480 const { children } = index.externalIds[eid]
481 // push the next generation onto the list so we accumulate them all
482 for (let i = 0; i < children.length; i++) {
483 const nextGen = index.processes[children[i]].children
484 if (nextGen && nextGen.length) {
485 children.push(...nextGen.filter(uuid => children.indexOf(uuid) === -1))
486 }
487 }
488 })
489
490 fs.writeFileSync(path.resolve(dir, 'index.json'), JSON.stringify(index))
491 }
492
493 showProcessTree () {
494 var processTree = ProcessInfo.buildProcessTree(this._loadProcessInfos())
495
496 console.log(processTree.render(this))
497 }
498
499 checkCoverage (thresholds, perFile) {
500 var map = this.getCoverageMapFromAllCoverageFiles()
501 var nyc = this
502
503 if (perFile) {
504 map.files().forEach(function (file) {
505 // ERROR: Coverage for lines (90.12%) does not meet threshold (120%) for index.js
506 nyc._checkCoverage(map.fileCoverageFor(file).toSummary(), thresholds, file)
507 })
508 } else {
509 // ERROR: Coverage for lines (90.12%) does not meet global threshold (120%)
510 nyc._checkCoverage(map.getCoverageSummary(), thresholds)
511 }
512 }
513
514 _checkCoverage (summary, thresholds, file) {
515 Object.keys(thresholds).forEach(function (key) {
516 var coverage = summary[key].pct
517 if (coverage < thresholds[key]) {
518 process.exitCode = 1
519 if (file) {
520 console.error('ERROR: Coverage for ' + key + ' (' + coverage + '%) does not meet threshold (' + thresholds[key] + '%) for ' + file)
521 } else {
522 console.error('ERROR: Coverage for ' + key + ' (' + coverage + '%) does not meet global threshold (' + thresholds[key] + '%)')
523 }
524 }
525 })
526 }
527
528 _loadProcessInfos () {
529 return fs.readdirSync(this.processInfoDirectory()).map(f => {
530 let data
531 try {
532 data = JSON.parse(fs.readFileSync(
533 path.resolve(this.processInfoDirectory(), f),
534 'utf-8'
535 ))
536 } catch (e) { // handle corrupt JSON output.
537 return null
538 }
539 if (f !== 'index.json') {
540 data.nodes = []
541 data = new ProcessInfo(data)
542 }
543 return { file: path.basename(f, '.json'), data: data }
544 }).filter(Boolean).reduce((infos, info) => {
545 infos[info.file] = info.data
546 return infos
547 }, {})
548 }
549
550 eachReport (filenames, iterator, baseDirectory) {
551 baseDirectory = baseDirectory || this.tempDirectory()
552
553 if (typeof filenames === 'function') {
554 iterator = filenames
555 filenames = undefined
556 }
557
558 var _this = this
559 var files = filenames || fs.readdirSync(baseDirectory)
560
561 files.forEach(function (f) {
562 var report
563 try {
564 report = JSON.parse(fs.readFileSync(
565 path.resolve(baseDirectory, f),
566 'utf-8'
567 ))
568
569 _this.sourceMaps.reloadCachedSourceMaps(report)
570 } catch (e) { // handle corrupt JSON output.
571 report = {}
572 }
573
574 iterator(report)
575 })
576 }
577
578 loadReports (filenames) {
579 var reports = []
580
581 this.eachReport(filenames, (report) => {
582 reports.push(report)
583 })
584
585 return reports
586 }
587
588 tempDirectory () {
589 return path.resolve(this.cwd, this._tempDirectory)
590 }
591
592 reportDirectory () {
593 return path.resolve(this.cwd, this._reportDir)
594 }
595
596 processInfoDirectory () {
597 return path.resolve(this.tempDirectory(), 'processinfo')
598 }
599}
600
601module.exports = NYC