Skip to content

Commit

Permalink
[js-code-coverage] Create merge_js_source_maps rule
Browse files Browse the repository at this point in the history
During sourcemap generation there is a need to merge multiple sourcemaps
when a rule doesn't provide support ingesting and transforming a
generated file. This build rule is initially built to support merging
sourcemaps appended via tsc.

This will read the input file, check for multiple sourcemaps appended to
the end of the file and in the case multiple are identified they get
merged. These files are then sent to the output directory and the
.manifest file updates its paths to point to the rewritten files.

Bug: 1337530
Test: ./merge_js_source_maps_test.py
Change-Id: Ice757ffd6382cb14b3eb56dbf73542cbde086df1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3810757
Commit-Queue: Ben Reich <benreich@chromium.org>
Reviewed-by: Tibor Goldschwendt <tiborg@chromium.org>
Reviewed-by: Bruce Dawson <brucedawson@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1033422}
  • Loading branch information
ben-reich authored and Chromium LUCI CQ committed Aug 10, 2022
1 parent 1dacd2c commit d4c272f
Show file tree
Hide file tree
Showing 8 changed files with 465 additions and 0 deletions.
2 changes: 2 additions & 0 deletions tools/code_coverage/merge_js_source_maps/OWNERS
@@ -0,0 +1,2 @@
benreich@chromium.org
tiborg@chromium.org
31 changes: 31 additions & 0 deletions tools/code_coverage/merge_js_source_maps/merge_js_source_maps.gni
@@ -0,0 +1,31 @@
# Copyright 2022 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import("//ui/webui/webui_features.gni")

template("merge_js_source_maps") {
assert(enable_webui_inline_sourcemaps)

action(target_name) {
forward_variables_from(invoker,
[
"sources",
"outputs",
"deps",
])
script =
"//tools/code_coverage/merge_js_source_maps/merge_js_source_maps.py"
args = [ "--manifest-files" ] +
rebase_path(invoker.manifest_files, root_out_dir) + [ "--sources" ] +
rebase_path(invoker.sources, root_out_dir) + [ "--outputs" ] +
rebase_path(invoker.outputs, root_out_dir)
inputs =
[ "//tools/code_coverage/merge_js_source_maps/merge_js_source_maps.js" ]
foreach(manifest, invoker.manifest_files) {
outputs += [ get_path_info(manifest, "dir") + "/" +
get_path_info(manifest, "name") + "__processed." +
get_path_info(manifest, "extension") ]
}
}
}
172 changes: 172 additions & 0 deletions tools/code_coverage/merge_js_source_maps/merge_js_source_maps.js
@@ -0,0 +1,172 @@
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
* @fileoverview Merges 2 inline sourcemaps for a input file and writes a new
* file to an output directory.
*/

import fs from 'fs';
import path from 'path';

import {ArgumentParser} from '../../../third_party/js_code_coverage/node_modules/argparse/argparse.js';
import {SourceMapConsumer, SourceMapGenerator} from '../../../third_party/js_code_coverage/node_modules/source-map/source-map.js';

/**
* The prefix comment that indicates a data URL containing the sourcemap.
*/
const SOURCEMAPPING_DATA_URL_PREFIX =
'//# sourceMappingURL=data:application/json;base64,';

/**
* Decode a base64 encoded string representing a sourcemap to its utf-8
* equivalent.
* @param {string} contents Base64 encoded string.
* @returns Decoded utf-8 string of the sourcemap.
*/
function decodeBase64SourceMap(contents) {
const removedLeadingComment =
contents.replace(SOURCEMAPPING_DATA_URL_PREFIX, '');
const buf = Buffer.from(removedLeadingComment, 'base64');
return buf.toString('utf-8');
}

/**
* Helper to identify if a supplied line is an inline sourcemap.
* @param {string} lineContents Contents of an individual line.
* @returns True if line is an inline sourcemap, null otherwise.
*/
function isSourceMapComment(lineContents) {
return lineContents && lineContents.startsWith(SOURCEMAPPING_DATA_URL_PREFIX);
}

/**
* Convert `contents` into a valid dataURL sourceMappingURL comment.
* @param {string} contents A string representation of the sourcemap
* @returns A base64 encoded dataURL with the `SOURCEMAPPING_DATA_URL_PREFIX`
* prepended.
*/
function encodeBase64SourceMap(contents) {
const buf = Buffer.from(contents, 'utf-8');
return SOURCEMAPPING_DATA_URL_PREFIX + buf.toString('base64');
}

/**
* Merge multiple sourcemaps to a single file.
* @param {!Array<string>} sourceMaps An array of stringified sourcemaps.
* @returns Returns a single sourcemap as a string.
*/
async function mergeSourcemaps(sourceMaps) {
let generator = null;
let originalSource = null;
for (const sourcemap of sourceMaps) {
const parsedMap = JSON.parse(sourcemap);
if (!originalSource) {
originalSource = parsedMap.sources[0];
}
const consumer = await new SourceMapConsumer(parsedMap);
if (generator) {
generator.applySourceMap(consumer, originalSource);
} else {
generator = await SourceMapGenerator.fromSourceMap(consumer);
}
consumer.destroy();
}
return generator.toString();
}

