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