Не подтверждена Коммит 3235435c создал по автору Connor Peet's avatar Connor Peet
Просмотр файлов

testing: implement initial UI for per-test coverage

- There's now a toolbar on top of the file with test info for that file.
	This was inspired by some coverage extension I saw at one point:
	previously this was only shown in the explorer/test coverage view
	versus being contextual in the file
	- I really like the concept and utility of the toolbar, but it could
	  certainly use a bit of polish. Maybe have it be sticky like
	  breadcrumbs and styled more after notebooks'
- I relocated the "toggle inline coverage" action from being the annoying
	popup on the line numbers into the toolbar
- When per-test coverage is available, that's shown in the toolbar as
	well. Clicking on it allows you to filter to see only coverage
	generated by that test case. Per-test coverage filtering is global,
	and also applies in the Test Coverage view.
		- There's a pseudo-select box for filtering in the Test Coverage view
		  (native select boxes are painful with a large number of items)
		- I think it's useful to show the code run by a test in the coverage
		  view, but the numbers per-file are a little bogus, at least for the
		  selfhost test provider, since I only show #'s for functions run by
		  that test. Maybe we just don't show percentages in this mode.

https://memes.peet.io/img/24-05-1941df72-bd93-42f9-9363-32fc3ea69e7d.mp4
владелец 5447d0db
......@@ -143,7 +143,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh
transaction(tx => {
let value = task.coverage.read(undefined);
if (!value) {
value = new TestCoverage(taskId, this.uriIdentityService, {
value = new TestCoverage(run, taskId, this.uriIdentityService, {
getCoverageDetails: (id, token) => this.proxy.$getCoverageDetails(id, token)
.then(r => r.map(CoverageDetails.deserialize)),
});
......
......@@ -565,6 +565,7 @@ class TestRunTracker extends Disposable {
throw new Error('Attempted to `addCoverage` for a test item not included in the run');
}
this.ensureTestIsKnown(testItem);
testItemIdPart = testItemCoverageId.get(testItem);
if (testItemIdPart === undefined) {
testItemIdPart = testItemCoverageId.size;
......
......@@ -4,41 +4,44 @@
*--------------------------------------------------------------------------------------------*/
import * as dom from 'vs/base/browser/dom';
import { HoverWidget } from 'vs/base/browser/ui/hover/hoverWidget';
import { mapFindFirst } from 'vs/base/common/arraysFind';
import { assertNever } from 'vs/base/common/assert';
import { assert, assertNever } from 'vs/base/common/assert';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { KeyChord, KeyCode, KeyMod } from 'vs/base/common/keyCodes';
import { Lazy } from 'vs/base/common/lazy';
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { autorun, derived, observableFromEvent, observableValue } from 'vs/base/common/observable';
import { ThemeIcon } from 'vs/base/common/themables';
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, MouseTargetType } from 'vs/editor/browser/editorBrowser';
import { MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer';
import { ICodeEditor, MouseTargetType } from 'vs/editor/browser/editorBrowser';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { IModelDecorationOptions, ITextModel, InjectedTextCursorStops, InjectedTextOptions } from 'vs/editor/common/model';
import { HoverOperation, HoverStartMode, IHoverComputer } from 'vs/editor/contrib/hover/browser/hoverOperation';
import { IModelDecorationOptions, InjectedTextCursorStops, InjectedTextOptions, ITextModel } from 'vs/editor/common/model';
import { localize, localize2 } from 'vs/nls';
import { Categories } from 'vs/platform/action/common/actionCommonCategories';
import { Action2, registerAction2 } from 'vs/platform/actions/common/actions';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
import { ILogService } from 'vs/platform/log/common/log';
import { IQuickInputService, QuickPickInput } from 'vs/platform/quickinput/common/quickInput';
import * as coverUtils from 'vs/workbench/contrib/testing/browser/codeCoverageDisplayUtils';
import { testingCoverageMissingBranch } from 'vs/workbench/contrib/testing/browser/icons';
import { ManagedTestCoverageBars } from 'vs/workbench/contrib/testing/browser/testCoverageBars';
import { getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration';
import { FileCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService';
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
import { CoverageDetails, DetailType, IDeclarationCoverage, IStatementCoverage } from 'vs/workbench/contrib/testing/common/testTypes';
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
const MAX_HOVERED_LINES = 30;
const CLASS_HIT = 'coverage-deco-hit';
const CLASS_MISS = 'coverage-deco-miss';
const TOGGLE_INLINE_COMMAND_TEXT = localize('testing.toggleInlineCoverage', 'Toggle Inline Coverage');
const TOGGLE_INLINE_COMMAND_TEXT = localize('testing.toggleInlineCoverage', 'Toggle Inline');
const TOGGLE_INLINE_COMMAND_ID = 'testing.toggleInlineCoverage';
const BRANCH_MISS_INDICATOR_CHARS = 4;
......@@ -49,7 +52,7 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
private loadingCancellation?: CancellationTokenSource;
private readonly displayedStore = this._register(new DisposableStore());
private readonly hoveredStore = this._register(new DisposableStore());
private readonly lineHoverWidget: Lazy<LineHoverWidget>;
private readonly summaryWidget: Lazy<CoverageSummaryWidget>;
private decorationIds = new Map<string, {
detail: DetailRange;
options: IModelDecorationOptions;
......@@ -66,7 +69,7 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
) {
super();
this.lineHoverWidget = new Lazy(() => this._register(instantiationService.createInstance(LineHoverWidget, this.editor)));
this.summaryWidget = new Lazy(() => this._register(instantiationService.createInstance(CoverageSummaryWidget, this.editor)));
const modelObs = observableFromEvent(editor.onDidChangeModel, () => editor.getModel());
const configObs = observableFromEvent(editor.onDidChangeConfiguration, i => i);
......@@ -82,8 +85,13 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
return;
}
const file = report.getUri(model.uri);
let file = report.getUri(model.uri);
if (file) {
const testFilter = coverage.filterToTest.read(reader);
if (testFilter) {
file = file.perTestData?.get(testFilter.toString()) || file;
}
return file;
}
......@@ -114,8 +122,6 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
const model = editor.getModel();
if (e.target.type === MouseTargetType.GUTTER_LINE_NUMBERS && model) {
this.hoverLineNumber(editor.getModel()!, e.target.position.lineNumber);
} else if (this.lineHoverWidget.hasValue && this.lineHoverWidget.value.getDomNode().contains(e.target.element)) {
// don't dismiss the hover
} else if (CodeCoverageDecorations.showInline.get() && e.target.type === MouseTargetType.CONTENT_TEXT && model) {
this.hoverInlineDecoration(model, e.target.position);
} else {
......@@ -184,7 +190,6 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
const todo = [{ line: lineNumber, dir: 0 }];
const toEnable = new Set<string>();
const inlineEnabled = CodeCoverageDecorations.showInline.get();
if (!CodeCoverageDecorations.showInline.get()) {
for (let i = 0; i < todo.length && i < MAX_HOVERED_LINES; i++) {
const { line, dir } = todo[i];
......@@ -215,16 +220,11 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
});
}
if (toEnable.size || inlineEnabled) {
this.lineHoverWidget.value.startShowingAt(lineNumber);
}
this.hoveredStore.add(this.editor.onMouseLeave(() => {
this.hoveredStore.clear();
}));
this.hoveredStore.add(toDisposable(() => {
this.lineHoverWidget.value.hide();
this.hoveredSubject = undefined;
model.changeDecorations(e => {
......@@ -245,6 +245,7 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
}
this.displayedStore.clear();
this.summaryWidget.value.setCoverage(coverage);
model.changeDecorations(e => {
for (const detailRange of details.ranges) {
......@@ -308,6 +309,8 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
});
this.displayedStore.add(toDisposable(() => {
this.summaryWidget.value.setCoverage(undefined);
model.changeDecorations(e => {
for (const decoration of this.decorationIds.keys()) {
e.removeDecoration(decoration);
......@@ -499,27 +502,6 @@ function tidyLocation(location: Range | Position): Range {
return location;
}
class LineHoverComputer implements IHoverComputer<IMarkdownString> {
public line = -1;
constructor(@IKeybindingService private readonly keybindingService: IKeybindingService) { }
/** @inheritdoc */
public computeSync(): IMarkdownString[] {
const strs: IMarkdownString[] = [];
const s = new MarkdownString().appendMarkdown(`[${TOGGLE_INLINE_COMMAND_TEXT}](command:${TOGGLE_INLINE_COMMAND_ID})`);
s.isTrusted = true;
const binding = this.keybindingService.lookupKeybinding(TOGGLE_INLINE_COMMAND_ID);
if (binding) {
s.appendText(` (${binding.getLabel()})`);
}
strs.push(s);
return strs;
}
}
function wrapInBackticks(str: string) {
return '`' + str.replace(/[\n\r`]/g, '') + '`';
}
......@@ -531,95 +513,155 @@ function wrapName(functionNameOrCode: string) {
return wrapInBackticks(functionNameOrCode);
}
class LineHoverWidget extends Disposable implements IOverlayWidget {
public static readonly ID = 'editor.contrib.testingCoverageLineHoverWidget';
class CoverageSummaryWidget implements IDisposable {
private current: FileCoverage | undefined;
private registered = false;
private readonly registration = new DisposableStore();
private readonly computer: LineHoverComputer;
private readonly hoverOperation: HoverOperation<IMarkdownString>;
private readonly hover = this._register(new HoverWidget());
private readonly renderDisposables = this._register(new DisposableStore());
private readonly markdownRenderer: MarkdownRenderer;
private readonly _domNode = dom.h('div.coverage-summary-widget', [
dom.h('div', [
dom.h('span.bars@bars'),
dom.h('span.stat@stat'),
dom.h('a.toggleInline@toggleInline'),
dom.h('a.perTestFilter@perTestFilter'),
]),
]);
constructor(private readonly editor: ICodeEditor, @IInstantiationService instantiationService: IInstantiationService) {
super();
this.computer = instantiationService.createInstance(LineHoverComputer);
this.markdownRenderer = this._register(instantiationService.createInstance(MarkdownRenderer, { editor: this.editor }));
this.hoverOperation = this._register(new HoverOperation(this.editor, this.computer));
this.hover.containerDomNode.classList.add('hidden');
this.hoverOperation.onResult(result => {
if (result.value.length) {
this.render(result.value);
} else {
this.hide();
}
private readonly bars: ManagedTestCoverageBars;
constructor(
private readonly editor: ICodeEditor,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IQuickInputService private readonly quickInputService: IQuickInputService,
@ITestCoverageService private readonly testCoverageService: ITestCoverageService,
@IKeybindingService keybindingService: IKeybindingService,
@IInstantiationService instaService: IInstantiationService,
) {
this._domNode.perTestFilter.ariaLabel = this._domNode.perTestFilter.title = coverUtils.labels.clickToChangeFiltering;
this.bars = instaService.createInstance(ManagedTestCoverageBars, {
compact: false,
overall: false,
container: this._domNode.bars,
});
this.editor.addOverlayWidget(this);
}
/** @inheritdoc */
getId(): string {
return LineHoverWidget.ID;
const kb = keybindingService.lookupKeybinding(TOGGLE_INLINE_COMMAND_ID);
if (kb) {
this._domNode.toggleInline.title = `${TOGGLE_INLINE_COMMAND_TEXT} (${kb.getLabel()})`;
}
}
/** @inheritdoc */
public getDomNode(): HTMLElement {
return this.hover.containerDomNode;
}
public setCoverage(coverage: FileCoverage | undefined) {
this.current = coverage;
this.bars.setCoverageInfo(coverage);
/** @inheritdoc */
public getPosition(): IOverlayWidgetPosition | null {
return null;
if (!coverage) {
return this.unregister();
}
const displayStat = coverUtils.calculateDisplayedStat(coverage, getTestingConfiguration(this.configurationService, TestingConfigKeys.CoveragePercent));
this._domNode.stat.innerText = localize('testing.percentCoverage', '{0} Coverage', coverUtils.displayPercent(displayStat));
this._domNode.perTestFilter.classList.toggle('active', !!coverage.isForTest);
if (coverage.isForTest) {
const testItem = coverage.fromResult.getTestById(coverage.isForTest.id.toString());
assert(!!testItem, 'got coverage for an unreported test');
this._domNode.perTestFilter.style.display = 'inline';
this._domNode.perTestFilter.innerText = coverUtils.labels.showingFilterFor(testItem.label);
} else if (coverage.perTestData?.size) {
this._domNode.perTestFilter.style.display = 'inline';
this._domNode.perTestFilter.innerText = localize('testing.coverageForTestAvailable', "{0} test(s) in this file", coverage.perTestData.size);
} else {
this._domNode.perTestFilter.style.display = 'none';
}
this.register();
}
/** @inheritdoc */
public override dispose(): void {
this.editor.removeOverlayWidget(this);
super.dispose();
public dispose() {
this.unregister();
this.bars.dispose();
}
/** Shows the hover widget at the given line */
public startShowingAt(lineNumber: number) {
this.hide();
const textModel = this.editor.getModel();
if (!textModel) {
private filterTest() {
const options = this.current?.perTestData ?? this.current?.isForTest?.parent.perTestData;
if (!options) {
return;
}
this.computer.line = lineNumber;
this.hoverOperation.start(HoverStartMode.Delayed);
}
const tests = [...options.values()];
const commonPrefix = TestId.getLengthOfCommonPrefix(tests.length, i => tests[i].isForTest!.id);
const result = this.current!.fromResult;
const previousSelection = this.testCoverageService.filterToTest.get();
/** Hides the hover widget */
public hide() {
this.hoverOperation.cancel();
this.hover.containerDomNode.classList.add('hidden');
}
type TItem = { label: string; description?: string; item: FileCoverage | undefined };
const items: QuickPickInput<TItem>[] = [
{ label: coverUtils.labels.allTests, item: undefined },
{ type: 'separator' },
...tests.map(item => ({ label: coverUtils.getLabelForItem(result, item.isForTest!.id, commonPrefix), description: coverUtils.labels.percentCoverage(item.tpc), item })),
];
private render(elements: IMarkdownString[]) {
const { hover: h, editor: editor } = this;
const fragment = document.createDocumentFragment();
this.quickInputService.pick(items, {
activeItem: items.find((item): item is TItem => 'item' in item && item.item === this.current),
placeHolder: coverUtils.labels.pickShowCoverage,
onDidFocus: (entry) => {
this.testCoverageService.filterToTest.set(entry.item?.isForTest!.id, undefined);
},
}).then(selected => {
this.testCoverageService.filterToTest.set(selected ? selected.item?.isForTest!.id : previousSelection, undefined);
});
}
for (const msg of elements) {
const markdownHoverElement = dom.$('div.hover-row.markdown-hover');
const hoverContentsElement = dom.append(markdownHoverElement, dom.$('div.hover-contents'));
const renderedContents = this.renderDisposables.add(this.markdownRenderer.render(msg));
hoverContentsElement.appendChild(renderedContents.element);
fragment.appendChild(markdownHoverElement);
private register() {
if (this.registered) {
return;
}
dom.clearNode(h.contentsDomNode);
h.contentsDomNode.appendChild(fragment);
h.containerDomNode.classList.remove('hidden');
const editorLayout = editor.getLayoutInfo();
const topForLineNumber = editor.getTopForLineNumber(this.computer.line);
const editorScrollTop = editor.getScrollTop();
const lineHeight = editor.getOption(EditorOption.lineHeight);
const nodeHeight = h.containerDomNode.clientHeight;
const top = topForLineNumber - editorScrollTop - ((nodeHeight - lineHeight) / 2);
const left = editorLayout.lineNumbersLeft + editorLayout.lineNumbersWidth;
h.containerDomNode.style.left = `${left}px`;
h.containerDomNode.style.top = `${Math.max(Math.round(top), 0)}px`;
this.registered = true;
let viewZoneId: string;
this.editor.changeViewZones(accessor => {
viewZoneId = accessor.addZone({
afterLineNumber: 0,
afterColumn: 0,
domNode: this._domNode.root,
heightInPx: 30,
ordinal: -1, // show before code lenses
});
});
this.registration.add(toDisposable(() => {
this.editor.changeViewZones(accessor => {
accessor.removeZone(viewZoneId);
});
this.registered = false;
}));
this.registration.add(dom.addStandardDisposableListener(this._domNode.perTestFilter, 'click', () => {
this.filterTest();
}));
this.registration.add(this.configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(TestingConfigKeys.CoverageBarThresholds) || e.affectsConfiguration(TestingConfigKeys.CoveragePercent)) {
this.setCoverage(this.current);
}
}));
this.registration.add(dom.addStandardDisposableListener(this._domNode.toggleInline, 'click', () => {
CodeCoverageDecorations.showInline.set(!CodeCoverageDecorations.showInline.get(), undefined);
}));
this.registration.add(autorun(reader => {
this._domNode.toggleInline.innerText = CodeCoverageDecorations.showInline.read(reader)
? localize('testing.hideInlineCoverage', 'Hide Inline Coverage')
: localize('testing.showInlineCoverage', 'Show Inline Coverage');
}));
}
private unregister() {
this.registration.clear();
}
}
......
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { assertNever } from 'vs/base/common/assert';
import { clamp } from 'vs/base/common/numbers';
import { localize } from 'vs/nls';
import { chartsGreen, chartsRed, chartsYellow } from 'vs/platform/theme/common/colorRegistry';
import { asCssVariableName } from 'vs/platform/theme/common/colorUtils';
import { CoverageBarSource } from 'vs/workbench/contrib/testing/browser/testCoverageBars';
import { ITestingCoverageBarThresholds, TestingDisplayedCoveragePercent } from 'vs/workbench/contrib/testing/common/configuration';
import { getTotalCoveragePercent } from 'vs/workbench/contrib/testing/common/testCoverage';
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult';
import { ICoverageCount } from 'vs/workbench/contrib/testing/common/testTypes';
export const percent = (cc: ICoverageCount) => clamp(cc.total === 0 ? 1 : cc.covered / cc.total, 0, 1);
const colorThresholds = [
{ color: `var(${asCssVariableName(chartsRed)})`, key: 'red' },
{ color: `var(${asCssVariableName(chartsYellow)})`, key: 'yellow' },
{ color: `var(${asCssVariableName(chartsGreen)})`, key: 'green' },
] as const;
export const getCoverageColor = (pct: number, thresholds: ITestingCoverageBarThresholds) => {
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;
}
}
return best;
};
const epsilon = 10e-8;
export const displayPercent = (value: number, precision = 2) => {
const display = (value * 100).toFixed(precision);
// avoid showing 100% coverage if it just rounds up:
if (value < 1 - epsilon && display === '100') {
return `${100 - (10 ** -precision)}%`;
}
return `${display}%`;
};
export const calculateDisplayedStat = (coverage: CoverageBarSource, method: TestingDisplayedCoveragePercent) => {
switch (method) {
case TestingDisplayedCoveragePercent.Statement:
return percent(coverage.statement);
case TestingDisplayedCoveragePercent.Minimum: {
let value = percent(coverage.statement);
if (coverage.branch) { value = Math.min(value, percent(coverage.branch)); }
if (coverage.declaration) { value = Math.min(value, percent(coverage.declaration)); }
return value;
}
case TestingDisplayedCoveragePercent.TotalCoverage:
return getTotalCoveragePercent(coverage.statement, coverage.branch, coverage.declaration);
default:
assertNever(method);
}
};
export function getLabelForItem(result: LiveTestResult, testId: TestId, commonPrefixLen: number) {
const parts: string[] = [];
for (const id of testId.idsFromRoot()) {
const item = result.getTestById(id.toString());
if (!item) {
break;
}
parts.push(item.label);
}
return parts.slice(commonPrefixLen).join(' \u203a ');
}
export namespace labels {
export const showingFilterFor = (label: string) => localize('testing.coverageForTest', "Showing \"{0}\"", label);
export const clickToChangeFiltering = localize('changePerTestFilter', 'Click to view coverage for a single test');
export const percentCoverage = (percent: number, precision?: number) => localize('testing.percentCoverage', '{0} Coverage', displayPercent(percent, precision));
export const allTests = localize('testing.allTests', 'All tests');
export const pickShowCoverage = localize('testing.pickTest', 'Pick a test to show coverage for');
}
......@@ -401,6 +401,84 @@
opacity: 0.7;
}
.coverage-summary-widget {
color: var(--vscode-editor-foreground);
z-index: 1;
line-height: 25px;
> div {
display: flex;
align-items: center;
border-bottom: 1px solid var(--vscode-menu-border);
}
.toggleInline, .perTestFilter {
border-left: 1px solid var(--vscode-menu-border);
padding: 0 6px;
}
.stat, .toggleInline {
padding-right: 6px;
}
> span, > a {
display: inline;
position: relative;
padding: 0 6px;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
a {
color: var(--vscode-textLink-foreground);
cursor: pointer;
}
a:hover {
color: var(--vscode-textLink-activeForeground);
}
.toggleInline, .perTestFilter {
border-left: 1px solid var(--vscode-menu-border);
}
}
.test-coverage-tree-per-test-switcher {
display: flex;
background-color: var(--vscode-dropdown-background);
color: var(--vscode-dropdown-foreground);
border: 1px solid var(--vscode-dropdown-border);
margin: 3px 0;
cursor: pointer;
margin-right: 22px;
line-height: 20px;
padding: 0 6px;
width: fit-content;
max-width: calc(100% - 44px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&::after {
content: '';
content: var(--vscode-icon-chevron-right-content);
font-family: var(--vscode-icon-chevron-right-font-family);
font-size: 18px;
padding-left: 22px;
}
}
/** -- coverage in the explorer */
.explorer-item-with-test-coverage {
......
......@@ -6,11 +6,9 @@
import { h } from 'vs/base/browser/dom';
import type { IUpdatableHover, IUpdatableHoverTooltipMarkdownString } from 'vs/base/browser/ui/hover/hover';
import { getDefaultHoverDelegate } from 'vs/base/browser/ui/hover/hoverDelegateFactory';
import { assertNever } from 'vs/base/common/assert';
import { MarkdownString } from 'vs/base/common/htmlContent';
import { Lazy } from 'vs/base/common/lazy';
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
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';
......@@ -18,12 +16,12 @@ import { localize } from 'vs/nls';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IHoverService } from 'vs/platform/hover/browser/hover';
import { Registry } from 'vs/platform/registry/common/platform';
import { asCssVariableName, chartsGreen, chartsRed, chartsYellow } from 'vs/platform/theme/common/colorRegistry';
import { ExplorerExtensions, IExplorerFileContribution, IExplorerFileContributionRegistry } from 'vs/workbench/contrib/files/browser/explorerFileContrib';
import { ITestingCoverageBarThresholds, TestingConfigKeys, TestingDisplayedCoveragePercent, getTestingConfiguration, observeTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration';
import { AbstractFileCoverage, getTotalCoveragePercent } from 'vs/workbench/contrib/testing/common/testCoverage';
import * as coverUtils from 'vs/workbench/contrib/testing/browser/codeCoverageDisplayUtils';
import { calculateDisplayedStat } from 'vs/workbench/contrib/testing/browser/codeCoverageDisplayUtils';
import { ITestingCoverageBarThresholds, TestingConfigKeys, getTestingConfiguration, observeTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration';
import { AbstractFileCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService';
import { ICoverageCount } from 'vs/workbench/contrib/testing/common/testTypes';
export interface TestCoverageBarsOptions {
/**
......@@ -31,6 +29,10 @@ export interface TestCoverageBarsOptions {
* overall bar is shown and more details are given in the hover.
*/
compact: boolean;
/**
* Whether the overall stat is shown, defaults to true.
*/
overall?: boolean;
/**
* Container in which is render the bars.
*/
......@@ -120,19 +122,21 @@ export class ManagedTestCoverageBars extends Disposable {
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 (this.options.overall !== false) {
el.overall.textContent = coverUtils.displayPercent(overallStat, precision);
} else {
el.overall.style.display = 'none';
}
if ('tpcBar' in el) { // compact mode
renderBar(el.tpcBar, overallStat, false, thresholds);
} else {
renderBar(el.statement, percent(coverage.statement), coverage.statement.total === 0, thresholds);
renderBar(el.function, coverage.declaration && percent(coverage.declaration), coverage.declaration?.total === 0, thresholds);
renderBar(el.branch, coverage.branch && percent(coverage.branch), coverage.branch?.total === 0, thresholds);
renderBar(el.statement, coverUtils.percent(coverage.statement), coverage.statement.total === 0, thresholds);
renderBar(el.function, coverage.declaration && coverUtils.percent(coverage.declaration), coverage.declaration?.total === 0, thresholds);
renderBar(el.branch, coverage.branch && coverUtils.percent(coverage.branch), coverage.branch?.total === 0, thresholds);
}
}
}
const percent = (cc: ICoverageCount) => 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, isZero: boolean, thresholds: ITestingCoverageBarThresholds) => {
......@@ -152,59 +156,14 @@ const renderBar = (bar: HTMLElement, pct: number | undefined, isZero: boolean, t
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.color = coverUtils.getCoverageColor(pct, thresholds);
bar.style.opacity = '1';
};
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);
case TestingDisplayedCoveragePercent.Minimum: {
let value = percent(coverage.statement);
if (coverage.branch) { value = Math.min(value, percent(coverage.branch)); }
if (coverage.declaration) { value = Math.min(value, percent(coverage.declaration)); }
return value;
}
case TestingDisplayedCoveragePercent.TotalCoverage:
return getTotalCoveragePercent(coverage.statement, coverage.branch, coverage.declaration);
default:
assertNever(method);
}
};
const displayPercent = (value: number, precision = 2) => {
const display = (value * 100).toFixed(precision);
// avoid showing 100% coverage if it just rounds up:
if (value < 1 - epsilon && display === '100') {
return `${100 - (10 ** -precision)}%`;
}
return `${display}%`;
};
const nf = new Intl.NumberFormat();
const stmtCoverageText = (coverage: CoverageBarSource) => localize('statementCoverage', '{0}/{1} statements covered ({2})', nf.format(coverage.statement.covered), nf.format(coverage.statement.total), displayPercent(percent(coverage.statement)));
const fnCoverageText = (coverage: CoverageBarSource) => coverage.declaration && localize('functionCoverage', '{0}/{1} functions covered ({2})', nf.format(coverage.declaration.covered), nf.format(coverage.declaration.total), displayPercent(percent(coverage.declaration)));
const branchCoverageText = (coverage: CoverageBarSource) => coverage.branch && localize('branchCoverage', '{0}/{1} branches covered ({2})', nf.format(coverage.branch.covered), nf.format(coverage.branch.total), displayPercent(percent(coverage.branch)));
const stmtCoverageText = (coverage: CoverageBarSource) => localize('statementCoverage', '{0}/{1} statements covered ({2})', nf.format(coverage.statement.covered), nf.format(coverage.statement.total), coverUtils.displayPercent(coverUtils.percent(coverage.statement)));
const fnCoverageText = (coverage: CoverageBarSource) => coverage.declaration && localize('functionCoverage', '{0}/{1} functions covered ({2})', nf.format(coverage.declaration.covered), nf.format(coverage.declaration.total), coverUtils.displayPercent(coverUtils.percent(coverage.declaration)));
const branchCoverageText = (coverage: CoverageBarSource) => coverage.branch && localize('branchCoverage', '{0}/{1} branches covered ({2})', nf.format(coverage.branch.covered), nf.format(coverage.branch.total), coverUtils.displayPercent(coverUtils.percent(coverage.branch)));
const getOverallHoverText = (coverage: CoverageBarSource): IUpdatableHoverTooltipMarkdownString => {
const str = [
......
......@@ -23,7 +23,8 @@ 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, localize2 } from 'vs/nls';
import { MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
......@@ -35,19 +36,22 @@ 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 { IQuickInputService, IQuickPickItem, QuickPickInput } 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, ViewAction, ViewPane } from 'vs/workbench/browser/parts/views/viewPane';
import { IViewDescriptorService } from 'vs/workbench/common/views';
import * as coverUtils from 'vs/workbench/contrib/testing/browser/codeCoverageDisplayUtils';
import { testingStatesToIcons, testingWasCovered } from 'vs/workbench/contrib/testing/browser/icons';
import { CoverageBarSource, ManagedTestCoverageBars } from 'vs/workbench/contrib/testing/browser/testCoverageBars';
import { TestCommandId, Testing } from 'vs/workbench/contrib/testing/common/constants';
import { onObservableChange } from 'vs/workbench/contrib/testing/common/observableUtils';
import { ComputedFileCoverage, FileCoverage, TestCoverage, getTotalCoveragePercent } from 'vs/workbench/contrib/testing/common/testCoverage';
import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService';
import { CoverageDetails, DetailType, ICoverageCount, IDeclarationCoverage, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes';
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
import { CoverageDetails, DetailType, ICoverageCount, IDeclarationCoverage, ITestItem, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes';
import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
const enum CoverageSortOrder {
......@@ -86,7 +90,7 @@ export class TestCoverageView extends ViewPane {
const coverage = this.coverageService.selected.read(reader);
if (coverage) {
const t = (this.tree.value ??= this.instantiationService.createInstance(TestCoverageTree, container, labels, this.sortOrder));
t.setInput(coverage);
t.setInput(coverage, this.coverageService.filterToTest.read(reader));
} else {
this.tree.clear();
}
......@@ -191,9 +195,16 @@ class LoadingDetails {
public readonly label = localize('loadingCoverageDetails', "Loading Coverage Details...");
}
class PerTestCoverageSwitcher {
public readonly id = String(fnNodeId++);
public readonly label = localize('changePerTestFilter', 'Click to change test filtering');
constructor(public readonly currentFilter: ITestItem | undefined) { }
}
/** Type of nodes returned from {@link TestCoverage}. Note: value is *always* defined. */
type TestCoverageFileNode = IPrefixTreeNode<ComputedFileCoverage | FileCoverage>;
type CoverageTreeElement = TestCoverageFileNode | DeclarationCoverageNode | LoadingDetails | RevealUncoveredDeclarations;
type CoverageTreeElement = TestCoverageFileNode | DeclarationCoverageNode | LoadingDetails | RevealUncoveredDeclarations | PerTestCoverageSwitcher;
const isFileCoverage = (c: CoverageTreeElement): c is TestCoverageFileNode => typeof c === 'object' && 'value' in c;
const isDeclarationCoverage = (c: CoverageTreeElement): c is DeclarationCoverageNode => c instanceof DeclarationCoverageNode;
......@@ -222,6 +233,7 @@ class TestCoverageTree extends Disposable {
instantiationService.createInstance(FileCoverageRenderer, labels),
instantiationService.createInstance(DeclarationCoverageRenderer),
instantiationService.createInstance(BasicRenderer),
instantiationService.createInstance(PerTestCoverageSwitcherRenderer),
],
{
expandOnlyOnTwistieClick: true,
......@@ -298,11 +310,22 @@ class TestCoverageTree extends Disposable {
}));
}
public setInput(coverage: TestCoverage) {
public setInput(coverage: TestCoverage, showOnlyTest?: TestId) {
this.inputDisposables.clear();
const files = [];
for (let node of coverage.tree.nodes) {
let tree = coverage.tree;
// Filter to only a test, generate a new tree with only those items selected
if (showOnlyTest) {
tree = coverage.filterTreeForTest(showOnlyTest);
}
const files: (PerTestCoverageSwitcher | TestCoverageFileNode)[] = [];
if (coverage.perTestCoverageIDs.size) {
files.push(new PerTestCoverageSwitcher(showOnlyTest ? coverage.result.getTestById(showOnlyTest.toString()) : undefined));
}
for (let node of 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())!;
......@@ -310,15 +333,23 @@ class TestCoverageTree extends Disposable {
files.push(node);
}
const toChild = (file: TestCoverageFileNode): ICompressedTreeElement<CoverageTreeElement> => {
const isFile = !file.children?.size;
const toChild = (value: TestCoverageFileNode | PerTestCoverageSwitcher): ICompressedTreeElement<CoverageTreeElement> => {
if (value instanceof PerTestCoverageSwitcher) {
return {
element: value,
incompressible: true,
collapsible: false,
};
}
const isFile = !value.children?.size;
return {
element: file,
element: value,
incompressible: isFile,
collapsed: isFile,
// directories can be expanded, and items with function info can be expanded
collapsible: !isFile || !!file.value?.declaration?.total,
children: file.children && Iterable.map(file.children?.values(), toChild)
collapsible: !isFile || !!value.value?.declaration?.total,
children: value.children && Iterable.map(value.children?.values(), toChild)
};
};
......@@ -378,6 +409,10 @@ class TestCoverageTree extends Disposable {
class TestCoverageTreeListDelegate implements IListVirtualDelegate<CoverageTreeElement> {
getHeight(element: CoverageTreeElement): number {
if (element instanceof PerTestCoverageSwitcher) {
return PerTestCoverageSwitcherRenderer.height;
}
return 22;
}
......@@ -391,6 +426,9 @@ class TestCoverageTreeListDelegate implements IListVirtualDelegate<CoverageTreeE
if (element instanceof LoadingDetails || element instanceof RevealUncoveredDeclarations) {
return BasicRenderer.ID;
}
if (element instanceof PerTestCoverageSwitcher) {
return PerTestCoverageSwitcherRenderer.ID;
}
assertNever(element);
}
}
......@@ -582,6 +620,59 @@ class BasicRenderer implements ICompressibleTreeRenderer<CoverageTreeElement, Fu
}
}
interface PerTestCoverageSwitcherRendererTemplateData {
container: HTMLElement;
text: HTMLElement;
elementDisposables: DisposableStore;
}
class PerTestCoverageSwitcherRenderer implements ICompressibleTreeRenderer<PerTestCoverageSwitcher, FuzzyScore, PerTestCoverageSwitcherRendererTemplateData> {
public static readonly ID = 'S';
public static readonly height = 28;
public readonly templateId = PerTestCoverageSwitcherRenderer.ID;
constructor(@ICommandService private readonly commandService: ICommandService) { }
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<PerTestCoverageSwitcher>, FuzzyScore>, _index: number, data: PerTestCoverageSwitcherRendererTemplateData): void {
this.renderInner(node.element.elements[node.element.elements.length - 1], data);
}
renderTemplate(container: HTMLElement): PerTestCoverageSwitcherRendererTemplateData {
const el = document.createElement('div');
const text = document.createElement('span');
el.classList.add('test-coverage-tree-per-test-switcher');
el.appendChild(text);
container.appendChild(el);
return {
container: el,
text,
elementDisposables: new DisposableStore(),
};
}
renderElement(node: ITreeNode<PerTestCoverageSwitcher, FuzzyScore>, index: number, data: PerTestCoverageSwitcherRendererTemplateData): void {
this.renderInner(node.element, data);
}
disposeTemplate(data: PerTestCoverageSwitcherRendererTemplateData): void {
data.elementDisposables.dispose();
data.container.parentElement?.removeChild(data.container);
}
private renderInner(element: PerTestCoverageSwitcher, { container, text, elementDisposables }: PerTestCoverageSwitcherRendererTemplateData) {
elementDisposables.clear();
text.innerText = element.currentFilter
? coverUtils.labels.showingFilterFor(element.currentFilter.label)
: localize('testing.filterCovToTest', 'Show coverage for test...');
elementDisposables.add(dom.addStandardDisposableListener(container, 'click', evt => {
this.commandService.executeCommand(TestCommandId.CoverageFilterToTest, element.currentFilter?.extId);
evt.preventDefault();
}));
}
}
class TestCoverageIdentityProvider implements IIdentityProvider<CoverageTreeElement> {
public getId(element: CoverageTreeElement) {
return isFileCoverage(element)
......@@ -590,6 +681,50 @@ class TestCoverageIdentityProvider implements IIdentityProvider<CoverageTreeElem
}
}
registerAction2(class TestCoverageChangePerTestFilterAction extends Action2 {
constructor() {
super({
id: TestCommandId.CoverageFilterToTest,
title: localize2('testing.changeCoverageFilter', 'Filter Coverage by Test...'),
precondition: TestingContextKeys.hasPerTestCoverage,
f1: true,
});
}
override run(accessor: ServicesAccessor): void {
const coverageService = accessor.get(ITestCoverageService);
const quickInputService = accessor.get(IQuickInputService);
const coverage = coverageService.selected.get();
if (!coverage) {
return;
}
const tests = [...coverage.perTestCoverageIDs].map(TestId.fromString);
const commonPrefix = TestId.getLengthOfCommonPrefix(tests.length, i => tests[i]);
const result = coverage.result;
const previousSelection = coverageService.filterToTest.get();
const previousSelectionStr = previousSelection?.toString();
type TItem = { label: string; testId?: TestId };
const items: QuickPickInput<TItem>[] = [
{ label: coverUtils.labels.allTests, id: undefined },
{ type: 'separator' },
...tests.map(testId => ({ label: coverUtils.getLabelForItem(result, testId, commonPrefix), testId })),
];
quickInputService.pick(items, {
activeItem: items.find((item): item is TItem => 'testId' in item && item.testId?.toString() === previousSelectionStr),
placeHolder: coverUtils.labels.pickShowCoverage,
onDidFocus: (entry) => {
coverageService.filterToTest.set(entry.testId, undefined);
},
}).then(selected => {
coverageService.filterToTest.set(selected ? selected.testId : previousSelection, undefined);
});
}
});
registerAction2(class TestCoverageChangeSortingAction extends ViewAction<TestCoverageView> {
constructor() {
super({
......
......@@ -63,11 +63,12 @@ export const enum TestCommandId {
ContinousRunUsingForTest = 'testing.continuousRunUsingForTest',
CoverageAtCursor = 'testing.coverageAtCursor',
CoverageByUri = 'testing.coverage.uri',
CoverageViewChangeSorting = 'testing.coverageViewChangeSorting',
CoverageClear = 'testing.coverage.close',
CoverageCurrentFile = 'testing.coverageCurrentFile',
CoverageFilterToTest = 'testing.coverageFilterToTest',
CoverageLastRun = 'testing.coverageLastRun',
CoverageSelectedAction = 'testing.coverageSelected',
CoverageViewChangeSorting = 'testing.coverageViewChangeSorting',
DebugAction = 'testing.debug',
DebugAllAction = 'testing.debugAll',
DebugAtCursor = 'testing.debugAtCursor',
......@@ -81,8 +82,8 @@ export const enum TestCommandId {
GetSelectedProfiles = 'testing.getSelectedProfiles',
GoToTest = 'testing.editFocusedTest',
HideTestAction = 'testing.hideTest',
OpenOutputPeek = 'testing.openOutputPeek',
OpenCoverage = 'testing.openCoverage',
OpenOutputPeek = 'testing.openOutputPeek',
RefreshTestsAction = 'testing.refreshTests',
ReRunFailedTests = 'testing.reRunFailTests',
ReRunLastRun = 'testing.reRunLastRun',
......
......@@ -11,6 +11,8 @@ import { ITransaction, observableSignal } from 'vs/base/common/observable';
import { IPrefixTreeNode, WellDefinedPrefixTree } from 'vs/base/common/prefixTree';
import { URI } from 'vs/base/common/uri';
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult';
import { CoverageDetails, ICoverageCount, IFileCoverage } from 'vs/workbench/contrib/testing/common/testTypes';
export interface ICoverageAccessor {
......@@ -26,10 +28,13 @@ export class TestCoverage {
private readonly fileCoverage = new ResourceMap<FileCoverage>();
public readonly didAddCoverage = observableSignal<IPrefixTreeNode<AbstractFileCoverage>[]>(this);
public readonly tree = new WellDefinedPrefixTree<AbstractFileCoverage>();
public readonly associatedData = new Map<unknown, unknown>();
/** Test IDs that have per-test coverage in this output. */
public readonly perTestCoverageIDs = new Set<string>();
constructor(
public readonly result: LiveTestResult,
public readonly fromTaskId: string,
private readonly uriIdentityService: IUriIdentityService,
private readonly accessor: ICoverageAccessor,
......@@ -37,6 +42,7 @@ export class TestCoverage {
public append(coverage: IFileCoverage, tx: ITransaction | undefined) {
const previous = this.getComputedForUri(coverage.uri);
const result = this.result;
const applyDelta = (kind: 'statement' | 'branch' | 'declaration', node: ComputedFileCoverage) => {
if (!node[kind]) {
if (coverage[kind]) {
......@@ -54,16 +60,21 @@ export class TestCoverage {
const canonical = [...this.treePathForUri(coverage.uri, /* canonical = */ true)];
const chain: IPrefixTreeNode<AbstractFileCoverage>[] = [];
const isPerTestCoverage = !!coverage.testId;
if (coverage.testId) {
this.perTestCoverageIDs.add(coverage.testId.toString());
}
this.tree.mutatePath(this.treePathForUri(coverage.uri, /* canonical = */ false), node => {
chain.push(node);
if (chain.length === canonical.length) {
// we reached our destination node, apply the coverage as necessary:
if (isPerTestCoverage) {
const v = node.value ??= new FileCoverage(IFileCoverage.empty(String(incId++), coverage.uri), this.accessor);
const v = node.value ??= new FileCoverage(IFileCoverage.empty(String(incId++), coverage.uri), result, this.accessor);
assert(v instanceof FileCoverage, 'coverage is unexpectedly computed');
v.perTestData ??= new Map();
v.perTestData.set(coverage.testId!.toString(), new FileCoverage(coverage, this.accessor));
const perTest = new FileCoverage(coverage, result, this.accessor);
perTest.isForTest = { id: coverage.testId!, parent: v };
v.perTestData.set(coverage.testId!.toString(), perTest);
this.fileCoverage.set(coverage.uri, v);
} else if (node.value) {
const v = node.value;
......@@ -72,10 +83,8 @@ export class TestCoverage {
v.statement = coverage.statement;
v.branch = coverage.branch;
v.declaration = coverage.declaration;
v.existsInExtHost = true;
} else {
const v = node.value = new FileCoverage(coverage, this.accessor);
v.existsInExtHost = true;
const v = node.value = new FileCoverage(coverage, result, this.accessor);
this.fileCoverage.set(coverage.uri, v);
}
} else if (!isPerTestCoverage) {
......@@ -87,7 +96,7 @@ export class TestCoverage {
const intermediate = deepClone(coverage);
intermediate.id = String(incId++);
intermediate.uri = this.treePathToUri(canonical.slice(0, chain.length));
node.value = new ComputedFileCoverage(intermediate);
node.value = new ComputedFileCoverage(intermediate, result);
} else {
applyDelta('statement', node.value);
applyDelta('branch', node.value);
......@@ -102,6 +111,48 @@ export class TestCoverage {
}
}
/**
* Builds a new tree filtered to per-test coverage data for the given ID.
*/
public filterTreeForTest(testId: TestId) {
const tree = new WellDefinedPrefixTree<AbstractFileCoverage>();
for (const node of this.tree.values()) {
if (node instanceof FileCoverage) {
const fileData = node.perTestData?.get(testId.toString());
if (!fileData) {
continue;
}
const canonical = [...this.treePathForUri(fileData.uri, /* canonical = */ true)];
const chain: IPrefixTreeNode<AbstractFileCoverage>[] = [];
tree.mutatePath(this.treePathForUri(fileData.uri, /* canonical = */ false), node => {
chain.push(node);
if (chain.length === canonical.length) {
node.value = fileData;
} else {
node.value ??= new ComputedFileCoverage({
id: String(incId++),
uri: this.treePathToUri(canonical.slice(0, chain.length)),
statement: { covered: 0, total: 0 },
}, fileData.fromResult);
for (const kind of ['statement', 'branch', 'declaration'] as const) {
const count = fileData[kind];
if (count) {
const cc = (node.value[kind] ??= { covered: 0, total: 0 });
cc.covered += count.covered;
cc.total += count.total;
}
}
}
});
}
}
return tree;
}
/**
* Gets coverage information for all files.
*/
......@@ -162,13 +213,6 @@ export abstract class AbstractFileCoverage {
public declaration?: ICoverageCount;
public readonly didChange = observableSignal(this);
/**
* Whether this coverage item exists in the extension host. This is false
* if we have only {@link perTestData} and not summary data for the file, or
* if the node is computed for a directory.
*/
public existsInExtHost = false;
/**
* Gets the total coverage percent based on information provided.
* This is based on the Clover total coverage formula
......@@ -177,7 +221,7 @@ export abstract class AbstractFileCoverage {
return getTotalCoveragePercent(this.statement, this.branch, this.declaration);
}
constructor(coverage: IFileCoverage) {
constructor(coverage: IFileCoverage, public readonly fromResult: LiveTestResult) {
this.id = coverage.id;
this.uri = coverage.uri;
this.statement = coverage.statement;
......@@ -206,8 +250,13 @@ export class FileCoverage extends AbstractFileCoverage {
*/
public perTestData?: Map<string, FileCoverage>;
constructor(coverage: IFileCoverage, private readonly accessor: ICoverageAccessor) {
super(coverage);
/**
* If this is for a single test item, gets the test item.
*/
public isForTest?: { id: TestId; parent: FileCoverage };
constructor(coverage: IFileCoverage, fromResult: LiveTestResult, private readonly accessor: ICoverageAccessor) {
super(coverage, fromResult);
}
/**
......
......@@ -5,11 +5,12 @@
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle';
import { IObservable, observableValue } from 'vs/base/common/observable';
import { IObservable, ISettableObservable, observableValue, transaction } from 'vs/base/common/observable';
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { Testing } from 'vs/workbench/contrib/testing/common/constants';
import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
import { ITestRunTaskResults } from 'vs/workbench/contrib/testing/common/testResult';
import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService';
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
......@@ -26,6 +27,11 @@ export interface ITestCoverageService {
*/
readonly selected: IObservable<TestCoverage | undefined>;
/**
* Filter to per-test coverage from the given test ID.
*/
readonly filterToTest: ISettableObservable<TestId | undefined>;
/**
* Opens a test coverage report from a task, optionally focusing it in the editor.
*/
......@@ -40,9 +46,11 @@ export interface ITestCoverageService {
export class TestCoverageService extends Disposable implements ITestCoverageService {
declare readonly _serviceBrand: undefined;
private readonly _isOpenKey: IContextKey<boolean>;
private readonly _hasPerTestCoverage: IContextKey<boolean>;
private readonly lastOpenCts = this._register(new MutableDisposable<CancellationTokenSource>());
public readonly selected = observableValue<TestCoverage | undefined>('testCoverage', undefined);
public readonly filterToTest = observableValue<TestId | undefined>('filterToTest', undefined);
constructor(
@IContextKeyService contextKeyService: IContextKeyService,
......@@ -51,6 +59,7 @@ export class TestCoverageService extends Disposable implements ITestCoverageServ
) {
super();
this._isOpenKey = TestingContextKeys.isTestCoverageOpen.bindTo(contextKeyService);
this._hasPerTestCoverage = TestingContextKeys.hasPerTestCoverage.bindTo(contextKeyService);
this._register(resultService.onResultsChanged(evt => {
if ('completed' in evt) {
......@@ -78,8 +87,13 @@ export class TestCoverageService extends Disposable implements ITestCoverageServ
return;
}
this.selected.set(coverage, undefined);
transaction(tx => {
// todo: may want to preserve this if coverage for that test in the new run?
this.filterToTest.set(undefined, tx);
this.selected.set(coverage, tx);
});
this._isOpenKey.set(true);
this._hasPerTestCoverage.set(coverage.perTestCoverageIDs.size > 0);
if (focus && !cts.token.isCancellationRequested) {
this.viewsService.openView(Testing.CoverageViewId, true);
......@@ -89,6 +103,7 @@ export class TestCoverageService extends Disposable implements ITestCoverageServ
/** @inheritdoc */
public closeCoverage() {
this._isOpenKey.set(false);
this._hasPerTestCoverage.set(false);
this.selected.set(undefined, undefined);
}
}
......@@ -128,6 +128,27 @@ export class TestId {
return TestPosition.Disconnected;
}
public static getLengthOfCommonPrefix(length: number, getId: (i: number) => TestId): number {
if (length === 0) {
return 0;
}
let commonPrefix = 0;
while (commonPrefix < length - 1) {
for (let i = 1; i < length; i++) {
const a = getId(i - 1);
const b = getId(i);
if (a.path[commonPrefix] !== b.path[commonPrefix]) {
return commonPrefix;
}
}
commonPrefix++;
}
return commonPrefix;
}
constructor(
public readonly path: readonly string[],
private readonly viewEnd = path.length,
......
......@@ -313,6 +313,11 @@ export class LiveTestResult extends Disposable implements ITestResult {
return this.testById.values();
}
/** Gets an included test item by ID. */
public getTestById(id: string) {
return this.testById.get(id)?.item;
}
private readonly computedStateAccessor: IComputedStateAccessor<TestResultItemWithChildren> = {
getOwnState: i => i.ownComputedState,
getCurrentComputedState: i => i.computedState,
......
......@@ -22,6 +22,7 @@ export namespace TestingContextKeys {
export const isParentRunningContinuously = new RawContextKey('testing.isParentRunningContinuously', false, { type: 'boolean', description: localize('testing.isParentRunningContinuously', 'Indicates whether the parent of a test is continuously running, set in the menu context of test items') });
export const activeEditorHasTests = new RawContextKey('testing.activeEditorHasTests', false, { type: 'boolean', description: localize('testing.activeEditorHasTests', 'Indicates whether any tests are present in the current editor') });
export const isTestCoverageOpen = new RawContextKey('testing.isTestCoverageOpen', false, { type: 'boolean', description: localize('testing.isTestCoverageOpen', 'Indicates whether a test coverage report is open') });
export const hasPerTestCoverage = new RawContextKey('testing.hasPerTestCoverage', false, { type: 'boolean', description: localize('testing.hasPerTestCoverage', 'Indicates whether per-test coverage is available') });
export const capabilityToContextKey: { [K in TestRunProfileBitset]: RawContextKey<boolean> } = {
[TestRunProfileBitset.Run]: hasRunnableTests,
......
......@@ -16,6 +16,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/uti
import { onObservableChange } from 'vs/workbench/contrib/testing/common/observableUtils';
import { ICoverageAccessor, TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult';
import { IFileCoverage } from 'vs/workbench/contrib/testing/common/testTypes';
suite('TestCoverage', () => {
......@@ -30,7 +31,7 @@ suite('TestCoverage', () => {
coverageAccessor = {
getCoverageDetails: sandbox.stub().resolves([]),
};
testCoverage = new TestCoverage('taskId', { extUri: { ignorePathCasing: () => true } } as any, coverageAccessor);
testCoverage = new TestCoverage({} as LiveTestResult, 'taskId', { extUri: { ignorePathCasing: () => true } } as any, coverageAccessor);
});
teardown(() => {
......@@ -68,7 +69,6 @@ suite('TestCoverage', () => {
assert.deepEqual(fileCoverage?.statement, raw1.statement);
assert.deepEqual(fileCoverage?.branch, raw1.branch);
assert.deepEqual(fileCoverage?.declaration, raw1.declaration);
assert.strictEqual(fileCoverage?.existsInExtHost, true);
assert.strictEqual(testCoverage.getComputedForUri(raw1.uri), testCoverage.getUri(raw1.uri));
assert.strictEqual(testCoverage.getComputedForUri(URI.file('/path/to/x')), undefined);
......@@ -81,7 +81,6 @@ suite('TestCoverage', () => {
assert.deepEqual(dirCoverage?.statement, { covered: 15, total: 30 });
assert.deepEqual(dirCoverage?.branch, { covered: 6, total: 15 });
assert.deepEqual(dirCoverage?.declaration, raw1.declaration);
assert.strictEqual(dirCoverage?.existsInExtHost, false);
});
test('should incrementally diff updates to existing files', async () => {
......@@ -158,7 +157,6 @@ suite('TestCoverage', () => {
// should be unchanged:
assert.deepEqual(fileCoverage?.statement, { covered: 10, total: 20 });
assert.deepEqual(fileCoverage?.existsInExtHost, true);
const dirCoverage = testCoverage.getComputedForUri(URI.file('/path/to'));
assert.deepEqual(dirCoverage?.statement, { covered: 15, total: 30 });
});
......@@ -175,7 +173,6 @@ suite('TestCoverage', () => {
testCoverage.append(raw3, undefined);
const fileCoverage = testCoverage.getUri(raw3.uri);
assert.deepEqual(fileCoverage?.existsInExtHost, false);
addTests();
......@@ -187,7 +184,6 @@ suite('TestCoverage', () => {
// should be the expected values:
assert.deepEqual(fileCoverage?.statement, { covered: 10, total: 20 });
assert.deepEqual(fileCoverage?.existsInExtHost, true);
const dirCoverage = testCoverage.getComputedForUri(URI.file('/path/to'));
assert.deepEqual(dirCoverage?.statement, { covered: 15, total: 30 });
});
......
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать