| // 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 type * as Bindings from '../../models/bindings/bindings.js'; |
| 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'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| |
| export const enum CoverageType { |
| CSS = (1 << 0), |
| JavaScript = (1 << 1), |
| JavaScriptPerFunction = (1 << 2), |
| } |
| |
| export const enum SuspensionState { |
| Active = 'Active', |
| Suspending = 'Suspending', |
| Suspended = 'Suspended', |
| } |
| |
| // TODO(crbug.com/1167717): Make this a const enum again |
| // eslint-disable-next-line rulesdir/const_enum |
| export enum Events { |
| CoverageUpdated = 'CoverageUpdated', |
| CoverageReset = 'CoverageReset', |
| } |
| |
| export type EventTypes = { |
| [Events.CoverageUpdated]: CoverageInfo[], |
| [Events.CoverageReset]: void, |
| }; |
| |
| const COVERAGE_POLLING_PERIOD_MS: number = 200; |
| |
| interface BacklogItem<T> { |
| rawCoverageData: Array<T>; |
| stamp: number; |
| } |
| |
| export class CoverageModel extends SDK.SDKModel.SDKModel<EventTypes> { |
| private cpuProfilerModel: SDK.CPUProfilerModel.CPUProfilerModel|null; |
| private cssModel: SDK.CSSModel.CSSModel|null; |
| private debuggerModel: SDK.DebuggerModel.DebuggerModel|null; |
| private coverageByURL: Map<Platform.DevToolsPath.UrlString, URLCoverageInfo>; |
| private coverageByContentProvider: Map<TextUtils.ContentProvider.ContentProvider, CoverageInfo>; |
| private coverageUpdateTimes: Set<number>; |
| private suspensionState: SuspensionState; |
| private pollTimer: number|null; |
| private currentPollPromise: Promise<void>|null; |
| private shouldResumePollingOnResume: boolean|null; |
| private jsBacklog: BacklogItem<Protocol.Profiler.ScriptCoverage>[]; |
| private cssBacklog: BacklogItem<Protocol.CSS.RuleUsage>[]; |
| private performanceTraceRecording: boolean|null; |
| |
| constructor(target: SDK.Target.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 = SuspensionState.Active; |
| this.pollTimer = null; |
| this.currentPollPromise = null; |
| this.shouldResumePollingOnResume = false; |
| this.jsBacklog = []; |
| this.cssBacklog = []; |
| this.performanceTraceRecording = false; |
| } |
| |
| async start(jsCoveragePerBlock: boolean): Promise<boolean> { |
| 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 Boolean(this.cssModel || this.cpuProfilerModel); |
| } |
| |
| preciseCoverageDeltaUpdate(timestamp: number, occasion: string, coverageData: Protocol.Profiler.ScriptCoverage[]): |
| void { |
| this.coverageUpdateTimes.add(timestamp); |
| void this.backlogOrProcessJSCoverage(coverageData, timestamp); |
| } |
| |
| async stop(): Promise<void> { |
| 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(): void { |
| this.coverageByURL = new Map(); |
| this.coverageByContentProvider = new Map(); |
| this.coverageUpdateTimes = new Set(); |
| this.dispatchEventToListeners(Events.CoverageReset); |
| } |
| |
| async startPolling(): Promise<void> { |
| if (this.currentPollPromise || this.suspensionState !== SuspensionState.Active) { |
| return; |
| } |
| await this.pollLoop(); |
| } |
| |
| private async pollLoop(): Promise<void> { |
| this.clearTimer(); |
| this.currentPollPromise = this.pollAndCallback(); |
| await this.currentPollPromise; |
| if (this.suspensionState === SuspensionState.Active || this.performanceTraceRecording) { |
| this.pollTimer = window.setTimeout(() => this.pollLoop(), COVERAGE_POLLING_PERIOD_MS); |
| } |
| } |
| |
| async stopPolling(): Promise<void> { |
| this.clearTimer(); |
| await this.currentPollPromise; |
| this.currentPollPromise = null; |
| // Do one last poll to get the final data. |
| await this.pollAndCallback(); |
| } |
| |
| private async pollAndCallback(): Promise<void> { |
| 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 || Boolean(this.performanceTraceRecording), |
| 'CoverageModel was suspended while polling.'); |
| if (updates.length) { |
| this.dispatchEventToListeners(Events.CoverageUpdated, updates); |
| } |
| } |
| |
| private clearTimer(): void { |
| 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 async preSuspendModel(reason?: string): Promise<void> { |
| 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 async suspendModel(_reason?: string): Promise<void> { |
| this.suspensionState = SuspensionState.Suspended; |
| } |
| |
| override async resumeModel(): Promise<void> { |
| } |
| |
| /** |
| * Restarts polling after suspension. Note that the function is idempotent |
| * because starting polling is idempotent. |
| */ |
| override async postResumeModel(): Promise<void> { |
| this.suspensionState = SuspensionState.Active; |
| this.performanceTraceRecording = false; |
| if (this.shouldResumePollingOnResume) { |
| this.shouldResumePollingOnResume = false; |
| await this.startPolling(); |
| } |
| } |
| |
| entries(): URLCoverageInfo[] { |
| return Array.from(this.coverageByURL.values()); |
| } |
| |
| getCoverageForUrl(url: Platform.DevToolsPath.UrlString): URLCoverageInfo|null { |
| return this.coverageByURL.get(url) || null; |
| } |
| |
| usageForRange(contentProvider: TextUtils.ContentProvider.ContentProvider, startOffset: number, endOffset: number): |
| boolean|undefined { |
| const coverageInfo = this.coverageByContentProvider.get(contentProvider); |
| return coverageInfo && coverageInfo.usageForRange(startOffset, endOffset); |
| } |
| |
| private clearCSS(): void { |
| for (const entry of this.coverageByContentProvider.values()) { |
| if (entry.type() !== CoverageType.CSS) { |
| continue; |
| } |
| const contentProvider = entry.getContentProvider() as SDK.CSSStyleSheetHeader.CSSStyleSheetHeader; |
| 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); |
| } |
| } |
| } |
| |
| private async takeAllCoverage(): Promise<CoverageInfo[]> { |
| const [updatesCSS, updatesJS] = await Promise.all([this.takeCSSCoverage(), this.takeJSCoverage()]); |
| return [...updatesCSS, ...updatesJS]; |
| } |
| |
| private async takeJSCoverage(): Promise<CoverageInfo[]> { |
| if (!this.cpuProfilerModel) { |
| return []; |
| } |
| const {coverage, timestamp} = await this.cpuProfilerModel.takePreciseCoverage(); |
| this.coverageUpdateTimes.add(timestamp); |
| return this.backlogOrProcessJSCoverage(coverage, timestamp); |
| } |
| |
| getCoverageUpdateTimes(): Set<number> { |
| return this.coverageUpdateTimes; |
| } |
| |
| private async backlogOrProcessJSCoverage( |
| freshRawCoverageData: Protocol.Profiler.ScriptCoverage[], freshTimestamp: number): Promise<CoverageInfo[]> { |
| if (freshRawCoverageData.length > 0) { |
| this.jsBacklog.push({rawCoverageData: freshRawCoverageData, stamp: freshTimestamp}); |
| } |
| if (this.suspensionState !== SuspensionState.Active) { |
| return []; |
| } |
| const ascendingByTimestamp = (x: {stamp: number}, y: {stamp: number}): number => 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(): Promise<void> { |
| void this.backlogOrProcessJSCoverage([], 0); |
| } |
| |
| private processJSCoverage(scriptsCoverage: Protocol.Profiler.ScriptCoverage[], stamp: number): CoverageInfo[] { |
| 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 as CoverageType, stamp); |
| if (subentry) { |
| updatedEntries.push(subentry); |
| } |
| } |
| return updatedEntries; |
| } |
| |
| private handleStyleSheetAdded( |
| event: Common.EventTarget.EventTargetEvent<SDK.CSSStyleSheetHeader.CSSStyleSheetHeader>): void { |
| this.addStyleSheetToCSSCoverage(event.data); |
| } |
| |
| private async takeCSSCoverage(): Promise<CoverageInfo[]> { |
| // 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); |
| } |
| |
| private async backlogOrProcessCSSCoverage(freshRawCoverageData: Protocol.CSS.RuleUsage[], freshTimestamp: number): |
| Promise<CoverageInfo[]> { |
| if (freshRawCoverageData.length > 0) { |
| this.cssBacklog.push({rawCoverageData: freshRawCoverageData, stamp: freshTimestamp}); |
| } |
| if (this.suspensionState !== SuspensionState.Active) { |
| return []; |
| } |
| const ascendingByTimestamp = (x: {stamp: number}, y: {stamp: number}): number => 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(); |
| } |
| |
| private processCSSCoverage(ruleUsageList: Protocol.CSS.RuleUsage[], stamp: number): CoverageInfo[] { |
| if (!this.cssModel) { |
| return []; |
| } |
| const updatedEntries = []; |
| const rulesByStyleSheet = new Map<SDK.CSSStyleSheetHeader.CSSStyleSheetHeader, RangeUseCount[]>(); |
| 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] as SDK.CSSStyleSheetHeader.CSSStyleSheetHeader; |
| const ranges = entry[1] as RangeUseCount[]; |
| const subentry = this.addCoverage( |
| styleSheetHeader, styleSheetHeader.contentLength, styleSheetHeader.startLine, styleSheetHeader.startColumn, |
| ranges, CoverageType.CSS, stamp); |
| if (subentry) { |
| updatedEntries.push(subentry); |
| } |
| } |
| return updatedEntries; |
| } |
| |
| private static convertToDisjointSegments(ranges: RangeUseCount[], stamp: number): CoverageSegment[] { |
| ranges.sort((a, b) => a.startOffset - b.startOffset); |
| |
| const result: CoverageSegment[] = []; |
| const stack = []; |
| for (const entry of ranges) { |
| let top: RangeUseCount = 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: number, count: number): void { |
| 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; |
| } |
| |
| private addStyleSheetToCSSCoverage(styleSheetHeader: SDK.CSSStyleSheetHeader.CSSStyleSheetHeader): void { |
| this.addCoverage( |
| styleSheetHeader, styleSheetHeader.contentLength, styleSheetHeader.startLine, styleSheetHeader.startColumn, [], |
| CoverageType.CSS, Date.now()); |
| } |
| |
| private addCoverage( |
| contentProvider: TextUtils.ContentProvider.ContentProvider, contentLength: number, startLine: number, |
| startColumn: number, ranges: RangeUseCount[], type: CoverageType, stamp: number): CoverageInfo|null { |
| 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: Bindings.FileUtils.FileOutputStream): Promise<void> { |
| const result: {url: string, ranges: {start: number, end: number}[], text: string|null}[] = []; |
| 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}); |
| |
| export interface EntryForExport { |
| url: Platform.DevToolsPath.UrlString; |
| ranges: {start: number, end: number}[]; |
| text: string|null; |
| } |
| |
| function locationCompare(a: string, b: string): number { |
| 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<URLCoverageInfo.EventTypes> { |
| private readonly urlInternal: Platform.DevToolsPath.UrlString; |
| private coverageInfoByLocation: Map<string, CoverageInfo>; |
| private sizeInternal: number; |
| private usedSizeInternal: number; |
| private typeInternal!: CoverageType; |
| private isContentScriptInternal: boolean; |
| |
| constructor(url: Platform.DevToolsPath.UrlString) { |
| super(); |
| |
| this.urlInternal = url; |
| this.coverageInfoByLocation = new Map(); |
| this.sizeInternal = 0; |
| this.usedSizeInternal = 0; |
| this.isContentScriptInternal = false; |
| } |
| |
| url(): Platform.DevToolsPath.UrlString { |
| return this.urlInternal; |
| } |
| |
| type(): CoverageType { |
| return this.typeInternal; |
| } |
| |
| size(): number { |
| return this.sizeInternal; |
| } |
| |
| usedSize(): number { |
| return this.usedSizeInternal; |
| } |
| |
| unusedSize(): number { |
| return this.sizeInternal - this.usedSizeInternal; |
| } |
| |
| usedPercentage(): number { |
| // Per convention, empty files are reported as 100 % uncovered |
| if (this.sizeInternal === 0) { |
| return 0; |
| } |
| return this.usedSize() / this.size(); |
| } |
| |
| unusedPercentage(): number { |
| // Per convention, empty files are reported as 100 % uncovered |
| if (this.sizeInternal === 0) { |
| return 100; |
| } |
| return this.unusedSize() / this.size(); |
| } |
| |
| isContentScript(): boolean { |
| return this.isContentScriptInternal; |
| } |
| |
| entries(): IterableIterator<CoverageInfo> { |
| return this.coverageInfoByLocation.values(); |
| } |
| |
| numberOfEntries(): number { |
| return this.coverageInfoByLocation.size; |
| } |
| |
| removeCoverageEntry(key: string, entry: CoverageInfo): void { |
| if (!this.coverageInfoByLocation.delete(key)) { |
| return; |
| } |
| this.addToSizes(-entry.getUsedSize(), -entry.getSize()); |
| } |
| |
| addToSizes(usedSize: number, size: number): void { |
| this.usedSizeInternal += usedSize; |
| this.sizeInternal += size; |
| |
| if (usedSize !== 0 || size !== 0) { |
| this.dispatchEventToListeners(URLCoverageInfo.Events.SizesChanged); |
| } |
| } |
| |
| ensureEntry( |
| contentProvider: TextUtils.ContentProvider.ContentProvider, contentLength: number, lineOffset: number, |
| columnOffset: number, type: CoverageType): CoverageInfo { |
| const key = `${lineOffset}:${columnOffset}`; |
| let entry = this.coverageInfoByLocation.get(key); |
| |
| if ((type & CoverageType.JavaScript) && !this.coverageInfoByLocation.size) { |
| this.isContentScriptInternal = (contentProvider as SDK.Script.Script).isContentScript(); |
| } |
| this.typeInternal |= type; |
| |
| if (entry) { |
| entry.addCoverageType(type); |
| return entry; |
| } |
| |
| if ((type & CoverageType.JavaScript) && !this.coverageInfoByLocation.size) { |
| this.isContentScriptInternal = (contentProvider as SDK.Script.Script).isContentScript(); |
| } |
| |
| entry = new CoverageInfo(contentProvider, contentLength, lineOffset, columnOffset, type); |
| this.coverageInfoByLocation.set(key, entry); |
| this.addToSizes(0, contentLength); |
| |
| return entry; |
| } |
| |
| async getFullText(): Promise<TextUtils.Text.Text|null> { |
| // 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: TextUtils.Text.Text): EntryForExport { |
| const coverageByLocationKeys = Array.from(this.coverageInfoByLocation.keys()).sort(locationCompare); |
| const entry: EntryForExport = {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(): Promise<EntryForExport[]> { |
| 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: EntryForExport = { |
| url: this.url(), |
| ranges: info.rangesForExport(), |
| text: (await info.getContentProvider().requestContent()).content, |
| }; |
| result.push(entry); |
| } |
| return result; |
| } |
| |
| async entriesForExport(): Promise<EntryForExport[]> { |
| 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(); |
| } |
| } |
| |
| export namespace URLCoverageInfo { |
| // TODO(crbug.com/1167717): Make this a const enum again |
| // eslint-disable-next-line rulesdir/const_enum |
| export enum Events { |
| SizesChanged = 'SizesChanged', |
| } |
| |
| export type EventTypes = { |
| [Events.SizesChanged]: void, |
| }; |
| } |
| |
| export const mergeSegments = (segmentsA: CoverageSegment[], segmentsB: CoverageSegment[]): CoverageSegment[] => { |
| const result: CoverageSegment[] = []; |
| |
| 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 { |
| private contentProvider: TextUtils.ContentProvider.ContentProvider; |
| private size: number; |
| private usedSize: number; |
| private statsByTimestamp: Map<number, number>; |
| private lineOffset: number; |
| private columnOffset: number; |
| private coverageType: CoverageType; |
| private segments: CoverageSegment[]; |
| |
| constructor( |
| contentProvider: TextUtils.ContentProvider.ContentProvider, size: number, lineOffset: number, |
| columnOffset: number, type: CoverageType) { |
| 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(): TextUtils.ContentProvider.ContentProvider { |
| return this.contentProvider; |
| } |
| |
| url(): Platform.DevToolsPath.UrlString { |
| return this.contentProvider.contentURL(); |
| } |
| |
| type(): CoverageType { |
| return this.coverageType; |
| } |
| |
| addCoverageType(type: CoverageType): void { |
| this.coverageType |= type; |
| } |
| |
| getOffsets(): {lineOffset: number, columnOffset: number} { |
| return {lineOffset: this.lineOffset, columnOffset: this.columnOffset}; |
| } |
| |
| /** |
| * Returns the delta by which usedSize increased. |
| */ |
| mergeCoverage(segments: CoverageSegment[]): number { |
| const oldUsedSize = this.usedSize; |
| this.segments = mergeSegments(this.segments, segments); |
| this.updateStats(); |
| return this.usedSize - oldUsedSize; |
| } |
| |
| usedByTimestamp(): Map<number, number> { |
| return this.statsByTimestamp; |
| } |
| |
| getSize(): number { |
| return this.size; |
| } |
| |
| getUsedSize(): number { |
| return this.usedSize; |
| } |
| |
| usageForRange(start: number, end: number): boolean { |
| 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); |
| } |
| |
| private updateStats(): void { |
| 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: number = 0): {start: number, end: number}[] { |
| 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; |
| } |
| } |
| export interface RangeUseCount { |
| startOffset: number; |
| endOffset: number; |
| count: number; |
| } |
| export interface CoverageSegment { |
| end: number; |
| count: number; |
| stamp: number; |
| } |