// 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 Bindings from '../bindings/bindings.js'; // eslint-disable-line no-unused-vars import * as Common from '../common/common.js'; import * as SDK from '../sdk/sdk.js'; import * as TextUtils from '../text_utils/text_utils.js'; /** * @enum {number} */ export const CoverageType = { CSS: (1 << 0), JavaScript: (1 << 1), JavaScriptPerFunction: (1 << 2), }; /** @enum {symbol} */ export const SuspensionState = { Active: Symbol('Active'), Suspending: Symbol('Suspending'), Suspended: Symbol('Suspended') }; /** @enum {symbol} */ export const Events = { CoverageUpdated: Symbol('CoverageUpdated'), CoverageReset: Symbol('CoverageReset'), }; /** @type {number} */ const _coveragePollingPeriodMs = 200; export class CoverageModel extends SDK.SDKModel.SDKModel { /** * @param {!SDK.SDKModel.Target} target */ constructor(target) { super(target); /** @type {?SDK.CPUProfilerModel.CPUProfilerModel} */ this._cpuProfilerModel = target.model(SDK.CPUProfilerModel.CPUProfilerModel); /** @type {?SDK.CSSModel.CSSModel} */ this._cssModel = target.model(SDK.CSSModel.CSSModel); /** @type {?SDK.DebuggerModel.DebuggerModel} */ this._debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel); /** @type {!Map} */ this._coverageByURL = new Map(); /** @type {!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. /** @type {!Set} */ this._coverageUpdateTimes = new Set(); /** @type {!SuspensionState} */ this._suspensionState = SuspensionState.Active; /** @type {?number} */ this._pollTimer = null; /** @type {?Promise} */ this._currentPollPromise = null; /** @type {?boolean} */ this._shouldResumePollingOnResume = false; /** @type {!Array,stamp:number}>} */ this._jsBacklog = []; /** @type {!Array,stamp:number}>} */ this._cssBacklog = []; /** @type {?boolean} */ this._performanceTraceRecording = false; } /** * @param {boolean} jsCoveragePerBlock - Collect per Block coverage if `true`, per function coverage otherwise. * @return {!Promise} */ async start(jsCoveragePerBlock) { if (this._suspensionState !== 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 !!(this._cssModel || this._cpuProfilerModel); } /** * @param {number} timestamp * @param {string} occasion * @param {!Array} coverageData */ preciseCoverageDeltaUpdate(timestamp, occasion, coverageData) { this._coverageUpdateTimes.add(timestamp); this._backlogOrProcessJSCoverage(coverageData, timestamp); } /** * @return {!Promise} */ 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); } /** * @return {!Promise} */ async startPolling() { if (this._currentPollPromise || this._suspensionState !== SuspensionState.Active) { return; } await this._pollLoop(); } /** * @return {!Promise} */ async _pollLoop() { this._clearTimer(); this._currentPollPromise = this._pollAndCallback(); await this._currentPollPromise; if (this._suspensionState === SuspensionState.Active || this._performanceTraceRecording) { this._pollTimer = window.setTimeout(() => this._pollLoop(), _coveragePollingPeriodMs); } } async stopPolling() { this._clearTimer(); await this._currentPollPromise; this._currentPollPromise = null; // Do one last poll to get the final data. await this._pollAndCallback(); } /** * @return {!Promise} */ async _pollAndCallback() { if (this._suspensionState === 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 !== SuspensionState.Suspended || !!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. * @override * @param {string=} reason - optionally provide a reason, so the model can respond accordingly * @return {!Promise} */ async preSuspendModel(reason) { if (this._suspensionState !== SuspensionState.Active) { return; } this._suspensionState = 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; } } /** * @override * @param {string=} reason - optionally provide a reason, so the model can respond accordingly * @return {!Promise} */ async suspendModel(reason) { this._suspensionState = SuspensionState.Suspended; } /** * @override * @return {!Promise} */ async resumeModel() { } /** * Restarts polling after suspension. Note that the function is idempotent * because starting polling is idempotent. * @override * @return {!Promise} */ async postResumeModel() { this._suspensionState = SuspensionState.Active; this._performanceTraceRecording = false; if (this._shouldResumePollingOnResume) { this._shouldResumePollingOnResume = false; await this.startPolling(); } } /** * @return {!Array} */ entries() { return Array.from(this._coverageByURL.values()); } /** * @param {string} url * @return {?URLCoverageInfo} */ getCoverageForUrl(url) { return this._coverageByURL.get(url) || null; } /** * @param {!TextUtils.ContentProvider.ContentProvider} contentProvider * @param {number} startOffset * @param {number} endOffset * @return {boolean|undefined} */ 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() !== CoverageType.CSS) { continue; } const contentProvider = /** @type {!SDK.CSSStyleSheetHeader.CSSStyleSheetHeader} */ (entry.contentProvider()); this._coverageByContentProvider.delete(contentProvider); const key = `${contentProvider.startLine}:${contentProvider.startColumn}`; const urlEntry = this._coverageByURL.get(entry.url()); if (!urlEntry || !urlEntry._coverageInfoByLocation.delete(key)) { continue; } urlEntry._addToSizes(-entry._usedSize, -entry._size); if (!urlEntry._coverageInfoByLocation.size) { this._coverageByURL.delete(entry.url()); } } if (this._cssModel) { for (const styleSheetHeader of this._cssModel.getAllStyleSheetHeaders()) { this._addStyleSheetToCSSCoverage(styleSheetHeader); } } } /** * @return {!Promise>} */ async _takeAllCoverage() { const [updatesCSS, updatesJS] = await Promise.all([this._takeCSSCoverage(), this._takeJSCoverage()]); return [...updatesCSS, ...updatesJS]; } /** * @return {!Promise>} */ async _takeJSCoverage() { if (!this._cpuProfilerModel) { return []; } const {coverage, timestamp} = await this._cpuProfilerModel.takePreciseCoverage(); this._coverageUpdateTimes.add(timestamp); return this._backlogOrProcessJSCoverage(coverage, timestamp); } coverageUpdateTimes() { return this._coverageUpdateTimes; } /** * @param {!Array} freshRawCoverageData * @param {number} freshTimestamp */ async _backlogOrProcessJSCoverage(freshRawCoverageData, freshTimestamp) { if (freshRawCoverageData.length > 0) { this._jsBacklog.push({rawCoverageData: freshRawCoverageData, stamp: freshTimestamp}); } if (this._suspensionState !== SuspensionState.Active) { return []; } /** * @param {!{stamp: number}} x * @param {!{stamp: number}} y */ 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() { this._backlogOrProcessJSCoverage([], 0); } /** * @param {!Array} scriptsCoverage * @param {number} stamp * @return {!Array} */ _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 = 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 |= CoverageType.JavaScriptPerFunction; } for (const range of func.ranges) { ranges.push(range); } } const subentry = this._addCoverage( script, script.contentLength, script.lineOffset, script.columnOffset, ranges, /** @type {!CoverageType} */ (type), stamp); if (subentry) { updatedEntries.push(subentry); } } return updatedEntries; } /** * @param {!Common.EventTarget.EventTargetEvent} event */ _handleStyleSheetAdded(event) { const styleSheetHeader = /** @type {!SDK.CSSStyleSheetHeader.CSSStyleSheetHeader} */ (event.data); this._addStyleSheetToCSSCoverage(styleSheetHeader); } /** * @return {!Promise>} */ async _takeCSSCoverage() { // Don't poll if we have no model, or are suspended. if (!this._cssModel || this._suspensionState !== SuspensionState.Active) { return []; } const {coverage, timestamp} = await this._cssModel.takeCoverageDelta(); this._coverageUpdateTimes.add(timestamp); return this._backlogOrProcessCSSCoverage(coverage, timestamp); } /** * @param {!Array} freshRawCoverageData * @param {number} freshTimestamp */ async _backlogOrProcessCSSCoverage(freshRawCoverageData, freshTimestamp) { if (freshRawCoverageData.length > 0) { this._cssBacklog.push({rawCoverageData: freshRawCoverageData, stamp: freshTimestamp}); } if (this._suspensionState !== SuspensionState.Active) { return []; } /** * @param {!{stamp:number}} x * @param {!{stamp:number}} y */ 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(); } /** * @param {!Array} ruleUsageList * @param {number} stamp * @return {!Array} */ _processCSSCoverage(ruleUsageList, stamp) { if (!this._cssModel) { return []; } const updatedEntries = []; /** @type {!Map>} */ 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 = /** @type {!SDK.CSSStyleSheetHeader.CSSStyleSheetHeader} */ (entry[0]); const ranges = /** @type {!Array} */ (entry[1]); const subentry = this._addCoverage( styleSheetHeader, styleSheetHeader.contentLength, styleSheetHeader.startLine, styleSheetHeader.startColumn, ranges, CoverageType.CSS, stamp); if (subentry) { updatedEntries.push(subentry); } } return updatedEntries; } /** * @param {!Array} ranges * @param {number} stamp * @return {!Array} */ static _convertToDisjointSegments(ranges, stamp) { ranges.sort((a, b) => a.startOffset - b.startOffset); /** @type {!Array} */ const result = []; const stack = []; for (const entry of ranges) { let top = stack.peekLast(); while (top && top.endOffset <= entry.startOffset) { append(top.endOffset, top.count); stack.pop(); top = stack.peekLast(); } append(entry.startOffset, top ? top.count : 0); stack.push(entry); } for (let top = stack.pop(); top; top = stack.pop()) { append(top.endOffset, top.count); } /** * @param {number} end * @param {number} count */ function append(end, count) { const last = result.peekLast(); 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; } /** * @param {!SDK.CSSStyleSheetHeader.CSSStyleSheetHeader} styleSheetHeader */ _addStyleSheetToCSSCoverage(styleSheetHeader) { this._addCoverage( styleSheetHeader, styleSheetHeader.contentLength, styleSheetHeader.startLine, styleSheetHeader.startColumn, [], CoverageType.CSS, Date.now()); } /** * @param {!TextUtils.ContentProvider.ContentProvider} contentProvider * @param {number} contentLength * @param {number} startLine * @param {number} startColumn * @param {!Array} ranges * @param {!CoverageType} type * @param {number} stamp * @return {?CoverageInfo} */ _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.peekLast(); if (last && last.end < contentLength) { segments.push({end: contentLength, stamp: stamp, count: 0}); } const oldUsedSize = coverageInfo._usedSize; coverageInfo.mergeCoverage(segments); if (!isNewUrlCoverage && coverageInfo._usedSize === oldUsedSize) { return null; } urlCoverage._addToSizes(coverageInfo._usedSize - oldUsedSize, 0); return coverageInfo; } /** * @param {!Bindings.FileUtils.FileOutputStream} fos */ async exportReport(fos) { /** @type {!Array, text: (string|null)}>} */ const result = []; /** * @param {string} a * @param {string} b */ 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); } 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; } // For .html resources, multiple scripts share URL, but have different offsets. let useFullText = false; for (const info of urlInfo._coverageInfoByLocation.values()) { if (info._lineOffset || info._columnOffset) { useFullText = !!url; break; } } let fullText = null; if (useFullText) { const resource = SDK.ResourceTreeModel.ResourceTreeModel.resourceForURL(url); if (resource) { const content = (await resource.requestContent()).content; fullText = new TextUtils.Text.Text(content || ''); } } const coverageByLocationKeys = Array.from(urlInfo._coverageInfoByLocation.keys()).sort(locationCompare); // We have full text for this resource, resolve the offsets using the text line endings. if (fullText) { /** @type {!{url: string, ranges: !Array, text: string}} */ const entry = {url, ranges: [], text: fullText.value()}; for (const infoKey of coverageByLocationKeys) { const info = urlInfo._coverageInfoByLocation.get(infoKey); if (!info) { continue; } const offset = fullText ? fullText.offsetFromPosition(info._lineOffset, info._columnOffset) : 0; let start = 0; for (const segment of info._segments) { if (segment.count) { entry.ranges.push({start: start + offset, end: segment.end + offset}); } else { start = segment.end; } } } result.push(entry); continue; } // Fall back to the per-script operation. for (const infoKey of coverageByLocationKeys) { const info = urlInfo._coverageInfoByLocation.get(infoKey); if (!info) { continue; } /** @type {!{url: string, ranges: !Array, text: (string|null)}} */ const entry = {url, ranges: [], text: (await info.contentProvider().requestContent()).content}; let start = 0; for (const segment of info._segments) { if (segment.count) { entry.ranges.push({start: start, end: segment.end}); } else { start = segment.end; } } result.push(entry); } } await fos.write(JSON.stringify(result, undefined, 2)); fos.close(); } } SDK.SDKModel.SDKModel.register(CoverageModel, SDK.SDKModel.Capability.None, false); export class URLCoverageInfo extends Common.ObjectWrapper.ObjectWrapper { /** * @param {string} url */ constructor(url) { super(); this._url = url; /** @type {!Map} */ this._coverageInfoByLocation = new Map(); this._size = 0; this._usedSize = 0; /** @type {!CoverageType} */ this._type; this._isContentScript = false; } /** * @return {string} */ url() { return this._url; } /** * @return {!CoverageType} */ type() { return this._type; } /** * @return {number} */ size() { return this._size; } /** * @return {number} */ usedSize() { return this._usedSize; } /** * @return {number} */ unusedSize() { return this._size - this._usedSize; } /** * @return {number} */ usedPercentage() { // Per convention, empty files are reported as 100 % uncovered if (this._size === 0) { return 0; } return this.usedSize() / this.size() * 100; } /** * @return {number} */ unusedPercentage() { // Per convention, empty files are reported as 100 % uncovered if (this._size === 0) { return 100; } return this.unusedSize() / this.size() * 100; } /** * @return {boolean} */ isContentScript() { return this._isContentScript; } entries() { return this._coverageInfoByLocation.values(); } /** * @param {number} usedSize * @param {number} size */ _addToSizes(usedSize, size) { this._usedSize += usedSize; this._size += size; if (usedSize !== 0 || size !== 0) { this.dispatchEventToListeners(URLCoverageInfo.Events.SizesChanged); } } /** * @param {!TextUtils.ContentProvider.ContentProvider} contentProvider * @param {number} contentLength * @param {number} lineOffset * @param {number} columnOffset * @param {!CoverageType} type * @return {!CoverageInfo} */ _ensureEntry(contentProvider, contentLength, lineOffset, columnOffset, type) { const key = `${lineOffset}:${columnOffset}`; let entry = this._coverageInfoByLocation.get(key); if ((type & CoverageType.JavaScript) && !this._coverageInfoByLocation.size) { this._isContentScript = /** @type {!SDK.Script.Script} */ (contentProvider).isContentScript(); } this._type |= type; if (entry) { entry._coverageType |= type; return entry; } if ((type & CoverageType.JavaScript) && !this._coverageInfoByLocation.size) { this._isContentScript = /** @type {!SDK.Script.Script} */ (contentProvider).isContentScript(); } entry = new CoverageInfo(contentProvider, contentLength, lineOffset, columnOffset, type); this._coverageInfoByLocation.set(key, entry); this._addToSizes(0, contentLength); return entry; } } /** @enum {symbol} */ URLCoverageInfo.Events = { SizesChanged: Symbol('SizesChanged') }; /** * @param {!Array} segmentsA * @param {!Array} segmentsB */ export const mergeSegments = (segmentsA, segmentsB) => { /** @type {!Array} */ 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.peekLast(); 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 { /** * @param {!TextUtils.ContentProvider.ContentProvider} contentProvider * @param {number} size * @param {number} lineOffset * @param {number} columnOffset * @param {!CoverageType} type */ constructor(contentProvider, size, lineOffset, columnOffset, type) { this._contentProvider = contentProvider; this._size = size; this._usedSize = 0; /** @type {!Map} */ this._statsByTimestamp = new Map(); this._lineOffset = lineOffset; this._columnOffset = columnOffset; this._coverageType = type; /** @type {!Array} */ this._segments = []; } /** * @return {!TextUtils.ContentProvider.ContentProvider} */ contentProvider() { return this._contentProvider; } /** * @return {string} */ url() { return this._contentProvider.contentURL(); } /** * @return {!CoverageType} */ type() { return this._coverageType; } /** * @param {!Array} segments */ mergeCoverage(segments) { this._segments = mergeSegments(this._segments, segments); this._updateStats(); } usedByTimestamp() { return this._statsByTimestamp; } size() { return this._size; } /** * @param {number} start * @param {number} end * @return {boolean} */ usageForRange(start, end) { let index = this._segments.upperBound(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 && !!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; } } } /** @typedef {{startOffset: number, endOffset: number, count: number}} */ // @ts-ignore typedef export let RangeUseCount; /** @typedef {{end: number, count: number, stamp: number}} */ // @ts-ignore typedef export let CoverageSegment;