mirror of
https://github.com/Microsoft/vscode
synced 2024-10-02 17:32:41 +00:00
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:
parent
95c1e5236a
commit
c016ce64fb
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -376,7 +376,6 @@
|
|||
}
|
||||
|
||||
.test-coverage-bars .bar {
|
||||
width: 16px;
|
||||
height: 8px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 2px;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -5836,16 +5836,16 @@ istanbul-lib-coverage@^3.2.0:
|
|||
resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3"
|
||||
integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==
|
||||
|
||||
istanbul-lib-instrument@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz#31d18bdd127f825dd02ea7bfdfd906f8ab840e9f"
|
||||
integrity sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==
|
||||
istanbul-lib-instrument@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz#71e87707e8041428732518c6fb5211761753fbdf"
|
||||
integrity sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==
|
||||
dependencies:
|
||||
"@babel/core" "^7.12.3"
|
||||
"@babel/parser" "^7.14.7"
|
||||
"@istanbuljs/schema" "^0.1.2"
|
||||
istanbul-lib-coverage "^3.2.0"
|
||||
semver "^6.3.0"
|
||||
semver "^7.5.4"
|
||||
|
||||
istanbul-lib-report@^3.0.0:
|
||||
version "3.0.0"
|
||||
|
|
Loading…
Reference in a new issue