| // Copyright 2016 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import '../../ui/legacy/legacy.js'; |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as Host from '../../core/host/host.js'; |
| import * as i18n from '../../core/i18n/i18n.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import * as Bindings from '../../models/bindings/bindings.js'; |
| import * as Workspace from '../../models/workspace/workspace.js'; |
| import * as Buttons from '../../ui/components/buttons/buttons.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import {Directives, html, i18nTemplate as unboundI18nTemplate, render, type TemplateResult} from '../../ui/lit/lit.js'; |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; |
| |
| import {CoverageDecorationManager} from './CoverageDecorationManager.js'; |
| import {type CoverageListItem, CoverageListView} from './CoverageListView.js'; |
| import { |
| type CoverageInfo, |
| CoverageModel, |
| CoverageType, |
| Events, |
| SourceURLCoverageInfo, |
| type URLCoverageInfo, |
| } from './CoverageModel.js'; |
| import coverageViewStyles from './coverageView.css.js'; |
| |
| const UIStrings = { |
| /** |
| * @description Tooltip in Coverage List View of the Coverage tab for selecting JavaScript coverage mode |
| */ |
| chooseCoverageGranularityPer: |
| 'Choose coverage granularity: Per function has low overhead, per block has significant overhead.', |
| /** |
| * @description Text in Coverage List View of the Coverage tab |
| */ |
| perFunction: 'Per function', |
| /** |
| * @description Text in Coverage List View of the Coverage tab |
| */ |
| perBlock: 'Per block', |
| /** |
| * @description Text in Coverage View of the Coverage tab |
| */ |
| filterByUrl: 'Filter by URL', |
| /** |
| * @description Label for the type filter in the Coverage Panel |
| */ |
| filterCoverageByType: 'Filter coverage by type', |
| /** |
| * @description Text for everything |
| */ |
| all: 'All', |
| /** |
| * @description Text that appears on a button for the css resource type filter. |
| */ |
| css: 'CSS', |
| /** |
| * @description Text in Timeline Tree View of the Performance panel |
| */ |
| javascript: 'JavaScript', |
| /** |
| * @description Tooltip text that appears on the setting when hovering over it in Coverage View of the Coverage tab |
| */ |
| includeExtensionContentScripts: 'Include extension content scripts', |
| /** |
| * @description Title for a type of source files |
| */ |
| contentScripts: 'Content scripts', |
| /** |
| * @description Message in Coverage View of the Coverage tab |
| */ |
| noCoverageData: 'No coverage data', |
| /** |
| * @description Message in Coverage View of the Coverage tab |
| */ |
| reloadPage: 'Reload page', |
| /** |
| * @description Message in Coverage View of the Coverage tab |
| */ |
| startRecording: 'Start recording', |
| |
| /** |
| * @description Message in Coverage View of the Coverage tab |
| * @example {Reload page} PH1 |
| */ |
| clickTheReloadButtonSToReloadAnd: 'Click the "{PH1}" button to reload and start capturing coverage.', |
| /** |
| * @description Message in Coverage View of the Coverage tab |
| * @example {Start recording} PH1 |
| */ |
| clickTheRecordButtonSToStart: 'Click the "{PH1}" button to start capturing coverage.', |
| /** |
| * @description Message in the Coverage View explaining that DevTools could not capture coverage. |
| */ |
| bfcacheNoCapture: 'Could not capture coverage info because the page was served from the back/forward cache.', |
| /** |
| * @description Message in the Coverage View explaining that DevTools could not capture coverage. |
| */ |
| activationNoCapture: 'Could not capture coverage info because the page was prerendered in the background.', |
| /** |
| * @description Message in the Coverage View prompting the user to reload the page. |
| * @example {reload button icon} PH1 |
| */ |
| reloadPrompt: 'Click the reload button {PH1} to reload and get coverage.', |
| |
| /** |
| * @description Footer message in Coverage View of the Coverage tab |
| * @example {300k used, 600k unused} PH1 |
| * @example {500k used, 800k unused} PH2 |
| */ |
| filteredSTotalS: 'Filtered: {PH1} Total: {PH2}', |
| /** |
| * @description Footer message in Coverage View of the Coverage tab |
| * @example {1.5 MB} PH1 |
| * @example {2.1 MB} PH2 |
| * @example {71%} PH3 |
| * @example {29%} PH4 |
| */ |
| sOfSSUsedSoFarSUnused: '{PH1} of {PH2} ({PH3}%) used so far, {PH4} unused.', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('panels/coverage/CoverageView.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| const i18nTemplate = unboundI18nTemplate.bind(undefined, str_); |
| const {ref} = Directives; |
| const {bindToAction, bindToSetting} = UI.UIUtils; |
| const {widgetConfig} = UI.Widget; |
| |
| let coverageViewInstance: CoverageView|undefined; |
| |
| export interface CoverageViewInput { |
| coverageType: number; |
| recording: boolean; |
| supportsRecordOnReload: boolean; |
| textFilter: RegExp|null; |
| typeFilter: number|null; |
| showContentScriptsSetting: Common.Settings.Setting<boolean>; |
| |
| needsReload: 'bfcache-page'|'prerender-page'|null; |
| coverageInfo: CoverageListItem[]|null; |
| selectedUrl: Platform.DevToolsPath.UrlString|null; |
| statusMessage: string; |
| |
| onCoverageTypeChanged: (newValue: number) => void; |
| onFilterChanged: (e: string) => void; |
| onTypeFilterChanged: (newValue: number) => void; |
| } |
| |
| export interface CoverageViewOutput { |
| focusResults: () => void; |
| } |
| |
| export type View = (input: CoverageViewInput, output: CoverageViewOutput, target: HTMLElement) => void; |
| |
| export const DEFAULT_VIEW: View = (input, output, target) => { |
| // clang-format off |
| render(html` |
| <style>${coverageViewStyles}</style> |
| <div class="coverage-toolbar-container" jslog=${VisualLogging.toolbar()} role="toolbar"> |
| <devtools-toolbar class="coverage-toolbar" role="presentation" wrappable> |
| <select title=${i18nString(UIStrings.chooseCoverageGranularityPer)} |
| aria-label=${i18nString(UIStrings.chooseCoverageGranularityPer)} |
| jslog=${VisualLogging.dropDown('coverage-type').track({change: true})} |
| @change=${(event: Event) => input.onCoverageTypeChanged((event.target as HTMLSelectElement).selectedIndex)} |
| .selectedIndex=${input.coverageType} |
| ?disabled=${input.recording}> |
| <option value=${CoverageType.JAVA_SCRIPT | CoverageType.JAVA_SCRIPT_PER_FUNCTION} |
| jslog=${VisualLogging.item(`${CoverageType.JAVA_SCRIPT | CoverageType.JAVA_SCRIPT_PER_FUNCTION}`).track({click: true})}> |
| ${i18nString(UIStrings.perFunction)} |
| </option> |
| <option value=${CoverageType.JAVA_SCRIPT} |
| jslog=${VisualLogging.item(`${CoverageType.JAVA_SCRIPT}`).track({click: true})}> |
| ${i18nString(UIStrings.perBlock)} |
| </option> |
| </select> |
| <devtools-button ${bindToAction(input.supportsRecordOnReload && !input.recording ? |
| 'coverage.start-with-reload' : 'coverage.toggle-recording')}> |
| </devtools-button> |
| <devtools-button ${bindToAction('coverage.clear')}></devtools-button> |
| <div class="toolbar-divider"></div> |
| <devtools-button ${bindToAction('coverage.export')}></devtools-button> |
| <div class="toolbar-divider"></div> |
| <devtools-toolbar-input type="filter" placeholder=${i18nString(UIStrings.filterByUrl)} |
| ?disabled=${!Boolean(input.coverageInfo)} |
| @change=${(e: CustomEvent<string>) => input.onFilterChanged(e.detail)} |
| style="flex-grow:1; flex-shrink:1"> |
| </devtools-toolbar-input> |
| <div class="toolbar-divider"></div> |
| <select title=${i18nString(UIStrings.filterCoverageByType)} |
| aria-label=${i18nString(UIStrings.filterCoverageByType)} |
| jslog=${VisualLogging.dropDown('coverage-by-type').track({change: true})} |
| ?disabled=${!Boolean(input.coverageInfo)} |
| @change=${(event: Event) => input.onTypeFilterChanged( |
| Number((event.target as HTMLSelectElement).selectedOptions[0]?.value))}> |
| <option value="" jslog=${VisualLogging.item('').track({click: true})} |
| .selected=${input.typeFilter === null}>${i18nString(UIStrings.all)}</option> |
| <option value=${CoverageType.CSS} |
| jslog=${VisualLogging.item(`${CoverageType.CSS}`).track({click: true})} |
| .selected=${input.typeFilter === CoverageType.CSS}> |
| ${i18nString(UIStrings.css)} |
| </option> |
| <option value=${CoverageType.JAVA_SCRIPT | CoverageType.JAVA_SCRIPT_PER_FUNCTION} |
| jslog=${VisualLogging.item(`${CoverageType.JAVA_SCRIPT | CoverageType.JAVA_SCRIPT_PER_FUNCTION}`).track({click: true})} |
| .selected=${(input.typeFilter !== null && Boolean(input.typeFilter & (CoverageType.JAVA_SCRIPT | CoverageType.JAVA_SCRIPT_PER_FUNCTION)))}> |
| ${i18nString(UIStrings.javascript)} |
| </option> |
| </select> |
| <div class="toolbar-divider"></div> |
| <devtools-checkbox title=${i18nString(UIStrings.includeExtensionContentScripts)} |
| ${bindToSetting(input.showContentScriptsSetting)} |
| ?disabled=${!Boolean(input.coverageInfo)}> |
| ${i18nString(UIStrings.contentScripts)} |
| </devtools-checkbox> |
| </devtools-toolbar> |
| </div> |
| <div class="coverage-results"> |
| ${input.needsReload ? |
| renderReloadPromptPage(input.needsReload === 'bfcache-page' ? |
| i18nString(UIStrings.bfcacheNoCapture) : i18nString(UIStrings.activationNoCapture), |
| input.needsReload) |
| : input.coverageInfo ? html` |
| <devtools-widget autofocus class="results" .widgetConfig=${widgetConfig(CoverageListView, { |
| coverageInfo: input.coverageInfo, |
| highlightRegExp: input.textFilter, |
| selectedUrl: input.selectedUrl, |
| })} |
| ${ref(e => { if (e instanceof HTMLElement) { output.focusResults = () => { e.focus(); };}})}>` |
| : renderLandingPage(input.supportsRecordOnReload)} |
| </div> |
| <div class="coverage-toolbar-summary"> |
| <div class="coverage-message"> |
| ${input.statusMessage} |
| </div> |
| </div>`, target); |
| // clang-format on |
| }; |
| |
| function renderLandingPage(supportsRecordOnReload: boolean): TemplateResult { |
| if (supportsRecordOnReload) { |
| // clang-format off |
| return html` |
| <devtools-widget .widgetConfig=${widgetConfig(UI.EmptyWidget.EmptyWidget,{ |
| header: i18nString(UIStrings.noCoverageData), |
| link: 'https://developer.chrome.com/docs/devtools/coverage' as Platform.DevToolsPath.UrlString, |
| text: i18nString(UIStrings.clickTheReloadButtonSToReloadAnd, {PH1: i18nString(UIStrings.reloadPage)}), |
| })}> |
| <devtools-button ${bindToAction('coverage.start-with-reload')} |
| .variant=${Buttons.Button.Variant.TONAL} .iconName=${undefined}> |
| ${i18nString(UIStrings.reloadPage)} |
| </devtools-button> |
| </devtools-widget>`; |
| // clang-format on |
| } |
| // clang-format off |
| return html` |
| <devtools-widget .widgetConfig=${widgetConfig(UI.EmptyWidget.EmptyWidget,{ |
| header: i18nString(UIStrings.noCoverageData), |
| link: 'https://developer.chrome.com/docs/devtools/coverage' as Platform.DevToolsPath.UrlString, |
| text: i18nString(UIStrings.clickTheRecordButtonSToStart, {PH1: i18nString(UIStrings.startRecording)}), |
| })}> |
| <devtools-button ${bindToAction('coverage.toggle-recording')} |
| .variant=${Buttons.Button.Variant.TONAL} .iconName=${undefined}> |
| ${i18nString(UIStrings.startRecording)} |
| </devtools-button> |
| </devtools-widget>`; |
| // clang-format on |
| } |
| |
| function renderReloadPromptPage(message: Common.UIString.LocalizedString, className: string): TemplateResult { |
| // clang-format off |
| return html` |
| <div class="widget vbox ${className}"> |
| <div class="message">${message}</div> |
| <span class="message"> |
| ${i18nTemplate(UIStrings.reloadPrompt, {PH1: html` |
| <devtools-button class="inline-button" ${bindToAction('inspector-main.reload')}></devtools-button>`})} |
| </span> |
| </div>`; |
| // clang-format on |
| } |
| |
| export class CoverageView extends UI.Widget.VBox { |
| #model: CoverageModel|null; |
| #decorationManager: CoverageDecorationManager|null; |
| readonly #coverageTypeComboBoxSetting: Common.Settings.Setting<number>; |
| readonly #toggleRecordAction: UI.ActionRegistration.Action; |
| readonly #clearAction: UI.ActionRegistration.Action; |
| readonly #exportAction: UI.ActionRegistration.Action; |
| #textFilter: RegExp|null; |
| #typeFilter: number|null; |
| readonly #showContentScriptsSetting: Common.Settings.Setting<boolean>; |
| |
| readonly #view: View; |
| #supportsRecordOnReload: boolean; |
| #needsReload: 'bfcache-page'|'prerender-page'|null = null; |
| #statusMessage = ''; |
| #output: CoverageViewOutput = {focusResults: () => {}}; |
| #coverageInfo: CoverageListItem[]|null = null; |
| #selectedUrl: Platform.DevToolsPath.UrlString|null = null; |
| |
| constructor(view: View = DEFAULT_VIEW) { |
| super({ |
| jslog: `${VisualLogging.panel('coverage').track({resize: true})}`, |
| useShadowDom: true, |
| delegatesFocus: true, |
| }); |
| this.registerRequiredCSS(coverageViewStyles); |
| this.#view = view; |
| |
| this.#model = null; |
| this.#decorationManager = null; |
| |
| this.#coverageTypeComboBoxSetting = |
| Common.Settings.Settings.instance().createSetting('coverage-view-coverage-type', 0); |
| |
| this.#toggleRecordAction = UI.ActionRegistry.ActionRegistry.instance().getAction('coverage.toggle-recording'); |
| |
| const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); |
| this.#supportsRecordOnReload = Boolean(mainTarget?.model(SDK.ResourceTreeModel.ResourceTreeModel)); |
| this.#clearAction = UI.ActionRegistry.ActionRegistry.instance().getAction('coverage.clear'); |
| this.#clearAction.setEnabled(false); |
| this.#exportAction = UI.ActionRegistry.ActionRegistry.instance().getAction('coverage.export'); |
| this.#exportAction.setEnabled(false); |
| |
| this.#textFilter = null; |
| |
| this.#typeFilter = null; |
| |
| this.#showContentScriptsSetting = Common.Settings.Settings.instance().createSetting('show-content-scripts', false); |
| this.#showContentScriptsSetting.addChangeListener(this.#onFilterChanged, this); |
| |
| this.requestUpdate(); |
| } |
| |
| override performUpdate(): void { |
| const input: CoverageViewInput = { |
| coverageType: this.#coverageTypeComboBoxSetting.get(), |
| recording: this.#toggleRecordAction.toggled(), |
| supportsRecordOnReload: this.#supportsRecordOnReload, |
| typeFilter: this.#typeFilter, |
| showContentScriptsSetting: this.#showContentScriptsSetting, |
| needsReload: this.#needsReload, |
| coverageInfo: this.#coverageInfo, |
| textFilter: this.#textFilter, |
| selectedUrl: this.#selectedUrl, |
| statusMessage: this.#statusMessage, |
| onCoverageTypeChanged: this.#onCoverageTypeChanged.bind(this), |
| onFilterChanged: (value: string) => { |
| this.#textFilter = value ? Platform.StringUtilities.createPlainTextSearchRegex(value, 'i') : null; |
| this.#onFilterChanged(); |
| }, |
| onTypeFilterChanged: this.#onTypeFilterChanged.bind(this), |
| }; |
| this.#view(input, this.#output, this.contentElement); |
| } |
| |
| static instance(): CoverageView { |
| if (!coverageViewInstance) { |
| coverageViewInstance = new CoverageView(); |
| } |
| return coverageViewInstance; |
| } |
| |
| static removeInstance(): void { |
| coverageViewInstance = undefined; |
| } |
| |
| clear(): void { |
| if (this.#model) { |
| this.#model.reset(); |
| } |
| this.#reset(); |
| } |
| |
| #reset(): void { |
| if (this.#decorationManager) { |
| this.#decorationManager.dispose(); |
| this.#decorationManager = null; |
| } |
| this.#needsReload = null; |
| this.#coverageInfo = null; |
| this.#statusMessage = ''; |
| this.#exportAction.setEnabled(false); |
| this.requestUpdate(); |
| } |
| |
| toggleRecording(): void { |
| const enable = !this.#toggleRecordAction.toggled(); |
| |
| if (enable) { |
| void this.startRecording({reload: false, jsCoveragePerBlock: this.isBlockCoverageSelected()}); |
| } else { |
| void this.stopRecording(); |
| } |
| } |
| |
| isBlockCoverageSelected(): boolean { |
| // Check that Coverage.CoverageType.JavaScriptPerFunction is not present. |
| return this.#coverageTypeComboBoxSetting.get() === CoverageType.JAVA_SCRIPT; |
| } |
| |
| #selectCoverageType(jsCoveragePerBlock: boolean): void { |
| const selectedIndex = jsCoveragePerBlock ? 1 : 0; |
| this.#coverageTypeComboBoxSetting.set(selectedIndex); |
| } |
| |
| #onCoverageTypeChanged(newValue: number): void { |
| this.#coverageTypeComboBoxSetting.set(newValue); |
| } |
| |
| async startRecording(options: {reload: (boolean|undefined), jsCoveragePerBlock: (boolean|undefined)}|null): |
| Promise<void> { |
| this.#reset(); |
| const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); |
| if (!mainTarget) { |
| return; |
| } |
| |
| const {reload, jsCoveragePerBlock} = {reload: false, jsCoveragePerBlock: false, ...options}; |
| |
| if (!this.#model || reload) { |
| this.#model = mainTarget.model(CoverageModel); |
| } |
| if (!this.#model) { |
| return; |
| } |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.CoverageStarted); |
| if (jsCoveragePerBlock) { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.CoverageStartedPerBlock); |
| } |
| const success = await this.#model.start(Boolean(jsCoveragePerBlock)); |
| if (!success) { |
| return; |
| } |
| this.#selectCoverageType(Boolean(jsCoveragePerBlock)); |
| this.#model.addEventListener(Events.CoverageUpdated, this.#onCoverageDataReceived, this); |
| this.#model.addEventListener(Events.SourceMapResolved, this.#updateListView, this); |
| const resourceTreeModel = mainTarget.model(SDK.ResourceTreeModel.ResourceTreeModel); |
| SDK.TargetManager.TargetManager.instance().addModelListener( |
| SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, |
| this.#onPrimaryPageChanged, this); |
| this.#decorationManager = new CoverageDecorationManager( |
| this.#model, Workspace.Workspace.WorkspaceImpl.instance(), |
| Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(), |
| Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance()); |
| this.#toggleRecordAction.setToggled(true); |
| this.#clearAction.setEnabled(false); |
| this.#coverageInfo = []; |
| this.#needsReload = null; |
| this.requestUpdate(); |
| await this.updateComplete; |
| |
| this.#output.focusResults(); |
| if (reload && resourceTreeModel) { |
| resourceTreeModel.reloadPage(); |
| } else { |
| void this.#model.startPolling(); |
| } |
| } |
| |
| #onCoverageDataReceived(event: Common.EventTarget.EventTargetEvent<CoverageInfo[]>): void { |
| const data = event.data; |
| this.#updateViews(data); |
| } |
| |
| #updateListView(): void { |
| const entries = |
| (this.#model?.entries() || []) |
| .map(entry => this.#toCoverageListItem(entry)) |
| .filter(info => this.#isVisible(info)) |
| .map( |
| (entry: CoverageListItem) => |
| ({...entry, sources: entry.sources.filter((entry: CoverageListItem) => this.#isVisible(entry))})); |
| this.#coverageInfo = entries; |
| } |
| |
| #toCoverageListItem(info: URLCoverageInfo): CoverageListItem { |
| return { |
| url: info.url(), |
| type: info.type(), |
| size: info.size(), |
| usedSize: info.usedSize(), |
| unusedSize: info.unusedSize(), |
| usedPercentage: info.usedPercentage(), |
| unusedPercentage: info.unusedPercentage(), |
| sources: [...info.sourcesURLCoverageInfo.values()].map(this.#toCoverageListItem, this), |
| isContentScript: info.isContentScript(), |
| generatedUrl: info instanceof SourceURLCoverageInfo ? info.generatedURLCoverageInfo.url() : undefined, |
| }; |
| } |
| |
| async stopRecording(): Promise<void> { |
| SDK.TargetManager.TargetManager.instance().removeModelListener( |
| SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, |
| this.#onPrimaryPageChanged, this); |
| // Stopping the model triggers one last poll to get the final data. |
| if (this.#model) { |
| await this.#model.stop(); |
| this.#model.removeEventListener(Events.CoverageUpdated, this.#onCoverageDataReceived, this); |
| } |
| this.#toggleRecordAction.setToggled(false); |
| this.#clearAction.setEnabled(true); |
| this.requestUpdate(); |
| } |
| |
| async #onPrimaryPageChanged( |
| event: Common.EventTarget.EventTargetEvent< |
| {frame: SDK.ResourceTreeModel.ResourceTreeFrame, type: SDK.ResourceTreeModel.PrimaryPageChangeType}>): |
| Promise<void> { |
| const frame = event.data.frame; |
| const coverageModel = frame.resourceTreeModel().target().model(CoverageModel); |
| if (!coverageModel) { |
| return; |
| } |
| // If the primary page target has changed (due to MPArch activation), switch to new CoverageModel. |
| if (this.#model !== coverageModel) { |
| if (this.#model) { |
| await this.#model.stop(); |
| this.#model.removeEventListener(Events.CoverageUpdated, this.#onCoverageDataReceived, this); |
| } |
| this.#model = coverageModel; |
| const success = await this.#model.start(this.isBlockCoverageSelected()); |
| if (!success) { |
| return; |
| } |
| |
| this.#model.addEventListener(Events.CoverageUpdated, this.#onCoverageDataReceived, this); |
| this.#decorationManager = new CoverageDecorationManager( |
| this.#model, Workspace.Workspace.WorkspaceImpl.instance(), |
| Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(), |
| Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance()); |
| } |
| |
| if (event.data.type === SDK.ResourceTreeModel.PrimaryPageChangeType.ACTIVATION) { |
| this.#needsReload = 'prerender-page'; |
| } else if (frame.backForwardCacheDetails.restoredFromCache) { |
| this.#needsReload = 'bfcache-page'; |
| } else { |
| this.#needsReload = null; |
| this.#coverageInfo = []; |
| } |
| this.requestUpdate(); |
| |
| this.#model.reset(); |
| this.#decorationManager?.reset(); |
| void this.#model.startPolling(); |
| } |
| |
| #updateViews(updatedEntries: CoverageInfo[]): void { |
| this.#updateStats(); |
| this.#updateListView(); |
| this.#exportAction.setEnabled(this.#model !== null && this.#model.entries().length > 0); |
| this.#decorationManager?.update(updatedEntries); |
| this.requestUpdate(); |
| } |
| |
| #updateStats(): void { |
| const all = {total: 0, unused: 0}; |
| const filtered = {total: 0, unused: 0}; |
| const filterApplied = this.#textFilter !== null; |
| if (this.#model) { |
| for (const info of this.#model.entries()) { |
| all.total += info.size(); |
| all.unused += info.unusedSize(); |
| const listItem = this.#toCoverageListItem(info); |
| if (this.#isVisible(listItem)) { |
| if (this.#textFilter?.test(info.url())) { |
| filtered.total += info.size(); |
| filtered.unused += info.unusedSize(); |
| } else { |
| // If it doesn't match the filter, calculate the stats from visible children if there are any |
| for (const childInfo of info.sourcesURLCoverageInfo.values()) { |
| if (this.#isVisible(this.#toCoverageListItem(childInfo))) { |
| filtered.total += childInfo.size(); |
| filtered.unused += childInfo.unusedSize(); |
| } |
| } |
| } |
| } |
| } |
| } |
| this.#statusMessage = filterApplied ? |
| i18nString(UIStrings.filteredSTotalS, {PH1: formatStat(filtered), PH2: formatStat(all)}) : |
| formatStat(all); |
| |
| function formatStat({total, unused}: {total: number, unused: number}): string { |
| const used = total - unused; |
| const percentUsed = total ? Math.round(100 * used / total) : 0; |
| return i18nString(UIStrings.sOfSSUsedSoFarSUnused, { |
| PH1: i18n.ByteUtilities.bytesToString(used), |
| PH2: i18n.ByteUtilities.bytesToString(total), |
| PH3: percentUsed, |
| PH4: i18n.ByteUtilities.bytesToString(unused), |
| }); |
| } |
| } |
| |
| #onFilterChanged(): void { |
| this.#updateListView(); |
| this.#updateStats(); |
| this.requestUpdate(); |
| } |
| |
| #onTypeFilterChanged(typeFilter: number): void { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.CoverageReportFiltered); |
| |
| this.#typeFilter = typeFilter; |
| this.#updateListView(); |
| this.#updateStats(); |
| this.requestUpdate(); |
| } |
| |
| #isVisible(coverageInfo: CoverageListItem): boolean { |
| const url = coverageInfo.url; |
| if (url.startsWith(CoverageView.EXTENSION_BINDINGS_URL_PREFIX)) { |
| return false; |
| } |
| if (coverageInfo.isContentScript && !this.#showContentScriptsSetting.get()) { |
| return false; |
| } |
| if (this.#typeFilter && !(coverageInfo.type & this.#typeFilter)) { |
| return false; |
| } |
| // If it's a parent, check if any children are visible |
| if (coverageInfo.sources.length > 0) { |
| for (const sourceURLCoverageInfo of coverageInfo.sources) { |
| if (this.#isVisible(sourceURLCoverageInfo)) { |
| return true; |
| } |
| } |
| } |
| |
| return !this.#textFilter || this.#textFilter.test(url); |
| } |
| |
| async exportReport(): Promise<void> { |
| const fos = new Bindings.FileUtils.FileOutputStream(); |
| const fileName = |
| `Coverage-${Platform.DateUtilities.toISO8601Compact(new Date())}.json` as Platform.DevToolsPath.RawPathString; |
| const accepted = await fos.open(fileName); |
| if (!accepted) { |
| return; |
| } |
| this.#model && await this.#model.exportReport(fos); |
| } |
| |
| selectCoverageItemByUrl(url: string): void { |
| this.#selectedUrl = url as Platform.DevToolsPath.UrlString; |
| this.requestUpdate(); |
| } |
| |
| static readonly EXTENSION_BINDINGS_URL_PREFIX = 'extensions::'; |
| |
| override wasShown(): void { |
| UI.Context.Context.instance().setFlavor(CoverageView, this); |
| super.wasShown(); |
| } |
| |
| override willHide(): void { |
| super.willHide(); |
| UI.Context.Context.instance().setFlavor(CoverageView, null); |
| } |
| |
| get model(): CoverageModel|null { |
| return this.#model; |
| } |
| } |
| |
| export class ActionDelegate implements UI.ActionRegistration.ActionDelegate { |
| handleAction(_context: UI.Context.Context, actionId: string): boolean { |
| const coverageViewId = 'coverage'; |
| void UI.ViewManager.ViewManager.instance() |
| .showView(coverageViewId, /** userGesture= */ false, /** omitFocus= */ true) |
| .then(() => { |
| const view = UI.ViewManager.ViewManager.instance().view(coverageViewId); |
| return view?.widget(); |
| }) |
| .then(widget => this.#handleAction(widget as CoverageView, actionId)); |
| |
| return true; |
| } |
| |
| #handleAction(coverageView: CoverageView, actionId: string): void { |
| switch (actionId) { |
| case 'coverage.toggle-recording': |
| coverageView.toggleRecording(); |
| break; |
| case 'coverage.start-with-reload': |
| void coverageView.startRecording({reload: true, jsCoveragePerBlock: coverageView.isBlockCoverageSelected()}); |
| break; |
| case 'coverage.clear': |
| coverageView.clear(); |
| break; |
| case 'coverage.export': |
| void coverageView.exportReport(); |
| break; |
| default: |
| console.assert(false, `Unknown action: ${actionId}`); |
| } |
| } |
| } |