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)
This commit is contained in:
Connor Peet 2024-01-03 23:42:22 -08:00 committed by GitHub
parent 95c1e5236a
commit c016ce64fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 362 additions and 131 deletions

View File

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

View File

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

View File

@ -376,7 +376,6 @@
}
.test-coverage-bars .bar {
width: 16px;
height: 8px;
border: 1px solid currentColor;
border-radius: 2px;

View File

@ -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<AbstractFileCoverage, 'statement' | 'branch' | 'function'>;
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;

View File

@ -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<ComputedFileCoverage>;
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<ComputedFileCoverage | FileCoverage>;
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<FileCoverage> =>
isFileCoverage(c) && c.value instanceof FileCoverage && !!c.value.function?.total;
class TestCoverageTree extends Disposable {
private readonly tree: WorkbenchCompressibleAsyncDataTree<TestCoverageInput | undefined, CoverageTreeElement, void>;
private readonly tree: WorkbenchCompressibleObjectTree<CoverageTreeElement, void>;
constructor(
container: HTMLElement,
@ -106,34 +160,60 @@ class TestCoverageTree extends Disposable {
) {
super();
this.tree = <WorkbenchCompressibleAsyncDataTree<TestCoverageInput | undefined, CoverageTreeElement, void>>instantiationService.createInstance(
WorkbenchCompressibleAsyncDataTree,
this.tree = <WorkbenchCompressibleObjectTree<CoverageTreeElement, void>>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<CoverageTreeElement> => {
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<TestCoverageInput, CoverageTreeElement> {
public hasChildren(element: CoverageTreeElement | TestCoverageInput): boolean {
return element instanceof TestCoverageInput || (isFileCoverage(element) && !!element.children?.size);
}
public async getChildren(element: CoverageTreeElement | TestCoverageInput): Promise<Iterable<CoverageTreeElement>> {
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<CoverageTreeElement> {
isIncompressible(element: CoverageTreeElement): boolean {
return isFunctionCoverage(element) || !element.children?.size;
}
}
class TestCoverageTreeListDelegate implements IListVirtualDelegate<CoverageTreeElement> {
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<CoverageTreeElement, FuzzyScore, TemplateData> {
class FileCoverageRenderer implements ICompressibleTreeRenderer<CoverageTreeElement, FuzzyScore, FileTemplateData> {
public static readonly ID = 'F';
public readonly templateId = FileCoverageRenderer.ID;
@ -220,7 +297,7 @@ class FileCoverageRenderer implements ICompressibleTreeRenderer<CoverageTreeElem
) { }
/** @inheritdoc */
public renderTemplate(container: HTMLElement): TemplateData {
public renderTemplate(container: HTMLElement): FileTemplateData {
const templateDisposables = new DisposableStore();
container.classList.add('test-coverage-list-item');
@ -235,21 +312,21 @@ class FileCoverageRenderer implements ICompressibleTreeRenderer<CoverageTreeElem
}
/** @inheritdoc */
public renderElement(node: ITreeNode<CoverageTreeElement, FuzzyScore>, _index: number, templateData: TemplateData): void {
public renderElement(node: ITreeNode<CoverageTreeElement, FuzzyScore>, _index: number, templateData: FileTemplateData): void {
this.doRender(node.element as TestCoverageFileNode, templateData, node.filterData);
}
/** @inheritdoc */
public renderCompressedElements(node: ITreeNode<ICompressedTreeNode<CoverageTreeElement>, FuzzyScore>, _index: number, templateData: TemplateData): void {
public renderCompressedElements(node: ITreeNode<ICompressedTreeNode<CoverageTreeElement>, 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<CoverageTreeElem
}
}
class TestCoverageIdentityProvider implements IIdentityProvider<CoverageTreeElement> {
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<CoverageTreeElement, FuzzyScore, FunctionTemplateData> {
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<CoverageTreeElement, FuzzyScore>, _index: number, templateData: FileTemplateData): void {
this.doRender(node.element as FunctionCoverageNode, templateData, node.filterData);
}
/** @inheritdoc */
public renderCompressedElements(node: ITreeNode<ICompressedTreeNode<CoverageTreeElement>, 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<CoverageTreeElement, FuzzyScore, void> {
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<CoverageTreeElement> {
public getId(element: CoverageTreeElement) {
return isFileCoverage(element)
? element.value!.uri.toString()
: isFunctionCoverage(element)
? element.id
: element.toString();
}
}

View File

@ -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 = <K extends TestingConfigKeys>(config: IConfigurationService, key: K) => config.getValue<ITestingConfiguration[K]>(key);
export const observeTestingConfiguration = <K extends TestingConfigKeys>(config: IConfigurationService, key: K) => observableFromEvent(config.onDidChangeConfiguration, () =>
getTestingConfiguration(config, key));

View File

@ -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<CoverageDetails[]>;
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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