From 03853823823340ed3f5834cb8fa6c65e84d9d923 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 4 Jan 2024 12:22:46 -0800 Subject: [PATCH] testing: more out-of-editor refinements (#201834) - Som more tweaks to our own runner scripts to allow asking for the generated coverage formats. - Add actions alongside debug/run for executing coverage profiles - Finish with displaying function coverage stats in Coverage view, allow changing its sort order. Fixes #200529 Fixes #199380 --- package.json | 2 +- .../contrib/testing/browser/icons.ts | 7 +- .../contrib/testing/browser/media/testing.css | 10 + .../testing/browser/testCoverageBars.ts | 52 +-- .../testing/browser/testCoverageView.ts | 332 +++++++++++++----- .../testing/browser/testExplorerActions.ts | 148 ++++++-- .../testing/browser/testingOutputPeek.ts | 2 +- .../contrib/testing/common/constants.ts | 8 + test/unit/coverage.js | 14 +- test/unit/electron/index.js | 4 +- test/unit/electron/renderer.js | 2 +- test/unit/node/index.js | 7 +- 12 files changed, 450 insertions(+), 138 deletions(-) diff --git a/package.json b/package.json index fa7242f618b..57a6dd672af 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.86.0", - "distro": "536a5224f3131ed95718b23b3f82d75485d89721", + "distro": "31d3f5c070f8d16d2907b37f44f2b7e3f595f28c", "author": { "name": "Microsoft Corporation" }, diff --git a/src/vs/workbench/contrib/testing/browser/icons.ts b/src/vs/workbench/contrib/testing/browser/icons.ts index 6b65cba6ca2..455f07ec53a 100644 --- a/src/vs/workbench/contrib/testing/browser/icons.ts +++ b/src/vs/workbench/contrib/testing/browser/icons.ts @@ -19,6 +19,10 @@ export const testingRunAllIcon = registerIcon('testing-run-all-icon', Codicon.ru // todo: https://github.com/microsoft/vscode-codicons/issues/72 export const testingDebugAllIcon = registerIcon('testing-debug-all-icon', Codicon.debugAltSmall, localize('testingDebugAllIcon', 'Icon of the "debug all tests" action.')); export const testingDebugIcon = registerIcon('testing-debug-icon', Codicon.debugAltSmall, localize('testingDebugIcon', 'Icon of the "debug test" action.')); +// todo: https://github.com/microsoft/vscode-codicons/issues/205 +export const testingCoverageIcon = registerIcon('testing-coverage-icon', Codicon.heartFilled, localize('testingCoverageIcon', 'Icon of the "run test with coverage" action.')); +// todo: https://github.com/microsoft/vscode-codicons/issues/205 +export const testingCoverageAllIcon = registerIcon('testing-coverage-all-icon', Codicon.heartFilled, localize('testingRunAllWithCoverageIcon', 'Icon of the "run all tests with coverage" action.')); export const testingCancelIcon = registerIcon('testing-cancel-icon', Codicon.debugStop, localize('testingCancelIcon', 'Icon to cancel ongoing test runs.')); export const testingFilterIcon = registerIcon('testing-filter', Codicon.filter, localize('filterIcon', 'Icon for the \'Filter\' action in the testing view.')); export const testingHiddenIcon = registerIcon('testing-hidden', Codicon.eyeClosed, localize('hiddenIcon', 'Icon shown beside hidden tests, when they\'ve been shown.')); @@ -33,7 +37,8 @@ export const testingTurnContinuousRunOff = registerIcon('testing-turn-continuous export const testingContinuousIsOn = registerIcon('testing-continuous-is-on', Codicon.eye, localize('testingTurnContinuousRunIsOn', 'Icon when continuous run is on for a test ite,.')); export const testingCancelRefreshTests = registerIcon('testing-cancel-refresh-tests', Codicon.stop, localize('testingCancelRefreshTests', 'Icon on the button to cancel refreshing tests.')); -export const testingCoverage = registerIcon('testing-coverage', Codicon.lightBulb, localize('testingCoverage', 'Icon representing test coverage')); +export const testingCoverageReport = registerIcon('testing-coverage', Codicon.lightBulb, localize('testingCoverage', 'Icon representing test coverage')); +export const testingWasCovered = registerIcon('testing-was-covered', Codicon.check, localize('testingWasCovered', 'Icon representing that an element was covered')); export const testingStatesToIcons = new Map([ [TestResultState.Errored, registerIcon('testing-error-icon', Codicon.issues, localize('testingErrorIcon', 'Icon shown for tests that have an error.'))], diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index 20c87255f36..a523e914d59 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -43,6 +43,7 @@ .test-explorer .test-item .label, .test-output-peek-tree .test-peek-item .name, +.test-coverage-list-item .name, .test-coverage-list-item-label { flex-grow: 1; width: 0; @@ -365,6 +366,7 @@ .test-coverage-list-item { display: flex; + align-items: center; } .test-coverage-bars { @@ -392,6 +394,14 @@ opacity: 0.7; } +.test-coverage-list-item .icon { + margin-right: 0.2em; +} + +.test-coverage-list-item.not-covered .name { + opacity: 0.7; +} + /** -- coverage in the explorer */ .explorer-item-with-test-coverage { diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts b/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts index b6fe5ee6be8..9a1c2987b9d 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageBars.ts @@ -139,11 +139,11 @@ export class ManagedTestCoverageBars extends Disposable { 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, thresholds); + renderBar(el.tpcBar, overallStat, false, thresholds); } else { - 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); + renderBar(el.statement, percent(coverage.statement), coverage.statement.total === 0, thresholds); + renderBar(el.function, coverage.function && percent(coverage.function), coverage.function?.total === 0, thresholds); + renderBar(el.branch, coverage.branch && percent(coverage.branch), coverage.branch?.total === 0, thresholds); } } } @@ -152,27 +152,35 @@ const percent = (cc: ICoveredCount) => clamp(cc.total === 0 ? 1 : cc.covered / c const epsilon = 10e-8; const barWidth = 16; -const renderBar = (bar: HTMLElement, pct: number | undefined, thresholds: ITestingCoverageBarThresholds) => { +const renderBar = (bar: HTMLElement, pct: number | undefined, isZero: boolean, thresholds: ITestingCoverageBarThresholds) => { if (pct === undefined) { bar.style.display = 'none'; - } else { - bar.style.display = 'block'; - 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; + return; } + + bar.style.display = 'block'; + 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`); + + if (isZero) { + bar.style.color = 'currentColor'; + bar.style.opacity = '0.5'; + return; + } + + 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; + bar.style.opacity = '1'; }; const colorThresholds = [ diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts index 90d6231894a..ecf22d64e7e 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts @@ -3,46 +3,59 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as dom from 'vs/base/browser/dom'; import { IIdentityProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { ICompressedTreeElement, ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; -import { ITreeNode } from 'vs/base/browser/ui/tree/tree'; +import { ITreeNode, ITreeSorter } 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 { IObservable, autorun, observableValue } from 'vs/base/common/observable'; 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 { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { localize } from 'vs/nls'; +import { localize, localize2 } from 'vs/nls'; +import { MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { EditorOpenSource } from 'vs/platform/editor/common/editor'; +import { EditorOpenSource, TextEditorSelectionRevealType } from 'vs/platform/editor/common/editor'; import { FileKind } from 'vs/platform/files/common/files'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ILabelService } from 'vs/platform/label/common/label'; import { WorkbenchCompressibleObjectTree } from 'vs/platform/list/browser/listService'; import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; 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 { IViewPaneOptions, ViewAction, ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; import { IViewDescriptorService } from 'vs/workbench/common/views'; +import { testingStatesToIcons, testingWasCovered } from 'vs/workbench/contrib/testing/browser/icons'; import { CoverageBarSource, ManagedTestCoverageBars } from 'vs/workbench/contrib/testing/browser/testCoverageBars'; -import { ComputedFileCoverage, FileCoverage, TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; +import { TestCommandId, Testing } from 'vs/workbench/contrib/testing/common/constants'; +import { ComputedFileCoverage, FileCoverage, TestCoverage, getTotalCoveragePercent } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; -import { CoverageDetails, DetailType, ICoveredCount, IFunctionCoverage } from 'vs/workbench/contrib/testing/common/testTypes'; +import { CoverageDetails, DetailType, ICoveredCount, IFunctionCoverage, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; +const enum CoverageSortOrder { + Coverage, + Location, + Name, +} + export class TestCoverageView extends ViewPane { private readonly tree = new MutableDisposable(); + public readonly sortOrder = observableValue('sortOrder', CoverageSortOrder.Location); constructor( options: IViewPaneOptions, @@ -68,7 +81,7 @@ export class TestCoverageView extends ViewPane { this._register(autorun(reader => { const coverage = this.coverageService.selected.read(reader); if (coverage) { - const t = (this.tree.value ??= this.instantiationService.createInstance(TestCoverageTree, container, labels)); + const t = (this.tree.value ??= this.instantiationService.createInstance(TestCoverageTree, container, labels, this.sortOrder)); t.setInput(coverage); } else { this.tree.clear(); @@ -86,19 +99,45 @@ let fnNodeId = 0; class FunctionCoverageNode { public readonly id = String(fnNodeId++); + public readonly containedDetails = new Set(); + public readonly children: FunctionCoverageNode[] = []; public get hits() { return this.data.count; } - public get name() { + public get label() { return this.data.name; } + public get location() { + return this.data.location; + } + + public get tpc() { + const attr = this.attributableCoverage(); + return attr && getTotalCoveragePercent(attr.statement, attr.branch, undefined); + } + constructor( + public readonly uri: URI, private readonly data: IFunctionCoverage, - private readonly details: CoverageDetails[], - ) { } + details: readonly CoverageDetails[], + ) { + if (data.location instanceof Range) { + for (const detail of details) { + if (this.contains(detail.location)) { + this.containedDetails.add(detail); + } + } + } + } + + /** Gets whether this function has a defined range and contains the given range. */ + public contains(location: Range | Position) { + const own = this.data.location; + return own instanceof Range && (location instanceof Range ? own.containsRange(location) : own.containsPosition(location)); + } /** * If the function defines a range, we can look at statements within the @@ -114,21 +153,16 @@ class FunctionCoverageNode { const statement: ICoveredCount = { covered: 0, total: 0 }; const branch: ICoveredCount = { covered: 0, total: 0 }; - for (const detail of this.details) { + for (const detail of this.containedDetails) { 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.covered += detail.count > 0 ? 1 : 0; statement.total++; - if (detail.branches) { for (const { count } of detail.branches) { - branch.covered += count > 0 ? 0 : 1; + branch.covered += count > 0 ? 1 : 0; branch.total++; } } @@ -138,11 +172,24 @@ class FunctionCoverageNode { } } -const LoadingDetails = Symbol(); -const loadingDetailsLabel = localize('loadingCoverageDetails', "Loading Coverage Details..."); +class RevealUncoveredFunctions { + public readonly id = String(fnNodeId++); + + public get label() { + return localize('functionsWithoutCoverage', "{0} functions without coverage...", this.n); + } + + constructor(public readonly n: number) { } +} + +class LoadingDetails { + public readonly id = String(fnNodeId++); + public readonly label = 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 | FunctionCoverageNode | LoadingDetails | RevealUncoveredFunctions; const isFileCoverage = (c: CoverageTreeElement): c is TestCoverageFileNode => typeof c === 'object' && 'value' in c; const isFunctionCoverage = (c: CoverageTreeElement): c is FunctionCoverageNode => c instanceof FunctionCoverageNode; @@ -155,6 +202,7 @@ class TestCoverageTree extends Disposable { constructor( container: HTMLElement, labels: ResourceLabels, + sortOrder: IObservable, @IInstantiationService instantiationService: IInstantiationService, @IEditorService editorService: IEditorService, ) { @@ -167,25 +215,30 @@ class TestCoverageTree extends Disposable { new TestCoverageTreeListDelegate(), [ instantiationService.createInstance(FileCoverageRenderer, labels), - instantiationService.createInstance(FunctionCoverageRenderer, labels), - instantiationService.createInstance(LoadingDetailsRenderer), + instantiationService.createInstance(FunctionCoverageRenderer), + instantiationService.createInstance(BasicRenderer), ], { expandOnlyOnTwistieClick: true, + sorter: new Sorter(sortOrder), + keyboardNavigationLabelProvider: { + getCompressedNodeKeyboardNavigationLabel(elements: CoverageTreeElement[]) { + return elements.map(e => this.getKeyboardNavigationLabel(e)).join('/'); + }, + getKeyboardNavigationLabel(e: CoverageTreeElement) { + return isFileCoverage(e) + ? basenameOrAuthority(e.value!.uri) + : e.label; + }, + }, 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)); + } else { + return element.label; } - if (isFunctionCoverage(element)) { - return element.name; - } - if (element === LoadingDetails) { - return loadingDetailsLabel; - } - - assertNever(element); }, getWidgetAriaLabel() { return localize('testCoverageTreeLabel', "Test Coverage Explorer"); @@ -195,29 +248,32 @@ class TestCoverageTree extends Disposable { } ); + this._register(autorun(reader => { + sortOrder.read(reader); + this.tree.resort(null, true); + })); + 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 }]); + this.tree.setChildren(el, [{ element: new 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 }))); - }); + el.value!.details().then(details => this.updateWithDetails(el, details)); } })); this._register(this.tree.onDidOpen(e => { let resource: URI | undefined; - if (e.element && isFileCoverage(e.element) && !e.element.children?.size) { - resource = e.element.value!.uri; + let selection: Range | Position | undefined; + if (e.element) { + if (isFileCoverage(e.element) && !e.element.children?.size) { + resource = e.element.value!.uri; + } else if (isFunctionCoverage(e.element)) { + resource = e.element.uri; + selection = e.element.location; + } } if (!resource) { return; @@ -225,7 +281,14 @@ class TestCoverageTree extends Disposable { editorService.openEditor({ resource, - options: { preserveFocus: e.editorOptions.preserveFocus, pinned: e.editorOptions.pinned, source: EditorOpenSource.USER } + options: { + selection: selection instanceof Position ? Range.fromPositions(selection, selection) : selection, + revealIfOpened: true, + selectionRevealType: TextEditorSelectionRevealType.NearTopIfOutsideViewport, + preserveFocus: e.editorOptions.preserveFocus, + pinned: e.editorOptions.pinned, + source: EditorOpenSource.USER, + }, }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); })); } @@ -258,6 +321,41 @@ class TestCoverageTree extends Disposable { public layout(height: number, width: number) { this.tree.layout(height, width); } + + private updateWithDetails(el: IPrefixTreeNode, details: readonly CoverageDetails[]) { + if (!this.tree.hasElement(el)) { + return; // avoid any issues if the tree changes in the meanwhile + } + + const functions: FunctionCoverageNode[] = []; + for (const fn of details) { + if (fn.type !== DetailType.Function) { + continue; + } + + let arr = functions; + while (true) { + const parent = arr.find(p => p.containedDetails.has(fn)); + if (parent) { + arr = parent.children; + } else { + break; + } + } + + arr.push(new FunctionCoverageNode(el.value!.uri, fn, details)); + } + + const makeChild = (fn: FunctionCoverageNode): ICompressedTreeElement => ({ + element: fn, + incompressible: true, + collapsed: true, + collapsible: fn.children.length > 0, + children: fn.children.map(makeChild) + }); + + this.tree.setChildren(el, functions.map(makeChild)); + } } class TestCoverageTreeListDelegate implements IListVirtualDelegate { @@ -272,13 +370,48 @@ class TestCoverageTreeListDelegate implements IListVirtualDelegate { + constructor(private readonly order: IObservable) { } + compare(a: CoverageTreeElement, b: CoverageTreeElement): number { + const order = this.order.get(); + if (isFileCoverage(a) && isFileCoverage(b)) { + switch (order) { + case CoverageSortOrder.Location: + case CoverageSortOrder.Name: + return a.value!.uri.toString().localeCompare(b.value!.uri.toString()); + case CoverageSortOrder.Coverage: + return b.value!.tpc - a.value!.tpc; + } + } else if (isFunctionCoverage(a) && isFunctionCoverage(b)) { + switch (order) { + case CoverageSortOrder.Location: + return Position.compare( + a.location instanceof Range ? a.location.getStartPosition() : a.location, + b.location instanceof Range ? b.location.getStartPosition() : b.location, + ); + case CoverageSortOrder.Name: + return a.label.localeCompare(b.label); + case CoverageSortOrder.Coverage: { + const attrA = a.tpc; + const attrB = b.tpc; + return (attrA !== undefined && attrB !== undefined && attrB - attrA) + || (b.hits - a.hits) + || a.label.localeCompare(b.label); + } + } + } else { + return 0; + } + } +} + interface FileTemplateData { container: HTMLElement; bars: ManagedTestCoverageBars; @@ -345,7 +478,8 @@ interface FunctionTemplateData { container: HTMLElement; bars: ManagedTestCoverageBars; templateDisposables: DisposableStore; - label: IResourceLabel; + icon: HTMLElement; + label: HTMLElement; } class FunctionCoverageRenderer implements ICompressibleTreeRenderer { @@ -353,81 +487,119 @@ class FunctionCoverageRenderer implements ICompressibleTreeRenderer, _index: number, templateData: FileTemplateData): void { + public renderElement(node: ITreeNode, _index: number, templateData: FunctionTemplateData): void { this.doRender(node.element as FunctionCoverageNode, templateData, node.filterData); } /** @inheritdoc */ - public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: FileTemplateData): void { + public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: FunctionTemplateData): void { this.doRender(node.element.elements[node.element.elements.length - 1] as FunctionCoverageNode, templateData, node.filterData); } - public disposeTemplate(templateData: FileTemplateData) { + public disposeTemplate(templateData: FunctionTemplateData) { 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)); - } - + private doRender(element: FunctionCoverageNode, templateData: FunctionTemplateData, _filterData: FuzzyScore | undefined) { + const covered = element.hits > 0; + const icon = covered ? testingWasCovered : testingStatesToIcons.get(TestResultState.Unset); + templateData.container.classList.toggle('not-covered', !covered); + templateData.icon.className = `computed-state ${ThemeIcon.asClassName(icon!)}`; + templateData.label.innerText = element.label; 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; +class BasicRenderer implements ICompressibleTreeRenderer { + public static readonly ID = 'B'; + public readonly templateId = BasicRenderer.ID; - renderCompressedElements(): void { - // no-op + renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, container: HTMLElement): void { + this.renderInner(node.element.elements[node.element.elements.length - 1], container); } - renderTemplate(container: HTMLElement): void { - container.innerText = loadingDetailsLabel; + renderTemplate(container: HTMLElement): HTMLElement { + return container; } - renderElement(): void { - // no-op + renderElement(node: ITreeNode, index: number, container: HTMLElement): void { + this.renderInner(node.element, container); } disposeTemplate(): void { // no-op } + + private renderInner(element: CoverageTreeElement, container: HTMLElement) { + container.innerText = (element as RevealUncoveredFunctions | LoadingDetails).label; + } } class TestCoverageIdentityProvider implements IIdentityProvider { public getId(element: CoverageTreeElement) { return isFileCoverage(element) ? element.value!.uri.toString() - : isFunctionCoverage(element) - ? element.id - : element.toString(); + : element.id; } } + +registerAction2(class TestCoverageChangeSortingAction extends ViewAction { + constructor() { + super({ + id: TestCommandId.CoverageViewChangeSorting, + viewId: Testing.CoverageViewId, + title: localize2('testing.changeCoverageSort', 'Change Sort Order'), + icon: Codicon.sortPrecedence, + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', Testing.CoverageViewId), + group: 'navigation', + } + }); + } + + override runInView(accessor: ServicesAccessor, view: TestCoverageView) { + type Item = IQuickPickItem & { value: CoverageSortOrder }; + + const disposables = new DisposableStore(); + const quickInput = disposables.add(accessor.get(IQuickInputService).createQuickPick()); + const items: Item[] = [ + { label: localize('testing.coverageSortByLocation', 'Sort by Location'), value: CoverageSortOrder.Location, description: localize('testing.coverageSortByLocationDescription', 'Files are sorted alphabetically, functions are sorted by position') }, + { label: localize('testing.coverageSortByCoverage', 'Sort by Coverage'), value: CoverageSortOrder.Coverage, description: localize('testing.coverageSortByCoverageDescription', 'Files and functions are sorted by total coverage') }, + { label: localize('testing.coverageSortByName', 'Sort by Name'), value: CoverageSortOrder.Name, description: localize('testing.coverageSortByNameDescription', 'Files and functions are sorted alphabetically') }, + ]; + + quickInput.placeholder = localize('testing.coverageSortPlaceholder', 'Sort the Test Coverage view...'); + quickInput.items = items; + quickInput.show(); + quickInput.onDidHide(() => quickInput.dispose()); + quickInput.onDidAccept(() => { + const picked = quickInput.selectedItems[0]?.value; + if (picked !== undefined) { + view.sortOrder.set(picked, undefined); + quickInput.dispose(); + } + }); + } +}); diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index 5b7abc3825e..1716ae12a8a 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -76,6 +76,7 @@ const hasAnyTestProvider = ContextKeyGreaterExpr.create(TestingContextKeys.provi const LABEL_RUN_TESTS = { value: localize('runSelectedTests', 'Run Tests'), original: 'Run Tests' }; const LABEL_DEBUG_TESTS = { value: localize('debugSelectedTests', 'Debug Tests'), original: 'Debug Tests' }; +const LABEL_COVERAGE_TESTS = { value: localize('coverageSelectedTests', 'Run Tests with Coverage'), original: 'Run Tests withCoverage' }; export class HideTestAction extends Action2 { constructor() { @@ -152,13 +153,10 @@ const testItemInlineAndInContext = (order: ActionOrder, when?: ContextKeyExpress } ]; -export class DebugAction extends ViewAction { - constructor() { +abstract class RunVisibleAction extends ViewAction { + constructor(private readonly bitset: TestRunProfileBitset, desc: Readonly) { super({ - id: TestCommandId.DebugAction, - title: localize('debug test', 'Debug Test'), - icon: icons.testingDebugIcon, - menu: testItemInlineAndInContext(ActionOrder.Debug, TestingContextKeys.hasDebuggableTests.isEqualTo(true)), + ...desc, viewId: Testing.ExplorerViewId, }); } @@ -171,7 +169,29 @@ export class DebugAction extends ViewAction { return accessor.get(ITestService).runTests({ tests: include, exclude, - group: TestRunProfileBitset.Debug, + group: this.bitset, + }); + } +} + +export class DebugAction extends RunVisibleAction { + constructor() { + super(TestRunProfileBitset.Debug, { + id: TestCommandId.DebugAction, + title: localize('debug test', 'Debug Test'), + icon: icons.testingDebugIcon, + menu: testItemInlineAndInContext(ActionOrder.Debug, TestingContextKeys.hasDebuggableTests.isEqualTo(true)), + }); + } +} + +export class CoverageAction extends RunVisibleAction { + constructor() { + super(TestRunProfileBitset.Coverage, { + id: TestCommandId.RunWithCoverageAction, + title: localize('run with cover test', 'Run Test with Coverage'), + icon: icons.testingCoverageIcon, + menu: testItemInlineAndInContext(ActionOrder.Coverage, TestingContextKeys.hasCoverableTests.isEqualTo(true)), }); } } @@ -212,26 +232,13 @@ export class RunUsingProfileAction extends Action2 { } } -export class RunAction extends ViewAction { +export class RunAction extends RunVisibleAction { constructor() { - super({ + super(TestRunProfileBitset.Run, { id: TestCommandId.RunAction, title: localize('run test', 'Run Test'), icon: icons.testingRunIcon, menu: testItemInlineAndInContext(ActionOrder.Run, TestingContextKeys.hasRunnableTests.isEqualTo(true)), - viewId: Testing.ExplorerViewId, - }); - } - - /** - * @override - */ - public runInView(accessor: ServicesAccessor, view: TestingExplorerView, ...elements: TestItemTreeElement[]): Promise { - const { include, exclude } = view.getTreeIncludeExclude(elements.map(e => e.test)); - return accessor.get(ITestService).runTests({ - tests: include, - exclude, - group: TestRunProfileBitset.Run, }); } } @@ -587,6 +594,16 @@ export class DebugSelectedAction extends ExecuteSelectedAction { } } +export class CoverageSelectedAction extends ExecuteSelectedAction { + constructor() { + super({ + id: TestCommandId.CoverageSelectedAction, + title: LABEL_COVERAGE_TESTS, + icon: icons.testingCoverageAllIcon, + }, TestRunProfileBitset.Coverage); + } +} + const showDiscoveringWhile = (progress: IProgressService, task: Promise): Promise => { return progress.withProgress( { @@ -659,6 +676,24 @@ export class DebugAllAction extends RunOrDebugAllTestsAction { } } +export class CoverageAllAction extends RunOrDebugAllTestsAction { + constructor() { + super( + { + id: TestCommandId.RunAllWithCoverageAction, + title: localize('runAllWithCoverage', 'Run All Tests with Coverage'), + icon: icons.testingCoverageIcon, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.Semicolon, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA), + }, + }, + TestRunProfileBitset.Coverage, + localize('noCoverageTestProvider', 'No tests with coverage runners found in this workspace. You may need to install a test provider extension'), + ); + } +} + export class CancelTestRunAction extends Action2 { constructor() { super({ @@ -1060,6 +1095,21 @@ export class DebugAtCursor extends ExecuteTestAtCursor { } } +export class CoverageAtCursor extends ExecuteTestAtCursor { + constructor() { + super({ + id: TestCommandId.CoverageAtCursor, + title: localize2('testing.coverageAtCursor', 'Run Test at Cursor with Coverage'), + category, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + when: EditorContextKeys.editorTextFocus, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.Semicolon, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC), + }, + }, TestRunProfileBitset.Coverage); + } +} + abstract class ExecuteTestsUnderUriAction extends Action2 { constructor(options: IAction2Options, protected readonly group: TestRunProfileBitset) { super({ @@ -1111,6 +1161,16 @@ class DebugTestsUnderUri extends ExecuteTestsUnderUriAction { } } +class CoverageTestsUnderUri extends ExecuteTestsUnderUriAction { + constructor() { + super({ + id: TestCommandId.CoverageByUri, + title: LABEL_COVERAGE_TESTS, + category, + }, TestRunProfileBitset.Coverage); + } +} + abstract class ExecuteTestsInCurrentFile extends Action2 { constructor(options: IAction2Options, protected readonly group: TestRunProfileBitset) { super({ @@ -1189,7 +1249,6 @@ export class RunCurrentFile extends ExecuteTestsInCurrentFile { } export class DebugCurrentFile extends ExecuteTestsInCurrentFile { - constructor() { super({ id: TestCommandId.DebugCurrentFile, @@ -1204,6 +1263,21 @@ export class DebugCurrentFile extends ExecuteTestsInCurrentFile { } } +export class CoverageCurrentFile extends ExecuteTestsInCurrentFile { + constructor() { + super({ + id: TestCommandId.CoverageCurrentFile, + title: localize2('testing.coverageCurrentFile', 'Run Tests with Coverage in Current File'), + category, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + when: EditorContextKeys.editorTextFocus, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.Semicolon, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyF), + }, + }, TestRunProfileBitset.Coverage); + } +} + export const discoverAndRunTests = async ( collection: IMainThreadTestCollection, progress: IProgressService, @@ -1381,6 +1455,27 @@ export class DebugLastRun extends RunOrDebugLastRun { } } +export class CoverageLastRun extends RunOrDebugLastRun { + constructor() { + super({ + id: TestCommandId.CoverageLastRun, + title: localize2('testing.coverageLastRun', 'Rerun Last Run with Coverage'), + category, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.Semicolon, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyL), + }, + }); + } + + protected runTest(service: ITestService, internalTests: InternalTestItem[]): Promise { + return service.runTests({ + group: TestRunProfileBitset.Coverage, + tests: internalTests, + }); + } +} + export class SearchForTestExtension extends Action2 { constructor() { super({ @@ -1543,6 +1638,13 @@ export const allTestActions = [ ConfigureTestProfilesAction, ContinuousRunTestAction, ContinuousRunUsingProfileTestAction, + CoverageAction, + CoverageAllAction, + CoverageAtCursor, + CoverageCurrentFile, + CoverageLastRun, + CoverageSelectedAction, + CoverageTestsUnderUri, DebugAction, DebugAllAction, DebugAtCursor, diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index 39e673936b6..776340abfca 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -1717,7 +1717,7 @@ class CoverageElement implements ITreeElement { } public get icon() { - return this.isOpen ? widgetClose : icons.testingCoverage; + return this.isOpen ? widgetClose : icons.testingCoverageReport; } public get isOpen() { diff --git a/src/vs/workbench/contrib/testing/common/constants.ts b/src/vs/workbench/contrib/testing/common/constants.ts index e8a22d62340..e3fff330e6a 100644 --- a/src/vs/workbench/contrib/testing/common/constants.ts +++ b/src/vs/workbench/contrib/testing/common/constants.ts @@ -58,7 +58,13 @@ export const enum TestCommandId { CollapseAllAction = 'testing.collapseAll', ConfigureTestProfilesAction = 'testing.configureProfile', ContinousRunUsingForTest = 'testing.continuousRunUsingForTest', + CoverageAtCursor = 'testing.coverageAtCursor', + CoverageByUri = 'testing.coverage.uri', + CoverageViewChangeSorting = 'testing.coverageViewChangeSorting', CoverageClose = 'testing.coverage.close', + CoverageCurrentFile = 'testing.coverageCurrentFile', + CoverageLastRun = 'testing.coverageLastRun', + CoverageSelectedAction = 'testing.coverageSelected', DebugAction = 'testing.debug', DebugAllAction = 'testing.debugAll', DebugAtCursor = 'testing.debugAtCursor', @@ -78,11 +84,13 @@ export const enum TestCommandId { ReRunLastRun = 'testing.reRunLastRun', RunAction = 'testing.run', RunAllAction = 'testing.runAll', + RunAllWithCoverageAction = 'testing.coverageAll', RunAtCursor = 'testing.runAtCursor', RunByUri = 'testing.run.uri', RunCurrentFile = 'testing.runCurrentFile', RunSelectedAction = 'testing.runSelected', RunUsingProfileAction = 'testing.runUsing', + RunWithCoverageAction = 'testing.coverage', SearchForTestExtension = 'testing.searchForTestExtension', SelectDefaultTestProfiles = 'testing.selectDefaultTestProfiles', ShowMostRecentOutputAction = 'testing.showMostRecentOutput', diff --git a/test/unit/coverage.js b/test/unit/coverage.js index 5134343c74b..13712241b4c 100644 --- a/test/unit/coverage.js +++ b/test/unit/coverage.js @@ -37,7 +37,7 @@ exports.initialize = function (loaderConfig) { }; }; -exports.createReport = function (isSingle, coveragePath) { +exports.createReport = function (isSingle, coveragePath, formats) { const mapStore = iLibSourceMaps.createSourceMapStore(); const coverageMap = iLibCoverage.createCoverageMap(global.__coverage__); return mapStore.transformCoverage(coverageMap).then((transformed) => { @@ -58,11 +58,15 @@ exports.createReport = function (isSingle, coveragePath) { const tree = context.getTree('flat'); const reports = []; - if (isSingle) { - reports.push(iReports.create('lcovonly')); - if (coveragePath) { - reports.push(iReports.create('json')); + if (formats) { + if (typeof formats === 'string') { + formats = [formats]; } + formats.forEach(format => { + reports.push(iReports.create(format)); + }); + } else if (isSingle) { + reports.push(iReports.create('lcovonly')); } 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 d02921fb483..cfc2a5a9890 100644 --- a/test/unit/electron/index.js +++ b/test/unit/electron/index.js @@ -39,11 +39,13 @@ const minimist = require('minimist'); * tfs: string; * build: boolean; * coverage: boolean; + * coveragePath: string; + * coverageFormats: string | string[]; * help: boolean; * }} */ const args = minimist(process.argv.slice(2), { - string: ['grep', 'run', 'runGlob', 'reporter', 'reporter-options', 'waitServer', 'timeout', 'crash-reporter-directory', 'tfs', 'coveragePath'], + string: ['grep', 'run', 'runGlob', 'reporter', 'reporter-options', 'waitServer', 'timeout', 'crash-reporter-directory', 'tfs', 'coveragePath', 'coverageFormats'], boolean: ['build', 'coverage', 'help', 'dev'], alias: { 'grep': ['g', 'f'], diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index 1488dfe85f5..243d5a5b142 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, opts.coveragePath); + return coverage.createReport(opts.run || opts.runGlob, opts.coveragePath, opts.coverageFormats); } return Promise.resolve(undefined); } diff --git a/test/unit/node/index.js b/test/unit/node/index.js index d1a787e4419..14ceed7177c 100644 --- a/test/unit/node/index.js +++ b/test/unit/node/index.js @@ -19,11 +19,11 @@ const minimist = require('minimist'); const { takeSnapshotAndCountClasses } = require('../analyzeSnapshot'); /** - * @type {{ build: boolean; run: string; runGlob: string; coverage: boolean; help: boolean; }} + * @type {{ build: boolean; run: string; runGlob: string; coverage: boolean; help: boolean; coverageFormats: string | string[]; coveragePath: string; }} */ const args = minimist(process.argv.slice(2), { boolean: ['build', 'coverage', 'help'], - string: ['run', 'coveragePath'], + string: ['run', 'coveragePath', 'coverageFormats'], alias: { h: 'help' }, @@ -37,6 +37,7 @@ const args = minimist(process.argv.slice(2), { run: 'Run a single file', coverage: 'Generate a coverage report', coveragePath: 'Path to coverage report to generate', + coverageFormats: 'Coverage formats to generate', help: 'Show help' } }); @@ -142,7 +143,7 @@ function main() { if (code !== 0) { return; } - coverage.createReport(args.run || args.runGlob, args.coveragePath); + coverage.createReport(args.run || args.runGlob, args.coveragePath, args.coverageFormats); }); }