UNPKG

chrome-devtools-frontend

Version:
705 lines (639 loc) • 27.5 kB
// 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}`); } } }