1// Copyright 2021 The Chromium Authors 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5/** 6 * @fileoverview Takes raw v8 coverage files and converts to IstanbulJS 7 * compliant coverage files. 8 */ 9 10// Relative path to the node modules. 11const NODE_MODULES = [ 12 '..', '..', '..', 'third_party', 'js_code_coverage', 'node_modules']; 13 14const {createHash} = require('crypto'); 15const {join, dirname, normalize} = require('path'); 16const {readdir, readFile, writeFile, mkdir, access} = require('fs').promises; 17const V8ToIstanbul = require(join(...NODE_MODULES, 'v8-to-istanbul')); 18const {ArgumentParser} = require(join(...NODE_MODULES, 'argparse')); 19const convertSourceMap = require(join(...NODE_MODULES, 'convert-source-map')); 20const sourceMap = require(join(...NODE_MODULES, 'source-map')); 21 22/** 23 * Validate that the mapping in the sourcemaps is valid. 24 * @param {Object} mapping Individual mapping to validate. 25 * @param {Map} sourcesMap Map of the sources in the mappings to it's content. 26 * @param {string} instrumentedFilePath Path to the instrumented file. 27 * @returns {boolean} true if mapping is valid, false otherwise. 28 */ 29function validateMapping(mapping, sourcesMap, instrumentedFilePath) { 30 if (!mapping.generatedLine || !mapping.originalLine || !mapping.source) { 31 console.log(`Invalid mapping found for ${instrumentedFilePath}`); 32 return false; 33 } 34 35 // Verify that we have file contents. 36 if (!sourcesMap[mapping.source]) { 37 return false; 38 } 39 40 // Verify that the mapping line numbers refers to actual lines in source. 41 const origLine = sourcesMap[mapping.source][mapping.originalLine-1]; 42 const genLine = sourcesMap[instrumentedFilePath][mapping.generatedLine-1]; 43 if (origLine === undefined || genLine === undefined) { 44 return false; 45 } 46 47 // Verify that the mapping columns refers to actual column bounds in source. 48 if (mapping.generatedColumn > genLine.length || 49 mapping.originalColumn > origLine.length) { 50 return false; 51 } 52 53 return true; 54} 55 56/** 57 * Validate the sourcemap by looking at: 58 * 1. Existence of the source files in sourcemap 59 * 2. Verify original and generated lines are within bounds. 60 * @param {string} instrumentedFilePath Path to the file with source map. 61 * @returns {boolean} true if sourcemap is valid, false otherwise 62 */ 63async function validateSourceMaps(instrumentedFilePath) { 64 const rawSource = await readFile(instrumentedFilePath, 'utf8'); 65 const rawSourceMap = convertSourceMap.fromSource(rawSource) || 66 convertSourceMap.fromMapFileSource( 67 rawSource, dirname(instrumentedFilePath)); 68 69 if (!rawSourceMap || rawSourceMap.sourcemap.sources.length < 1) { 70 console.log(`No valid source map found for ${instrumentedFilePath}`); 71 return false; 72 } 73 74 let sourcesMap = {}; 75 sourcesMap[instrumentedFilePath] = rawSource.toString().split('\n'); 76 for (let i = 0; i < rawSourceMap.sourcemap.sources.length; i++) { 77 const sourcePath = normalize(join( 78 rawSourceMap.sourcemap.sourceRoot, rawSourceMap.sourcemap.sources[i])); 79 try { 80 const content = await readFile(sourcePath, 'utf-8'); 81 sourcesMap[sourcePath] = content.toString().split('\n'); 82 } catch (error) { 83 if (error.code === 'ENOENT') { 84 console.error(`Original missing for ${sourcePath}`); 85 return false; 86 } else { 87 throw error; 88 } 89 } 90 } 91 92 let validMap = true; 93 const consumer = 94 await new sourceMap.SourceMapConsumer(rawSourceMap.sourcemap); 95 consumer.eachMapping(function(mapping) { 96 if (!validMap || 97 !validateMapping(mapping, sourcesMap, instrumentedFilePath)) { 98 validMap = false; 99 } 100 }); 101 102 // Destroy consumer as we dont need it anymore. 103 consumer.destroy(); 104 return validMap; 105} 106 107/** 108 * Extracts the raw coverage data from the v8 coverage reports and converts 109 * them into IstanbulJS compliant reports. 110 * @param {string} coverageDirectory Directory containing the raw v8 output. 111 * @param {string} instrumentedDirectoryRoot Directory containing the source 112 * files where the coverage was instrumented from. 113 * @param {string} outputDir Directory to store the istanbul coverage reports. 114 * @param {!Map<string, string>} urlToPathMap A mapping of URL observed during 115 * test execution to the on-disk location created in previous steps. 116 */ 117async function extractCoverage( 118 coverageDirectory, instrumentedDirectoryRoot, outputDir, urlToPathMap) { 119 const coverages = await readdir(coverageDirectory); 120 121 for (const fileName of coverages) { 122 if (!fileName.endsWith('.cov.json')) 123 continue; 124 125 const filePath = join(coverageDirectory, fileName); 126 const contents = await readFile(filePath, 'utf-8'); 127 128 const {result: scriptCoverages} = JSON.parse(contents); 129 if (!scriptCoverages) 130 throw new Error(`result key missing for file: ${filePath}`); 131 132 for (const coverage of scriptCoverages) { 133 if (!urlToPathMap[coverage.url]) 134 continue; 135 136 const instrumentedFilePath = 137 join(instrumentedDirectoryRoot, urlToPathMap[coverage.url]); 138 const validSourceMap = await validateSourceMaps(instrumentedFilePath) 139 if (!validSourceMap) { 140 continue; 141 } 142 143 const converter = V8ToIstanbul(instrumentedFilePath); 144 await converter.load(); 145 converter.applyCoverage(coverage.functions); 146 const convertedCoverage = converter.toIstanbul(); 147 148 const jsonString = JSON.stringify(convertedCoverage); 149 await writeFile( 150 join(outputDir, createSHA1HashFromFileContents(jsonString) + '.json'), 151 jsonString); 152 } 153 } 154} 155 156/** 157 * Helper function to provide a unique file name for resultant istanbul reports. 158 * @param {string} str File contents 159 * @return {string} A sha1 hash to be used as a file name. 160 */ 161function createSHA1HashFromFileContents(contents) { 162 return createHash('sha1').update(contents).digest('hex'); 163} 164 165/** 166 * The entry point to the function to enable the async functionality throughout. 167 * @param {Object} args The parsed CLI arguments. 168 * @return {!Promise<string>} Directory containing istanbul reports. 169 */ 170async function main(args) { 171 const urlToPathMapFile = 172 await readFile(join(args.source_dir, 'parsed_scripts.json')); 173 const urlToPathMap = JSON.parse(urlToPathMapFile.toString()); 174 175 const outputDir = join(args.output_dir, 'istanbul') 176 await mkdir(outputDir, {recursive: true}); 177 for (const coverageDir of args.raw_coverage_dirs) 178 await extractCoverage( 179 coverageDir, args.source_dir, outputDir, urlToPathMap); 180 181 return outputDir; 182} 183 184const parser = new ArgumentParser({ 185 description: 'Converts raw v8 coverage into IstanbulJS compliant files.', 186}); 187 188parser.add_argument('-s', '--source-dir', { 189 required: true, 190 help: 'Root directory where source files live. The corresponding ' + 191 'coverage files must refer to these files. Currently source ' + 192 'maps are not supported.', 193}); 194parser.add_argument('-o', '--output-dir', { 195 required: true, 196 help: 'Root directory to output all the converted istanbul coverage ' + 197 'reports.', 198}); 199parser.add_argument('-c', '--raw-coverage-dirs', { 200 required: true, 201 nargs: '*', 202 help: 'Directory that contains the raw v8 coverage files (files ' + 203 'ending in .cov.json)', 204}); 205 206const args = parser.parse_args(); 207main(args) 208 .then(outputDir => { 209 console.log(`Successfully converted from v8 to IstanbulJS: ${outputDir}`); 210 }) 211 .catch(error => { 212 console.error(error); 213 process.exit(1); 214 }); 215