| // Copyright 2017 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/components/highlighting/highlighting.js'; |
| import '../../ui/legacy/components/data_grid/data_grid.js'; |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as i18n from '../../core/i18n/i18n.js'; |
| import type * as Platform from '../../core/platform/platform.js'; |
| import * as Workspace from '../../models/workspace/workspace.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import {Directives, html, nothing, render, type TemplateResult} from '../../ui/lit/lit.js'; |
| |
| import coverageListViewStyles from './coverageListView.css.js'; |
| import {CoverageType} from './CoverageModel.js'; |
| |
| export interface CoverageListItem { |
| url: Platform.DevToolsPath.UrlString; |
| type: CoverageType; |
| size: number; |
| usedSize: number; |
| unusedSize: number; |
| usedPercentage: number; |
| unusedPercentage: number; |
| sources: CoverageListItem[]; |
| isContentScript: boolean; |
| generatedUrl?: Platform.DevToolsPath.UrlString; |
| } |
| |
| const UIStrings = { |
| /** |
| * @description Text that appears on a button for the css resource type filter. |
| */ |
| css: 'CSS', |
| /** |
| * @description Text in Coverage List View of the Coverage tab |
| */ |
| jsPerFunction: 'JS (per function)', |
| /** |
| * @description Text in Coverage List View of the Coverage tab |
| */ |
| jsPerBlock: 'JS (per block)', |
| /** |
| * @description Text for web URLs |
| */ |
| url: 'URL', |
| /** |
| * @description Text that refers to some types |
| */ |
| type: 'Type', |
| /** |
| * @description Text in Coverage List View of the Coverage tab |
| */ |
| totalBytes: 'Total Bytes', |
| /** |
| * @description Text in Coverage List View of the Coverage tab |
| */ |
| unusedBytes: 'Unused Bytes', |
| /** |
| * @description Text in the Coverage List View of the Coverage Tab |
| */ |
| usageVisualization: 'Usage Visualization', |
| /** |
| * @description Data grid name for Coverage data grids |
| */ |
| codeCoverage: 'Code Coverage', |
| /** |
| * @description Cell title in Coverage List View of the Coverage tab. The coverage tool tells |
| *developers which functions (logical groups of lines of code) were actually run/executed. If a |
| *function does get run, then it is marked in the UI to indicate that it was covered. |
| */ |
| jsCoverageWithPerFunction: |
| 'JS coverage with per function granularity: Once a function was executed, the whole function is marked as covered.', |
| /** |
| * @description Cell title in Coverage List View of the Coverage tab. The coverage tool tells |
| *developers which blocks (logical groups of lines of code, smaller than a function) were actually |
| *run/executed. If a block does get run, then it is marked in the UI to indicate that it was |
| *covered. |
| */ |
| jsCoverageWithPerBlock: |
| 'JS coverage with per block granularity: Once a block of JavaScript was executed, that block is marked as covered.', |
| /** |
| * @description Accessible text for the value in bytes in memory allocation or coverage view. |
| */ |
| sBytes: '{n, plural, =1 {# byte} other {# bytes}}', |
| /** |
| * @description Accessible text for the unused bytes column in the coverage tool that describes the total unused bytes and percentage of the file unused. |
| * @example {88%} percentage |
| */ |
| sBytesS: '{n, plural, =1 {# byte, {percentage}} other {# bytes, {percentage}}}', |
| /** |
| * @description Tooltip text for the bar in the coverage list view of the coverage tool that illustrates the relation between used and unused bytes. |
| * @example {1000} PH1 |
| * @example {12.34} PH2 |
| */ |
| sBytesSBelongToFunctionsThatHave: '{PH1} bytes ({PH2}) belong to functions that have not (yet) been executed.', |
| /** |
| * @description Tooltip text for the bar in the coverage list view of the coverage tool that illustrates the relation between used and unused bytes. |
| * @example {1000} PH1 |
| * @example {12.34} PH2 |
| */ |
| sBytesSBelongToBlocksOf: '{PH1} bytes ({PH2}) belong to blocks of JavaScript that have not (yet) been executed.', |
| /** |
| * @description Message in Coverage View of the Coverage tab |
| * @example {1000} PH1 |
| * @example {12.34} PH2 |
| */ |
| sBytesSBelongToFunctionsThatHaveExecuted: '{PH1} bytes ({PH2}) belong to functions that have executed at least once.', |
| /** |
| * @description Message in Coverage View of the Coverage tab |
| * @example {1000} PH1 |
| * @example {12.34} PH2 |
| */ |
| sBytesSBelongToBlocksOfJavascript: |
| '{PH1} bytes ({PH2}) belong to blocks of JavaScript that have executed at least once.', |
| /** |
| * @description Accessible text for the visualization column of coverage tool. Contains percentage of unused bytes to used bytes. |
| * @example {12.3} PH1 |
| * @example {12.3} PH2 |
| */ |
| sOfFileUnusedSOfFileUsed: '{PH1} % of file unused, {PH2} % of file used', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('panels/coverage/CoverageListView.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| const {styleMap, repeat} = Directives; |
| |
| export function coverageTypeToString(type: CoverageType): string { |
| const types = []; |
| if (type & CoverageType.CSS) { |
| types.push(i18nString(UIStrings.css)); |
| } |
| if (type & CoverageType.JAVA_SCRIPT_PER_FUNCTION) { |
| types.push(i18nString(UIStrings.jsPerFunction)); |
| } else if (type & CoverageType.JAVA_SCRIPT) { |
| types.push(i18nString(UIStrings.jsPerBlock)); |
| } |
| return types.join('+'); |
| } |
| |
| interface ViewInput { |
| items: CoverageListItem[]; |
| selectedUrl: Platform.DevToolsPath.UrlString|null; |
| maxSize: number; |
| onOpen: (url: Platform.DevToolsPath.UrlString) => void; |
| highlightRegExp: RegExp|null; |
| } |
| |
| type View = (input: ViewInput, output: object, target: HTMLElement) => void; |
| |
| const formatBytes = (value: number|undefined): string => { |
| return getBytesFormatter().format(value ?? 0); |
| }; |
| const formatPercent = (value: number|undefined): string => { |
| return getPercentageFormatter().format(value ?? 0); |
| }; |
| export const DEFAULT_VIEW: View = (input, _output, target) => { |
| // clang-format off |
| render(html` |
| <style>${coverageListViewStyles}</style> |
| <devtools-data-grid class="flex-auto" name=${i18nString(UIStrings.codeCoverage)} striped autofocus resize="last" |
| .template=${html` |
| <table> |
| <tr> |
| <th id="url" width="250px" weight="3" sortable>${i18nString(UIStrings.url)}</th> |
| <th id="type" width="45px" weight="1" fixed sortable>${i18nString(UIStrings.type)}</th> |
| <th id="size" width="60px" align="right" weight="1" fixed sortable>${i18nString(UIStrings.totalBytes)}</th> |
| <th id="unused-size" width="100px" align="right" weight="1" fixed sortable sort="descending">${ |
| i18nString(UIStrings.unusedBytes)}</th> |
| <th id="bars" width="250px" weight="1" sortable>${i18nString(UIStrings.usageVisualization)}</th> |
| </tr> |
| ${repeat(input.items, info => info.url, info => renderItem(info, input))} |
| </table>`}> |
| </devtools-data-grid>`, |
| target); |
| // clang-format on |
| }; |
| |
| export class CoverageListView extends UI.Widget.VBox { |
| #highlightRegExp: RegExp|null; |
| #coverageInfo: CoverageListItem[] = []; |
| #selectedUrl: Platform.DevToolsPath.UrlString|null = null; |
| #maxSize = 0; |
| #view: View; |
| |
| constructor(element?: HTMLElement, view = DEFAULT_VIEW) { |
| super(element, {useShadowDom: true, delegatesFocus: true}); |
| this.#view = view; |
| this.#highlightRegExp = null; |
| } |
| |
| set highlightRegExp(highlightRegExp: RegExp|null) { |
| this.#highlightRegExp = highlightRegExp; |
| this.requestUpdate(); |
| } |
| |
| get highlightRegExp(): RegExp|null { |
| return this.#highlightRegExp; |
| } |
| |
| set coverageInfo(coverageInfo: CoverageListItem[]) { |
| this.#coverageInfo = coverageInfo; |
| this.#maxSize = coverageInfo.reduce((acc, entry) => Math.max(acc, entry.size), 0); |
| this.requestUpdate(); |
| } |
| |
| get coverageInfo(): CoverageListItem[] { |
| return this.#coverageInfo; |
| } |
| |
| override performUpdate(): void { |
| const input: ViewInput = { |
| items: this.#coverageInfo, |
| selectedUrl: this.#selectedUrl, |
| maxSize: this.#maxSize, |
| onOpen: (url: Platform.DevToolsPath.UrlString) => { |
| this.selectedUrl = url; |
| }, |
| highlightRegExp: this.#highlightRegExp, |
| }; |
| this.#view(input, {}, this.contentElement); |
| } |
| |
| reset(): void { |
| this.#coverageInfo = []; |
| this.#maxSize = 0; |
| this.requestUpdate(); |
| } |
| |
| set selectedUrl(url: Platform.DevToolsPath.UrlString|null) { |
| const info = this.#coverageInfo.find(info => info.url === url); |
| if (!info) { |
| return; |
| } |
| if (this.#selectedUrl !== url) { |
| this.#selectedUrl = url as Platform.DevToolsPath.UrlString; |
| this.requestUpdate(); |
| } |
| const sourceCode = url ? Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(url) : null; |
| if (!sourceCode) { |
| return; |
| } |
| |
| void Common.Revealer.reveal(sourceCode); |
| } |
| |
| get selectedUrl(): Platform.DevToolsPath.UrlString|null { |
| return this.#selectedUrl; |
| } |
| } |
| |
| let percentageFormatter: Intl.NumberFormat|null = null; |
| |
| function getPercentageFormatter(): Intl.NumberFormat { |
| if (!percentageFormatter) { |
| percentageFormatter = new Intl.NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, { |
| style: 'percent', |
| maximumFractionDigits: 1, |
| }); |
| } |
| return percentageFormatter; |
| } |
| |
| let bytesFormatter: Intl.NumberFormat|null = null; |
| |
| function getBytesFormatter(): Intl.NumberFormat { |
| if (!bytesFormatter) { |
| bytesFormatter = new Intl.NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale); |
| } |
| return bytesFormatter; |
| } |
| |
| function renderItem(info: CoverageListItem, input: ViewInput): TemplateResult { |
| function highlightRange(textContent: string): string { |
| const matches = input.highlightRegExp?.exec(textContent); |
| return matches?.length ? `${matches.index},${matches[0].length}` : ''; |
| } |
| |
| const splitURL = /^(.*)(\/[^/]*)$/.exec(info.url); |
| // clang-format off |
| return html` |
| <style>${coverageListViewStyles}</style> |
| <tr data-url=${info.url} selected=${info.url === input.selectedUrl} |
| @open=${() => input.onOpen(info.url)}> |
| <td data-value=${info.url} title=${info.url} aria-label=${info.url}> |
| <devtools-highlight ranges=${highlightRange(info.url)} class="url-outer" aria-hidden="true"> |
| <div class="url-prefix">${splitURL ? splitURL[1] : info.url}</div> |
| <div class="url-suffix">${splitURL ? splitURL[2] : ''}</div> |
| </devtools-highlight> |
| </td> |
| <td data-value=${coverageTypeToString(info.type)} |
| title=${info.type & CoverageType.JAVA_SCRIPT_PER_FUNCTION ? i18nString(UIStrings.jsCoverageWithPerFunction) : |
| info.type & CoverageType.JAVA_SCRIPT ? i18nString(UIStrings.jsCoverageWithPerBlock) : |
| ''}> |
| ${coverageTypeToString(info.type)} |
| </td> |
| <td data-value=${info.size} aria-label=${i18nString(UIStrings.sBytes, {n: info.size || 0})}> |
| <span>${formatBytes(info.size)}</span> |
| </td> |
| <td data-value=${info.unusedSize} aria-label=${i18nString(UIStrings.sBytesS, {n: info.unusedSize, percentage: formatPercent(info.unusedPercentage)})}> |
| <span>${formatBytes(info.unusedSize)}</span> |
| <span class="percent-value"> |
| ${formatPercent(info.unusedPercentage)} |
| </span> |
| </td> |
| <td data-value=${info.unusedSize} aria-label=${i18nString(UIStrings.sOfFileUnusedSOfFileUsed, {PH1: formatPercent(info.unusedPercentage), PH2: formatPercent(info.usedPercentage)})}> |
| <div class="bar-container"> |
| ${info.unusedSize > 0 ? html` |
| <div class="bar bar-unused-size" |
| title=${ |
| info.type & CoverageType.JAVA_SCRIPT_PER_FUNCTION ? i18nString(UIStrings.sBytesSBelongToFunctionsThatHave, {PH1: info.unusedSize, PH2: formatPercent(info.unusedPercentage)}) : |
| info.type & CoverageType.JAVA_SCRIPT ? i18nString(UIStrings.sBytesSBelongToBlocksOf, {PH1: info.unusedSize, PH2: formatPercent(info.unusedPercentage)}) : |
| ''} |
| style=${styleMap({width: ((info.unusedSize / input.maxSize) * 100 || 0) + '%'})}> |
| </div>` : nothing} |
| ${info.usedSize > 0 ? html` |
| <div class="bar bar-used-size" |
| title=${ |
| info.type & CoverageType.JAVA_SCRIPT_PER_FUNCTION ? i18nString(UIStrings.sBytesSBelongToFunctionsThatHaveExecuted, {PH1: info.usedSize, PH2: formatPercent(info.usedPercentage)}) : |
| info.type & CoverageType.JAVA_SCRIPT ? i18nString(UIStrings.sBytesSBelongToBlocksOfJavascript, {PH1: info.usedSize, PH2: formatPercent(info.usedPercentage)}) : |
| ''} |
| style=${styleMap({width:((info.usedSize / input.maxSize) * 100 || 0) + '%'})}> |
| </div>` : nothing} |
| </div> |
| </td> |
| ${info.sources.length > 0 ? html` |
| <td><table> |
| ${repeat(info.sources, source => source.url, source => renderItem(source, input))} |
| </table></td>` : nothing} |
| </tr>`; |
| // clang-format on |
| } |