report.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  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 isCjsEsmBridgeCov = require('./is-cjs-esm-bridge')
  18. const util = require('util')
  19. const debuglog = util.debuglog('c8')
  20. class Report {
  21. constructor ({
  22. exclude,
  23. extension,
  24. excludeAfterRemap,
  25. include,
  26. reporter,
  27. reporterOptions,
  28. reportsDirectory,
  29. tempDirectory,
  30. watermarks,
  31. omitRelative,
  32. wrapperLength,
  33. resolve: resolvePaths,
  34. all,
  35. src,
  36. allowExternal = false,
  37. skipFull,
  38. excludeNodeModules,
  39. mergeAsync
  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. }
  64. _getSrc (src) {
  65. if (typeof src === 'string') {
  66. return [src]
  67. } else if (Array.isArray(src)) {
  68. return src
  69. } else {
  70. return [process.cwd()]
  71. }
  72. }
  73. async run () {
  74. const context = libReport.createContext({
  75. dir: this.reportsDirectory,
  76. watermarks: this.watermarks,
  77. coverageMap: await this.getCoverageMapFromAllCoverageFiles()
  78. })
  79. for (const _reporter of this.reporter) {
  80. reports.create(_reporter, {
  81. skipEmpty: false,
  82. skipFull: this.skipFull,
  83. maxCols: process.stdout.columns || 100,
  84. ...this.reporterOptions[_reporter]
  85. }).execute(context)
  86. }
  87. }
  88. async getCoverageMapFromAllCoverageFiles () {
  89. // the merge process can be very expensive, and it's often the case that
  90. // check-coverage is called immediately after a report. We memoize the
  91. // result from getCoverageMapFromAllCoverageFiles() to address this
  92. // use-case.
  93. if (this._allCoverageFiles) return this._allCoverageFiles
  94. const map = libCoverage.createCoverageMap()
  95. let v8ProcessCov
  96. if (this.mergeAsync) {
  97. v8ProcessCov = await this._getMergedProcessCovAsync()
  98. } else {
  99. v8ProcessCov = this._getMergedProcessCov()
  100. }
  101. const resultCountPerPath = new Map()
  102. const possibleCjsEsmBridges = new Map()
  103. for (const v8ScriptCov of v8ProcessCov.result) {
  104. try {
  105. const sources = this._getSourceMap(v8ScriptCov)
  106. const path = resolve(this.resolve, v8ScriptCov.url)
  107. const converter = v8toIstanbul(path, this.wrapperLength, sources, (path) => {
  108. if (this.excludeAfterRemap) {
  109. return !this._shouldInstrument(path)
  110. }
  111. })
  112. await converter.load()
  113. if (resultCountPerPath.has(path)) {
  114. resultCountPerPath.set(path, resultCountPerPath.get(path) + 1)
  115. } else {
  116. resultCountPerPath.set(path, 0)
  117. }
  118. if (isCjsEsmBridgeCov(v8ScriptCov)) {
  119. possibleCjsEsmBridges.set(converter, {
  120. path,
  121. functions: v8ScriptCov.functions
  122. })
  123. } else {
  124. converter.applyCoverage(v8ScriptCov.functions)
  125. map.merge(converter.toIstanbul())
  126. }
  127. } catch (err) {
  128. debuglog(`file: ${v8ScriptCov.url} error: ${err.stack}`)
  129. }
  130. }
  131. for (const [converter, { path, functions }] of possibleCjsEsmBridges) {
  132. if (resultCountPerPath.get(path) <= 1) {
  133. converter.applyCoverage(functions)
  134. map.merge(converter.toIstanbul())
  135. }
  136. }
  137. this._allCoverageFiles = map
  138. return this._allCoverageFiles
  139. }
  140. /**
  141. * Returns source-map and fake source file, if cached during Node.js'
  142. * execution. This is used to support tools like ts-node, which transpile
  143. * using runtime hooks.
  144. *
  145. * Note: requires Node.js 13+
  146. *
  147. * @return {Object} sourceMap and fake source file (created from line #s).
  148. * @private
  149. */
  150. _getSourceMap (v8ScriptCov) {
  151. const sources = {}
  152. const sourceMapAndLineLengths = this.sourceMapCache[pathToFileURL(v8ScriptCov.url).href]
  153. if (sourceMapAndLineLengths) {
  154. // See: https://github.com/nodejs/node/pull/34305
  155. if (!sourceMapAndLineLengths.data) return
  156. sources.sourceMap = {
  157. sourcemap: sourceMapAndLineLengths.data
  158. }
  159. if (sourceMapAndLineLengths.lineLengths) {
  160. let source = ''
  161. sourceMapAndLineLengths.lineLengths.forEach(length => {
  162. source += `${''.padEnd(length, '.')}\n`
  163. })
  164. sources.source = source
  165. }
  166. }
  167. return sources
  168. }
  169. /**
  170. * Returns the merged V8 process coverage.
  171. *
  172. * The result is computed from the individual process coverages generated
  173. * by Node. It represents the sum of their counts.
  174. *
  175. * @return {ProcessCov} Merged V8 process coverage.
  176. * @private
  177. */
  178. _getMergedProcessCov () {
  179. const { mergeProcessCovs } = require('@bcoe/v8-coverage')
  180. const v8ProcessCovs = []
  181. const fileIndex = new Set() // Set<string>
  182. for (const v8ProcessCov of this._loadReports()) {
  183. if (this._isCoverageObject(v8ProcessCov)) {
  184. if (v8ProcessCov['source-map-cache']) {
  185. Object.assign(this.sourceMapCache, this._normalizeSourceMapCache(v8ProcessCov['source-map-cache']))
  186. }
  187. v8ProcessCovs.push(this._normalizeProcessCov(v8ProcessCov, fileIndex))
  188. }
  189. }
  190. if (this.all) {
  191. const emptyReports = this._includeUncoveredFiles(fileIndex)
  192. v8ProcessCovs.unshift({
  193. result: emptyReports
  194. })
  195. }
  196. return mergeProcessCovs(v8ProcessCovs)
  197. }
  198. /**
  199. * Returns the merged V8 process coverage.
  200. *
  201. * It asynchronously and incrementally reads and merges individual process coverages
  202. * generated by Node. This can be used via the `--merge-async` CLI arg. It's intended
  203. * to be used across a large multi-process test run.
  204. *
  205. * @return {ProcessCov} Merged V8 process coverage.
  206. * @private
  207. */
  208. async _getMergedProcessCovAsync () {
  209. const { mergeProcessCovs } = require('@bcoe/v8-coverage')
  210. const fileIndex = new Set() // Set<string>
  211. let mergedCov = null
  212. for (const file of readdirSync(this.tempDirectory)) {
  213. try {
  214. const rawFile = await readFile(
  215. resolve(this.tempDirectory, file),
  216. 'utf8'
  217. )
  218. let report = JSON.parse(rawFile)
  219. if (this._isCoverageObject(report)) {
  220. if (report['source-map-cache']) {
  221. Object.assign(this.sourceMapCache, this._normalizeSourceMapCache(report['source-map-cache']))
  222. }
  223. report = this._normalizeProcessCov(report, fileIndex)
  224. if (mergedCov) {
  225. mergedCov = mergeProcessCovs([mergedCov, report])
  226. } else {
  227. mergedCov = mergeProcessCovs([report])
  228. }
  229. }
  230. } catch (err) {
  231. debuglog(`${err.stack}`)
  232. }
  233. }
  234. if (this.all) {
  235. const emptyReports = this._includeUncoveredFiles(fileIndex)
  236. const emptyReport = {
  237. result: emptyReports
  238. }
  239. mergedCov = mergeProcessCovs([emptyReport, mergedCov])
  240. }
  241. return mergedCov
  242. }
  243. /**
  244. * Adds empty coverage reports to account for uncovered/untested code.
  245. * This is only done when the `--all` flag is present.
  246. *
  247. * @param {Set} fileIndex list of files that have coverage
  248. * @returns {Array} list of empty coverage reports
  249. */
  250. _includeUncoveredFiles (fileIndex) {
  251. const emptyReports = []
  252. const workingDirs = this.src
  253. const { extension } = this.exclude
  254. for (const workingDir of workingDirs) {
  255. this.exclude.globSync(workingDir).forEach((f) => {
  256. const fullPath = resolve(workingDir, f)
  257. if (!fileIndex.has(fullPath)) {
  258. const ext = extname(fullPath)
  259. if (extension.includes(ext)) {
  260. const stat = statSync(fullPath)
  261. const sourceMap = getSourceMapFromFile(fullPath)
  262. if (sourceMap) {
  263. this.sourceMapCache[pathToFileURL(fullPath)] = { data: sourceMap }
  264. }
  265. emptyReports.push({
  266. scriptId: 0,
  267. url: resolve(fullPath),
  268. functions: [{
  269. functionName: '(empty-report)',
  270. ranges: [{
  271. startOffset: 0,
  272. endOffset: stat.size,
  273. count: 0
  274. }],
  275. isBlockCoverage: true
  276. }]
  277. })
  278. }
  279. }
  280. })
  281. }
  282. return emptyReports
  283. }
  284. /**
  285. * Make sure v8ProcessCov actually contains coverage information.
  286. *
  287. * @return {boolean} does it look like v8ProcessCov?
  288. * @private
  289. */
  290. _isCoverageObject (maybeV8ProcessCov) {
  291. return maybeV8ProcessCov && Array.isArray(maybeV8ProcessCov.result)
  292. }
  293. /**
  294. * Returns the list of V8 process coverages generated by Node.
  295. *
  296. * @return {ProcessCov[]} Process coverages generated by Node.
  297. * @private
  298. */
  299. _loadReports () {
  300. const reports = []
  301. for (const file of readdirSync(this.tempDirectory)) {
  302. try {
  303. reports.push(JSON.parse(readFileSync(
  304. resolve(this.tempDirectory, file),
  305. 'utf8'
  306. )))
  307. } catch (err) {
  308. debuglog(`${err.stack}`)
  309. }
  310. }
  311. return reports
  312. }
  313. /**
  314. * Normalizes a process coverage.
  315. *
  316. * This function replaces file URLs (`url` property) by their corresponding
  317. * system-dependent path and applies the current inclusion rules to filter out
  318. * the excluded script coverages.
  319. *
  320. * The result is a copy of the input, with script coverages filtered based
  321. * on their `url` and the current inclusion rules.
  322. * There is no deep cloning.
  323. *
  324. * @param v8ProcessCov V8 process coverage to normalize.
  325. * @param fileIndex a Set<string> of paths discovered in coverage
  326. * @return {v8ProcessCov} Normalized V8 process coverage.
  327. * @private
  328. */
  329. _normalizeProcessCov (v8ProcessCov, fileIndex) {
  330. const result = []
  331. for (const v8ScriptCov of v8ProcessCov.result) {
  332. // https://github.com/nodejs/node/pull/35498 updates Node.js'
  333. // builtin module filenames:
  334. if (/^node:/.test(v8ScriptCov.url)) {
  335. v8ScriptCov.url = `${v8ScriptCov.url.replace(/^node:/, '')}.js`
  336. }
  337. if (/^file:\/\//.test(v8ScriptCov.url)) {
  338. try {
  339. v8ScriptCov.url = fileURLToPath(v8ScriptCov.url)
  340. fileIndex.add(v8ScriptCov.url)
  341. } catch (err) {
  342. debuglog(`${err.stack}`)
  343. continue
  344. }
  345. }
  346. if ((!this.omitRelative || isAbsolute(v8ScriptCov.url))) {
  347. if (this.excludeAfterRemap || this._shouldInstrument(v8ScriptCov.url)) {
  348. result.push(v8ScriptCov)
  349. }
  350. }
  351. }
  352. return { result }
  353. }
  354. /**
  355. * Normalizes a V8 source map cache.
  356. *
  357. * This function normalizes file URLs to a system-independent format.
  358. *
  359. * @param v8SourceMapCache V8 source map cache to normalize.
  360. * @return {v8SourceMapCache} Normalized V8 source map cache.
  361. * @private
  362. */
  363. _normalizeSourceMapCache (v8SourceMapCache) {
  364. const cache = {}
  365. for (const fileURL of Object.keys(v8SourceMapCache)) {
  366. cache[pathToFileURL(fileURLToPath(fileURL)).href] = v8SourceMapCache[fileURL]
  367. }
  368. return cache
  369. }
  370. /**
  371. * this.exclude.shouldInstrument with cache
  372. *
  373. * @private
  374. * @return {boolean}
  375. */
  376. _shouldInstrument (filename) {
  377. const cacheResult = this.shouldInstrumentCache.get(filename)
  378. if (cacheResult !== undefined) {
  379. return cacheResult
  380. }
  381. const result = this.exclude.shouldInstrument(filename)
  382. this.shouldInstrumentCache.set(filename, result)
  383. return result
  384. }
  385. }
  386. module.exports = function (opts) {
  387. return new Report(opts)
  388. }