• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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