// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Worker to convert V8 coverage to IstanbulJS
* compliant coverage files.
*/
// Relative path to the node modules.
const NODE_MODULES =
['..', '..', '..', 'third_party', 'js_code_coverage', 'node_modules'];
const {createHash} = require('crypto');
const {join, dirname, normalize} = require('path');
const {readdir, readFile, writeFile} = require('fs').promises;
const V8ToIstanbul = require(join(...NODE_MODULES, 'v8-to-istanbul'));
const convertSourceMap = require(join(...NODE_MODULES, 'convert-source-map'));
const sourceMap = require(join(...NODE_MODULES, 'source-map'));
const {workerData, parentPort} = require('worker_threads');
/**
* Validate that the mapping in the sourcemaps is valid.
* @param mapping Individual mapping to validate.
* @param sourcesMap Map of the sources in the mappings to it's content.
* @param instrumentedFilePath Path to the instrumented file.
* @returns true if mapping is valid, false otherwise.
*/
function validateMapping(mapping, sourcesMap, instrumentedFilePath) {
if (!mapping.generatedLine || !mapping.originalLine || !mapping.source) {
console.log(`Invalid mapping found for ${instrumentedFilePath}`);
return false;
}
// Verify that we have file contents.
if (!sourcesMap[mapping.source]) {
return false;
}
// Verify that the mapping line numbers refers to actual lines in source.
const origLine = sourcesMap[mapping.source][mapping.originalLine - 1];
const genLine = sourcesMap[instrumentedFilePath][mapping.generatedLine - 1];
if (origLine === undefined || genLine === undefined) {
return false;
}
// Verify that the mapping columns refers to actual column bounds in source.
if (mapping.generatedColumn > genLine.length ||
mapping.originalColumn > origLine.length) {
return false;
}
return true;
}
/**
* Validate the sourcemap by looking at:
* 1. Existence of the source files in sourcemap
* 2. Verify original and generated lines are within bounds.
* @param instrumentedFilePath Path to the file with source map.
* @returns true if sourcemap is valid, false otherwise
*/
async function validateSourceMaps(instrumentedFilePath) {
const rawSource = await readFile(instrumentedFilePath, 'utf8');
const rawSourceMap = convertSourceMap.fromSource(rawSource) ||
convertSourceMap.fromMapFileSource(
rawSource, dirname(instrumentedFilePath));
if (!rawSourceMap || rawSourceMap.sourcemap.sources.length < 1) {
console.log(`No valid source map found for ${instrumentedFilePath}`);
return false;
}
let sourcesMap = {};
sourcesMap[instrumentedFilePath] = rawSource.toString().split('\n');
for (const source of rawSourceMap.sourcemap.sources) {
const sourcePath =
normalize(join(rawSourceMap.sourcemap.sourceRoot, source));
try {
const content = await readFile(sourcePath, 'utf-8');
sourcesMap[sourcePath] = content.toString().split('\n');
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`Original missing for ${sourcePath}`);
return false;
} else {
throw error;
}
}
}
let validMap = true;
const consumer =
await new sourceMap.SourceMapConsumer(rawSourceMap.sourcemap);
consumer.eachMapping(function(mapping) {
if (!validMap ||
!validateMapping(mapping, sourcesMap, instrumentedFilePath)) {
validMap = false;
}
});
// Destroy consumer as we dont need it anymore.
consumer.destroy();
return validMap;
}
/**
* Helper function to provide a unique file name for resultant istanbul reports.
* @param str File contents
* @return A sha1 hash to be used as a file name.
*/
function createSHA1HashFromFileContents(contents) {
return createHash('sha1').update(contents).digest('hex');
}
/**
* Extracts the raw coverage data from the v8 coverage reports and converts
* them into IstanbulJS compliant reports.
* @param coverageDirectory Directory containing the raw v8 output.
* @param instrumentedDirectoryRoot Directory containing the source
* files where the coverage was instrumented from.
* @param outputDir Directory to store the istanbul coverage reports.
* @param urlToPathMap A mapping of URL observed during
* test execution to the on-disk location created in previous steps.
*/
async function extractCoverage(
coverageDirectory, instrumentedDirectoryRoot, outputDir, urlToPathMap) {
const start = Math.floor(Date.now() / 1000)
const coverages = await readdir(coverageDirectory);
for (const fileName of coverages) {
if (!fileName.endsWith('.cov.json'))
continue;
const filePath = join(coverageDirectory, fileName);
const contents = await readFile(filePath, 'utf-8');
const {result: scriptCoverages} = JSON.parse(contents);
if (!scriptCoverages)
throw new Error(`result key missing for file: ${filePath}`);
for (const coverage of scriptCoverages) {
if (!urlToPathMap[coverage.url])
continue;
const instrumentedFilePath =
join(instrumentedDirectoryRoot, urlToPathMap[coverage.url]);
const validSourceMap = await validateSourceMaps(instrumentedFilePath)
if (!validSourceMap) {
continue;
}
const converter = V8ToIstanbul(instrumentedFilePath);
await converter.load();
converter.applyCoverage(coverage.functions);
const convertedCoverage = converter.toIstanbul();
const jsonString = JSON.stringify(convertedCoverage);
await writeFile(
join(outputDir, createSHA1HashFromFileContents(jsonString) + '.json'),
jsonString);
}
}
const end = Math.floor(Date.now() / 1000) - start
parentPort.postMessage(
`Successfully converted for ${workerData.coverageDir} in ${end}s`);
}
extractCoverage(
workerData.coverageDir, workerData.sourceDir, workerData.outputDir,
workerData.urlToPathMap)