@quick-game/cli
Version:
Command line interface for rapid qg development
687 lines • 26.6 kB
JavaScript
// Copyright (c) 2020 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 * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
// TODO(crbug.com/1167717): Make this a const enum again
// eslint-disable-next-line rulesdir/const_enum
export var Events;
(function (Events) {
Events["CoverageUpdated"] = "CoverageUpdated";
Events["CoverageReset"] = "CoverageReset";
})(Events || (Events = {}));
const COVERAGE_POLLING_PERIOD_MS = 200;
export class CoverageModel extends SDK.SDKModel.SDKModel {
cpuProfilerModel;
cssModel;
debuggerModel;
coverageByURL;
coverageByContentProvider;
coverageUpdateTimes;
suspensionState;
pollTimer;
currentPollPromise;
shouldResumePollingOnResume;
jsBacklog;
cssBacklog;
performanceTraceRecording;
constructor(target) {
super(target);
this.cpuProfilerModel = target.model(SDK.CPUProfilerModel.CPUProfilerModel);
this.cssModel = target.model(SDK.CSSModel.CSSModel);
this.debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel);
this.coverageByURL = new Map();
this.coverageByContentProvider = new Map();
// We keep track of the update times, because the other data-structures don't change if an
// update doesn't change the coverage. Some visualizations want to convey to the user that
// an update was received at a certain time, but did not result in a coverage change.
this.coverageUpdateTimes = new Set();
this.suspensionState = "Active" /* SuspensionState.Active */;
this.pollTimer = null;
this.currentPollPromise = null;
this.shouldResumePollingOnResume = false;
this.jsBacklog = [];
this.cssBacklog = [];
this.performanceTraceRecording = false;
}
async start(jsCoveragePerBlock) {
if (this.suspensionState !== "Active" /* SuspensionState.Active */) {
throw Error('Cannot start CoverageModel while it is not active.');
}
const promises = [];
if (this.cssModel) {
// Note there's no JS coverage since JS won't ever return
// coverage twice, even after it's restarted.
this.clearCSS();
this.cssModel.addEventListener(SDK.CSSModel.Events.StyleSheetAdded, this.handleStyleSheetAdded, this);
promises.push(this.cssModel.startCoverage());
}
if (this.cpuProfilerModel) {
promises.push(this.cpuProfilerModel.startPreciseCoverage(jsCoveragePerBlock, this.preciseCoverageDeltaUpdate.bind(this)));
}
await Promise.all(promises);
return Boolean(this.cssModel || this.cpuProfilerModel);
}
preciseCoverageDeltaUpdate(timestamp, occasion, coverageData) {
this.coverageUpdateTimes.add(timestamp);
void this.backlogOrProcessJSCoverage(coverageData, timestamp);
}
async stop() {
await this.stopPolling();
const promises = [];
if (this.cpuProfilerModel) {
promises.push(this.cpuProfilerModel.stopPreciseCoverage());
}
if (this.cssModel) {
promises.push(this.cssModel.stopCoverage());
this.cssModel.removeEventListener(SDK.CSSModel.Events.StyleSheetAdded, this.handleStyleSheetAdded, this);
}
await Promise.all(promises);
}
reset() {
this.coverageByURL = new Map();
this.coverageByContentProvider = new Map();
this.coverageUpdateTimes = new Set();
this.dispatchEventToListeners(Events.CoverageReset);
}
async startPolling() {
if (this.currentPollPromise || this.suspensionState !== "Active" /* SuspensionState.Active */) {
return;
}
await this.pollLoop();
}
async pollLoop() {
this.clearTimer();
this.currentPollPromise = this.pollAndCallback();
await this.currentPollPromise;
if (this.suspensionState === "Active" /* SuspensionState.Active */ || this.performanceTraceRecording) {
this.pollTimer = window.setTimeout(() => this.pollLoop(), COVERAGE_POLLING_PERIOD_MS);
}
}
async stopPolling() {
this.clearTimer();
await this.currentPollPromise;
this.currentPollPromise = null;
// Do one last poll to get the final data.
await this.pollAndCallback();
}
async pollAndCallback() {
if (this.suspensionState === "Suspended" /* SuspensionState.Suspended */ && !this.performanceTraceRecording) {
return;
}
const updates = await this.takeAllCoverage();
// This conditional should never trigger, as all intended ways to stop
// polling are awaiting the `_currentPollPromise` before suspending.
console.assert(this.suspensionState !== "Suspended" /* SuspensionState.Suspended */ || Boolean(this.performanceTraceRecording), 'CoverageModel was suspended while polling.');
if (updates.length) {
this.dispatchEventToListeners(Events.CoverageUpdated, updates);
}
}
clearTimer() {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
}
/**
* Stops polling as preparation for suspension. This function is idempotent
* due because it changes the state to suspending.
*/
async preSuspendModel(reason) {
if (this.suspensionState !== "Active" /* SuspensionState.Active */) {
return;
}
this.suspensionState = "Suspending" /* SuspensionState.Suspending */;
if (reason === 'performance-timeline') {
this.performanceTraceRecording = true;
// Keep polling to the backlog if a performance trace is recorded.
return;
}
if (this.currentPollPromise) {
await this.stopPolling();
this.shouldResumePollingOnResume = true;
}
}
async suspendModel(_reason) {
this.suspensionState = "Suspended" /* SuspensionState.Suspended */;
}
async resumeModel() {
}
/**
* Restarts polling after suspension. Note that the function is idempotent
* because starting polling is idempotent.
*/
async postResumeModel() {
this.suspensionState = "Active" /* SuspensionState.Active */;
this.performanceTraceRecording = false;
if (this.shouldResumePollingOnResume) {
this.shouldResumePollingOnResume = false;
await this.startPolling();
}
}
entries() {
return Array.from(this.coverageByURL.values());
}
getCoverageForUrl(url) {
return this.coverageByURL.get(url) || null;
}
usageForRange(contentProvider, startOffset, endOffset) {
const coverageInfo = this.coverageByContentProvider.get(contentProvider);
return coverageInfo && coverageInfo.usageForRange(startOffset, endOffset);
}
clearCSS() {
for (const entry of this.coverageByContentProvider.values()) {
if (entry.type() !== 1 /* CoverageType.CSS */) {
continue;
}
const contentProvider = entry.getContentProvider();
this.coverageByContentProvider.delete(contentProvider);
const urlEntry = this.coverageByURL.get(entry.url());
if (!urlEntry) {
continue;
}
const key = `${contentProvider.startLine}:${contentProvider.startColumn}`;
urlEntry.removeCoverageEntry(key, entry);
if (urlEntry.numberOfEntries() === 0) {
this.coverageByURL.delete(entry.url());
}
}
if (this.cssModel) {
for (const styleSheetHeader of this.cssModel.getAllStyleSheetHeaders()) {
this.addStyleSheetToCSSCoverage(styleSheetHeader);
}
}
}
async takeAllCoverage() {
const [updatesCSS, updatesJS] = await Promise.all([this.takeCSSCoverage(), this.takeJSCoverage()]);
return [...updatesCSS, ...updatesJS];
}
async takeJSCoverage() {
if (!this.cpuProfilerModel) {
return [];
}
const { coverage, timestamp } = await this.cpuProfilerModel.takePreciseCoverage();
this.coverageUpdateTimes.add(timestamp);
return this.backlogOrProcessJSCoverage(coverage, timestamp);
}
getCoverageUpdateTimes() {
return this.coverageUpdateTimes;
}
async backlogOrProcessJSCoverage(freshRawCoverageData, freshTimestamp) {
if (freshRawCoverageData.length > 0) {
this.jsBacklog.push({ rawCoverageData: freshRawCoverageData, stamp: freshTimestamp });
}
if (this.suspensionState !== "Active" /* SuspensionState.Active */) {
return [];
}
const ascendingByTimestamp = (x, y) => x.stamp - y.stamp;
const results = [];
for (const { rawCoverageData, stamp } of this.jsBacklog.sort(ascendingByTimestamp)) {
results.push(this.processJSCoverage(rawCoverageData, stamp));
}
this.jsBacklog = [];
return results.flat();
}
async processJSBacklog() {
void this.backlogOrProcessJSCoverage([], 0);
}
processJSCoverage(scriptsCoverage, stamp) {
if (!this.debuggerModel) {
return [];
}
const updatedEntries = [];
for (const entry of scriptsCoverage) {
const script = this.debuggerModel.scriptForId(entry.scriptId);
if (!script) {
continue;
}
const ranges = [];
let type = 2 /* CoverageType.JavaScript */;
for (const func of entry.functions) {
// Do not coerce undefined to false, i.e. only consider blockLevel to be false
// if back-end explicitly provides blockLevel field, otherwise presume blockLevel
// coverage is not available. Also, ignore non-block level functions that weren't
// ever called.
if (func.isBlockCoverage === false && !(func.ranges.length === 1 && !func.ranges[0].count)) {
type |= 4 /* CoverageType.JavaScriptPerFunction */;
}
for (const range of func.ranges) {
ranges.push(range);
}
}
const subentry = this.addCoverage(script, script.contentLength, script.lineOffset, script.columnOffset, ranges, type, stamp);
if (subentry) {
updatedEntries.push(subentry);
}
}
return updatedEntries;
}
handleStyleSheetAdded(event) {
this.addStyleSheetToCSSCoverage(event.data);
}
async takeCSSCoverage() {
// Don't poll if we have no model, or are suspended.
if (!this.cssModel || this.suspensionState !== "Active" /* SuspensionState.Active */) {
return [];
}
const { coverage, timestamp } = await this.cssModel.takeCoverageDelta();
this.coverageUpdateTimes.add(timestamp);
return this.backlogOrProcessCSSCoverage(coverage, timestamp);
}
async backlogOrProcessCSSCoverage(freshRawCoverageData, freshTimestamp) {
if (freshRawCoverageData.length > 0) {
this.cssBacklog.push({ rawCoverageData: freshRawCoverageData, stamp: freshTimestamp });
}
if (this.suspensionState !== "Active" /* SuspensionState.Active */) {
return [];
}
const ascendingByTimestamp = (x, y) => x.stamp - y.stamp;
const results = [];
for (const { rawCoverageData, stamp } of this.cssBacklog.sort(ascendingByTimestamp)) {
results.push(this.processCSSCoverage(rawCoverageData, stamp));
}
this.cssBacklog = [];
return results.flat();
}
processCSSCoverage(ruleUsageList, stamp) {
if (!this.cssModel) {
return [];
}
const updatedEntries = [];
const rulesByStyleSheet = new Map();
for (const rule of ruleUsageList) {
const styleSheetHeader = this.cssModel.styleSheetHeaderForId(rule.styleSheetId);
if (!styleSheetHeader) {
continue;
}
let ranges = rulesByStyleSheet.get(styleSheetHeader);
if (!ranges) {
ranges = [];
rulesByStyleSheet.set(styleSheetHeader, ranges);
}
ranges.push({ startOffset: rule.startOffset, endOffset: rule.endOffset, count: Number(rule.used) });
}
for (const entry of rulesByStyleSheet) {
const styleSheetHeader = entry[0];
const ranges = entry[1];
const subentry = this.addCoverage(styleSheetHeader, styleSheetHeader.contentLength, styleSheetHeader.startLine, styleSheetHeader.startColumn, ranges, 1 /* CoverageType.CSS */, stamp);
if (subentry) {
updatedEntries.push(subentry);
}
}
return updatedEntries;
}
static convertToDisjointSegments(ranges, stamp) {
ranges.sort((a, b) => a.startOffset - b.startOffset);
const result = [];
const stack = [];
for (const entry of ranges) {
let top = stack[stack.length - 1];
while (top && top.endOffset <= entry.startOffset) {
append(top.endOffset, top.count);
stack.pop();
top = stack[stack.length - 1];
}
append(entry.startOffset, top ? top.count : 0);
stack.push(entry);
}
for (let top = stack.pop(); top; top = stack.pop()) {
append(top.endOffset, top.count);
}
function append(end, count) {
const last = result[result.length - 1];
if (last) {
if (last.end === end) {
return;
}
if (last.count === count) {
last.end = end;
return;
}
}
result.push({ end: end, count: count, stamp: stamp });
}
return result;
}
addStyleSheetToCSSCoverage(styleSheetHeader) {
this.addCoverage(styleSheetHeader, styleSheetHeader.contentLength, styleSheetHeader.startLine, styleSheetHeader.startColumn, [], 1 /* CoverageType.CSS */, Date.now());
}
addCoverage(contentProvider, contentLength, startLine, startColumn, ranges, type, stamp) {
const url = contentProvider.contentURL();
if (!url) {
return null;
}
let urlCoverage = this.coverageByURL.get(url);
let isNewUrlCoverage = false;
if (!urlCoverage) {
isNewUrlCoverage = true;
urlCoverage = new URLCoverageInfo(url);
this.coverageByURL.set(url, urlCoverage);
}
const coverageInfo = urlCoverage.ensureEntry(contentProvider, contentLength, startLine, startColumn, type);
this.coverageByContentProvider.set(contentProvider, coverageInfo);
const segments = CoverageModel.convertToDisjointSegments(ranges, stamp);
const last = segments[segments.length - 1];
if (last && last.end < contentLength) {
segments.push({ end: contentLength, stamp: stamp, count: 0 });
}
const usedSizeDelta = coverageInfo.mergeCoverage(segments);
if (!isNewUrlCoverage && usedSizeDelta === 0) {
return null;
}
urlCoverage.addToSizes(usedSizeDelta, 0);
return coverageInfo;
}
async exportReport(fos) {
const result = [];
const coverageByUrlKeys = Array.from(this.coverageByURL.keys()).sort();
for (const urlInfoKey of coverageByUrlKeys) {
const urlInfo = this.coverageByURL.get(urlInfoKey);
if (!urlInfo) {
continue;
}
const url = urlInfo.url();
if (url.startsWith('extensions::') || url.startsWith('chrome-extension://')) {
continue;
}
result.push(...await urlInfo.entriesForExport());
}
await fos.write(JSON.stringify(result, undefined, 2));
void fos.close();
}
}
SDK.SDKModel.SDKModel.register(CoverageModel, { capabilities: SDK.Target.Capability.None, autostart: false });
function locationCompare(a, b) {
const [aLine, aPos] = a.split(':');
const [bLine, bPos] = b.split(':');
return Number.parseInt(aLine, 10) - Number.parseInt(bLine, 10) ||
Number.parseInt(aPos, 10) - Number.parseInt(bPos, 10);
}
export class URLCoverageInfo extends Common.ObjectWrapper.ObjectWrapper {
urlInternal;
coverageInfoByLocation;
sizeInternal;
usedSizeInternal;
typeInternal;
isContentScriptInternal;
constructor(url) {
super();
this.urlInternal = url;
this.coverageInfoByLocation = new Map();
this.sizeInternal = 0;
this.usedSizeInternal = 0;
this.isContentScriptInternal = false;
}
url() {
return this.urlInternal;
}
type() {
return this.typeInternal;
}
size() {
return this.sizeInternal;
}
usedSize() {
return this.usedSizeInternal;
}
unusedSize() {
return this.sizeInternal - this.usedSizeInternal;
}
usedPercentage() {
// Per convention, empty files are reported as 100 % uncovered
if (this.sizeInternal === 0) {
return 0;
}
return this.usedSize() / this.size();
}
unusedPercentage() {
// Per convention, empty files are reported as 100 % uncovered
if (this.sizeInternal === 0) {
return 100;
}
return this.unusedSize() / this.size();
}
isContentScript() {
return this.isContentScriptInternal;
}
entries() {
return this.coverageInfoByLocation.values();
}
numberOfEntries() {
return this.coverageInfoByLocation.size;
}
removeCoverageEntry(key, entry) {
if (!this.coverageInfoByLocation.delete(key)) {
return;
}
this.addToSizes(-entry.getUsedSize(), -entry.getSize());
}
addToSizes(usedSize, size) {
this.usedSizeInternal += usedSize;
this.sizeInternal += size;
if (usedSize !== 0 || size !== 0) {
this.dispatchEventToListeners(URLCoverageInfo.Events.SizesChanged);
}
}
ensureEntry(contentProvider, contentLength, lineOffset, columnOffset, type) {
const key = `${lineOffset}:${columnOffset}`;
let entry = this.coverageInfoByLocation.get(key);
if ((type & 2 /* CoverageType.JavaScript */) && !this.coverageInfoByLocation.size) {
this.isContentScriptInternal = contentProvider.isContentScript();
}
this.typeInternal |= type;
if (entry) {
entry.addCoverageType(type);
return entry;
}
if ((type & 2 /* CoverageType.JavaScript */) && !this.coverageInfoByLocation.size) {
this.isContentScriptInternal = contentProvider.isContentScript();
}
entry = new CoverageInfo(contentProvider, contentLength, lineOffset, columnOffset, type);
this.coverageInfoByLocation.set(key, entry);
this.addToSizes(0, contentLength);
return entry;
}
async getFullText() {
// For .html resources, multiple scripts share URL, but have different offsets.
let useFullText = false;
const url = this.url();
for (const info of this.coverageInfoByLocation.values()) {
const { lineOffset, columnOffset } = info.getOffsets();
if (lineOffset || columnOffset) {
useFullText = Boolean(url);
break;
}
}
if (!useFullText) {
return null;
}
const resource = SDK.ResourceTreeModel.ResourceTreeModel.resourceForURL(url);
if (!resource) {
return null;
}
const content = (await resource.requestContent()).content;
return new TextUtils.Text.Text(content || '');
}
entriesForExportBasedOnFullText(fullText) {
const coverageByLocationKeys = Array.from(this.coverageInfoByLocation.keys()).sort(locationCompare);
const entry = { url: this.url(), ranges: [], text: fullText.value() };
for (const infoKey of coverageByLocationKeys) {
const info = this.coverageInfoByLocation.get(infoKey);
if (!info) {
continue;
}
const { lineOffset, columnOffset } = info.getOffsets();
const offset = fullText ? fullText.offsetFromPosition(lineOffset, columnOffset) : 0;
entry.ranges.push(...info.rangesForExport(offset));
}
return entry;
}
async entriesForExportBasedOnContent() {
const coverageByLocationKeys = Array.from(this.coverageInfoByLocation.keys()).sort(locationCompare);
const result = [];
for (const infoKey of coverageByLocationKeys) {
const info = this.coverageInfoByLocation.get(infoKey);
if (!info) {
continue;
}
const entry = {
url: this.url(),
ranges: info.rangesForExport(),
text: (await info.getContentProvider().requestContent()).content,
};
result.push(entry);
}
return result;
}
async entriesForExport() {
const fullText = await this.getFullText();
// We have full text for this resource, resolve the offsets using the text line endings.
if (fullText) {
return [await this.entriesForExportBasedOnFullText(fullText)];
}
// Fall back to the per-script operation.
return this.entriesForExportBasedOnContent();
}
}
(function (URLCoverageInfo) {
// TODO(crbug.com/1167717): Make this a const enum again
// eslint-disable-next-line rulesdir/const_enum
let Events;
(function (Events) {
Events["SizesChanged"] = "SizesChanged";
})(Events = URLCoverageInfo.Events || (URLCoverageInfo.Events = {}));
})(URLCoverageInfo || (URLCoverageInfo = {}));
export const mergeSegments = (segmentsA, segmentsB) => {
const result = [];
let indexA = 0;
let indexB = 0;
while (indexA < segmentsA.length && indexB < segmentsB.length) {
const a = segmentsA[indexA];
const b = segmentsB[indexB];
const count = (a.count || 0) + (b.count || 0);
const end = Math.min(a.end, b.end);
const last = result[result.length - 1];
const stamp = Math.min(a.stamp, b.stamp);
if (!last || last.count !== count || last.stamp !== stamp) {
result.push({ end: end, count: count, stamp: stamp });
}
else {
last.end = end;
}
if (a.end <= b.end) {
indexA++;
}
if (a.end >= b.end) {
indexB++;
}
}
for (; indexA < segmentsA.length; indexA++) {
result.push(segmentsA[indexA]);
}
for (; indexB < segmentsB.length; indexB++) {
result.push(segmentsB[indexB]);
}
return result;
};
export class CoverageInfo {
contentProvider;
size;
usedSize;
statsByTimestamp;
lineOffset;
columnOffset;
coverageType;
segments;
constructor(contentProvider, size, lineOffset, columnOffset, type) {
this.contentProvider = contentProvider;
this.size = size;
this.usedSize = 0;
this.statsByTimestamp = new Map();
this.lineOffset = lineOffset;
this.columnOffset = columnOffset;
this.coverageType = type;
this.segments = [];
}
getContentProvider() {
return this.contentProvider;
}
url() {
return this.contentProvider.contentURL();
}
type() {
return this.coverageType;
}
addCoverageType(type) {
this.coverageType |= type;
}
getOffsets() {
return { lineOffset: this.lineOffset, columnOffset: this.columnOffset };
}
/**
* Returns the delta by which usedSize increased.
*/
mergeCoverage(segments) {
const oldUsedSize = this.usedSize;
this.segments = mergeSegments(this.segments, segments);
this.updateStats();
return this.usedSize - oldUsedSize;
}
usedByTimestamp() {
return this.statsByTimestamp;
}
getSize() {
return this.size;
}
getUsedSize() {
return this.usedSize;
}
usageForRange(start, end) {
let index = Platform.ArrayUtilities.upperBound(this.segments, start, (position, segment) => position - segment.end);
for (; index < this.segments.length && this.segments[index].end < end; ++index) {
if (this.segments[index].count) {
return true;
}
}
return index < this.segments.length && Boolean(this.segments[index].count);
}
updateStats() {
this.statsByTimestamp = new Map();
this.usedSize = 0;
let last = 0;
for (const segment of this.segments) {
let previousCount = this.statsByTimestamp.get(segment.stamp);
if (previousCount === undefined) {
previousCount = 0;
}
if (segment.count) {
const used = segment.end - last;
this.usedSize += used;
this.statsByTimestamp.set(segment.stamp, previousCount + used);
}
last = segment.end;
}
}
rangesForExport(offset = 0) {
const ranges = [];
let start = 0;
for (const segment of this.segments) {
if (segment.count) {
const last = ranges.length > 0 ? ranges[ranges.length - 1] : null;
if (last && last.end === start + offset) {
// We can extend the last segment.
last.end = segment.end + offset;
}
else {
// There was a gap, add a new segment.
ranges.push({ start: start + offset, end: segment.end + offset });
}
}
start = segment.end;
}
return ranges;
}
}
//# sourceMappingURL=CoverageModel.js.map