v8-to-istanbul.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. const assert = require('assert')
  2. const convertSourceMap = require('convert-source-map')
  3. const util = require('util')
  4. const debuglog = util.debuglog('c8')
  5. const { dirname, isAbsolute, join, resolve } = require('path')
  6. const { fileURLToPath } = require('url')
  7. const CovBranch = require('./branch')
  8. const CovFunction = require('./function')
  9. const CovSource = require('./source')
  10. const { sliceRange } = require('./range')
  11. const compatError = Error(`requires Node.js ${require('../package.json').engines.node}`)
  12. const { readFileSync } = require('fs')
  13. let readFile = () => { throw compatError }
  14. try {
  15. readFile = require('fs').promises.readFile
  16. } catch (_err) {
  17. // most likely we're on an older version of Node.js.
  18. }
  19. const { TraceMap } = require('@jridgewell/trace-mapping')
  20. const isOlderNode10 = /^v10\.(([0-9]\.)|(1[0-5]\.))/u.test(process.version)
  21. const isNode8 = /^v8\./.test(process.version)
  22. // Injected when Node.js is loading script into isolate pre Node 10.16.x.
  23. // see: https://github.com/nodejs/node/pull/21573.
  24. const cjsWrapperLength = isOlderNode10 ? require('module').wrapper[0].length : 0
  25. module.exports = class V8ToIstanbul {
  26. constructor (scriptPath, wrapperLength, sources, excludePath) {
  27. assert(typeof scriptPath === 'string', 'scriptPath must be a string')
  28. assert(!isNode8, 'This module does not support node 8 or lower, please upgrade to node 10')
  29. this.path = parsePath(scriptPath)
  30. this.wrapperLength = wrapperLength === undefined ? cjsWrapperLength : wrapperLength
  31. this.excludePath = excludePath || (() => false)
  32. this.sources = sources || {}
  33. this.generatedLines = []
  34. this.branches = {}
  35. this.functions = {}
  36. this.covSources = []
  37. this.rawSourceMap = undefined
  38. this.sourceMap = undefined
  39. this.sourceTranspiled = undefined
  40. // Indicate that this report was generated with placeholder data from
  41. // running --all:
  42. this.all = false
  43. }
  44. async load () {
  45. const rawSource = this.sources.source || await readFile(this.path, 'utf8')
  46. this.rawSourceMap = this.sources.sourceMap ||
  47. // if we find a source-map (either inline, or a .map file) we load
  48. // both the transpiled and original source, both of which are used during
  49. // the backflips we perform to remap absolute to relative positions.
  50. convertSourceMap.fromSource(rawSource) || convertSourceMap.fromMapFileSource(rawSource, this._readFileFromDir.bind(this))
  51. if (this.rawSourceMap) {
  52. if (this.rawSourceMap.sourcemap.sources.length > 1) {
  53. this.sourceMap = new TraceMap(this.rawSourceMap.sourcemap)
  54. if (!this.sourceMap.sourcesContent) {
  55. this.sourceMap.sourcesContent = await this.sourcesContentFromSources()
  56. }
  57. this.covSources = this.sourceMap.sourcesContent.map((rawSource, i) => ({ source: new CovSource(rawSource, this.wrapperLength), path: this.sourceMap.sources[i] }))
  58. this.sourceTranspiled = new CovSource(rawSource, this.wrapperLength)
  59. } else {
  60. const candidatePath = this.rawSourceMap.sourcemap.sources.length >= 1 ? this.rawSourceMap.sourcemap.sources[0] : this.rawSourceMap.sourcemap.file
  61. this.path = this._resolveSource(this.rawSourceMap, candidatePath || this.path)
  62. this.sourceMap = new TraceMap(this.rawSourceMap.sourcemap)
  63. let originalRawSource
  64. if (this.sources.sourceMap && this.sources.sourceMap.sourcemap && this.sources.sourceMap.sourcemap.sourcesContent && this.sources.sourceMap.sourcemap.sourcesContent.length === 1) {
  65. // If the sourcesContent field has been provided, return it rather than attempting
  66. // to load the original source from disk.
  67. // TODO: investigate whether there's ever a case where we hit this logic with 1:many sources.
  68. originalRawSource = this.sources.sourceMap.sourcemap.sourcesContent[0]
  69. } else if (this.sources.originalSource) {
  70. // Original source may be populated on the sources object.
  71. originalRawSource = this.sources.originalSource
  72. } else if (this.sourceMap.sourcesContent && this.sourceMap.sourcesContent[0]) {
  73. // perhaps we loaded sourcesContent was populated by an inline source map, or .map file?
  74. // TODO: investigate whether there's ever a case where we hit this logic with 1:many sources.
  75. originalRawSource = this.sourceMap.sourcesContent[0]
  76. } else {
  77. // We fallback to reading the original source from disk.
  78. originalRawSource = await readFile(this.path, 'utf8')
  79. }
  80. this.covSources = [{ source: new CovSource(originalRawSource, this.wrapperLength), path: this.path }]
  81. this.sourceTranspiled = new CovSource(rawSource, this.wrapperLength)
  82. }
  83. } else {
  84. this.covSources = [{ source: new CovSource(rawSource, this.wrapperLength), path: this.path }]
  85. }
  86. }
  87. _readFileFromDir (filename) {
  88. return readFileSync(resolve(dirname(this.path), filename), 'utf-8')
  89. }
  90. async sourcesContentFromSources () {
  91. const fileList = this.sourceMap.sources.map(relativePath => {
  92. const realPath = this._resolveSource(this.rawSourceMap, relativePath)
  93. return readFile(realPath, 'utf-8')
  94. .then(result => result)
  95. .catch(err => {
  96. debuglog(`failed to load ${realPath}: ${err.message}`)
  97. })
  98. })
  99. return await Promise.all(fileList)
  100. }
  101. destroy () {
  102. // no longer necessary, but preserved for backwards compatibility.
  103. }
  104. _resolveSource (rawSourceMap, sourcePath) {
  105. if (sourcePath.startsWith('file://')) {
  106. return fileURLToPath(sourcePath)
  107. }
  108. sourcePath = sourcePath.replace(/^webpack:\/\//, '')
  109. const sourceRoot = rawSourceMap.sourcemap.sourceRoot ? rawSourceMap.sourcemap.sourceRoot.replace('file://', '') : ''
  110. const candidatePath = join(sourceRoot, sourcePath)
  111. if (isAbsolute(candidatePath)) {
  112. return candidatePath
  113. } else {
  114. return resolve(dirname(this.path), candidatePath)
  115. }
  116. }
  117. applyCoverage (blocks) {
  118. blocks.forEach(block => {
  119. block.ranges.forEach((range, i) => {
  120. const isEmptyCoverage = block.functionName === '(empty-report)'
  121. const { startCol, endCol, path, covSource } = this._maybeRemapStartColEndCol(range, isEmptyCoverage)
  122. if (this.excludePath(path)) {
  123. return
  124. }
  125. let lines
  126. if (isEmptyCoverage) {
  127. // (empty-report), this will result in a report that has all lines zeroed out.
  128. lines = covSource.lines.filter((line) => {
  129. line.count = 0
  130. return true
  131. })
  132. this.all = lines.length > 0
  133. } else {
  134. lines = sliceRange(covSource.lines, startCol, endCol)
  135. }
  136. if (!lines.length) {
  137. return
  138. }
  139. const startLineInstance = lines[0]
  140. const endLineInstance = lines[lines.length - 1]
  141. if (block.isBlockCoverage) {
  142. this.branches[path] = this.branches[path] || []
  143. // record branches.
  144. this.branches[path].push(new CovBranch(
  145. startLineInstance.line,
  146. startCol - startLineInstance.startCol,
  147. endLineInstance.line,
  148. endCol - endLineInstance.startCol,
  149. range.count
  150. ))
  151. // if block-level granularity is enabled, we still create a single
  152. // CovFunction tracking object for each set of ranges.
  153. if (block.functionName && i === 0) {
  154. this.functions[path] = this.functions[path] || []
  155. this.functions[path].push(new CovFunction(
  156. block.functionName,
  157. startLineInstance.line,
  158. startCol - startLineInstance.startCol,
  159. endLineInstance.line,
  160. endCol - endLineInstance.startCol,
  161. range.count
  162. ))
  163. }
  164. } else if (block.functionName) {
  165. this.functions[path] = this.functions[path] || []
  166. // record functions.
  167. this.functions[path].push(new CovFunction(
  168. block.functionName,
  169. startLineInstance.line,
  170. startCol - startLineInstance.startCol,
  171. endLineInstance.line,
  172. endCol - endLineInstance.startCol,
  173. range.count
  174. ))
  175. }
  176. // record the lines (we record these as statements, such that we're
  177. // compatible with Istanbul 2.0).
  178. lines.forEach(line => {
  179. // make sure branch spans entire line; don't record 'goodbye'
  180. // branch in `const foo = true ? 'hello' : 'goodbye'` as a
  181. // 0 for line coverage.
  182. //
  183. // All lines start out with coverage of 1, and are later set to 0
  184. // if they are not invoked; line.ignore prevents a line from being
  185. // set to 0, and is set if the special comment /* c8 ignore next */
  186. // is used.
  187. if (startCol <= line.startCol && endCol >= line.endCol && !line.ignore) {
  188. line.count = range.count
  189. }
  190. })
  191. })
  192. })
  193. }
  194. _maybeRemapStartColEndCol (range, isEmptyCoverage) {
  195. let covSource = this.covSources[0].source
  196. const covSourceWrapperLength = isEmptyCoverage ? 0 : covSource.wrapperLength
  197. let startCol = Math.max(0, range.startOffset - covSourceWrapperLength)
  198. let endCol = Math.min(covSource.eof, range.endOffset - covSourceWrapperLength)
  199. let path = this.path
  200. if (this.sourceMap) {
  201. const sourceTranspiledWrapperLength = isEmptyCoverage ? 0 : this.sourceTranspiled.wrapperLength
  202. startCol = Math.max(0, range.startOffset - sourceTranspiledWrapperLength)
  203. endCol = Math.min(this.sourceTranspiled.eof, range.endOffset - sourceTranspiledWrapperLength)
  204. const { startLine, relStartCol, endLine, relEndCol, source } = this.sourceTranspiled.offsetToOriginalRelative(
  205. this.sourceMap,
  206. startCol,
  207. endCol
  208. )
  209. const matchingSource = this.covSources.find(covSource => covSource.path === source)
  210. covSource = matchingSource ? matchingSource.source : this.covSources[0].source
  211. path = matchingSource ? matchingSource.path : this.covSources[0].path
  212. // next we convert these relative positions back to absolute positions
  213. // in the original source (which is the format expected in the next step).
  214. startCol = covSource.relativeToOffset(startLine, relStartCol)
  215. endCol = covSource.relativeToOffset(endLine, relEndCol)
  216. }
  217. return {
  218. path,
  219. covSource,
  220. startCol,
  221. endCol
  222. }
  223. }
  224. getInnerIstanbul (source, path) {
  225. // We apply the "Resolving Sources" logic (as defined in
  226. // sourcemaps.info/spec.html) as a final step for 1:many source maps.
  227. // for 1:1 source maps, the resolve logic is applied while loading.
  228. //
  229. // TODO: could we move the resolving logic for 1:1 source maps to the final
  230. // step as well? currently this breaks some tests in c8.
  231. let resolvedPath = path
  232. if (this.rawSourceMap && this.rawSourceMap.sourcemap.sources.length > 1) {
  233. resolvedPath = this._resolveSource(this.rawSourceMap, path)
  234. }
  235. if (this.excludePath(resolvedPath)) {
  236. return
  237. }
  238. return {
  239. [resolvedPath]: {
  240. path: resolvedPath,
  241. all: this.all,
  242. ...this._statementsToIstanbul(source, path),
  243. ...this._branchesToIstanbul(source, path),
  244. ...this._functionsToIstanbul(source, path)
  245. }
  246. }
  247. }
  248. toIstanbul () {
  249. return this.covSources.reduce((istanbulOuter, { source, path }) => Object.assign(istanbulOuter, this.getInnerIstanbul(source, path)), {})
  250. }
  251. _statementsToIstanbul (source, path) {
  252. const statements = {
  253. statementMap: {},
  254. s: {}
  255. }
  256. source.lines.forEach((line, index) => {
  257. statements.statementMap[`${index}`] = line.toIstanbul()
  258. statements.s[`${index}`] = line.ignore ? 1 : line.count
  259. })
  260. return statements
  261. }
  262. _branchesToIstanbul (source, path) {
  263. const branches = {
  264. branchMap: {},
  265. b: {}
  266. }
  267. this.branches[path] = this.branches[path] || []
  268. this.branches[path].forEach((branch, index) => {
  269. const srcLine = source.lines[branch.startLine - 1]
  270. const ignore = srcLine === undefined ? true : srcLine.ignore
  271. branches.branchMap[`${index}`] = branch.toIstanbul()
  272. branches.b[`${index}`] = [ignore ? 1 : branch.count]
  273. })
  274. return branches
  275. }
  276. _functionsToIstanbul (source, path) {
  277. const functions = {
  278. fnMap: {},
  279. f: {}
  280. }
  281. this.functions[path] = this.functions[path] || []
  282. this.functions[path].forEach((fn, index) => {
  283. const srcLine = source.lines[fn.startLine - 1]
  284. const ignore = srcLine === undefined ? true : srcLine.ignore
  285. functions.fnMap[`${index}`] = fn.toIstanbul()
  286. functions.f[`${index}`] = ignore ? 1 : fn.count
  287. })
  288. return functions
  289. }
  290. }
  291. function parsePath (scriptPath) {
  292. return scriptPath.startsWith('file://') ? fileURLToPath(scriptPath) : scriptPath
  293. }