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
This commit is contained in:
Connor Peet 2024-01-04 12:22:46 -08:00 committed by GitHub
parent 3b234eab72
commit 0385382382
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 450 additions and 138 deletions

View File

@ -1,7 +1,7 @@
{
"name": "code-oss-dev",
"version": "1.86.0",
"distro": "536a5224f3131ed95718b23b3f82d75485d89721",
"distro": "31d3f5c070f8d16d2907b37f44f2b7e3f595f28c",
"author": {
"name": "Microsoft Corporation"
},

View File

@ -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, ThemeIcon>([
[TestResultState.Errored, registerIcon('testing-error-icon', Codicon.issues, localize('testingErrorIcon', 'Icon shown for tests that have an error.'))],

View File

@ -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 {

View File

@ -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 = [

View File

@ -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<TestCoverageTree>();
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<CoverageDetails>();
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<ComputedFileCoverage | FileCoverage>;
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<CoverageSortOrder>,
@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<FileCoverage>, 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<CoverageTreeElement> => ({
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<CoverageTreeElement> {
@ -272,13 +370,48 @@ class TestCoverageTreeListDelegate implements IListVirtualDelegate<CoverageTreeE
if (isFunctionCoverage(element)) {
return FunctionCoverageRenderer.ID;
}
if (element === LoadingDetails) {
return LoadingDetailsRenderer.ID;
if (element instanceof LoadingDetails || element instanceof RevealUncoveredFunctions) {
return BasicRenderer.ID;
}
assertNever(element);
}
}
class Sorter implements ITreeSorter<CoverageTreeElement> {
constructor(private readonly order: IObservable<CoverageSortOrder>) { }
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<CoverageTreeElement, FuzzyScore, FunctionTemplateData> {
@ -353,81 +487,119 @@ class FunctionCoverageRenderer implements ICompressibleTreeRenderer<CoverageTree
public readonly templateId = FunctionCoverageRenderer.ID;
constructor(
private readonly labels: ResourceLabels,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) { }
/** @inheritdoc */
public renderTemplate(container: HTMLElement): FileTemplateData {
public renderTemplate(container: HTMLElement): FunctionTemplateData {
const templateDisposables = new DisposableStore();
container.classList.add('test-coverage-list-item');
const icon = dom.append(container, dom.$('.state'));
const label = dom.append(container, dom.$('.name'));
return {
container,
bars: templateDisposables.add(this.instantiationService.createInstance(ManagedTestCoverageBars, { compact: false, container })),
label: templateDisposables.add(this.labels.create(container, {
supportHighlights: true,
})),
templateDisposables,
icon,
label,
};
}
/** @inheritdoc */
public renderElement(node: ITreeNode<CoverageTreeElement, FuzzyScore>, _index: number, templateData: FileTemplateData): void {
public renderElement(node: ITreeNode<CoverageTreeElement, FuzzyScore>, _index: number, templateData: FunctionTemplateData): void {
this.doRender(node.element as FunctionCoverageNode, templateData, node.filterData);
}
/** @inheritdoc */
public renderCompressedElements(node: ITreeNode<ICompressedTreeNode<CoverageTreeElement>, FuzzyScore>, _index: number, templateData: FileTemplateData): void {
public renderCompressedElements(node: ITreeNode<ICompressedTreeNode<CoverageTreeElement>, 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<CoverageTreeElement, FuzzyScore, void> {
public static readonly ID = 'L';
public readonly templateId = LoadingDetailsRenderer.ID;
class BasicRenderer implements ICompressibleTreeRenderer<CoverageTreeElement, FuzzyScore, HTMLElement> {
public static readonly ID = 'B';
public readonly templateId = BasicRenderer.ID;
renderCompressedElements(): void {
// no-op
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<CoverageTreeElement>, 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<CoverageTreeElement, FuzzyScore>, 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<CoverageTreeElement> {
public getId(element: CoverageTreeElement) {
return isFileCoverage(element)
? element.value!.uri.toString()
: isFunctionCoverage(element)
? element.id
: element.toString();
: element.id;
}
}
registerAction2(class TestCoverageChangeSortingAction extends ViewAction<TestCoverageView> {
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<Item>());
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();
}
});
}
});

View File

@ -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<TestingExplorerView> {
constructor() {
abstract class RunVisibleAction extends ViewAction<TestingExplorerView> {
constructor(private readonly bitset: TestRunProfileBitset, desc: Readonly<IAction2Options>) {
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<TestingExplorerView> {
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<TestingExplorerView> {
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<unknown> {
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 = <R>(progress: IProgressService, task: Promise<R>): Promise<R> => {
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<ITestResult> {
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,

View File

@ -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() {

View File

@ -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',

View File

@ -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'));

View File

@ -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'],

View File

@ -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);
}

View File

@ -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);
});
}