/**
* Processes all input files for multiple inlined sourcemaps and merges them.
* @param {!Array<string>} inputFiles The list of TS / JS files to extract
* sourcemaps from.
*/
async function processFiles(inputFiles, outputFiles) {
for (let i = 0; i < inputFiles.length; i++) {
const inputFile = inputFiles[i];
const outputFile = outputFiles[i];
const fileContents = fs.readFileSync(inputFile, 'utf-8');
const inputLines = fileContents.split('\n');

// Skip any trailing blank lines to find the last non-null line.
let lastNonNullLine = inputLines.length - 1;
while (inputLines[lastNonNullLine].trim().length === 0 &&
lastNonNullLine > 0) {
lastNonNullLine--;
}

// If the last non-null line identified is not a sourcemap, ignore this file
// as it may have erroneously been marked for sourcemap merge.
if (!isSourceMapComment(inputLines[lastNonNullLine])) {
console.warn('Supplied file has no inline sourcemap', inputFile);
fs.copyFileSync(inputFile, outputFile);
continue;
}

// Extract out all the inline sourcemaps and decode them to their string
// equivalent.
const sourceMaps = [decodeBase64SourceMap(inputLines[lastNonNullLine])];
let sourceMapLineIdx = lastNonNullLine - 1;
while (isSourceMapComment(inputLines[sourceMapLineIdx]) &&
sourceMapLineIdx > 0) {
const sourceMap = decodeBase64SourceMap(inputLines[sourceMapLineIdx]);
sourceMaps.push(sourceMap);
sourceMapLineIdx--;
}

let mergedSourceMap = null;
try {
mergedSourceMap = await mergeSourcemaps(sourceMaps);
} catch (e) {
console.error(`Failed to merge inlined sourcemaps for ${inputFile}:`, e);
fs.copyFileSync(inputFile, outputFile);
continue;
}

// Drop off the lines that were previously identified as inline sourcemap
// comments and replace them with the merged sourcemap.
let finalFileContent =
inputLines.slice(0, sourceMapLineIdx + 1).join('\n') + '\n';
if (mergedSourceMap) {
finalFileContent += encodeBase64SourceMap(mergedSourceMap);
}
fs.writeFileSync(outputFile, finalFileContent);
}
}

async function main() {
const parser =
new ArgumentParser({description: 'Merge multiple inlined sourcemaps'});

parser.add_argument('--sources', {help: 'Input files', nargs: '*'});
parser.add_argument('--outputs', {help: 'Output files', nargs: '*'});
parser.add_argument(
'--manifest-files', {help: 'Output files', nargs: '*', required: false});

const argv = parser.parse_args();
await processFiles(argv.sources, argv.outputs);

if (argv.manifest_files) {
// TODO(crbug/1337530): Currently we just remove the final directory of the
// `base_dir` key. This is definitely brittle and also subject to changes
// made to the output directory. Consider updating this to be more robust.
for (const manifestFile of argv.manifest_files) {
try {
const manifestFileContents =
fs.readFileSync(manifestFile).toString('utf-8');
const manifest = JSON.parse(manifestFileContents);
manifest.base_dir = path.parse(manifest.base_dir).dir;
const parsedPath = path.parse(manifestFile);
fs.writeFileSync(
path.join(
parsedPath.dir,
(parsedPath.name + '__processed' + parsedPath.ext)),
JSON.stringify(manifest));
} catch (e) {
console.log(e);
}
}
}
}

(async () => main())();
32 changes: 32 additions & 0 deletions tools/code_coverage/merge_js_source_maps/merge_js_source_maps.py
@@ -0,0 +1,32 @@
#!/usr/bin/env vpython3
# Copyright 2022 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import argparse
import sys
from pathlib import Path

_HERE_DIR = Path(__file__).parent
_SOURCE_MAP_MERGER = (_HERE_DIR / 'merge_js_source_maps.js').resolve()

_NODE_PATH = (_HERE_DIR.parent.parent.parent / 'third_party' / 'node').resolve()
sys.path.append(str(_NODE_PATH))
import node


def main(argv):
parser = argparse.ArgumentParser()
parser.add_argument('--sources', required=True, nargs="*")
parser.add_argument('--outputs', required=True, nargs="*")
parser.add_argument('--manifest-files', required=True, nargs="*")
args = parser.parse_args(argv)

node.RunNode([
str(_SOURCE_MAP_MERGER), '--manifest-files', *args.manifest_files,
'--sources', *args.sources, '--outputs', *args.outputs
])


if __name__ == '__main__':
main(sys.argv[1:])
9 changes: 9 additions & 0 deletions tools/code_coverage/merge_js_source_maps/package.json
@@ -0,0 +1,9 @@
{
"name": "merge_js_source_maps",
"version": "0.1",
"description": "Merge 2 inline sourcemaps",
"main": "merge_js_source_maps.js",
"files": [ "merge_js_source_maps.js" ],
"license": "SEE LICENSE IN ../../../LICENSE",
"type" : "module"
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit d4c272f

Please sign in to comment.