v8-to-istanbul.js 12 KB

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