From c016ce64fba0b8ab1dfb7eb7b7e29f07448dd208 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 3 Jan 2024 23:42:22 -0800 Subject: [PATCH] testing: misc work on test coverage (#201758) - Allow coverage bar color thresholds to be configurable as the Java folks requested. - Update some of our scripts for integration into the selfhost test runner. - Initial parts of showing function coverage in the Test Coverage view. (Still a work in progress, more tomorrow) --- package.json | 2 +- src/vs/workbench/api/common/extHostTypes.ts | 15 +- .../contrib/testing/browser/media/testing.css | 1 - .../testing/browser/testCoverageBars.ts | 80 +++-- .../testing/browser/testCoverageView.ts | 302 ++++++++++++++---- .../contrib/testing/common/configuration.ts | 21 ++ .../contrib/testing/common/testCoverage.ts | 42 ++- .../contrib/testing/common/testTypes.ts | 4 +- test/unit/coverage.js | 7 +- test/unit/electron/index.js | 2 +- test/unit/electron/renderer.js | 2 +- test/unit/node/index.js | 5 +- yarn.lock | 10 +- 13 files changed, 362 insertions(+), 131 deletions(-) diff --git a/package.json b/package.json index e1d479a26eb..fa7242f618b 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,7 @@ "husky": "^0.13.1", "innosetup": "6.0.5", "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.2.0", + "istanbul-lib-instrument": "^6.0.1", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.1", "istanbul-reports": "^3.1.5", diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index de1b35423e8..fe574fb9c67 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3948,9 +3948,16 @@ export class TestTag implements vscode.TestTag { //#region Test Coverage export class CoveredCount implements vscode.CoveredCount { - constructor(public covered: number, public total: number) { } + constructor(public covered: number, public total: number) { + } } +const validateCC = (cc?: vscode.CoveredCount) => { + if (cc && cc.covered > cc.total) { + throw new Error(`The total number of covered items (${cc.covered}) cannot be greater than the total (${cc.total})`); + } +}; + export class FileCoverage implements vscode.FileCoverage { public static fromDetails(uri: vscode.Uri, details: vscode.DetailedCoverage[]): vscode.FileCoverage { const statements = new CoveredCount(0, 0); @@ -3991,7 +3998,11 @@ export class FileCoverage implements vscode.FileCoverage { public statementCoverage: vscode.CoveredCount, public branchCoverage?: vscode.CoveredCount, public functionCoverage?: vscode.CoveredCount, - ) { } + ) { + validateCC(statementCoverage); + validateCC(branchCoverage); + validateCC(functionCoverage); + } } export class StatementCoverage implements vscode.StatementCoverage { diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index 384e427d1a3..20c87255f36 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -376,7 +376,6 @@ } .test-coverage-bars .bar { - width: 16px; height: 8px; border: 1px solid currentColor; border-radius: 2px; diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts b/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts index 576294a7ffd..54272d86756 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts @@ -8,15 +8,16 @@ import { assertNever } from 'vs/base/common/assert'; import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; import { Lazy } from 'vs/base/common/lazy'; import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; -import { ITransaction, autorun, observableFromEvent, observableValue } from 'vs/base/common/observable'; +import { clamp } from 'vs/base/common/numbers'; +import { ITransaction, autorun, observableValue } from 'vs/base/common/observable'; import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { asCssVariableName, chartsGreen, chartsRed, chartsYellow } from 'vs/platform/theme/common/colorRegistry'; import { IExplorerFileContribution } from 'vs/workbench/contrib/files/browser/explorerFileContrib'; -import { TestingConfigKeys, TestingDisplayedCoveragePercent, getTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration'; -import { AbstractFileCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; +import { ITestingCoverageBarThresholds, TestingConfigKeys, TestingDisplayedCoveragePercent, getTestingConfiguration, observeTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration'; +import { AbstractFileCoverage, getTotalCoveragePercent } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; import { ICoveredCount } from 'vs/workbench/contrib/testing/common/testTypes'; import { IHoverService } from 'vs/workbench/services/hover/browser/hover'; @@ -33,14 +34,11 @@ export interface TestCoverageBarsOptions { container: HTMLElement; } -const colorThresholds = [ - { color: asCssVariableName(chartsGreen), threshold: 0.9 }, - { color: asCssVariableName(chartsYellow), threshold: 0.6 }, - { color: asCssVariableName(chartsRed), threshold: -Infinity }, -]; +/** Type that can be used to render coverage bars */ +export type CoverageBarSource = Pick; export class ManagedTestCoverageBars extends Disposable { - private _coverage?: AbstractFileCoverage; + private _coverage?: CoverageBarSource; private readonly el = new Lazy(() => { if (this.options.compact) { const el = h('.test-coverage-bars.compact', [ @@ -78,7 +76,7 @@ export class ManagedTestCoverageBars extends Disposable { super(); } - private attachHover(target: HTMLElement, factory: (coverage: AbstractFileCoverage) => string | IMarkdownString | undefined) { + private attachHover(target: HTMLElement, factory: (coverage: CoverageBarSource) => string | IMarkdownString | undefined) { target.onmouseenter = () => { if (!this._coverage) { return; @@ -104,7 +102,7 @@ export class ManagedTestCoverageBars extends Disposable { }; } - public setCoverageInfo(coverage: AbstractFileCoverage | undefined) { + public setCoverageInfo(coverage: CoverageBarSource | undefined) { const ds = this.visibleStore; if (!coverage) { if (this._coverage) { @@ -119,7 +117,11 @@ export class ManagedTestCoverageBars extends Disposable { ds.add(toDisposable(() => this.options.container.removeChild(root))); this.options.container.appendChild(root); ds.add(this.configurationService.onDidChangeConfiguration(c => { - if (c.affectsConfiguration(TestingConfigKeys.CoveragePercent) && this._coverage) { + if (!this._coverage) { + return; + } + + if (c.affectsConfiguration(TestingConfigKeys.CoveragePercent) || c.affectsConfiguration(TestingConfigKeys.CoverageBarThresholds)) { this.doRender(this._coverage); } })); @@ -129,36 +131,57 @@ export class ManagedTestCoverageBars extends Disposable { this.doRender(coverage); } - private doRender(coverage: AbstractFileCoverage) { + private doRender(coverage: CoverageBarSource) { const el = this.el.value; const precision = this.options.compact ? 0 : 2; + const thresholds = getTestingConfiguration(this.configurationService, TestingConfigKeys.CoverageBarThresholds); const overallStat = calculateDisplayedStat(coverage, getTestingConfiguration(this.configurationService, TestingConfigKeys.CoveragePercent)); el.overall.textContent = displayPercent(overallStat, precision); if ('tpcBar' in el) { // compact mode - renderBar(el.tpcBar, overallStat); + renderBar(el.tpcBar, overallStat, thresholds); } else { - renderBar(el.statement, percent(coverage.statement)); - renderBar(el.function, coverage.function && percent(coverage.function)); - renderBar(el.branch, coverage.branch && percent(coverage.branch)); + renderBar(el.statement, percent(coverage.statement), thresholds); + renderBar(el.function, coverage.function && percent(coverage.function), thresholds); + renderBar(el.branch, coverage.branch && percent(coverage.branch), thresholds); } } } -const percent = (cc: ICoveredCount) => cc.total === 0 ? 1 : cc.covered / cc.total; +const percent = (cc: ICoveredCount) => clamp(cc.total === 0 ? 1 : cc.covered / cc.total, 0, 1); const epsilon = 10e-8; +const barWidth = 16; -const renderBar = (bar: HTMLElement, pct: number | undefined) => { +const renderBar = (bar: HTMLElement, pct: number | undefined, thresholds: ITestingCoverageBarThresholds) => { if (pct === undefined) { bar.style.display = 'none'; } else { bar.style.display = 'block'; - bar.style.setProperty('--test-bar-width', `${pct * 100}%`); - bar.style.color = `var(${colorThresholds.find(t => pct >= t.threshold)!.color})`; + bar.style.width = `${barWidth}px`; + // this is floored so the bar is only completely filled at 100% and not 99.9% + bar.style.setProperty('--test-bar-width', `${Math.floor(pct * 16)}px`); + + let best = colorThresholds[0].color; // red + let distance = pct; + for (const { key, color } of colorThresholds) { + const t = thresholds[key] / 100; + if (t && pct >= t && pct - t < distance) { + best = color; + distance = pct - t; + } + } + + bar.style.color = best; } }; -const calculateDisplayedStat = (coverage: AbstractFileCoverage, method: TestingDisplayedCoveragePercent) => { +const colorThresholds = [ + { color: `var(${asCssVariableName(chartsRed)})`, key: 'red' }, + { color: `var(${asCssVariableName(chartsYellow)})`, key: 'yellow' }, + { color: `var(${asCssVariableName(chartsGreen)})`, key: 'green' }, +] as const; + +const calculateDisplayedStat = (coverage: CoverageBarSource, method: TestingDisplayedCoveragePercent) => { switch (method) { case TestingDisplayedCoveragePercent.Statement: return percent(coverage.statement); @@ -169,7 +192,7 @@ const calculateDisplayedStat = (coverage: AbstractFileCoverage, method: TestingD return value; } case TestingDisplayedCoveragePercent.TotalCoverage: - return coverage.tpc; + return getTotalCoveragePercent(coverage.statement, coverage.branch, coverage.function); default: assertNever(method); } @@ -187,11 +210,11 @@ const displayPercent = (value: number, precision = 2) => { return `${display}%`; }; -const stmtCoverageText = (coverage: AbstractFileCoverage) => localize('statementCoverage', '{0}/{1} statements covered ({2})', coverage.statement.covered, coverage.statement.total, displayPercent(percent(coverage.statement))); -const fnCoverageText = (coverage: AbstractFileCoverage) => coverage.function && localize('functionCoverage', '{0}/{1} functions covered ({2})', coverage.function.covered, coverage.function.total, displayPercent(percent(coverage.function))); -const branchCoverageText = (coverage: AbstractFileCoverage) => coverage.branch && localize('branchCoverage', '{0}/{1} branches covered ({2})', coverage.branch.covered, coverage.branch.total, displayPercent(percent(coverage.branch))); +const stmtCoverageText = (coverage: CoverageBarSource) => localize('statementCoverage', '{0}/{1} statements covered ({2})', coverage.statement.covered, coverage.statement.total, displayPercent(percent(coverage.statement))); +const fnCoverageText = (coverage: CoverageBarSource) => coverage.function && localize('functionCoverage', '{0}/{1} functions covered ({2})', coverage.function.covered, coverage.function.total, displayPercent(percent(coverage.function))); +const branchCoverageText = (coverage: CoverageBarSource) => coverage.branch && localize('branchCoverage', '{0}/{1} branches covered ({2})', coverage.branch.covered, coverage.branch.total, displayPercent(percent(coverage.branch))); -const getOverallHoverText = (coverage: AbstractFileCoverage) => new MarkdownString([ +const getOverallHoverText = (coverage: CoverageBarSource) => new MarkdownString([ stmtCoverageText(coverage), fnCoverageText(coverage), branchCoverageText(coverage), @@ -212,8 +235,7 @@ export class ExplorerTestCoverageBars extends ManagedTestCoverageBars implements ) { super(options, hoverService, configurationService); - const isEnabled = observableFromEvent(configurationService.onDidChangeConfiguration, () => - getTestingConfiguration(configurationService, TestingConfigKeys.ShowCoverageInExplorer)); + const isEnabled = observeTestingConfiguration(configurationService, TestingConfigKeys.ShowCoverageInExplorer); this._register(autorun(async reader => { let info: AbstractFileCoverage | undefined; diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts index e20f3d18fbd..90d6231894a 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts @@ -4,17 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import { IIdentityProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; -import { ITreeCompressionDelegate } from 'vs/base/browser/ui/tree/asyncDataTree'; -import { ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; +import { ICompressedTreeElement, ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; -import { IAsyncDataSource, ITreeNode } from 'vs/base/browser/ui/tree/tree'; +import { ITreeNode } from 'vs/base/browser/ui/tree/tree'; +import { assertNever } from 'vs/base/common/assert'; +import { Codicon } from 'vs/base/common/codicons'; +import { memoize } from 'vs/base/common/decorators'; import { FuzzyScore, createMatches } from 'vs/base/common/filters'; import { Iterable } from 'vs/base/common/iterator'; import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; import { autorun } from 'vs/base/common/observable'; -import { IPrefixTreeNode, WellDefinedPrefixTree } from 'vs/base/common/prefixTree'; +import { IPrefixTreeNode } from 'vs/base/common/prefixTree'; import { basenameOrAuthority } from 'vs/base/common/resources'; +import { ThemeIcon } from 'vs/base/common/themables'; import { URI } from 'vs/base/common/uri'; +import { Range } from 'vs/editor/common/core/range'; import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -24,17 +28,17 @@ import { FileKind } from 'vs/platform/files/common/files'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ILabelService } from 'vs/platform/label/common/label'; -import { WorkbenchCompressibleAsyncDataTree } from 'vs/platform/list/browser/listService'; +import { WorkbenchCompressibleObjectTree } from 'vs/platform/list/browser/listService'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels'; import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; import { IViewDescriptorService } from 'vs/workbench/common/views'; -import { ManagedTestCoverageBars } from 'vs/workbench/contrib/testing/browser/testCoverageBars'; +import { CoverageBarSource, ManagedTestCoverageBars } from 'vs/workbench/contrib/testing/browser/testCoverageBars'; import { ComputedFileCoverage, FileCoverage, TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; -import { DetailType, IFunctionCoverage } from 'vs/workbench/contrib/testing/common/testTypes'; +import { CoverageDetails, DetailType, ICoveredCount, IFunctionCoverage } from 'vs/workbench/contrib/testing/common/testTypes'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; export class TestCoverageView extends ViewPane { @@ -78,25 +82,75 @@ export class TestCoverageView extends ViewPane { } } -class TestCoverageInput { - public readonly tree: WellDefinedPrefixTree; +let fnNodeId = 0; - constructor(coverage: TestCoverage) { - this.tree = coverage.tree; +class FunctionCoverageNode { + public readonly id = String(fnNodeId++); + + public get hits() { + return this.data.count; + } + + public get name() { + return this.data.name; + } + + constructor( + private readonly data: IFunctionCoverage, + private readonly details: CoverageDetails[], + ) { } + + /** + * If the function defines a range, we can look at statements within the + * function to get total coverage for the function, rather than a boolean + * yes/no. + */ + @memoize + public attributableCoverage() { + const { location, count } = this.data; + if (!(location instanceof Range) || !count) { + return; + } + + const statement: ICoveredCount = { covered: 0, total: 0 }; + const branch: ICoveredCount = { covered: 0, total: 0 }; + for (const detail of this.details) { + if (detail.type !== DetailType.Statement) { + continue; + } + const withinFn = detail.location instanceof Range ? location.containsRange(detail.location) : location.containsPosition(detail.location); + if (!withinFn) { + continue; + } + + statement.covered += detail.count > 0 ? 0 : 1; + statement.total++; + + if (detail.branches) { + for (const { count } of detail.branches) { + branch.covered += count > 0 ? 0 : 1; + branch.total++; + } + } + } + + return { statement, branch } satisfies CoverageBarSource; } } - +const LoadingDetails = Symbol(); +const loadingDetailsLabel = localize('loadingCoverageDetails', "Loading Coverage Details..."); /** Type of nodes returned from {@link TestCoverage}. Note: value is *always* defined. */ type TestCoverageFileNode = IPrefixTreeNode; +type CoverageTreeElement = TestCoverageFileNode | FunctionCoverageNode | typeof LoadingDetails; -type CoverageTreeElement = TestCoverageFileNode | IFunctionCoverage; - -const isFileCoverage = (c: CoverageTreeElement): c is TestCoverageFileNode => 'value' in c; -const isFunctionCoverage = (c: CoverageTreeElement): c is IFunctionCoverage => 'type' in c && c.type === DetailType.Function; +const isFileCoverage = (c: CoverageTreeElement): c is TestCoverageFileNode => typeof c === 'object' && 'value' in c; +const isFunctionCoverage = (c: CoverageTreeElement): c is FunctionCoverageNode => c instanceof FunctionCoverageNode; +const shouldShowFunctionDetailsOnExpand = (c: CoverageTreeElement): c is IPrefixTreeNode => + isFileCoverage(c) && c.value instanceof FileCoverage && !!c.value.function?.total; class TestCoverageTree extends Disposable { - private readonly tree: WorkbenchCompressibleAsyncDataTree; + private readonly tree: WorkbenchCompressibleObjectTree; constructor( container: HTMLElement, @@ -106,34 +160,60 @@ class TestCoverageTree extends Disposable { ) { super(); - this.tree = >instantiationService.createInstance( - WorkbenchCompressibleAsyncDataTree, + this.tree = >instantiationService.createInstance( + WorkbenchCompressibleObjectTree, 'TestCoverageView', container, new TestCoverageTreeListDelegate(), - new TestCoverageCompressionDelegate(), - [instantiationService.createInstance(FileCoverageRenderer, labels)], - instantiationService.createInstance(TestCoverageDataSource), + [ + instantiationService.createInstance(FileCoverageRenderer, labels), + instantiationService.createInstance(FunctionCoverageRenderer, labels), + instantiationService.createInstance(LoadingDetailsRenderer), + ], { + expandOnlyOnTwistieClick: true, accessibilityProvider: { getAriaLabel(element: CoverageTreeElement) { if (isFileCoverage(element)) { const name = basenameOrAuthority(element.value!.uri); return localize('testCoverageItemLabel', "{0} coverage: {0}%", name, (element.value!.tpc * 100).toFixed(2)); } + if (isFunctionCoverage(element)) { + return element.name; + } + if (element === LoadingDetails) { + return loadingDetailsLabel; + } - return element.name; + assertNever(element); }, getWidgetAriaLabel() { return localize('testCoverageTreeLabel', "Test Coverage Explorer"); } }, - autoExpandSingleChildren: true, identityProvider: new TestCoverageIdentityProvider(), } ); this._register(this.tree); + this._register(this.tree.onDidChangeCollapseState(e => { + const el = e.node.element; + if (!e.node.collapsed && !e.node.children.length && el && shouldShowFunctionDetailsOnExpand(el)) { + if (el.value!.hasSynchronousDetails) { + this.tree.setChildren(el, [{ element: LoadingDetails, incompressible: true }]); + } + + el.value!.details().then(details => { + if (!this.tree.hasElement(el)) { + return; // avoid any issues if the tree changes in the meanwhile + } + + this.tree.setChildren(el, details + .filter((d): d is IFunctionCoverage => d.type === DetailType.Function) + .map(fn => ({ element: new FunctionCoverageNode(fn, details), incompressible: true }))); + }); + } + })); this._register(this.tree.onDidOpen(e => { let resource: URI | undefined; if (e.element && isFileCoverage(e.element) && !e.element.children?.size) { @@ -151,7 +231,28 @@ class TestCoverageTree extends Disposable { } public setInput(coverage: TestCoverage) { - this.tree.setInput(new TestCoverageInput(coverage)); + const files = []; + for (let node of coverage.tree.nodes) { + // when showing initial children, only show from the first file or tee + while (!(node.value instanceof FileCoverage) && node.children?.size === 1) { + node = Iterable.first(node.children.values())!; + } + files.push(node); + } + + const toChild = (file: TestCoverageFileNode): ICompressedTreeElement => { + const isFile = !file.children?.size; + return { + element: file, + incompressible: isFile, + collapsed: isFile, + // directories can be expanded, and items with function info can be expanded + collapsible: !isFile || !!file.value?.function?.total, + children: file.children && Iterable.map(file.children?.values(), toChild) + }; + }; + + this.tree.setChildren(null, Iterable.map(files, toChild)); } public layout(height: number, width: number) { @@ -159,57 +260,33 @@ class TestCoverageTree extends Disposable { } } -class TestCoverageDataSource implements IAsyncDataSource { - - public hasChildren(element: CoverageTreeElement | TestCoverageInput): boolean { - return element instanceof TestCoverageInput || (isFileCoverage(element) && !!element.children?.size); - } - - public async getChildren(element: CoverageTreeElement | TestCoverageInput): Promise> { - if (element instanceof TestCoverageInput) { - const files = []; - for (let node of element.tree.nodes) { - // when showing initial children, only show from the first file or tee - while (!(node.value instanceof FileCoverage) && node.children?.size === 1) { - node = Iterable.first(node.children.values())!; - } - files.push(node); - } - return files; - } - - if (isFileCoverage(element) && element.children) { - return element.children.values(); - } - - return Iterable.empty(); - } -} - -class TestCoverageCompressionDelegate implements ITreeCompressionDelegate { - isIncompressible(element: CoverageTreeElement): boolean { - return isFunctionCoverage(element) || !element.children?.size; - } -} - class TestCoverageTreeListDelegate implements IListVirtualDelegate { getHeight(element: CoverageTreeElement): number { return 22; } - getTemplateId(_element: CoverageTreeElement): string { - return FileCoverageRenderer.ID; + getTemplateId(element: CoverageTreeElement): string { + if (isFileCoverage(element)) { + return FileCoverageRenderer.ID; + } + if (isFunctionCoverage(element)) { + return FunctionCoverageRenderer.ID; + } + if (element === LoadingDetails) { + return LoadingDetailsRenderer.ID; + } + assertNever(element); } } -interface TemplateData { +interface FileTemplateData { container: HTMLElement; bars: ManagedTestCoverageBars; templateDisposables: DisposableStore; label: IResourceLabel; } -class FileCoverageRenderer implements ICompressibleTreeRenderer { +class FileCoverageRenderer implements ICompressibleTreeRenderer { public static readonly ID = 'F'; public readonly templateId = FileCoverageRenderer.ID; @@ -220,7 +297,7 @@ class FileCoverageRenderer implements ICompressibleTreeRenderer, _index: number, templateData: TemplateData): void { + public renderElement(node: ITreeNode, _index: number, templateData: FileTemplateData): void { this.doRender(node.element as TestCoverageFileNode, templateData, node.filterData); } /** @inheritdoc */ - public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: TemplateData): void { + public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: FileTemplateData): void { this.doRender(node.element.elements, templateData, node.filterData); } - public disposeTemplate(templateData: TemplateData) { + public disposeTemplate(templateData: FileTemplateData) { templateData.templateDisposables.dispose(); } /** @inheritdoc */ - private doRender(element: CoverageTreeElement | CoverageTreeElement[], templateData: TemplateData, filterData: FuzzyScore | undefined) { + private doRender(element: CoverageTreeElement | CoverageTreeElement[], templateData: FileTemplateData, filterData: FuzzyScore | undefined) { const stat = (element instanceof Array ? element[element.length - 1] : element) as TestCoverageFileNode; const file = stat.value!; const name = element instanceof Array ? element.map(e => basenameOrAuthority((e as TestCoverageFileNode).value!.uri)) : basenameOrAuthority(file.uri); @@ -264,8 +341,93 @@ class FileCoverageRenderer implements ICompressibleTreeRenderer { - public getId(element: CoverageTreeElement) { - return isFileCoverage(element) ? element.value!.uri.toString() : element.name; +interface FunctionTemplateData { + container: HTMLElement; + bars: ManagedTestCoverageBars; + templateDisposables: DisposableStore; + label: IResourceLabel; +} + +class FunctionCoverageRenderer implements ICompressibleTreeRenderer { + public static readonly ID = 'N'; + public readonly templateId = FunctionCoverageRenderer.ID; + + constructor( + private readonly labels: ResourceLabels, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + /** @inheritdoc */ + public renderTemplate(container: HTMLElement): FileTemplateData { + const templateDisposables = new DisposableStore(); + container.classList.add('test-coverage-list-item'); + + return { + container, + bars: templateDisposables.add(this.instantiationService.createInstance(ManagedTestCoverageBars, { compact: false, container })), + label: templateDisposables.add(this.labels.create(container, { + supportHighlights: true, + })), + templateDisposables, + }; + } + + /** @inheritdoc */ + public renderElement(node: ITreeNode, _index: number, templateData: FileTemplateData): void { + this.doRender(node.element as FunctionCoverageNode, templateData, node.filterData); + } + + /** @inheritdoc */ + public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: FileTemplateData): void { + this.doRender(node.element.elements[node.element.elements.length - 1] as FunctionCoverageNode, templateData, node.filterData); + } + + public disposeTemplate(templateData: FileTemplateData) { + templateData.templateDisposables.dispose(); + } + + /** @inheritdoc */ + private doRender(element: FunctionCoverageNode, templateData: FileTemplateData, filterData: FuzzyScore | undefined) { + const classes = ['test-coverage-list-item-label']; + if (element.hits > 0) { + classes.push(...ThemeIcon.asClassNameArray(Codicon.pass)); + } + + templateData.bars.setCoverageInfo(element.attributableCoverage()); + templateData.label.setLabel(element.name, undefined, { + matches: createMatches(filterData), + extraClasses: ['test-coverage-list-item-label'], + }); + } +} + +class LoadingDetailsRenderer implements ICompressibleTreeRenderer { + public static readonly ID = 'L'; + public readonly templateId = LoadingDetailsRenderer.ID; + + renderCompressedElements(): void { + // no-op + } + + renderTemplate(container: HTMLElement): void { + container.innerText = loadingDetailsLabel; + } + + renderElement(): void { + // no-op + } + + disposeTemplate(): void { + // no-op + } +} + +class TestCoverageIdentityProvider implements IIdentityProvider { + public getId(element: CoverageTreeElement) { + return isFileCoverage(element) + ? element.value!.uri.toString() + : isFunctionCoverage(element) + ? element.id + : element.toString(); } } diff --git a/src/vs/workbench/contrib/testing/common/configuration.ts b/src/vs/workbench/contrib/testing/common/configuration.ts index e17fa6662ca..a10867e48b4 100644 --- a/src/vs/workbench/contrib/testing/common/configuration.ts +++ b/src/vs/workbench/contrib/testing/common/configuration.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { observableFromEvent } from 'vs/base/common/observable'; import { localize } from 'vs/nls'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IConfigurationNode } from 'vs/platform/configuration/common/configurationRegistry'; @@ -21,6 +22,7 @@ export const enum TestingConfigKeys { ShowAllMessages = 'testing.showAllMessages', CoveragePercent = 'testing.displayedCoveragePercent', ShowCoverageInExplorer = 'testing.showCoverageInExplorer', + CoverageBarThresholds = 'testing.coverageBarThresholds', } export const enum AutoOpenTesting { @@ -176,9 +178,24 @@ export const testingConfiguration: IConfigurationNode = { localize('testing.displayedCoveragePercent.minimum', 'The minimum of statement, function, and branch coverage.'), ], }, + [TestingConfigKeys.CoverageBarThresholds]: { + markdownDescription: localize('testing.coverageBarThresholds', "Configures the colors used for percentages in test coverage bars."), + default: { red: 0, yellow: 60, green: 90 }, + properties: { + red: { type: 'number', minimum: 0, maximum: 100, default: 0 }, + yellow: { type: 'number', minimum: 0, maximum: 100, default: 60 }, + green: { type: 'number', minimum: 0, maximum: 100, default: 90 }, + }, + }, } }; +export interface ITestingCoverageBarThresholds { + red: number; + green: number; + yellow: number; +} + export interface ITestingConfiguration { [TestingConfigKeys.AutoRunDelay]: number; [TestingConfigKeys.AutoOpenPeekView]: AutoOpenPeekViewWhen; @@ -193,6 +210,10 @@ export interface ITestingConfiguration { [TestingConfigKeys.ShowAllMessages]: boolean; [TestingConfigKeys.CoveragePercent]: TestingDisplayedCoveragePercent; [TestingConfigKeys.ShowCoverageInExplorer]: boolean; + [TestingConfigKeys.CoverageBarThresholds]: ITestingCoverageBarThresholds; } export const getTestingConfiguration = (config: IConfigurationService, key: K) => config.getValue(key); + +export const observeTestingConfiguration = (config: IConfigurationService, key: K) => observableFromEvent(config.onDidChangeConfiguration, () => + getTestingConfiguration(config, key)); diff --git a/src/vs/workbench/contrib/testing/common/testCoverage.ts b/src/vs/workbench/contrib/testing/common/testCoverage.ts index 2ec588a0a88..5dad3a5fa58 100644 --- a/src/vs/workbench/contrib/testing/common/testCoverage.ts +++ b/src/vs/workbench/contrib/testing/common/testCoverage.ts @@ -123,6 +123,23 @@ export class TestCoverage { } } +export const getTotalCoveragePercent = (statement: ICoveredCount, branch: ICoveredCount | undefined, function_: ICoveredCount | undefined) => { + let numerator = statement.covered; + let denominator = statement.total; + + if (branch) { + numerator += branch.covered; + denominator += branch.total; + } + + if (function_) { + numerator += function_.covered; + denominator += function_.total; + } + + return denominator === 0 ? 1 : numerator / denominator; +}; + export abstract class AbstractFileCoverage { public readonly uri: URI; public readonly statement: ICoveredCount; @@ -134,20 +151,7 @@ export abstract class AbstractFileCoverage { * This is based on the Clover total coverage formula */ public get tpc() { - let numerator = this.statement.covered; - let denominator = this.statement.total; - - if (this.branch) { - numerator += this.branch.covered; - denominator += this.branch.total; - } - - if (this.function) { - numerator += this.function.covered; - denominator += this.function.total; - } - - return denominator === 0 ? 1 : numerator / denominator; + return getTotalCoveragePercent(this.statement, this.branch, this.function); } constructor(coverage: IFileCoverage) { @@ -166,6 +170,12 @@ export class ComputedFileCoverage extends AbstractFileCoverage { } export class FileCoverage extends AbstractFileCoverage { private _details?: CoverageDetails[] | Promise; + private resolved?: boolean; + + /** Gets whether details are synchronously available */ + public get hasSynchronousDetails() { + return this._details instanceof Array || this.resolved; + } constructor(coverage: IFileCoverage, private readonly index: number, private readonly accessor: ICoverageAccessor) { super(coverage); @@ -179,7 +189,9 @@ export class FileCoverage extends AbstractFileCoverage { this._details ??= this.accessor.resolveFileCoverage(this.index, token); try { - return await this._details; + const d = await this._details; + this.resolved = true; + return d; } catch (e) { this._details = undefined; throw e; diff --git a/src/vs/workbench/contrib/testing/common/testTypes.ts b/src/vs/workbench/contrib/testing/common/testTypes.ts index 3c57f2b0a53..e242bf4de05 100644 --- a/src/vs/workbench/contrib/testing/common/testTypes.ts +++ b/src/vs/workbench/contrib/testing/common/testTypes.ts @@ -631,7 +631,7 @@ export interface IFunctionCoverage { type: DetailType.Function; name: string; count: number; - location?: Range | Position; + location: Range | Position; } export namespace IFunctionCoverage { @@ -639,7 +639,7 @@ export namespace IFunctionCoverage { type: DetailType.Function; name: string; count: number; - location?: IRange | IPosition; + location: IRange | IPosition; } export const serialize: (original: IFunctionCoverage) => Serialized = serializeThingWithLocation; diff --git a/test/unit/coverage.js b/test/unit/coverage.js index ac0c1440f4a..5134343c74b 100644 --- a/test/unit/coverage.js +++ b/test/unit/coverage.js @@ -37,7 +37,7 @@ exports.initialize = function (loaderConfig) { }; }; -exports.createReport = function (isSingle) { +exports.createReport = function (isSingle, coveragePath) { const mapStore = iLibSourceMaps.createSourceMapStore(); const coverageMap = iLibCoverage.createCoverageMap(global.__coverage__); return mapStore.transformCoverage(coverageMap).then((transformed) => { @@ -52,7 +52,7 @@ exports.createReport = function (isSingle) { transformed.data = newData; const context = iLibReport.createContext({ - dir: path.join(REPO_PATH, `.build/coverage${isSingle ? '-single' : ''}`), + dir: coveragePath || path.join(REPO_PATH, `.build/coverage${isSingle ? '-single' : ''}`), coverageMap: transformed }); const tree = context.getTree('flat'); @@ -60,6 +60,9 @@ exports.createReport = function (isSingle) { const reports = []; if (isSingle) { reports.push(iReports.create('lcovonly')); + if (coveragePath) { + reports.push(iReports.create('json')); + } } else { reports.push(iReports.create('json')); reports.push(iReports.create('lcov')); diff --git a/test/unit/electron/index.js b/test/unit/electron/index.js index f61514f72f1..d02921fb483 100644 --- a/test/unit/electron/index.js +++ b/test/unit/electron/index.js @@ -43,7 +43,7 @@ const minimist = require('minimist'); * }} */ const args = minimist(process.argv.slice(2), { - string: ['grep', 'run', 'runGlob', 'reporter', 'reporter-options', 'waitServer', 'timeout', 'crash-reporter-directory', 'tfs'], + string: ['grep', 'run', 'runGlob', 'reporter', 'reporter-options', 'waitServer', 'timeout', 'crash-reporter-directory', 'tfs', 'coveragePath'], boolean: ['build', 'coverage', 'help', 'dev'], alias: { 'grep': ['g', 'f'], diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index 61237ebd8e4..1488dfe85f5 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -123,7 +123,7 @@ function initLoader(opts) { function createCoverageReport(opts) { if (opts.coverage) { - return coverage.createReport(opts.run || opts.runGlob); + return coverage.createReport(opts.run || opts.runGlob, opts.coveragePath); } return Promise.resolve(undefined); } diff --git a/test/unit/node/index.js b/test/unit/node/index.js index c1b24ad362f..d1a787e4419 100644 --- a/test/unit/node/index.js +++ b/test/unit/node/index.js @@ -23,7 +23,7 @@ const { takeSnapshotAndCountClasses } = require('../analyzeSnapshot'); */ const args = minimist(process.argv.slice(2), { boolean: ['build', 'coverage', 'help'], - string: ['run'], + string: ['run', 'coveragePath'], alias: { h: 'help' }, @@ -36,6 +36,7 @@ const args = minimist(process.argv.slice(2), { build: 'Run from out-build', run: 'Run a single file', coverage: 'Generate a coverage report', + coveragePath: 'Path to coverage report to generate', help: 'Show help' } }); @@ -141,7 +142,7 @@ function main() { if (code !== 0) { return; } - coverage.createReport(args.run || args.runGlob); + coverage.createReport(args.run || args.runGlob, args.coveragePath); }); } diff --git a/yarn.lock b/yarn.lock index 060fe354a56..28a8dd70157 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5836,16 +5836,16 @@ istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== -istanbul-lib-instrument@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz#31d18bdd127f825dd02ea7bfdfd906f8ab840e9f" - integrity sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A== +istanbul-lib-instrument@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz#71e87707e8041428732518c6fb5211761753fbdf" + integrity sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA== dependencies: "@babel/core" "^7.12.3" "@babel/parser" "^7.14.7" "@istanbuljs/schema" "^0.1.2" istanbul-lib-coverage "^3.2.0" - semver "^6.3.0" + semver "^7.5.4" istanbul-lib-report@^3.0.0: version "3.0.0"