|
|
const assert = require('assert') const convertSourceMap = require('convert-source-map') const { dirname, isAbsolute, join, resolve } = require('path') const CovBranch = require('./branch') const CovFunction = require('./function') const CovSource = require('./source') const compatError = Error(`requires Node.js ${require('../package.json').engines.node}`) let readFile = () => { throw compatError } try { readFile = require('fs').promises.readFile } catch (_err) { // most likely we're on an older version of Node.js.
} const { SourceMapConsumer } = require('source-map') const isOlderNode10 = /^v10\.(([0-9]\.)|(1[0-5]\.))/u.test(process.version) const isNode8 = /^v8\./.test(process.version)
// Injected when Node.js is loading script into isolate pre Node 10.16.x.
// see: https://github.com/nodejs/node/pull/21573.
const cjsWrapperLength = isOlderNode10 ? require('module').wrapper[0].length : 0
module.exports = class V8ToIstanbul { constructor (scriptPath, wrapperLength, sources, excludePath) { assert(typeof scriptPath === 'string', 'scriptPath must be a string') assert(!isNode8, 'This module does not support node 8 or lower, please upgrade to node 10') this.path = parsePath(scriptPath) this.wrapperLength = wrapperLength === undefined ? cjsWrapperLength : wrapperLength this.excludePath = excludePath || (() => false) this.sources = sources || {} this.generatedLines = [] this.branches = {} this.functions = {} this.covSources = [] this.rawSourceMap = undefined this.sourceMap = undefined this.sourceTranspiled = undefined // Indicate that this report was generated with placeholder data from
// running --all:
this.all = false }
async load () { const rawSource = this.sources.source || await readFile(this.path, 'utf8') this.rawSourceMap = this.sources.sourceMap || // if we find a source-map (either inline, or a .map file) we load
// both the transpiled and original source, both of which are used during
// the backflips we perform to remap absolute to relative positions.
convertSourceMap.fromSource(rawSource) || convertSourceMap.fromMapFileSource(rawSource, dirname(this.path))
if (this.rawSourceMap) { if (this.rawSourceMap.sourcemap.sources.length > 1) { this.sourceMap = await new SourceMapConsumer(this.rawSourceMap.sourcemap) this.covSources = this.sourceMap.sourcesContent.map((rawSource, i) => ({ source: new CovSource(rawSource, this.wrapperLength), path: this.sourceMap.sources[i] })) this.sourceTranspiled = new CovSource(rawSource, this.wrapperLength) } else { const candidatePath = this.rawSourceMap.sourcemap.sources.length >= 1 ? this.rawSourceMap.sourcemap.sources[0] : this.rawSourceMap.sourcemap.file this.path = this._resolveSource(this.rawSourceMap, candidatePath || this.path) this.sourceMap = await new SourceMapConsumer(this.rawSourceMap.sourcemap)
let originalRawSource if (this.sources.sourceMap && this.sources.sourceMap.sourcemap && this.sources.sourceMap.sourcemap.sourcesContent && this.sources.sourceMap.sourcemap.sourcesContent.length === 1) { // If the sourcesContent field has been provided, return it rather than attempting
// to load the original source from disk.
// TODO: investigate whether there's ever a case where we hit this logic with 1:many sources.
originalRawSource = this.sources.sourceMap.sourcemap.sourcesContent[0] } else if (this.sources.originalSource) { // Original source may be populated on the sources object.
originalRawSource = this.sources.originalSource } else if (this.sourceMap.sourcesContent && this.sourceMap.sourcesContent[0]) { // perhaps we loaded sourcesContent was populated by an inline source map, or .map file?
// TODO: investigate whether there's ever a case where we hit this logic with 1:many sources.
originalRawSource = this.sourceMap.sourcesContent[0] } else { // We fallback to reading the original source from disk.
originalRawSource = await readFile(this.path, 'utf8') } this.covSources = [{ source: new CovSource(originalRawSource, this.wrapperLength), path: this.path }] this.sourceTranspiled = new CovSource(rawSource, this.wrapperLength) } } else { this.covSources = [{ source: new CovSource(rawSource, this.wrapperLength), path: this.path }] } }
_resolveSource (rawSourceMap, sourcePath) { sourcePath = sourcePath.replace(/(^file:\/\/)|(^webpack:\/\/)/, '') const sourceRoot = rawSourceMap.sourcemap.sourceRoot ? rawSourceMap.sourcemap.sourceRoot.replace('file://', '') : '' const candidatePath = join(sourceRoot, sourcePath)
if (isAbsolute(candidatePath)) { return candidatePath } else { return resolve(dirname(this.path), candidatePath) } }
applyCoverage (blocks) { blocks.forEach(block => { block.ranges.forEach((range, i) => { const { startCol, endCol, path, covSource } = this._maybeRemapStartColEndCol(range) if (this.excludePath(path)) { return } const lines = covSource.lines.filter(line => { // Upstream tooling can provide a block with the functionName
// (empty-report), this will result in a report that has all
// lines zeroed out.
if (block.functionName === '(empty-report)') { line.count = 0 this.all = true return true }
return startCol < line.endCol && endCol >= line.startCol }) const startLineInstance = lines[0] const endLineInstance = lines[lines.length - 1]
if (block.isBlockCoverage && lines.length) { this.branches[path] = this.branches[path] || [] // record branches.
this.branches[path].push(new CovBranch( startLineInstance.line, startCol - startLineInstance.startCol, endLineInstance.line, endCol - endLineInstance.startCol, range.count ))
// if block-level granularity is enabled, we we still create a single
// CovFunction tracking object for each set of ranges.
if (block.functionName && i === 0) { this.functions[path] = this.functions[path] || [] this.functions[path].push(new CovFunction( block.functionName, startLineInstance.line, startCol - startLineInstance.startCol, endLineInstance.line, endCol - endLineInstance.startCol, range.count )) } } else if (block.functionName && lines.length) { this.functions[path] = this.functions[path] || [] // record functions.
this.functions[path].push(new CovFunction( block.functionName, startLineInstance.line, startCol - startLineInstance.startCol, endLineInstance.line, endCol - endLineInstance.startCol, range.count )) }
// record the lines (we record these as statements, such that we're
// compatible with Istanbul 2.0).
lines.forEach(line => { // make sure branch spans entire line; don't record 'goodbye'
// branch in `const foo = true ? 'hello' : 'goodbye'` as a
// 0 for line coverage.
//
// All lines start out with coverage of 1, and are later set to 0
// if they are not invoked; line.ignore prevents a line from being
// set to 0, and is set if the special comment /* c8 ignore next */
// is used.
if (startCol <= line.startCol && endCol >= line.endCol && !line.ignore) { line.count = range.count } }) }) }) }
_maybeRemapStartColEndCol (range) { let covSource = this.covSources[0].source let startCol = Math.max(0, range.startOffset - covSource.wrapperLength) let endCol = Math.min(covSource.eof, range.endOffset - covSource.wrapperLength) let path = this.path
if (this.sourceMap) { startCol = Math.max(0, range.startOffset - this.sourceTranspiled.wrapperLength) endCol = Math.min(this.sourceTranspiled.eof, range.endOffset - this.sourceTranspiled.wrapperLength)
const { startLine, relStartCol, endLine, relEndCol, source } = this.sourceTranspiled.offsetToOriginalRelative( this.sourceMap, startCol, endCol )
const matchingSource = this.covSources.find(covSource => covSource.path === source) covSource = matchingSource ? matchingSource.source : this.covSources[0].source path = matchingSource ? matchingSource.path : this.covSources[0].path
// next we convert these relative positions back to absolute positions
// in the original source (which is the format expected in the next step).
startCol = covSource.relativeToOffset(startLine, relStartCol) endCol = covSource.relativeToOffset(endLine, relEndCol) }
return { path, covSource, startCol, endCol } }
getInnerIstanbul (source, path) { // We apply the "Resolving Sources" logic (as defined in
// sourcemaps.info/spec.html) as a final step for 1:many source maps.
// for 1:1 source maps, the resolve logic is applied while loading.
//
// TODO: could we move the resolving logic for 1:1 source maps to the final
// step as well? currently this breaks some tests in c8.
let resolvedPath = path if (this.rawSourceMap && this.rawSourceMap.sourcemap.sources.length > 1) { resolvedPath = this._resolveSource(this.rawSourceMap, path) }
if (this.excludePath(resolvedPath)) { return }
return { [resolvedPath]: { path: resolvedPath, all: this.all, ...this._statementsToIstanbul(source, path), ...this._branchesToIstanbul(source, path), ...this._functionsToIstanbul(source, path) } } }
toIstanbul () { return this.covSources.reduce((istanbulOuter, { source, path }) => Object.assign(istanbulOuter, this.getInnerIstanbul(source, path)), {}) }
_statementsToIstanbul (source, path) { const statements = { statementMap: {}, s: {} } source.lines.forEach((line, index) => { statements.statementMap[`${index}`] = line.toIstanbul() statements.s[`${index}`] = line.count }) return statements }
_branchesToIstanbul (source, path) { const branches = { branchMap: {}, b: {} } this.branches[path] = this.branches[path] || [] this.branches[path].forEach((branch, index) => { const ignore = source.lines[branch.startLine - 1].ignore branches.branchMap[`${index}`] = branch.toIstanbul() branches.b[`${index}`] = [ignore ? 1 : branch.count] }) return branches }
_functionsToIstanbul (source, path) { const functions = { fnMap: {}, f: {} } this.functions[path] = this.functions[path] || [] this.functions[path].forEach((fn, index) => { const ignore = source.lines[fn.startLine - 1].ignore functions.fnMap[`${index}`] = fn.toIstanbul() functions.f[`${index}`] = ignore ? 1 : fn.count }) return functions } }
function parsePath (scriptPath) { return scriptPath.replace('file://', '') }
|