report.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. const Exclude = require('test-exclude')
  2. const libCoverage = require('istanbul-lib-coverage')
  3. const libReport = require('istanbul-lib-report')
  4. const reports = require('istanbul-reports')
  5. let readFile
  6. try {
  7. ;({ readFile } = require('fs/promises'))
  8. } catch (err) {
  9. ;({ readFile } = require('fs').promises)
  10. }
  11. const { readdirSync, readFileSync, statSync } = require('fs')
  12. const { isAbsolute, resolve, extname } = require('path')
  13. const { pathToFileURL, fileURLToPath } = require('url')
  14. const getSourceMapFromFile = require('./source-map-from-file')
  15. // TODO: switch back to @c88/v8-coverage once patch is landed.
  16. const v8toIstanbul = require('v8-to-istanbul')
  17. const util = require('util')
  18. const debuglog = util.debuglog('c8')
  19. class Report {
  20. constructor ({
  21. exclude,
  22. extension,
  23. excludeAfterRemap,
  24. include,
  25. reporter,
  26. reporterOptions,
  27. reportsDirectory,
  28. tempDirectory,
  29. watermarks,
  30. omitRelative,
  31. wrapperLength,
  32. resolve: resolvePaths,
  33. all,
  34. src,
  35. allowExternal = false,
  36. skipFull,
  37. excludeNodeModules,
  38. mergeAsync,
  39. monocartArgv
  40. }) {
  41. this.reporter = reporter
  42. this.reporterOptions = reporterOptions || {}
  43. this.reportsDirectory = reportsDirectory
  44. this.tempDirectory = tempDirectory
  45. this.watermarks = watermarks
  46. this.resolve = resolvePaths
  47. this.exclude = new Exclude({
  48. exclude: exclude,
  49. include: include,
  50. extension: extension,
  51. relativePath: !allowExternal,
  52. excludeNodeModules: excludeNodeModules
  53. })
  54. this.excludeAfterRemap = excludeAfterRemap
  55. this.shouldInstrumentCache = new Map()
  56. this.omitRelative = omitRelative
  57. this.sourceMapCache = {}
  58. this.wrapperLength = wrapperLength
  59. this.all = all
  60. this.src = this._getSrc(src)
  61. this.skipFull = skipFull
  62. this.mergeAsync = mergeAsync
  63. this.monocartArgv = monocartArgv
  64. }
  65. _getSrc (src) {
  66. if (typeof src === 'string') {
  67. return [src]
  68. } else if (Array.isArray(src)) {
  69. return src
  70. } else {
  71. return [process.cwd()]
  72. }
  73. }
  74. async run () {
  75. if (this.monocartArgv) {
  76. return this.runMonocart()
  77. }
  78. const context = libReport.createContext({
  79. dir: this.reportsDirectory,
  80. watermarks: this.watermarks,
  81. coverageMap: await this.getCoverageMapFromAllCoverageFiles()
  82. })
  83. for (const _reporter of this.reporter) {
  84. reports.create(_reporter, {
  85. skipEmpty: false,
  86. skipFull: this.skipFull,
  87. maxCols: process.stdout.columns || 100,
  88. ...this.reporterOptions[_reporter]
  89. }).execute(context)
  90. }
  91. }
  92. async importMonocart () {
  93. return import('monocart-coverage-reports')
  94. }
  95. async getMonocart () {
  96. let MCR
  97. try {
  98. MCR = await this.importMonocart()
  99. } catch (e) {
  100. console.error('--experimental-monocart requires the plugin monocart-coverage-reports. Run: "npm i monocart-coverage-reports@2 --save-dev"')
  101. process.exit(1)
  102. }
  103. return MCR
  104. }
  105. async runMonocart () {
  106. const MCR = await this.getMonocart()
  107. if (!MCR) {
  108. return
  109. }
  110. const argv = this.monocartArgv
  111. const exclude = this.exclude
  112. function getEntryFilter () {
  113. return argv.entryFilter || argv.filter || function (entry) {
  114. return exclude.shouldInstrument(fileURLToPath(entry.url))
  115. }
  116. }
  117. function getSourceFilter () {
  118. return argv.sourceFilter || argv.filter || function (sourcePath) {
  119. if (argv.excludeAfterRemap) {
  120. // console.log(sourcePath)
  121. return exclude.shouldInstrument(sourcePath)
  122. }
  123. return true
  124. }
  125. }
  126. function getReports () {
  127. const reports = Array.isArray(argv.reporter) ? argv.reporter : [argv.reporter]
  128. const reporterOptions = argv.reporterOptions || {}
  129. return reports.map((reportName) => {
  130. const reportOptions = {
  131. ...reporterOptions[reportName]
  132. }
  133. if (reportName === 'text') {
  134. reportOptions.skipEmpty = false
  135. reportOptions.skipFull = argv.skipFull
  136. reportOptions.maxCols = process.stdout.columns || 100
  137. }
  138. return [reportName, reportOptions]
  139. })
  140. }
  141. // --all: add empty coverage for all files
  142. function getAllOptions () {
  143. if (!argv.all) {
  144. return
  145. }
  146. const src = argv.src
  147. const workingDirs = Array.isArray(src) ? src : (typeof src === 'string' ? [src] : [process.cwd()])
  148. return {
  149. dir: workingDirs,
  150. filter: (filePath) => {
  151. return exclude.shouldInstrument(filePath)
  152. }
  153. }
  154. }
  155. function initPct (summary) {
  156. Object.keys(summary).forEach(k => {
  157. if (summary[k].pct === '') {
  158. summary[k].pct = 100
  159. }
  160. })
  161. return summary
  162. }
  163. // adapt coverage options
  164. const coverageOptions = {
  165. logging: argv.logging,
  166. name: argv.name,
  167. reports: getReports(),
  168. outputDir: argv.reportsDir,
  169. baseDir: argv.baseDir,
  170. entryFilter: getEntryFilter(),
  171. sourceFilter: getSourceFilter(),
  172. inline: argv.inline,
  173. lcov: argv.lcov,
  174. all: getAllOptions(),
  175. clean: argv.clean,
  176. // use default value for istanbul
  177. defaultSummarizer: 'pkg',
  178. onEnd: (coverageResults) => {
  179. // for check coverage
  180. this._allCoverageFiles = {
  181. files: () => {
  182. return coverageResults.files.map(it => it.sourcePath)
  183. },
  184. fileCoverageFor: (file) => {
  185. const fileCoverage = coverageResults.files.find(it => it.sourcePath === file)
  186. return {
  187. toSummary: () => {
  188. return initPct(fileCoverage.summary)
  189. }
  190. }
  191. },
  192. getCoverageSummary: () => {
  193. return initPct(coverageResults.summary)
  194. }
  195. }
  196. }
  197. }
  198. const coverageReport = new MCR.CoverageReport(coverageOptions)
  199. coverageReport.cleanCache()
  200. // read v8 coverage data from tempDirectory
  201. await coverageReport.addFromDir(argv.tempDirectory)
  202. // generate report
  203. await coverageReport.generate()
  204. }
  205. async getCoverageMapFromAllCoverageFiles () {
  206. // the merge process can be very expensive, and it's often the case that
  207. // check-coverage is called immediately after a report. We memoize the
  208. // result from getCoverageMapFromAllCoverageFiles() to address this
  209. // use-case.
  210. if (this._allCoverageFiles) return this._allCoverageFiles
  211. const map = libCoverage.createCoverageMap()
  212. let v8ProcessCov
  213. if (this.mergeAsync) {
  214. v8ProcessCov = await this._getMergedProcessCovAsync()
  215. } else {
  216. v8ProcessCov = this._getMergedProcessCov()
  217. }
  218. const resultCountPerPath = new Map()
  219. for (const v8ScriptCov of v8ProcessCov.result) {
  220. try {
  221. const sources = this._getSourceMap(v8ScriptCov)
  222. const path = resolve(this.resolve, v8ScriptCov.url)
  223. const converter = v8toIstanbul(path, this.wrapperLength, sources, (path) => {
  224. if (this.excludeAfterRemap) {
  225. return !this._shouldInstrument(path)
  226. }
  227. })
  228. await converter.load()
  229. if (resultCountPerPath.has(path)) {
  230. resultCountPerPath.set(path, resultCountPerPath.get(path) + 1)
  231. } else {
  232. resultCountPerPath.set(path, 0)
  233. }
  234. converter.applyCoverage(v8ScriptCov.functions)
  235. map.merge(converter.toIstanbul())
  236. } catch (err) {
  237. debuglog(`file: ${v8ScriptCov.url} error: ${err.stack}`)
  238. }
  239. }
  240. this._allCoverageFiles = map
  241. return this._allCoverageFiles
  242. }
  243. /**
  244. * Returns source-map and fake source file, if cached during Node.js'
  245. * execution. This is used to support tools like ts-node, which transpile
  246. * using runtime hooks.
  247. *
  248. * Note: requires Node.js 13+
  249. *
  250. * @return {Object} sourceMap and fake source file (created from line #s).
  251. * @private
  252. */
  253. _getSourceMap (v8ScriptCov) {
  254. const sources = {}
  255. const sourceMapAndLineLengths = this.sourceMapCache[pathToFileURL(v8ScriptCov.url).href]
  256. if (sourceMapAndLineLengths) {
  257. // See: https://github.com/nodejs/node/pull/34305
  258. if (!sourceMapAndLineLengths.data) return
  259. sources.sourceMap = {
  260. sourcemap: sourceMapAndLineLengths.data
  261. }
  262. if (sourceMapAndLineLengths.lineLengths) {
  263. let source = ''
  264. sourceMapAndLineLengths.lineLengths.forEach(length => {
  265. source += `${''.padEnd(length, '.')}\n`
  266. })
  267. sources.source = source
  268. }
  269. }
  270. return sources
  271. }
  272. /**
  273. * Returns the merged V8 process coverage.
  274. *
  275. * The result is computed from the individual process coverages generated
  276. * by Node. It represents the sum of their counts.
  277. *
  278. * @return {ProcessCov} Merged V8 process coverage.
  279. * @private
  280. */
  281. _getMergedProcessCov () {
  282. const { mergeProcessCovs } = require('@bcoe/v8-coverage')
  283. const v8ProcessCovs = []
  284. const fileIndex = new Set() // Set<string>
  285. for (const v8ProcessCov of this._loadReports()) {
  286. if (this._isCoverageObject(v8ProcessCov)) {
  287. if (v8ProcessCov['source-map-cache']) {
  288. Object.assign(this.sourceMapCache, this._normalizeSourceMapCache(v8ProcessCov['source-map-cache']))
  289. }
  290. v8ProcessCovs.push(this._normalizeProcessCov(v8ProcessCov, fileIndex))
  291. }
  292. }
  293. if (this.all) {
  294. const emptyReports = this._includeUncoveredFiles(fileIndex)
  295. v8ProcessCovs.unshift({
  296. result: emptyReports
  297. })
  298. }
  299. return mergeProcessCovs(v8ProcessCovs)
  300. }
  301. /**
  302. * Returns the merged V8 process coverage.
  303. *
  304. * It asynchronously and incrementally reads and merges individual process coverages
  305. * generated by Node. This can be used via the `--merge-async` CLI arg. It's intended
  306. * to be used across a large multi-process test run.
  307. *
  308. * @return {ProcessCov} Merged V8 process coverage.
  309. * @private
  310. */
  311. async _getMergedProcessCovAsync () {
  312. const { mergeProcessCovs } = require('@bcoe/v8-coverage')
  313. const fileIndex = new Set() // Set<string>
  314. let mergedCov = null
  315. for (const file of readdirSync(this.tempDirectory)) {
  316. try {
  317. const rawFile = await readFile(
  318. resolve(this.tempDirectory, file),
  319. 'utf8'
  320. )
  321. let report = JSON.parse(rawFile)
  322. if (this._isCoverageObject(report)) {
  323. if (report['source-map-cache']) {
  324. Object.assign(this.sourceMapCache, this._normalizeSourceMapCache(report['source-map-cache']))
  325. }
  326. report = this._normalizeProcessCov(report, fileIndex)
  327. if (mergedCov) {
  328. mergedCov = mergeProcessCovs([mergedCov, report])
  329. } else {
  330. mergedCov = mergeProcessCovs([report])
  331. }
  332. }
  333. } catch (err) {
  334. debuglog(`${err.stack}`)
  335. }
  336. }
  337. if (this.all) {
  338. const emptyReports = this._includeUncoveredFiles(fileIndex)
  339. const emptyReport = {
  340. result: emptyReports
  341. }
  342. mergedCov = mergeProcessCovs([emptyReport, mergedCov])
  343. }
  344. return mergedCov
  345. }
  346. /**
  347. * Adds empty coverage reports to account for uncovered/untested code.
  348. * This is only done when the `--all` flag is present.
  349. *
  350. * @param {Set} fileIndex list of files that have coverage
  351. * @returns {Array} list of empty coverage reports
  352. */
  353. _includeUncoveredFiles (fileIndex) {
  354. const emptyReports = []
  355. const workingDirs = this.src
  356. const { extension } = this.exclude
  357. for (const workingDir of workingDirs) {
  358. this.exclude.globSync(workingDir).forEach((f) => {
  359. const fullPath = resolve(workingDir, f)
  360. if (!fileIndex.has(fullPath)) {
  361. const ext = extname(fullPath)
  362. if (extension.includes(ext)) {
  363. const stat = statSync(fullPath)
  364. const sourceMap = getSourceMapFromFile(fullPath)
  365. if (sourceMap) {
  366. this.sourceMapCache[pathToFileURL(fullPath)] = { data: sourceMap }
  367. }
  368. emptyReports.push({
  369. scriptId: 0,
  370. url: resolve(fullPath),
  371. functions: [{
  372. functionName: '(empty-report)',
  373. ranges: [{
  374. startOffset: 0,
  375. endOffset: stat.size,
  376. count: 0
  377. }],
  378. isBlockCoverage: true
  379. }]
  380. })
  381. }
  382. }
  383. })
  384. }
  385. return emptyReports
  386. }
  387. /**
  388. * Make sure v8ProcessCov actually contains coverage information.
  389. *
  390. * @return {boolean} does it look like v8ProcessCov?
  391. * @private
  392. */
  393. _isCoverageObject (maybeV8ProcessCov) {
  394. return maybeV8ProcessCov && Array.isArray(maybeV8ProcessCov.result)
  395. }
  396. /**
  397. * Returns the list of V8 process coverages generated by Node.
  398. *
  399. * @return {ProcessCov[]} Process coverages generated by Node.
  400. * @private
  401. */
  402. _loadReports () {
  403. const reports = []
  404. for (const file of readdirSync(this.tempDirectory)) {
  405. try {
  406. reports.push(JSON.parse(readFileSync(
  407. resolve(this.tempDirectory, file),
  408. 'utf8'
  409. )))
  410. } catch (err) {
  411. debuglog(`${err.stack}`)
  412. }
  413. }
  414. return reports
  415. }
  416. /**
  417. * Normalizes a process coverage.
  418. *
  419. * This function replaces file URLs (`url` property) by their corresponding
  420. * system-dependent path and applies the current inclusion rules to filter out
  421. * the excluded script coverages.
  422. *
  423. * The result is a copy of the input, with script coverages filtered based
  424. * on their `url` and the current inclusion rules.
  425. * There is no deep cloning.
  426. *
  427. * @param v8ProcessCov V8 process coverage to normalize.
  428. * @param fileIndex a Set<string> of paths discovered in coverage
  429. * @return {v8ProcessCov} Normalized V8 process coverage.
  430. * @private
  431. */
  432. _normalizeProcessCov (v8ProcessCov, fileIndex) {
  433. const result = []
  434. for (const v8ScriptCov of v8ProcessCov.result) {
  435. // https://github.com/nodejs/node/pull/35498 updates Node.js'
  436. // builtin module filenames:
  437. if (/^node:/.test(v8ScriptCov.url)) {
  438. v8ScriptCov.url = `${v8ScriptCov.url.replace(/^node:/, '')}.js`
  439. }
  440. if (/^file:\/\//.test(v8ScriptCov.url)) {
  441. try {
  442. v8ScriptCov.url = fileURLToPath(v8ScriptCov.url)
  443. fileIndex.add(v8ScriptCov.url)
  444. } catch (err) {
  445. debuglog(`${err.stack}`)
  446. continue
  447. }
  448. }
  449. if ((!this.omitRelative || isAbsolute(v8ScriptCov.url))) {
  450. if (this.excludeAfterRemap || this._shouldInstrument(v8ScriptCov.url)) {
  451. result.push(v8ScriptCov)
  452. }
  453. }
  454. }
  455. return { result }
  456. }
  457. /**
  458. * Normalizes a V8 source map cache.
  459. *
  460. * This function normalizes file URLs to a system-independent format.
  461. *
  462. * @param v8SourceMapCache V8 source map cache to normalize.
  463. * @return {v8SourceMapCache} Normalized V8 source map cache.
  464. * @private
  465. */
  466. _normalizeSourceMapCache (v8SourceMapCache) {
  467. const cache = {}
  468. for (const fileURL of Object.keys(v8SourceMapCache)) {
  469. cache[pathToFileURL(fileURLToPath(fileURL)).href] = v8SourceMapCache[fileURL]
  470. }
  471. return cache
  472. }
  473. /**
  474. * this.exclude.shouldInstrument with cache
  475. *
  476. * @private
  477. * @return {boolean}
  478. */
  479. _shouldInstrument (filename) {
  480. const cacheResult = this.shouldInstrumentCache.get(filename)
  481. if (cacheResult !== undefined) {
  482. return cacheResult
  483. }
  484. const result = this.exclude.shouldInstrument(filename)
  485. this.shouldInstrumentCache.set(filename, result)
  486. return result
  487. }
  488. }
  489. module.exports = function (opts) {
  490. return new Report(opts)
  491. }