1// Copyright 2024 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 Worker to convert V8 coverage 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} = require('fs').promises; 17const V8ToIstanbul = require(join(...NODE_MODULES, 'v8-to-istanbul')); 18const convertSourceMap = require(join(...NODE_MODULES, 'convert-source-map')); 19const sourceMap = require(join(...NODE_MODULES, 'source-map')); 20const {workerData, parentPort} = require('worker_threads'); 21 22/** 23 * Validate that the mapping in the sourcemaps is valid. 24 * @param mapping Individual mapping to validate. 25 * @param sourcesMap Map of the sources in the mappings to it's content. 26 * @param instrumentedFilePath Path to the instrumented file. 27 * @returns 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 instrumentedFilePath Path to the file with source map. 61 * @returns 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 (const source of rawSourceMap.sourcemap.sources) { 77 const sourcePath = 78 normalize(join(rawSourceMap.sourcemap.sourceRoot, source)); 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 * Helper function to provide a unique file name for resultant istanbul reports. 109 * @param str File contents 110 * @return A sha1 hash to be used as a file name. 111 */ 112function createSHA1HashFromFileContents(contents) { 113 return createHash('sha1').update(contents).digest('hex'); 114} 115 116/** 117 * Extracts the raw coverage data from the v8 coverage reports and converts 118 * them into IstanbulJS compliant reports. 119 * @param coverageDirectory Directory containing the raw v8 output. 120 * @param instrumentedDirectoryRoot Directory containing the source 121 * files where the coverage was instrumented from. 122 * @param outputDir Directory to store the istanbul coverage reports. 123 * @param urlToPathMap A mapping of URL observed during 124 * test execution to the on-disk location created in previous steps. 125 */ 126async function extractCoverage( 127 coverageDirectory, instrumentedDirectoryRoot, outputDir, urlToPathMap) { 128 const start = Math.floor(Date.now() / 1000) 129 const coverages = await readdir(coverageDirectory); 130 for (const fileName of coverages) { 131 if (!fileName.endsWith('.cov.json')) 132 continue; 133 134 const filePath = join(coverageDirectory, fileName); 135 const contents = await readFile(filePath, 'utf-8'); 136 const {result: scriptCoverages} = JSON.parse(contents); 137 if (!scriptCoverages) 138 throw new Error(`result key missing for file: ${filePath}`); 139 140 for (const coverage of scriptCoverages) { 141 if (!urlToPathMap[coverage.url]) 142 continue; 143 144 const instrumentedFilePath = 145 join(instrumentedDirectoryRoot, urlToPathMap[coverage.url]); 146 const validSourceMap = await validateSourceMaps(instrumentedFilePath) 147 if (!validSourceMap) { 148 continue; 149 } 150 151 const converter = V8ToIstanbul(instrumentedFilePath); 152 await converter.load(); 153 converter.applyCoverage(coverage.functions); 154 const convertedCoverage = converter.toIstanbul(); 155 156 const jsonString = JSON.stringify(convertedCoverage); 157 await writeFile( 158 join(outputDir, createSHA1HashFromFileContents(jsonString) + '.json'), 159 jsonString); 160 } 161 } 162 163 const end = Math.floor(Date.now() / 1000) - start 164 parentPort.postMessage( 165 `Successfully converted for ${workerData.coverageDir} in ${end}s`); 166} 167 168extractCoverage( 169 workerData.coverageDir, workerData.sourceDir, workerData.outputDir, 170 workerData.urlToPathMap) 171