[system-analyzer] Add source map support

- Asynchronously load source map from sourceMappingURL
  - Once loaded, annotate source positions with their original position
  - Update script panel tooltip to include link to original source
     - For the above, make DOM.element a slightly more flexible API,
       allowing defining attributes and children
     - Also fix ToolTipEvent handling to support nodes.
  - Shuffle around some code to make createScriptNode async, in case
    we want to load the source map when building the script node itself.
  - Drive-by: make source markers a simple backgroundColor when there is
    only one group.

Change-Id: I0926807761cbfe8b6dd8ff5154815a7e5ccb39bf
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2972827
Commit-Queue: Leszek Swirski <leszeks@chromium.org>
Commit-Queue: Camillo Bruni <cbruni@chromium.org>
Auto-Submit: Leszek Swirski <leszeks@chromium.org>
Reviewed-by: Camillo Bruni <cbruni@chromium.org>
Cr-Commit-Position: refs/heads/master@{#75302}
This commit is contained in:
Leszek Swirski 2021-06-22 15:32:53 +02:00 committed by V8 LUCI CQ
parent 329ee4d423
commit 18bcc9a6f2
7 changed files with 217 additions and 56 deletions

View File

@ -27,16 +27,22 @@
import { CodeMap, CodeEntry } from "./codemap.mjs";
import { ConsArray } from "./consarray.mjs";
import { WebInspector } from "./sourcemap.mjs";
// Used to associate log entries with source positions in scripts.
// TODO: move to separate modules
export class SourcePosition {
script = null;
line = -1;
column = -1;
entries = [];
isFunction = false;
originalPosition = undefined;
constructor(script, line, column) {
this.script = script;
this.line = line;
this.column = column;
this.entries = [];
this.isFunction = false;
}
addEntry(entry) {
@ -66,9 +72,11 @@ export class Script {
url;
source;
name;
sourcePosition = undefined;
// Map<line, Map<column, SourcePosition>>
lineToColumn = new Map();
_entries = [];
_sourceMapState = "unknown";
constructor(id) {
this.id = id;
@ -89,6 +97,14 @@ export class Script {
return this._entries;
}
get startLine() {
return this.sourcePosition?.line ?? 1;
}
get sourceMapState() {
return this._sourceMapState;
}
findFunctionSourcePosition(sourcePosition) {
// TODO(cbruni) implmenent
return undefined;
@ -100,6 +116,10 @@ export class Script {
sourcePosition = new SourcePosition(this, line, column,)
this._addSourcePosition(line, column, sourcePosition);
}
if (entry.entry?.type == "Script") {
// Mark the source position of scripts, for inline scripts which
this.sourcePosition = sourcePosition;
}
sourcePosition.addEntry(entry);
this._entries.push(entry);
return sourcePosition;
@ -149,6 +169,67 @@ export class Script {
matchingScripts.push(script);
return url;
}
ensureSourceMapCalculated(sourceMapFetchPrefix=undefined) {
if (this._sourceMapState !== "unknown") return;
const sourceMapURLMatch =
this.source.match(/\/\/# sourceMappingURL=(.*)\n/);
if (!sourceMapURLMatch) {
this._sourceMapState = "none";
return;
}
this._sourceMapState = "loading";
let sourceMapURL = sourceMapURLMatch[1];
(async () => {
try {
let sourceMapPayload;
try {
sourceMapPayload = await fetch(sourceMapURL);
} catch (e) {
if (e instanceof TypeError && sourceMapFetchPrefix) {
// Try again with fetch prefix.
// TODO(leszeks): Remove the retry once the prefix is
// configurable.
sourceMapPayload =
await fetch(sourceMapFetchPrefix + sourceMapURL);
} else {
throw e;
}
}
sourceMapPayload = await sourceMapPayload.text();
if (sourceMapPayload.startsWith(')]}')) {
sourceMapPayload =
sourceMapPayload.substring(sourceMapPayload.indexOf('\n'));
}
sourceMapPayload = JSON.parse(sourceMapPayload);
const sourceMap =
new WebInspector.SourceMap(sourceMapURL, sourceMapPayload);
const startLine = this.startLine;
for (const sourcePosition of this.sourcePositions) {
const line = sourcePosition.line - startLine;
const column = sourcePosition.column - 1;
const mapping = sourceMap.findEntry(line, column);
if (mapping) {
sourcePosition.originalPosition = {
source: new URL(mapping[2], sourceMapURL).href,
line: mapping[3] + 1,
column: mapping[4] + 1
};
} else {
sourcePosition.originalPosition = {source: null, line:0, column:0};
}
}
this._sourceMapState = "loaded";
} catch (e) {
console.error(e);
this._sourceMapState = "failed";
}
})();
}
}

View File

@ -326,7 +326,7 @@ WebInspector.SourceMap.prototype = {
} while (digit & this._VLQ_CONTINUATION_MASK);
// Fix the sign.
const negative = result & 1;
const negate = result & 1;
// Use unsigned right shift, so that the 32nd bit is properly shifted
// to the 31st, and the 32nd becomes unset.
result >>>= 1;

View File

@ -26,6 +26,17 @@ export function delay(time) {
return new Promise(resolver => setTimeout(resolver, time));
}
export function defer() {
let resolve_func, reject_func;
const p = new Promise((resolve, reject) => {
resolve_func = resolve;
reject_func = resolve;
});
p.resolve = resolve_func;
p.reject = reject_func;
return p;
}
export class Group {
constructor(key, id, parentTotal, entries) {
this.key = key;

View File

@ -312,7 +312,8 @@ class App {
handleToolTip(event) {
let content = event.content;
if (typeof content !== 'string') {
if (typeof content !== 'string' &&
!(content?.nodeType && content?.nodeName)) {
content = content?.toolTipDict;
}
if (!content) {

View File

@ -123,13 +123,32 @@ export class CSSColor {
}
export class DOM {
static element(type, classes) {
static element(type, options) {
const node = document.createElement(type);
if (classes !== undefined) {
if (typeof classes === 'string') {
node.className = classes;
if (options !== undefined) {
if (typeof options === 'string') {
// Old behaviour: options = class string
node.className = options;
} else if (Array.isArray(options)) {
// Old behaviour: options = class array
DOM.addClasses(node, options);
} else {
DOM.addClasses(node, classes);
// New behaviour: options = attribute dict
for (const [key, value] of Object.entries(options)) {
if (key == 'className') {
node.className = value;
} else if (key == 'classList') {
node.classList = value;
} else if (key == 'textContent') {
node.textContent = value;
} else if (key == 'children') {
for (const child of value) {
node.appendChild(child);
}
} else {
node.setAttribute(key, value);
}
}
}
}
return node;
@ -158,20 +177,20 @@ export class DOM {
return button;
}
static div(classes) {
return this.element('div', classes);
static div(options) {
return this.element('div', options);
}
static span(classes) {
return this.element('span', classes);
static span(options) {
return this.element('span', options);
}
static table(classes) {
return this.element('table', classes);
static table(options) {
return this.element('table', options);
}
static tbody(classes) {
return this.element('tbody', classes);
static tbody(options) {
return this.element('tbody', options);
}
static td(textOrNode, className) {

View File

@ -1,17 +1,21 @@
// Copyright 2020 the V8 project 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 {groupBy} from '../helper.mjs';
import {defer, groupBy} from '../helper.mjs';
import {App} from '../index.mjs'
import {SelectRelatedEvent, ToolTipEvent} from './events.mjs';
import {CollapsableElement, CSSColor, delay, DOM, formatBytes, gradientStopsFromGroups} from './helper.mjs';
// A source mapping proxy for source maps that don't have CORS headers.
// TODO(leszeks): Make this configurable.
const sourceMapFetchPrefix = 'http://localhost:8080/';
DOM.defineCustomElement('view/script-panel',
(templateText) =>
class SourcePanel extends CollapsableElement {
_selectedSourcePositions = [];
_sourcePositionsToMarkNodes = [];
_sourcePositionsToMarkNodesPromise = Promise.resolve([]);
_scripts = [];
_script;
@ -34,6 +38,8 @@ DOM.defineCustomElement('view/script-panel',
set script(script) {
if (this._script === script) return;
this._script = script;
script.ensureSourceMapCalculated(sourceMapFetchPrefix);
this._sourcePositionsToMarkNodesPromise = defer();
this.requestUpdate();
}
@ -82,15 +88,19 @@ DOM.defineCustomElement('view/script-panel',
async _renderSourcePanel() {
let scriptNode;
if (this._script) {
const script = this._script;
if (script) {
await delay(1);
if (script != this._script) return;
const builder = new LineBuilder(this, this._script);
scriptNode = builder.createScriptNode();
this._sourcePositionsToMarkNodes = builder.sourcePositionToMarkers;
scriptNode = await builder.createScriptNode(this._script.startLine);
if (script != this._script) return;
this._sourcePositionsToMarkNodesPromise.resolve(
builder.sourcePositionToMarkers);
} else {
scriptNode = DOM.div();
this._selectedMarkNodes = undefined;
this._sourcePositionsToMarkNodes = new Map();
this._sourcePositionsToMarkNodesPromise.resolve(new Map());
}
const oldScriptNode = this.script.childNodes[1];
this.script.replaceChild(scriptNode, oldScriptNode);
@ -98,22 +108,24 @@ DOM.defineCustomElement('view/script-panel',
async _focusSelectedMarkers() {
await delay(100);
const sourcePositionsToMarkNodes =
await this._sourcePositionsToMarkNodesPromise;
// Remove all marked nodes.
for (let markNode of this._sourcePositionsToMarkNodes.values()) {
for (let markNode of sourcePositionsToMarkNodes.values()) {
markNode.className = '';
}
for (let sourcePosition of this._selectedSourcePositions) {
if (sourcePosition.script !== this._script) continue;
this._sourcePositionsToMarkNodes.get(sourcePosition).className = 'marked';
sourcePositionsToMarkNodes.get(sourcePosition).className = 'marked';
}
this._scrollToFirstSourcePosition()
this._scrollToFirstSourcePosition(sourcePositionsToMarkNodes)
}
_scrollToFirstSourcePosition() {
_scrollToFirstSourcePosition(sourcePositionsToMarkNodes) {
const sourcePosition = this._selectedSourcePositions.find(
each => each.script === this._script);
if (!sourcePosition) return;
const markNode = this._sourcePositionsToMarkNodes.get(sourcePosition);
const markNode = sourcePositionsToMarkNodes.get(sourcePosition);
markNode.scrollIntoView(
{behavior: 'auto', block: 'center', inline: 'center'});
}
@ -135,7 +147,8 @@ DOM.defineCustomElement('view/script-panel',
}
handleSourcePositionMouseOver(e) {
const entries = e.target.sourcePosition.entries;
const sourcePosition = e.target.sourcePosition;
const entries = sourcePosition.entries;
let text = groupBy(entries, each => each.constructor, true)
.map(group => {
let text = `${group.key.name}: ${group.count}\n`
@ -147,7 +160,46 @@ DOM.defineCustomElement('view/script-panel',
return text;
})
.join('\n');
this.dispatchEvent(new ToolTipEvent(text, e.target));
let sourceMapContent;
switch (this._script.sourceMapState) {
case 'loaded': {
const originalPosition = sourcePosition.originalPosition;
if (originalPosition.source === null) {
sourceMapContent =
DOM.element('i', {textContent: 'no source mapping for location'});
} else {
sourceMapContent = DOM.element('a', {
href: `${originalPosition.source}`,
target: '_blank',
textContent: `${originalPosition.source}:${originalPosition.line}:${
originalPosition.column}`
});
}
break;
}
case 'loading':
sourceMapContent =
DOM.element('i', {textContent: 'source map still loading...'});
break;
case 'failed':
sourceMapContent =
DOM.element('i', {textContent: 'source map failed to load'});
break;
case 'none':
sourceMapContent = DOM.element('i', {textContent: 'no source map'});
break;
default:
break;
}
const toolTipContent = DOM.div({
children: [
DOM.element('pre', {className: 'textContent', textContent: text}),
sourceMapContent
]
});
this.dispatchEvent(new ToolTipEvent(toolTipContent, e.target));
}
});
@ -214,49 +266,42 @@ class LineBuilder {
_script;
_clickHandler;
_mouseoverHandler;
_sourcePositions;
_sourcePositionToMarkers = new Map();
constructor(panel, script) {
this._script = script;
this._clickHandler = panel.handleSourcePositionClick.bind(panel);
this._mouseoverHandler = panel.handleSourcePositionMouseOver.bind(panel);
// TODO: sort on script finalization.
script.sourcePositions.sort((a, b) => {
if (a.line === b.line) return a.column - b.column;
return a.line - b.line;
});
this._sourcePositions = new SourcePositionIterator(script.sourcePositions);
}
get sourcePositionToMarkers() {
return this._sourcePositionToMarkers;
}
createScriptNode() {
async createScriptNode(startLine) {
const scriptNode = DOM.div('scriptNode');
let startLine = 1;
// Try to infer the start line of the script from its code entries.
for (const entry of this._script.entries) {
if (entry.type.startsWith('Script')) {
if (entry.sourcePosition) {
startLine = entry.sourcePosition.line;
}
break;
}
}
// TODO: sort on script finalization.
this._script.sourcePositions.sort((a, b) => {
if (a.line === b.line) return a.column - b.column;
return a.line - b.line;
});
const sourcePositionsIterator =
new SourcePositionIterator(this._script.sourcePositions);
scriptNode.style.counterReset = `sourceLineCounter ${startLine - 1}`;
for (let [lineIndex, line] of lineIterator(
this._script.source, startLine)) {
scriptNode.appendChild(this._createLineNode(lineIndex, line));
scriptNode.appendChild(
this._createLineNode(sourcePositionsIterator, lineIndex, line));
}
return scriptNode;
}
_createLineNode(lineIndex, line) {
_createLineNode(sourcePositionsIterator, lineIndex, line) {
const lineNode = DOM.span();
let columnIndex = 0;
for (const sourcePosition of this._sourcePositions.forLine(lineIndex)) {
for (const sourcePosition of sourcePositionsIterator.forLine(lineIndex)) {
const nextColumnIndex = sourcePosition.column - 1;
lineNode.appendChild(document.createTextNode(
line.substring(columnIndex, nextColumnIndex)));
@ -280,10 +325,14 @@ class LineBuilder {
marker.onmouseover = this._mouseoverHandler;
const entries = sourcePosition.entries;
const stops = gradientStopsFromGroups(
entries.length, '%', groupBy(entries, entry => entry.constructor),
type => LineBuilder.colorMap.get(type));
marker.style.backgroundImage = `linear-gradient(0deg,${stops.join(',')})`
const groups = groupBy(entries, entry => entry.constructor);
if (groups.length > 1) {
const stops = gradientStopsFromGroups(
entries.length, '%', groups, type => LineBuilder.colorMap.get(type));
marker.style.backgroundImage = `linear-gradient(0deg,${stops.join(',')})`
} else {
marker.style.backgroundColor = LineBuilder.colorMap.get(groups[0].key)
}
return marker;
}

View File

@ -90,7 +90,7 @@ DOM.defineCustomElement(
if (typeof content === 'string') {
this.contentNode.innerHTML = content;
this.contentNode.className = 'textContent';
} else if (content?.nodeType && nodeType?.nodeName) {
} else if (content?.nodeType && content?.nodeName) {
this._setContentNode(content);
} else {
if (this.contentNode.firstChild?.localName == 'property-link-table') {