v8-to-istanbul.js 11 KB

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