diff --git a/lib/line.js b/lib/line.js index 0fe1a608..b0877be4 100644 --- a/lib/line.js +++ b/lib/line.js @@ -1,5 +1,5 @@ module.exports = class CovLine { - constructor (line, startCol, lineStr) { + constructor(line, startCol, lineStr) { this.line = line // note that startCol and endCol are absolute positions // within a file, not relative to the line. @@ -13,13 +13,13 @@ module.exports = class CovLine { // we start with all lines having been executed, and work // backwards zeroing out lines based on V8 output. - this.count = 1 + // this.count = 1 // set by source.js during parsing, if /* c8 ignore next */ is found. this.ignore = false } - toIstanbul () { + toIstanbul() { return { start: { line: this.line, diff --git a/lib/source.js b/lib/source.js index f592f52e..e1e08d26 100644 --- a/lib/source.js +++ b/lib/source.js @@ -2,7 +2,7 @@ const CovLine = require('./line') const { GREATEST_LOWER_BOUND, LEAST_UPPER_BOUND } = require('source-map').SourceMapConsumer module.exports = class CovSource { - constructor (sourceRaw, wrapperLength) { + constructor(sourceRaw, wrapperLength) { sourceRaw = sourceRaw.trimEnd() this.lines = [] this.eof = sourceRaw.length @@ -11,7 +11,7 @@ module.exports = class CovSource { this._buildLines(sourceRaw) } - _buildLines (source) { + _buildLines(source) { let position = 0 let ignoreCount = 0 for (const [i, lineStr] of source.split(/(?<=\r?\n)/u).entries()) { @@ -27,7 +27,7 @@ module.exports = class CovSource { } } - _parseIgnoreNext (lineStr, line) { + _parseIgnoreNext(lineStr, line) { const testIgnoreNextLines = lineStr.match(/^\W*\/\* c8 ignore next (?[0-9]+)? *\*\/\W*$/) if (testIgnoreNextLines) { line.ignore = true @@ -47,53 +47,97 @@ module.exports = class CovSource { // given a start column and end column in absolute offsets within // a source file (0 - EOF), returns the relative line column positions. - offsetToOriginalRelative (sourceMap, startCol, endCol) { + offsetToOriginalRelative(sourceMap, startCol, endCol) { const lines = this.lines.filter((line, i) => { return startCol <= line.endCol && endCol >= line.startCol }) - if (!lines.length) return {} + if (!lines.length) { + console.log('unable to find lines?!') + return [] + } - const start = originalPositionTryBoth( + // this range covers multiple files, so we need to split it up + let thisStartCol = startCol; + let thisStartLine = 0; + let thisEndCol = startCol + 1; + let thisEndLine = 0; + let lastEndLine = thisEndLine; + if (lines[thisEndLine].endCol <= thisEndCol) { + thisEndLine++; + } + let lastEnd; + let thisStart = originalPositionTryBoth( sourceMap, lines[0].line, startCol - lines[0].startCol - ) - let end = originalEndPositionFor( - sourceMap, - lines[lines.length - 1].line, - endCol - lines[lines.length - 1].startCol - ) - - if (!(start && end)) { - return {} - } - - if (!(start.source && end.source)) { - return {} - } - - if (start.source !== end.source) { - return {} - } - - if (start.line === end.line && start.column === end.column) { - end = sourceMap.originalPositionFor({ - line: lines[lines.length - 1].line, - column: endCol - lines[lines.length - 1].startCol, - bias: LEAST_UPPER_BOUND - }) - end.column -= 1 + ); + let returnLocs = []; + while (thisEndCol <= endCol) { + const thisEnd = originalEndPositionFor( + sourceMap, + lines[thisEndLine].line, + thisEndCol - lines[thisEndLine].startCol + ); + + const isGoingForward = thisEnd && thisStart && thisEnd.line >= thisStart.line && (thisEnd.line > thisStart.line || thisEnd.column >= thisStart.column); + if (thisEnd && thisEnd.source === thisStart.source && thisEndCol !== endCol && isGoingForward) { + lastEnd = thisEnd; + } else { + // Give up if we werent previously valid to ignore non mapped blocks inside the region + if (lastEnd && thisStart.source) { + + if (thisStart.line === lastEnd.line && thisStart.column === lastEnd.column) { + const potentialLastEnd = sourceMap.originalPositionFor({ + line: lines[lastEndLine].line, + column: (thisEndCol - 1) - lines[lastEndLine].startCol, + bias: LEAST_UPPER_BOUND + }) + potentialLastEnd.column -= 1 + if (lastEnd.source === potentialLastEnd.source) { + lastEnd = potentialLastEnd + } + } + + // ignore 0 length maps + //if (thisStart.line !== lastEnd.line && thisStart.column !== lastEnd.column) { + if (thisStart.source.indexOf('App.tsx') >= 0) { + console.log(thisStartCol + '(' + startCol + ')', ':', thisEndCol + '(' + endCol + ')', + { l: lines[thisStartLine].line, c: thisStartCol - lines[thisStartLine].startCol }, + { l: lines[lastEndLine].line, c: (thisEndCol - 1) - lines[lastEndLine].startCol }, + '->', thisStart.source, { l: thisStart.line, c: thisStart.column }, { l: lastEnd.line, c: lastEnd.column }) + } + returnLocs.push({ + sourceFile: thisStart.source, + startLine: thisStart.line, + relStartCol: thisStart.column, + endLine: lastEnd.line, + relEndCol: lastEnd.column + }); + //} + //console.log('found range', returnLocs[returnLocs.length - 1]); + lastEnd = null; + } else { + //console.log('something broken', lastEnd, thisStart.source) + } + thisStartCol = thisEndCol; + thisStartLine = thisEndLine; + thisStart = originalPositionTryBoth( + sourceMap, + lines[thisEndLine].line, + Math.max(0, thisEndCol - lines[thisEndLine].startCol) + ); + } + thisEndCol++; + lastEndLine = thisEndLine; + if (lines[thisEndLine].endCol < thisEndCol) { + thisEndLine++; + } } - return { - startLine: start.line, - relStartCol: start.column, - endLine: end.line, - relEndCol: end.column - } + return returnLocs; } - relativeToOffset (line, relCol) { + relativeToOffset(line, relCol) { line = Math.max(line, 1) if (this.lines[line - 1] === undefined) return this.eof return Math.min(this.lines[line - 1].startCol + relCol, this.lines[line - 1].endCol) @@ -121,7 +165,7 @@ module.exports = class CovSource { * that generated range. * 3. Find the _end_ location of that original range. */ -function originalEndPositionFor (sourceMap, line, column) { +function originalEndPositionFor(sourceMap, line, column) { // Given the generated location, find the original location of the mapping // that corresponds to a range on the generated file that overlaps the // generated file end location. Note however that this position on its @@ -149,14 +193,14 @@ function originalEndPositionFor (sourceMap, line, column) { bias: LEAST_UPPER_BOUND }) if ( - // If this is null, it means that we've hit the end of the file, - // so we can use Infinity as the end column. + // If this is null, it means that we've hit the end of the file, + // so we can use Infinity as the end column. afterEndMapping.line === null || - // If these don't match, it means that the call to - // 'generatedPositionFor' didn't find any other original mappings on - // the line we gave, so consider the binding to extend to infinity. - sourceMap.originalPositionFor(afterEndMapping).line !== - beforeEndMapping.line + // If these don't match, it means that the call to + // 'generatedPositionFor' didn't find any other original mappings on + // the line we gave, so consider the binding to extend to infinity. + sourceMap.originalPositionFor(afterEndMapping).line !== + beforeEndMapping.line ) { return { source: beforeEndMapping.source, @@ -169,24 +213,29 @@ function originalEndPositionFor (sourceMap, line, column) { return sourceMap.originalPositionFor(afterEndMapping) } -function originalPositionTryBoth (sourceMap, line, column) { - const original = sourceMap.originalPositionFor({ +function originalPositionTryBoth(sourceMap, line, column) { + let original = sourceMap.originalPositionFor({ line, column, bias: GREATEST_LOWER_BOUND }) if (original.line === null) { - return sourceMap.originalPositionFor({ + original = sourceMap.originalPositionFor({ line, column, bias: LEAST_UPPER_BOUND }) - } else { - return original } + + if (original && original.source && original.source.indexOf('tsx') > 0) { + // console.log({ line, column }, 'became', original) + } + + return original + } -function getShebangLength (source) { +function getShebangLength(source) { if (source.indexOf('#!') === 0) { const match = source.match(/(?#!.*)/) if (match) { diff --git a/lib/v8-to-istanbul.js b/lib/v8-to-istanbul.js index 283f3d36..7e81dec1 100644 --- a/lib/v8-to-istanbul.js +++ b/lib/v8-to-istanbul.js @@ -20,21 +20,18 @@ const isNode8 = /^v8\./.test(process.version) const cjsWrapperLength = isOlderNode10 ? require('module').wrapper[0].length : 0 module.exports = class V8ToIstanbul { - constructor (scriptPath, wrapperLength, sources) { + constructor(scriptPath, wrapperLength, sources) { 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.sources = sources || {} - this.generatedLines = [] - this.branches = [] - this.functions = [] this.sourceMap = undefined - this.source = undefined this.sourceTranspiled = undefined + this.files = {}; } - async load () { + async load() { const rawSource = this.sources.source || await readFile(this.path, 'utf8') const rawSourceMap = this.sources.sourceMap || // if we find a source-map (either inline, or a .map file) we load @@ -43,80 +40,113 @@ module.exports = class V8ToIstanbul { convertSourceMap.fromSource(rawSource) || convertSourceMap.fromMapFileSource(rawSource, dirname(this.path)) if (rawSourceMap) { - if (rawSourceMap.sourcemap.sources.length > 1) { - console.warn('v8-to-istanbul: source-mappings from one to many files not yet supported') - this.source = new CovSource(rawSource, this.wrapperLength) - } else { - this._rewritePath(rawSourceMap) - this.sourceMap = await new SourceMapConsumer(rawSourceMap.sourcemap) + this.sourceMap = await new SourceMapConsumer(rawSourceMap.sourcemap) + const sourceRoot = rawSourceMap.sourcemap.sourceRoot; + + for (let i = 0; i < rawSourceMap.sourcemap.sources.length; i++) { + const originalPath = rawSourceMap.sourcemap.sources[i] + const resolvedPath = this._rewritePath(sourceRoot, originalPath); let originalRawSource // If the source map has inline source content then it should be here // so use this inline one instead of trying to read the file off disk // Not sure what it means to have an array of more than 1 here so just ignore it // since we wouldn't know how to handle it - if (this.sources.sourceMap && this.sources.sourceMap.sourcemap && this.sources.sourceMap.sourcemap.sourcesContent && this.sources.sourceMap.sourcemap.sourcesContent.length === 1) { - originalRawSource = this.sources.sourceMap.sourcemap.sourcesContent[0] + if (this.sources.sourceMap && this.sources.sourceMap.sourcemap && this.sources.sourceMap.sourcemap.sourcesContent[i]) { + originalRawSource = this.sources.sourceMap.sourcemap.sourcesContent[i] } else if (this.sources.originalSource) { originalRawSource = this.sources.originalSource } else { - originalRawSource = await readFile(this.path, 'utf8') + originalRawSource = await readFile(resolvedPath, 'utf8') } - this.source = new CovSource(originalRawSource, this.wrapperLength) - this.sourceTranspiled = new CovSource(rawSource, this.wrapperLength) + this._makeFileCoverage(originalPath, new CovSource(originalRawSource, this.wrapperLength), resolvedPath) } + this.sourceTranspiled = new CovSource(rawSource, this.wrapperLength) } else { - this.source = new CovSource(rawSource, this.wrapperLength) + this._makeFileCoverage(this.path, new CovSource(rawSource, this.wrapperLength)) } } - _rewritePath (rawSourceMap) { - const sourceRoot = rawSourceMap.sourcemap.sourceRoot ? rawSourceMap.sourcemap.sourceRoot.replace('file://', '') : '' - const sourcePath = rawSourceMap.sourcemap.sources[0].replace('file://', '') + _makeFileCoverage(path, source, resolvedPath = path) { + if (!source) { + throw new Error('no source passed'); + } + + this.files[path] = { + branches: [], + functions: [], + source, + path, + resolvedPath, + }; + } + + _rewritePath(sourceRoot, sourcePath) { + sourceRoot = sourceRoot ? sourceRoot.replace('file://', '') : '' + sourcePath = sourcePath.replace('file://', '') + const candidatePath = join(sourceRoot, sourcePath) if (isAbsolute(candidatePath)) { - this.path = candidatePath + return candidatePath } else { - this.path = resolve(dirname(this.path), sourcePath) + return resolve(dirname(this.path), sourcePath) } } - applyCoverage (blocks) { + applyCoverage(blocks) { blocks.forEach(block => { block.ranges.forEach((range, i) => { - const { - startCol, - endCol - } = this._maybeRemapStartColEndCol(range) - const lines = this.source.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 - return true + const subRanges = this._maybeRemapStartColEndCol(range) + subRanges.forEach((subRange) => { + const { + startCol, + endCol, + fileInfo + } = subRange; + if (!fileInfo) { + return; } - return startCol <= line.endCol && endCol >= line.startCol - }) - const startLineInstance = lines[0] - const endLineInstance = lines[lines.length - 1] - - if (block.isBlockCoverage && lines.length) { - // record branches. - this.branches.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.push(new CovFunction( + const { source, functions, branches } = fileInfo; + const lines = source.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 + return true + } + return startCol <= line.endCol && endCol >= line.startCol + }) + const startLineInstance = lines[0] + const endLineInstance = lines[lines.length - 1] + + if (block.isBlockCoverage && lines.length) { + // record branches. + branches.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) { + functions.push(new CovFunction( + block.functionName, + startLineInstance.line, + startCol - startLineInstance.startCol, + endLineInstance.line, + endCol - endLineInstance.startCol, + range.count + )) + } + } else if (block.functionName && lines.length) { + // record functions. + functions.push(new CovFunction( block.functionName, startLineInstance.line, startCol - startLineInstance.startCol, @@ -125,114 +155,140 @@ module.exports = class V8ToIstanbul { range.count )) } - } else if (block.functionName && lines.length) { - // record functions. - this.functions.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 - } + // 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 startCol = Math.max(0, range.startOffset - this.source.wrapperLength) - let endCol = Math.min(this.source.eof, range.endOffset - this.source.wrapperLength) - + _maybeRemapStartColEndCol(range) { if (this.sourceMap) { - startCol = Math.max(0, range.startOffset - this.sourceTranspiled.wrapperLength) - endCol = Math.min(this.sourceTranspiled.eof, range.endOffset - this.sourceTranspiled.wrapperLength) + const startCol = Math.max(0, range.startOffset - this.sourceTranspiled.wrapperLength) + const endCol = Math.min(this.sourceTranspiled.eof, range.endOffset - this.sourceTranspiled.wrapperLength) - const { startLine, relStartCol, endLine, relEndCol } = this.sourceTranspiled.offsetToOriginalRelative( + const locs = this.sourceTranspiled.offsetToOriginalRelative( this.sourceMap, startCol, endCol - ) + ); + + return locs.map((loc) => { + const { startLine, relStartCol, endLine, relEndCol, sourceFile } = loc; + + const fileInfo = this.files[sourceFile]; + + if (!fileInfo) { + console.log('did not find', sourceFile) + return {}; + } + + if (fileInfo.resolvedPath.indexOf('.tsx') >= 0) { + //console.log('-------------') + //console.log(fileInfo.resolvedPath) + //console.log(range) + //console.log({ startLine, relStartCol, endLine, relEndCol }); + } + + // next we convert these relative positions back to absolute positions + // in the original source (which is the format expected in the next step). + const startCol = fileInfo.source.relativeToOffset(startLine, relStartCol) + const endCol = fileInfo.source.relativeToOffset(endLine, relEndCol) + + if (fileInfo.resolvedPath.indexOf('tsx') >= 0) { + // console.log({ startCol, endCol }); + } - // next we convert these relative positions back to absolute positions - // in the original source (which is the format expected in the next step). - startCol = this.source.relativeToOffset(startLine, relStartCol) - endCol = this.source.relativeToOffset(endLine, relEndCol) + return { + fileInfo, + startCol, + endCol + } + }); } - return { + const fileInfo = this.files[this.path]; + const startCol = Math.max(0, range.startOffset - fileInfo.source.wrapperLength) + const endCol = Math.min(fileInfo.source.eof, range.endOffset - fileInfo.source.wrapperLength) + + return [{ + fileInfo, startCol, endCol - } + }] } - toIstanbul () { - const istanbulInner = Object.assign( - { path: this.path }, - this._statementsToIstanbul(), - this._branchesToIstanbul(), - this._functionsToIstanbul() - ) + toIstanbul() { + const istanbulOuter = {} - istanbulOuter[this.path] = istanbulInner + Object.keys(this.files).forEach((file) => { + + const { resolvedPath, source, branches, functions } = this.files[file]; + + const istanbulInner = Object.assign( + { path: resolvedPath }, + this._statementsToIstanbul(source), + this._branchesToIstanbul(source, branches), + this._functionsToIstanbul(source, functions) + ) + istanbulOuter[resolvedPath] = istanbulInner + }); return istanbulOuter } - _statementsToIstanbul () { + _statementsToIstanbul(source) { const statements = { statementMap: {}, s: {} } - this.source.lines.forEach((line, index) => { + source.lines.forEach((line, index) => { statements.statementMap[`${index}`] = line.toIstanbul() statements.s[`${index}`] = line.count }) return statements } - _branchesToIstanbul () { - const branches = { + _branchesToIstanbul(source, branches) { + const iBranches = { branchMap: {}, b: {} } - this.branches.forEach((branch, index) => { - const ignore = this.source.lines[branch.startLine - 1].ignore - branches.branchMap[`${index}`] = branch.toIstanbul() - branches.b[`${index}`] = [ignore ? 1 : branch.count] + branches.forEach((branch, index) => { + const ignore = source.lines[branch.startLine - 1].ignore + iBranches.branchMap[`${index}`] = branch.toIstanbul() + iBranches.b[`${index}`] = [ignore ? 1 : branch.count] }) - return branches + return iBranches } - _functionsToIstanbul () { - const functions = { + _functionsToIstanbul(source, functions) { + const iFunctions = { fnMap: {}, f: {} } - this.functions.forEach((fn, index) => { - const ignore = this.source.lines[fn.startLine - 1].ignore - functions.fnMap[`${index}`] = fn.toIstanbul() - functions.f[`${index}`] = ignore ? 1 : fn.count + functions.forEach((fn, index) => { + const ignore = source.lines[fn.startLine - 1].ignore + iFunctions.fnMap[`${index}`] = fn.toIstanbul() + iFunctions.f[`${index}`] = ignore ? 1 : fn.count }) - return functions + return iFunctions } } -function parsePath (scriptPath) { +function parsePath(scriptPath) { return scriptPath.replace('file://', '') } diff --git a/package-lock.json b/package-lock.json index 083667a1..664820b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "v8-to-istanbul", - "version": "4.0.1", + "version": "4.1.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/test/v8-to-istanbul.js b/test/v8-to-istanbul.js index 12eed5b9..03eae250 100644 --- a/test/v8-to-istanbul.js +++ b/test/v8-to-istanbul.js @@ -18,7 +18,7 @@ describe('V8ToIstanbul', async () => { require.resolve('./fixtures/scripts/functions.js') ) await v8ToIstanbul.load() - v8ToIstanbul.source.lines.length.should.equal(48) + v8ToIstanbul.files[v8ToIstanbul.path].source.lines.length.should.equal(48) v8ToIstanbul.wrapperLength.should.equal(0) // common-js header. }) @@ -28,7 +28,7 @@ describe('V8ToIstanbul', async () => { 0 ) await v8ToIstanbul.load() - v8ToIstanbul.source.lines.length.should.equal(48) + v8ToIstanbul.files[v8ToIstanbul.path].lines.length.should.equal(48) v8ToIstanbul.wrapperLength.should.equal(0) // ESM header. })