Merge pull request #202048 from microsoft/connor4312/test-coverage-decorations-1

testing: add initial editor decorations
pull/202106/head
Connor Peet 2024-01-09 11:20:46 -08:00 committed by GitHub
commit 801d79e284
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 261 additions and 63 deletions

View File

@ -679,6 +679,8 @@
"--vscode-terminalOverviewRuler-findMatchForeground",
"--vscode-terminalStickyScroll-background",
"--vscode-terminalStickyScrollHover-background",
"--vscode-testing-coveredBackground",
"--vscode-testing-coveredGutterBackground",
"--vscode-testing-iconErrored",
"--vscode-testing-iconFailed",
"--vscode-testing-iconPassed",
@ -692,6 +694,8 @@
"--vscode-testing-peekBorder",
"--vscode-testing-peekHeaderBackground",
"--vscode-testing-runAction",
"--vscode-testing-uncoveredBackground",
"--vscode-testing-uncoveredGutterBackground",
"--vscode-textBlockQuote-background",
"--vscode-textBlockQuote-border",
"--vscode-textCodeBlock-background",

View File

@ -250,7 +250,7 @@ export class View extends ViewEventHandler {
// Add all margin decorations
glyphs = glyphs.concat(model.getAllMarginDecorations().map((decoration) => {
const lane = decoration.options.glyphMargin?.position ?? GlyphMarginLane.Left;
const lane = decoration.options.glyphMargin?.position ?? GlyphMarginLane.Center;
return { range: decoration.range, lane };
}));
@ -263,40 +263,34 @@ export class View extends ViewEventHandler {
// Sorted by their start position
glyphs.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));
let leftDecRange: Range | null = null;
let rightDecRange: Range | null = null;
const maxLane = GlyphMarginLane.Right;
const lanes: (Range | undefined)[] = Array.from({ length: maxLane + 1 });
let requiredLanes = 1;
for (const decoration of glyphs) {
if (decoration.lane === GlyphMarginLane.Left && (!leftDecRange || Range.compareRangesUsingEnds(leftDecRange, decoration.range) < 0)) {
// assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane
leftDecRange = decoration.range;
// assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane
if (!lanes[decoration.lane] || Range.compareRangesUsingEnds(lanes[decoration.lane]!, decoration.range) < 0) {
lanes[decoration.lane] = decoration.range;
}
if (decoration.lane === GlyphMarginLane.Right && (!rightDecRange || Range.compareRangesUsingEnds(rightDecRange, decoration.range) < 0)) {
// assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane
rightDecRange = decoration.range;
}
if (leftDecRange && rightDecRange) {
if (leftDecRange.endLineNumber < rightDecRange.startLineNumber) {
// there's no chance for `leftDecRange` to ever intersect something going further
leftDecRange = null;
let requiredLanesHere = 0;
for (let i = 1; i <= maxLane; i++) {
const lane = lanes[i];
if (!lane || lane.endLineNumber < decoration.range.startLineNumber) {
lanes[i] = undefined;
continue;
}
if (rightDecRange.endLineNumber < leftDecRange.startLineNumber) {
// there's no chance for `rightDecRange` to ever intersect something going further
rightDecRange = null;
continue;
}
requiredLanesHere++;
}
// leftDecRange and rightDecRange are intersecting or touching => we need two lanes
return 2;
requiredLanes = Math.max(requiredLanes, requiredLanesHere);
if (requiredLanes === maxLane) {
return requiredLanes;
}
}
return 1;
return requiredLanes;
}
private _createPointerHandlerHelper(): IPointerHandlerHelper {

View File

@ -39,7 +39,8 @@ export enum OverviewRulerLane {
*/
export enum GlyphMarginLane {
Left = 1,
Right = 2
Center = 2,
Right = 3,
}
/**

View File

@ -2206,7 +2206,7 @@ export class ModelDecorationGlyphMarginOptions {
readonly position: model.GlyphMarginLane;
constructor(options: model.IModelDecorationGlyphMarginOptions | null | undefined) {
this.position = options?.position ?? model.GlyphMarginLane.Left;
this.position = options?.position ?? model.GlyphMarginLane.Center;
}
}

View File

@ -360,7 +360,8 @@ export enum EndOfLineSequence {
*/
export enum GlyphMarginLane {
Left = 1,
Right = 2
Center = 2,
Right = 3
}
/**

3
src/vs/monaco.d.ts vendored
View File

@ -1580,7 +1580,8 @@ declare namespace monaco.editor {
*/
export enum GlyphMarginLane {
Left = 1,
Right = 2
Center = 2,
Right = 3
}
/**

View File

@ -475,7 +475,11 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi
if (decorations) {
for (const { options } of decorations) {
const clz = options.glyphMarginClassName;
if (clz && (!clz.includes('codicon-') || clz.includes('codicon-testing-') || clz.includes('codicon-merge-') || clz.includes('codicon-arrow-') || clz.includes('codicon-loading') || clz.includes('codicon-fold') || clz.includes('codicon-inline-chat'))) {
if (!clz) {
continue;
}
const hasSomeActionableCodicon = !(clz.includes('codicon-') || clz.startsWith('coverage-deco-')) || clz.includes('codicon-testing-') || clz.includes('codicon-merge-') || clz.includes('codicon-arrow-') || clz.includes('codicon-loading') || clz.includes('codicon-fold') || clz.includes('codicon-inline-chat');
if (hasSomeActionableCodicon) {
return false;
}
}

View File

@ -0,0 +1,123 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
import { autorun, derived, observableFromEvent } from 'vs/base/common/observable';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { Range } from 'vs/editor/common/core/range';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { GlyphMarginLane, ITextModel } from 'vs/editor/common/model';
import { localize } from 'vs/nls';
import { ILogService } from 'vs/platform/log/common/log';
import { FileCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService';
import { DetailType } from 'vs/workbench/contrib/testing/common/testTypes';
export class CodeCoverageDecorations extends Disposable implements IEditorContribution {
private loadingCancellation?: CancellationTokenSource;
private readonly displayedStore = this._register(new DisposableStore());
constructor(
editor: ICodeEditor,
@ITestCoverageService coverage: ITestCoverageService,
@ILogService private readonly log: ILogService,
) {
super();
const modelObs = observableFromEvent(editor.onDidChangeModel, () => editor.getModel());
const fileCoverage = derived(reader => {
const report = coverage.selected.read(reader);
if (!report) {
return;
}
const model = modelObs.read(reader);
if (!model) {
return;
}
return report.getUri(model.uri);
});
this._register(autorun(reader => {
const c = fileCoverage.read(reader);
if (c) {
this.apply(editor.getModel()!, c);
} else {
this.clear();
}
}));
}
private async apply(model: ITextModel, coverage: FileCoverage) {
const details = await this.loadDetails(coverage);
if (!details) {
return this.clear();
}
const decorations: string[] = [];
model.changeDecorations(e => {
for (const detail of details) {
const range = detail.location instanceof Range ? detail.location : Range.fromPositions(detail.location);
if (detail.type === DetailType.Statement) {
const cls = detail.count > 0 ? 'coverage-deco-hit' : 'coverage-deco-miss';
decorations.push(e.addDecoration(range, {
showIfCollapsed: false,
glyphMargin: { position: GlyphMarginLane.Left },
description: localize('testing.hitCount', 'Hit count: {0}', detail.count),
glyphMarginClassName: `coverage-deco-gutter ${cls}`,
className: `coverage-deco-inline ${cls}`,
}));
if (detail.branches) {
for (const branch of detail.branches) {
const location = branch.location || range.getEndPosition();
const branchRange = location instanceof Range ? location : Range.fromPositions(location);
decorations.push(e.addDecoration(branchRange, {
showIfCollapsed: false,
glyphMargin: { position: GlyphMarginLane.Left },
description: localize('testing.hitCount', 'Hit count: {0}', detail.count),
glyphMarginClassName: `coverage-deco-gutter ${cls}`,
className: `coverage-deco-inline ${cls}`,
}));
}
}
}
}
});
this.displayedStore.add(toDisposable(() => {
model.changeDecorations(e => {
for (const decoration of decorations) {
e.removeDecoration(decoration);
}
});
}));
}
private clear() {
this.loadingCancellation?.cancel();
this.loadingCancellation = undefined;
this.displayedStore.clear();
}
private async loadDetails(coverage: FileCoverage) {
const cts = this.loadingCancellation = new CancellationTokenSource();
this.displayedStore.add(this.loadingCancellation);
try {
const details = await coverage.details(this.loadingCancellation.token);
if (!cts.token.isCancellationRequested) {
return details;
}
} catch (e) {
this.log.error('Error loading coverage details', e);
}
return undefined;
}
}

View File

@ -414,3 +414,43 @@
.explorer-item-with-test-coverage .monaco-icon-label::after {
margin-right: 12px; /* slightly reduce because the bars handle the scrollbar margin */
}
/** -- coverage decorations */
.coverage-deco-gutter::before {
content: '';
position: absolute;
inset: 0;
right: 25%;
left: 25%;
}
.coverage-deco-gutter.coverage-deco-hit::before {
background: var(--vscode-testing-coveredGutterBackground);
}
.coverage-deco-gutter.coverage-deco-miss::before {
background: var(--vscode-testing-uncoveredGutterBackground);
}
.coverage-deco-gutter.coverage-deco-miss.coverage-deco-hit::before {
background-image: linear-gradient(45deg,
var(--vscode-testing-coveredGutterBackground) 25%,
var(--vscode-testing-uncoveredGutterBackground) 25%,
var(--vscode-testing-uncoveredGutterBackground) 50%,
var(--vscode-testing-coveredGutterBackground) 50%,
75%,
var(--vscode-testing-uncoveredGutterBackground) 75%,
var(--vscode-testing-uncoveredGutterBackground) 100%
);
background-size: 6px 6px;
background-color: transparent;
}
.coverage-deco-inline.coverage-deco-hit {
background: var(--vscode-testing-coveredBackground);
}
.coverage-deco-inline.coverage-deco-miss {
background: var(--vscode-testing-uncoveredBackground);
}

View File

@ -754,11 +754,14 @@ abstract class RunTestDecoration {
this.showContextMenu(e);
break;
case DefaultGutterClickAction.Debug:
(alternateAction ? this.defaultRun() : this.defaultDebug());
this.runWith(alternateAction ? TestRunProfileBitset.Run : TestRunProfileBitset.Debug);
break;
case DefaultGutterClickAction.Coverage:
this.runWith(alternateAction ? TestRunProfileBitset.Debug : TestRunProfileBitset.Coverage);
break;
case DefaultGutterClickAction.Run:
default:
(alternateAction ? this.defaultDebug() : this.defaultRun());
this.runWith(alternateAction ? TestRunProfileBitset.Debug : TestRunProfileBitset.Run);
break;
}
@ -798,17 +801,10 @@ abstract class RunTestDecoration {
*/
abstract getContextMenuActions(): IReference<IAction[]>;
protected defaultRun() {
protected runWith(profile: TestRunProfileBitset) {
return this.testService.runTests({
tests: this.tests.map(({ test }) => test),
group: TestRunProfileBitset.Run,
});
}
protected defaultDebug() {
return this.testService.runTests({
tests: this.tests.map(({ test }) => test),
group: TestRunProfileBitset.Debug,
group: profile,
});
}
@ -823,6 +819,8 @@ abstract class RunTestDecoration {
return localize('testing.gutterMsg.contextMenu', 'Click for test options');
case DefaultGutterClickAction.Debug:
return localize('testing.gutterMsg.debug', 'Click to debug tests, right click for more options');
case DefaultGutterClickAction.Coverage:
return localize('testing.gutterMsg.coverage', 'Click to run tests with coverage, right click for more options');
case DefaultGutterClickAction.Run:
default:
return localize('testing.gutterMsg.run', 'Click to run tests, right click for more options');
@ -835,19 +833,17 @@ abstract class RunTestDecoration {
protected getTestContextMenuActions(test: InternalTestItem, resultItem?: TestResultItem): IReference<IAction[]> {
const testActions: IAction[] = [];
const capabilities = this.testProfileService.capabilitiesForTest(test);
if (capabilities & TestRunProfileBitset.Run) {
testActions.push(new Action('testing.gutter.run', localize('run test', 'Run Test'), undefined, undefined, () => this.testService.runTests({
group: TestRunProfileBitset.Run,
tests: [test],
})));
}
if (capabilities & TestRunProfileBitset.Debug) {
testActions.push(new Action('testing.gutter.debug', localize('debug test', 'Debug Test'), undefined, undefined, () => this.testService.runTests({
group: TestRunProfileBitset.Debug,
tests: [test],
})));
}
[
{ bitset: TestRunProfileBitset.Run, label: localize('run test', 'Run Test') },
{ bitset: TestRunProfileBitset.Debug, label: localize('debug test', 'Debug Test') },
{ bitset: TestRunProfileBitset.Coverage, label: localize('coverage test', 'Run with Coverage') },
].forEach(({ bitset, label }) => {
if (capabilities & bitset) {
testActions.push(new Action(`testing.gutter.${bitset}`, label, undefined, undefined,
() => this.testService.runTests({ group: bitset, tests: [test] })));
}
});
if (capabilities & TestRunProfileBitset.HasNonDefaultProfile) {
testActions.push(new Action('testing.runUsing', localize('testing.runUsing', 'Execute Using Profile...'), undefined, undefined, async () => {
@ -924,17 +920,19 @@ class MultiRunTestDecoration extends RunTestDecoration implements ITestDecoratio
super(tests, visible, model, codeEditorService, testService, contextMenuService, commandService, configurationService, testProfileService, contextKeyService, menuService);
}
override getContextMenuActions() {
public override getContextMenuActions() {
const allActions: IAction[] = [];
const canRun = this.tests.some(({ test }) => this.testProfileService.capabilitiesForTest(test) & TestRunProfileBitset.Run);
if (canRun) {
allActions.push(new Action('testing.gutter.runAll', localize('run all test', 'Run All Tests'), undefined, undefined, () => this.defaultRun()));
}
const canDebug = this.tests.some(({ test }) => this.testProfileService.capabilitiesForTest(test) & TestRunProfileBitset.Debug);
if (canDebug) {
allActions.push(new Action('testing.gutter.debugAll', localize('debug all test', 'Debug All Tests'), undefined, undefined, () => this.defaultDebug()));
}
[
{ bitset: TestRunProfileBitset.Run, label: localize('run all test', 'Run All Tests') },
{ bitset: TestRunProfileBitset.Coverage, label: localize('run all test with coverage', 'Run All Tests with Coverage') },
{ bitset: TestRunProfileBitset.Debug, label: localize('debug all test', 'Debug All Tests') },
].forEach(({ bitset, label }, i) => {
const canRun = this.tests.some(({ test }) => this.testProfileService.capabilitiesForTest(test) & bitset);
if (canRun) {
allActions.push(new Action(`testing.gutter.run${i}`, label, undefined, undefined, () => this.runWith(bitset)));
}
});
const testItems = this.tests.map((testItem): IMultiRunTest => ({
currentLabel: testItem.test.item.label,

View File

@ -5,7 +5,7 @@
import { Color, RGBA } from 'vs/base/common/color';
import { localize } from 'vs/nls';
import { contrastBorder, editorErrorForeground, editorForeground, editorInfoForeground, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry';
import { contrastBorder, diffInserted, diffRemoved, editorErrorForeground, editorForeground, editorInfoForeground, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry';
import { TestMessageType, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes';
export const testingColorIconFailed = registerColor('testing.iconFailed', {
@ -85,6 +85,34 @@ export const testingPeekMessageHeaderBackground = registerColor('testing.message
hcLight: null
}, localize('testing.messagePeekHeaderBackground', 'Color of the peek view borders and arrow when peeking a logged message.'));
export const testingCoveredBackground = registerColor('testing.coveredBackground', {
dark: diffInserted,
light: diffInserted,
hcDark: null,
hcLight: null
}, localize('testing.coveredBackground', 'Background color of text that was covered.'));
export const testingCoveredGutterBackground = registerColor('testing.coveredGutterBackground', {
dark: diffInserted,
light: diffInserted,
hcDark: null,
hcLight: null
}, localize('testing.coveredGutterBackground', 'Gutter color of regions where code was covered.'));
export const testingUncoveredBackground = registerColor('testing.uncoveredBackground', {
dark: diffRemoved,
light: diffRemoved,
hcDark: null,
hcLight: null
}, localize('testing.uncoveredBackground', 'Background color of text that was not covered.'));
export const testingUncoveredGutterBackground = registerColor('testing.uncoveredGutterBackground', {
dark: diffRemoved,
light: diffRemoved,
hcDark: null,
hcLight: null
}, localize('testing.uncoveredGutterBackground', 'Gutter color of regions where code not covered.'));
export const testMessageSeverityColors: {
[K in TestMessageType]: {
decorationForeground: string;

View File

@ -41,6 +41,7 @@ export const enum AutoOpenPeekViewWhen {
export const enum DefaultGutterClickAction {
Run = 'run',
Debug = 'debug',
Coverage = 'runWithCoverage',
ContextMenu = 'contextMenu',
}
@ -119,11 +120,13 @@ export const testingConfiguration: IConfigurationNode = {
enum: [
DefaultGutterClickAction.Run,
DefaultGutterClickAction.Debug,
DefaultGutterClickAction.Coverage,
DefaultGutterClickAction.ContextMenu,
],
enumDescriptions: [
localize('testing.defaultGutterClickAction.run', 'Run the test.'),
localize('testing.defaultGutterClickAction.debug', 'Debug the test.'),
localize('testing.defaultGutterClickAction.coverage', 'Run the test with coverage.'),
localize('testing.defaultGutterClickAction.contextMenu', 'Open the context menu for more options.'),
],
default: DefaultGutterClickAction.Run,

View File

@ -13,6 +13,7 @@ export const enum Testing {
ExplorerViewId = 'workbench.view.testing',
OutputPeekContributionId = 'editor.contrib.testingOutputPeek',
DecorationsContributionId = 'editor.contrib.testingDecorations',
CoverageDecorationsContributionId = 'editor.contrib.coverageDecorations',
CoverageViewId = 'workbench.view.testCoverage',
ResultsPanelId = 'workbench.panel.testResults',