testing: initial implementation of test decorations

This commit is contained in:
Connor Peet 2021-01-22 12:58:07 -08:00
parent 3e55989cca
commit add5b32d95
No known key found for this signature in database
GPG key ID: CF8FD2EA0DBC61BD
24 changed files with 810 additions and 202 deletions

View file

@ -32,7 +32,7 @@
"testing.enableProblemDiagnostics": {
"description": "%config.enableProblemDiagnostics%",
"type": "boolean",
"default": true
"default": false
}
}
}

View file

@ -45,7 +45,7 @@ class TestingConfig implements IDisposable {
}
public get diagnostics() {
return this.section.get(Constants.EnableDiagnosticsConfig, true);
return this.section.get(Constants.EnableDiagnosticsConfig, false);
}
public get isEnabled() {

View file

@ -106,6 +106,7 @@ export abstract class PeekViewWidget extends ZoneWidget {
private readonly _onDidClose = new Emitter<PeekViewWidget>();
readonly onDidClose = this._onDidClose.event;
private disposed?: true;
protected _headElement?: HTMLDivElement;
protected _primaryHeading?: HTMLElement;
@ -124,8 +125,11 @@ export abstract class PeekViewWidget extends ZoneWidget {
}
dispose(): void {
super.dispose();
this._onDidClose.fire(this);
if (!this.disposed) {
this.disposed = true; // prevent consumers who dispose on onDidClose from looping
super.dispose();
this._onDidClose.fire(this);
}
}
style(styles: IPeekViewStyles): void {

View file

@ -172,6 +172,24 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi
this.setDecorationsScheduler.schedule();
}
/**
* Returns context menu actions at the line number if breakpoints can be
* set. This is used by the {@link TestingDecorations} to allow breakpoint
* setting on lines where breakpoint "run" actions are present.
*/
public getContextMenuActionsAtPosition(lineNumber: number, model: ITextModel) {
if (!this.debugService.getAdapterManager().hasDebuggers()) {
return [];
}
if (!this.debugService.canSetBreakpointsIn(model)) {
return [];
}
const breakpoints = this.debugService.getModel().getBreakpoints({ lineNumber, uri: model.uri });
return this.getContextMenuActions(breakpoints, model.uri, lineNumber);
}
private registerListeners(): void {
this.toDispose.push(this.editor.onMouseDown(async (e: IEditorMouseEvent) => {
if (!this.debugService.getAdapterManager().hasDebuggers()) {
@ -376,7 +394,8 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi
const decorations = this.editor.getLineDecorations(line);
if (decorations) {
for (const { options } of decorations) {
if (options.glyphMarginClassName && options.glyphMarginClassName.indexOf('codicon-') === -1) {
const clz = options.glyphMarginClassName;
if (clz && (!clz.includes('codicon-') || clz.includes('codicon-testing-'))) {
return false;
}
}

View file

@ -25,6 +25,7 @@ import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configur
import { CancellationToken } from 'vs/base/common/cancellation';
import { DebugConfigurationProviderTriggerKind } from 'vs/workbench/api/common/extHostTypes';
import { DebugCompoundRoot } from 'vs/workbench/contrib/debug/common/debugCompoundRoot';
import { IAction } from 'vs/base/common/actions';
export const VIEWLET_ID = 'workbench.view.debug';
@ -951,6 +952,7 @@ export interface IDebugEditorContribution extends editorCommon.IEditorContributi
export interface IBreakpointEditorContribution extends editorCommon.IEditorContribution {
showBreakpointWidget(lineNumber: number, column: number | undefined, context?: BreakpointWidgetContext): void;
closeBreakpointWidget(): void;
getContextMenuActionsAtPosition(lineNumber: number, model: EditorIModel): IAction[];
}
// temporary debug helper service

View file

@ -46,14 +46,8 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes
this._register(listener.onFolderChange(this.applyFolderChange, this));
for (const [folder, collection] of listener.workspaceFolderCollections) {
const queue = [collection.rootIds];
while (queue.length) {
for (const id of queue.pop()!) {
const node = collection.getNodeById(id)!;
const item = this.createItem(node, folder.folder);
this.storeItem(item);
queue.push(node.children);
}
for (const node of collection.all) {
this.storeItem(this.createItem(node, folder.folder));
}
}

View file

@ -8,11 +8,11 @@ import { localize } from 'vs/nls';
import { registerIcon } from 'vs/platform/theme/common/iconRegistry';
import { registerThemingParticipant, ThemeIcon } from 'vs/platform/theme/common/themeService';
import { TestRunState } from 'vs/workbench/api/common/extHostTypes';
import { testStatesToIconColors } from 'vs/workbench/contrib/testing/browser/theme';
import { testingColorRunAction, testStatesToIconColors } from 'vs/workbench/contrib/testing/browser/theme';
export const testingViewIcon = registerIcon('test-view-icon', Codicon.beaker, localize('testViewIcon', 'View icon of the test view.'));
export const testingRunIcon = registerIcon('testing-run-icon', Codicon.run, localize('testingRunIcon', 'Icon of the "run test" action.'));
export const testingRunAllIcon = registerIcon('testing-run-icon', Codicon.runAll, localize('testingRunAllIcon', 'Icon of the "run all tests" action.'));
export const testingRunAllIcon = registerIcon('testing-run-all-icon', Codicon.runAll, localize('testingRunAllIcon', 'Icon of the "run all tests" action.'));
export const testingDebugIcon = registerIcon('testing-debug-icon', Codicon.debugAlt, localize('testingDebugIcon', 'Icon of the "debug test" action.'));
export const testingCancelIcon = registerIcon('testing-cancel-icon', Codicon.close, localize('testingCancelIcon', 'Icon to cancel ongoing test runs.'));
@ -24,7 +24,7 @@ export const testingStatesToIcons = new Map<TestRunState, ThemeIcon>([
[TestRunState.Failed, registerIcon('testing-failed-icon', Codicon.close, localize('testingFailedIcon', 'Icon shown for tests that failed.'))],
[TestRunState.Passed, registerIcon('testing-passed-icon', Codicon.pass, localize('testingPassedIcon', 'Icon shown for tests that passed.'))],
[TestRunState.Queued, registerIcon('testing-queued-icon', Codicon.watch, localize('testingQueuedIcon', 'Icon shown for tests that are queued.'))],
[TestRunState.Running, Codicon.loading],
[TestRunState.Running, ThemeIcon.modify(Codicon.loading, 'spin')],
[TestRunState.Skipped, registerIcon('testing-skipped-icon', Codicon.debugStepOver, localize('testingSkippedIcon', 'Icon shown for tests that are skipped.'))],
[TestRunState.Unset, registerIcon('testing-unset-icon', Codicon.circleOutline, localize('testingUnsetIcon', 'Icon shown for tests that are in an unset state.'))],
]);
@ -39,4 +39,11 @@ registerThemingParticipant((theme, collector) => {
color: ${theme.getColor(color)} !important;
}`);
}
collector.addRule(`
.monaco-editor ${ThemeIcon.asCSSSelector(testingRunIcon)},
.monaco-editor ${ThemeIcon.asCSSSelector(testingRunAllIcon)} {
color: ${theme.getColor(testingColorRunAction)};
}
`);
});

View file

@ -71,7 +71,6 @@
max-height: 29px;
}
/** -- filter */
.testing-filter-action-bar {
flex-shrink: 0;
@ -92,3 +91,13 @@
.testing-filter-action-item .testing-filter-wrapper {
flex-grow: 1;
}
/** -- decorations */
.monaco-editor .testing-run-glyph {
cursor: pointer;
}
.monaco-editor .testing-inline-message {
cursor: pointer;
}

View file

@ -10,6 +10,7 @@ import { localize } from 'vs/nls';
import { Action2, MenuId } from 'vs/platform/actions/common/actions';
import { ContextKeyAndExpr, ContextKeyEqualsExpr } from 'vs/platform/contextkey/common/contextkey';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { ThemeIcon } from 'vs/platform/theme/common/themeService';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol';
@ -20,10 +21,9 @@ import * as icons from 'vs/workbench/contrib/testing/browser/icons';
import { TestingExplorerView, TestingExplorerViewModel } from 'vs/workbench/contrib/testing/browser/testingExplorerView';
import { TestExplorerViewGrouping, TestExplorerViewMode, Testing } from 'vs/workbench/contrib/testing/common/constants';
import { EMPTY_TEST_RESULT, InternalTestItem, RunTestsResult, TestIdWithProvider } from 'vs/workbench/contrib/testing/common/testCollection';
import { IWorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService';
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
import { ITestService, waitForAllRoots } from 'vs/workbench/contrib/testing/common/testService';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { IWorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService';
const category = localize('testing.category', 'Test');
@ -197,18 +197,18 @@ abstract class RunOrDebugAllAllAction extends Action2 {
const tests: TestIdWithProvider[] = [];
await Promise.all(workspace.getWorkspace().folders.map(async (folder) => {
const handle = testService.subscribeToDiffs(ExtHostTestingResource.Workspace, folder.uri);
const ref = testService.subscribeToDiffs(ExtHostTestingResource.Workspace, folder.uri);
try {
await waitForAllRoots(handle.collection);
await waitForAllRoots(ref.object);
for (const root of handle.collection.rootIds) {
const node = handle.collection.getNodeById(root);
for (const root of ref.object.rootIds) {
const node = ref.object.getNodeById(root);
if (node && (this.debug ? node.item.debuggable : node.item.runnable)) {
tests.push({ testId: node.id, providerId: node.providerId });
}
}
} finally {
handle.dispose();
ref.dispose();
}
}));

View file

@ -5,8 +5,7 @@
import { Codicon } from 'vs/base/common/codicons';
import { KeyCode } from 'vs/base/common/keyCodes';
import { URI } from 'vs/base/common/uri';
import { ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { EditorAction2, registerEditorContribution } from 'vs/editor/browser/editorExtensions';
import { localize } from 'vs/nls';
import { registerAction2 } from 'vs/platform/actions/common/actions';
@ -20,6 +19,7 @@ import { Registry } from 'vs/platform/registry/common/platform';
import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
import { Extensions as ViewContainerExtensions, IViewContainersRegistry, IViewsRegistry, ViewContainerLocation } from 'vs/workbench/common/views';
import { testingViewIcon } from 'vs/workbench/contrib/testing/browser/icons';
import { TestingDecorations } from 'vs/workbench/contrib/testing/browser/testingDecorations';
import { ITestExplorerFilterState, TestExplorerFilterState } from 'vs/workbench/contrib/testing/browser/testingExplorerFilter';
import { TestingExplorerView } from 'vs/workbench/contrib/testing/browser/testingExplorerView';
import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browser/testingOutputPeek';
@ -32,7 +32,6 @@ import { ITestResultService, TestResultService } from 'vs/workbench/contrib/test
import { ITestService } from 'vs/workbench/contrib/testing/common/testService';
import { TestService } from 'vs/workbench/contrib/testing/common/testServiceImpl';
import { IWorkspaceTestCollectionService, WorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import * as Action from './testExplorerActions';
@ -99,6 +98,7 @@ registerAction2(Action.DebugAllAction);
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingContentProvider, LifecyclePhase.Eventually);
registerEditorContribution(Testing.OutputPeekContributionId, TestingOutputPeekController);
registerEditorContribution(Testing.DecorationsContributionId, TestingDecorations);
CommandsRegistry.registerCommand({
id: 'vscode.runTests',
@ -117,28 +117,10 @@ CommandsRegistry.registerCommand({
});
CommandsRegistry.registerCommand({
id: 'vscode.revealTestMessage',
handler: async (accessor: ServicesAccessor, testRef: TestIdWithProvider, messageIndex: number) => {
const editorService = accessor.get(IEditorService);
const testService = accessor.get(ITestService);
const test = await testService.lookupTest(testRef);
const message = test?.item.state.messages[messageIndex];
if (!test || !message?.location) {
return;
}
const pane = await editorService.openEditor({
resource: URI.revive(message.location.uri),
options: { selection: message.location.range }
});
const control = pane?.getControl();
if (!isCodeEditor(control)) {
return;
}
TestingOutputPeekController.get(control).show(test, messageIndex);
id: 'vscode.revealTestInExplorer',
handler: async (accessor: ServicesAccessor, path: string[]) => {
accessor.get(ITestExplorerFilterState).reveal = path;
await new Action.ShowTestView().run(accessor);
}
});

View file

@ -0,0 +1,328 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Action, IAction, Separator } from 'vs/base/common/actions';
import { MarkdownString } from 'vs/base/common/htmlContent';
import { Disposable, dispose, IDisposable, IReference, MutableDisposable } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser';
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { IRange } from 'vs/editor/common/core/range';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { IModelDeltaDecoration, OverviewRulerLane, TrackedRangeStickiness } from 'vs/editor/common/model';
import { Location as ModeLocation } from 'vs/editor/common/modes';
import { overviewRulerError, overviewRulerInfo, overviewRulerWarning } from 'vs/editor/common/view/editorColorRegistry';
import { localize } from 'vs/nls';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IThemeService, themeColorFromId, ThemeIcon } from 'vs/platform/theme/common/themeService';
import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol';
import { TestMessageSeverity, TestRunState } from 'vs/workbench/api/common/extHostTypes';
import { BREAKPOINT_EDITOR_CONTRIBUTION_ID, IBreakpointEditorContribution } from 'vs/workbench/contrib/debug/common/debug';
import { testingRunAllIcon, testingRunIcon, testingStatesToIcons } from 'vs/workbench/contrib/testing/browser/icons';
import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browser/testingOutputPeek';
import { testMessageSeverityColors } from 'vs/workbench/contrib/testing/browser/theme';
import { IncrementalTestCollectionItem, ITestMessage } from 'vs/workbench/contrib/testing/common/testCollection';
import { maxPriority } from 'vs/workbench/contrib/testing/common/testingStates';
import { buildTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri';
import { IMainThreadTestCollection, ITestService } from 'vs/workbench/contrib/testing/common/testService';
export class TestingDecorations extends Disposable implements IEditorContribution {
private collection = this._register(new MutableDisposable<IReference<IMainThreadTestCollection>>());
private lastDecorations: ITestDecoration[] = [];
constructor(
private readonly editor: ICodeEditor,
@ITestService private readonly testService: ITestService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this.attachModel(editor.getModel()?.uri);
this._register(this.editor.onDidChangeModel(e => this.attachModel(e.newModelUrl || undefined)));
this._register(this.editor.onMouseDown(e => {
for (const decoration of this.lastDecorations) {
if (decoration.click(e)) {
e.event.stopPropagation();
return;
}
}
}));
}
private attachModel(uri?: URI) {
if (!uri) {
this.collection.value = undefined;
this.clearDecorations();
return;
}
this.collection.value = this.testService.subscribeToDiffs(ExtHostTestingResource.TextDocument, uri, () => this.setDecorations(uri));
this.setDecorations(uri);
}
private setDecorations(uri: URI): void {
const ref = this.collection.value;
if (!ref) {
return;
}
this.editor.changeDecorations(accessor => {
const newDecorations: ITestDecoration[] = [];
for (const test of ref.object.all) {
if (hasValidLocation(uri, test.item)) {
newDecorations.push(this.instantiationService.createInstance(
RunTestDecoration, test, ref.object, test.item.location, this.editor));
}
for (let i = 0; i < test.item.state.messages.length; i++) {
const m = test.item.state.messages[i];
if (hasValidLocation(uri, m)) {
const uri = buildTestUri({
type: TestUriType.LiveMessage,
messageIndex: i,
providerId: test.providerId,
testId: test.id,
});
newDecorations.push(this.instantiationService.createInstance(TestMessageDecoration, m, uri, m.location, this.editor));
}
}
}
accessor
.deltaDecorations(this.lastDecorations.map(d => d.id), newDecorations.map(d => d.editorDecoration))
.forEach((id, i) => newDecorations[i].id = id);
this.lastDecorations = newDecorations;
});
}
private clearDecorations(): void {
this.editor.changeDecorations(accessor => {
for (const decoration of this.lastDecorations) {
accessor.removeDecoration(decoration.id);
}
this.lastDecorations = [];
});
}
}
interface ITestDecoration extends IDisposable {
/**
* ID of the decoration after being added to the editor, set after the
* decoration is applied.
*/
id: string;
readonly editorDecoration: IModelDeltaDecoration;
/**
* Handles a click event, returns true if it was handled.
*/
click(e: IEditorMouseEvent): boolean;
}
const hasValidLocation = <T extends { location?: ModeLocation }>(editorUri: URI, t: T): t is T & { location: ModeLocation } =>
t.location?.uri.toString() === editorUri.toString();
const firstLineRange = (editor: ICodeEditor, originalRange: IRange) => {
const model = editor.getModel();
const endColumn = model?.getLineMaxColumn(originalRange.startLineNumber);
return {
startLineNumber: originalRange.startLineNumber,
endLineNumber: originalRange.startLineNumber,
startColumn: 0,
endColumn: endColumn || 1,
};
};
class RunTestDecoration implements ITestDecoration {
/**
* @inheritdoc
*/
id = '';
/**
* @inheritdoc
*/
public readonly editorDecoration: IModelDeltaDecoration;
private line: number;
constructor(
private readonly test: IncrementalTestCollectionItem,
private readonly collection: IMainThreadTestCollection,
private readonly location: ModeLocation,
private readonly editor: ICodeEditor,
@ITestService private readonly testService: ITestService,
@IContextMenuService private readonly contextMenuService: IContextMenuService,
@ICommandService private readonly commandService: ICommandService,
) {
this.line = location.range.startLineNumber;
const queue = [test.children];
let state = this.test.item.state.runState;
while (queue.length) {
for (const child of queue.pop()!) {
const node = collection.getNodeById(child);
if (node) {
state = maxPriority(node.item.state.runState, state);
}
}
}
const icon = state !== TestRunState.Unset
? testingStatesToIcons.get(state)!
: test.children.size > 0 ? testingRunAllIcon : testingRunIcon;
this.editorDecoration = {
range: firstLineRange(this.editor, this.location.range),
options: {
isWholeLine: true,
glyphMarginClassName: ThemeIcon.asClassName(icon) + ' testing-run-glyph',
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
glyphMarginHoverMessage: new MarkdownString().appendText(localize('testing.clickToRun', 'Click to run tests, right click for more options')),
}
};
}
/**
* @inheritdoc
*/
public click(e: IEditorMouseEvent): boolean {
if (e.target.position?.lineNumber !== this.line || e.target.type !== MouseTargetType.GUTTER_GLYPH_MARGIN) {
return false;
}
if (e.event.rightButton) {
const actions = this.getContextMenu();
this.contextMenuService.showContextMenu({
getAnchor: () => ({ x: e.event.posx, y: e.event.posy }),
getActions: () => actions,
onHide: () => dispose(actions),
});
} else {
// todo: customize click behavior
this.testService.runTests({ tests: [{ testId: this.test.id, providerId: this.test.providerId }], debug: false });
}
return true;
}
public dispose() {
// no-op
}
private getContextMenu() {
const model = this.editor.getModel();
if (!model) {
return [];
}
const testActions: IAction[] = [];
if (this.test.item.runnable) {
testActions.push(new Action('testing.run', localize('run test', 'Run Test'), undefined, undefined, () => this.testService.runTests({
debug: false,
tests: [{ providerId: this.test.providerId, testId: this.test.id }],
})));
}
if (this.test.item.debuggable) {
testActions.push(new Action('testing.debug', localize('debug test', 'Debug Test'), undefined, undefined, () => this.testService.runTests({
debug: true,
tests: [{ providerId: this.test.providerId, testId: this.test.id }],
})));
}
testActions.push(new Action('testing.reveal', localize('reveal test', 'Reveal in Test Explorer'), undefined, undefined, async () => {
const path = [];
for (let id: string | null = this.test.id; id;) {
const node = this.collection.getNodeById(id);
if (!node) {
break;
}
path.unshift(node.item.label);
id = node.parent;
}
await this.commandService.executeCommand('vscode.revealTestInExplorer', this.test);
}));
const breakpointActions = this.editor
.getContribution<IBreakpointEditorContribution>(BREAKPOINT_EDITOR_CONTRIBUTION_ID)
.getContextMenuActionsAtPosition(this.line, model);
return breakpointActions.length ? [...testActions, new Separator(), ...breakpointActions] : testActions;
}
}
class TestMessageDecoration implements ITestDecoration {
public id = '';
public readonly editorDecoration: IModelDeltaDecoration;
private readonly decorationId = `testmessage-${generateUuid()}`;
constructor(
{ message, severity }: ITestMessage,
private readonly messageUri: URI,
location: ModeLocation,
private readonly editor: ICodeEditor,
@ICodeEditorService private readonly editorService: ICodeEditorService,
@IThemeService themeService: IThemeService,
) {
severity = severity || TestMessageSeverity.Error;
const colorTheme = themeService.getColorTheme();
editorService.registerDecorationType(this.decorationId, {
after: {
contentText: message.toString(),
color: `${colorTheme.getColor(testMessageSeverityColors[severity].decorationForeground)}`,
fontSize: `${editor.getOption(EditorOption.fontSize)}px`,
fontFamily: editor.getOption(EditorOption.fontFamily),
padding: `0px 12px`,
},
}, undefined, editor);
const options = editorService.resolveDecorationOptions(this.decorationId, true);
options.hoverMessage = typeof message === 'string' ? new MarkdownString().appendText(message) : message;
options.afterContentClassName = `${options.afterContentClassName} testing-inline-message`;
options.zIndex = 10; // todo: in spite of the z-index, this appears behind gitlens
const rulerColor = severity === TestMessageSeverity.Error
? overviewRulerError
: severity === TestMessageSeverity.Warning
? overviewRulerWarning
: severity === TestMessageSeverity.Information
? overviewRulerInfo
: undefined;
if (rulerColor) {
options.overviewRuler = { color: themeColorFromId(rulerColor), position: OverviewRulerLane.Right };
}
this.editorDecoration = { range: firstLineRange(editor, location.range), options };
}
click(e: IEditorMouseEvent): boolean {
if (e.event.rightButton) {
return false;
}
if (e.target.element?.className.includes(this.decorationId)) {
TestingOutputPeekController.get(this.editor).show(this.messageUri);
}
return false;
}
dispose(): void {
this.editorService.removeDecorationType(this.decorationId);
}
}

View file

@ -27,19 +27,34 @@ import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingC
export interface ITestExplorerFilterState {
_serviceBrand: undefined;
readonly onDidChange: Event<string>;
readonly onDidRequestReveal: Event<string[]>;
value: string;
reveal: string[] | undefined;
}
export const ITestExplorerFilterState = createDecorator<ITestExplorerFilterState>('testingFilterState');
export class TestExplorerFilterState implements ITestExplorerFilterState {
declare _serviceBrand: undefined;
private readonly revealRequest = new Emitter<string[]>();
private readonly changeEmitter = new Emitter<string>();
private _value = '';
private _reveal?: string[];
public readonly onDidRequestReveal = this.revealRequest.event;
public readonly onDidChange = this.changeEmitter.event;
public get reveal() {
return this._reveal;
}
public set reveal(v: string[] | undefined) {
this._reveal = v;
if (v !== undefined) {
this.revealRequest.fire(v);
}
}
public get value() {
return this._value;
}

View file

@ -56,6 +56,7 @@ import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browse
import { TestExplorerViewGrouping, TestExplorerViewMode, Testing } from 'vs/workbench/contrib/testing/common/constants';
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
import { cmpPriority, isFailedState } from 'vs/workbench/contrib/testing/common/testingStates';
import { buildTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri';
import { ITestResultService, sumCounts, TestStateCount } from 'vs/workbench/contrib/testing/common/testResultService';
import { ITestService } from 'vs/workbench/contrib/testing/common/testService';
import { IWorkspaceTestCollectionService, TestSubscriptionListener } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService';
@ -388,7 +389,13 @@ export class TestingExplorerViewModel extends Disposable {
return false;
}
TestingOutputPeekController.get(control).show(item.test, index);
TestingOutputPeekController.get(control).show(buildTestUri({
type: TestUriType.LiveMessage,
messageIndex: index,
providerId: item.test.providerId,
testId: item.test.id,
}));
return true;
}
@ -648,10 +655,6 @@ class TestsRenderer implements ITreeRenderer<ITestTreeElement, FuzzyScore, TestT
const state = getComputedState(element);
const icon = testingStatesToIcons.get(state);
data.icon.className = 'computed-state ' + (icon ? ThemeIcon.asClassName(icon) : '');
if (state === TestRunState.Running) {
data.icon.className += ' codicon-modifier-spin';
}
const test = element.test;
if (test) {
if (test.item.location) {

View file

@ -5,12 +5,13 @@
import * as dom from 'vs/base/browser/dom';
import { Color } from 'vs/base/common/color';
import { DisposableStore, IDisposable, IReference } from 'vs/base/common/lifecycle';
import { IReference, MutableDisposable } from 'vs/base/common/lifecycle';
import { clamp } from 'vs/base/common/numbers';
import { count } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget';
import { IDiffEditorOptions } from 'vs/editor/common/config/editorOptions';
import { EmbeddedCodeEditorWidget, EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget';
import { IDiffEditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService';
import { IPeekViewService, peekViewTitleBackground, peekViewTitleForeground, peekViewTitleInfoForeground, PeekViewWidget } from 'vs/editor/contrib/peekView/peekView';
@ -22,7 +23,17 @@ import { testingPeekBorder } from 'vs/workbench/contrib/testing/browser/theme';
import { Testing } from 'vs/workbench/contrib/testing/common/constants';
import { InternalTestItem, ITestMessage } from 'vs/workbench/contrib/testing/common/testCollection';
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
import { buildTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri';
import { buildTestUri, parseTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri';
import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService';
import { ITestService } from 'vs/workbench/contrib/testing/common/testService';
interface ITestDto {
messageIndex: number;
test: InternalTestItem;
expectedUri: URI;
actualUri: URI;
messageUri: URI;
}
export class TestingOutputPeekController implements IEditorContribution {
/**
@ -35,7 +46,7 @@ export class TestingOutputPeekController implements IEditorContribution {
/**
* Currently-shown peek view.
*/
private peek?: TestingOutputPeek;
private readonly peek = new MutableDisposable<TestingOutputPeek>();
/**
* Context key updated when the peek is visible/hidden.
@ -45,6 +56,8 @@ export class TestingOutputPeekController implements IEditorContribution {
constructor(
private readonly editor: ICodeEditor,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ITestResultService private readonly testResults: ITestResultService,
@ITestService private readonly testService: ITestService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
this.visible = TestingContextKeys.peekVisible.bindTo(contextKeyService);
@ -60,54 +73,92 @@ export class TestingOutputPeekController implements IEditorContribution {
/**
* Shows a peek for the message in th editor.
*/
public async show(test: InternalTestItem, messageIndex: number) {
const message = test?.item.state.messages[messageIndex];
if (!test || !message?.location) {
public async show(uri: URI) {
const dto = await this.retrieveTest(uri);
if (!dto) {
return;
}
if (!this.peek) {
this.peek = this.instantiationService.createInstance(TestingOutputPeek, this.editor);
const message = dto.test.item.state.messages[dto.messageIndex];
if (!message?.location) {
return;
}
this.visible.set(true);
this.peek.setModel(test, messageIndex);
this.peek.onDidClose(() => {
this.visible.set(false);
this.peek = undefined;
});
const ctor = message.actualOutput !== undefined && message.expectedOutput !== undefined
? TestingDiffOutputPeek : TestingMessageOutputPeek;
const isNew = !(this.peek.value instanceof ctor);
if (isNew) {
this.peek.value = this.instantiationService.createInstance(ctor, this.editor);
this.peek.value.onDidClose(() => {
this.visible.set(false);
this.peek.value = undefined;
});
}
if (isNew) {
this.visible.set(true);
this.peek.value!.create();
}
this.peek.value!.setModel(dto);
}
/**
* Disposes the peek view, if any.
*/
public removePeek() {
if (this.peek) {
this.peek.dispose();
this.peek = undefined;
this.peek.value = undefined;
}
private async retrieveTest(uri: URI): Promise<ITestDto | undefined> {
const parts = parseTestUri(uri);
if (!parts) {
return undefined;
}
if ('resultId' in parts) {
const test = this.testResults.lookup(parts.resultId)?.tests.find(t => t.id === parts.testId);
return test && {
test,
messageIndex: parts.messageIndex,
expectedUri: buildTestUri({ ...parts, type: TestUriType.ResultExpectedOutput }),
actualUri: buildTestUri({ ...parts, type: TestUriType.ResultActualOutput }),
messageUri: buildTestUri({ ...parts, type: TestUriType.ResultMessage }),
};
}
const test = await this.testService.lookupTest({ providerId: parts.providerId, testId: parts.testId });
if (!test) {
return;
}
return {
test,
messageIndex: parts.messageIndex,
expectedUri: buildTestUri({ ...parts, type: TestUriType.LiveActualOutput }),
actualUri: buildTestUri({ ...parts, type: TestUriType.LiveExpectedOutput }),
messageUri: buildTestUri({ ...parts, type: TestUriType.LiveMessage }),
};
}
}
export class TestingOutputPeek extends PeekViewWidget {
private readonly disposable = new DisposableStore();
private diff?: EmbeddedDiffEditorWidget;
private model?: IDisposable;
private dimension?: dom.Dimension;
abstract class TestingOutputPeek extends PeekViewWidget {
protected model = new MutableDisposable();
protected dimension?: dom.Dimension;
constructor(
editor: ICodeEditor,
@IThemeService themeService: IThemeService,
@IPeekViewService peekViewService: IPeekViewService,
@IInstantiationService protected readonly instantiationService: IInstantiationService,
@ITextModelService private readonly modelService: ITextModelService,
@ITextModelService protected readonly modelService: ITextModelService,
) {
super(editor, { showFrame: false, showArrow: true, isResizeable: true, isAccessible: true, className: 'test-output-peek' }, instantiationService);
this._disposables.add(themeService.onDidColorThemeChange(this.applyTheme, this));
this._disposables.add(this.model);
this.applyTheme(themeService.getColorTheme());
peekViewService.addExclusiveWidget(editor, this);
this.create();
}
private applyTheme(theme: IColorTheme) {
@ -121,67 +172,83 @@ export class TestingOutputPeek extends PeekViewWidget {
});
}
/**
* Updates the test to be shown.
*/
public abstract setModel(dto: ITestDto): Promise<void>;
/**
* @override
*/
public dispose() {
super.dispose();
this.model?.dispose();
this.disposable.dispose();
protected _doLayoutBody(height: number, width: number) {
super._doLayoutBody(height, width);
this.dimension = new dom.Dimension(width, height);
}
}
const commonEditorOptions: IEditorOptions = {
scrollBeyondLastLine: false,
scrollbar: {
verticalScrollbarSize: 14,
horizontal: 'auto',
useShadows: true,
verticalHasArrows: false,
horizontalHasArrows: false,
alwaysConsumeMouseWheel: false
},
fixedOverflowWidgets: true,
readOnly: true,
minimap: {
enabled: false
},
};
const diffEditorOptions: IDiffEditorOptions = {
...commonEditorOptions,
enableSplitViewResizing: true,
isInEmbeddedEditor: true,
renderOverviewRuler: false,
ignoreTrimWhitespace: false,
renderSideBySide: true,
};
class TestingDiffOutputPeek extends TestingOutputPeek {
private readonly diff = this._disposables.add(new MutableDisposable<EmbeddedDiffEditorWidget>());
/**
* @override
*/
protected _fillBody(containerElement: HTMLElement): void {
const diffContainer = dom.append(containerElement, dom.$('div.preview.inline'));
let options: IDiffEditorOptions = {
scrollBeyondLastLine: false,
scrollbar: {
verticalScrollbarSize: 14,
horizontal: 'auto',
useShadows: true,
verticalHasArrows: false,
horizontalHasArrows: false,
alwaysConsumeMouseWheel: false
},
fixedOverflowWidgets: true,
readOnly: true,
minimap: {
enabled: false
},
enableSplitViewResizing: true,
isInEmbeddedEditor: true,
renderOverviewRuler: false,
ignoreTrimWhitespace: false,
renderSideBySide: true,
};
const preview = this.diff = this.instantiationService.createInstance(EmbeddedDiffEditorWidget, diffContainer, options, this.editor);
this.disposable.add(preview);
const preview = this.diff.value = this.instantiationService.createInstance(EmbeddedDiffEditorWidget, diffContainer, diffEditorOptions, this.editor);
if (this.dimension) {
preview.layout(this.dimension);
}
}
public async setModel(test: InternalTestItem, messageIndex: number) {
/**
* @override
*/
public async setModel({ test, messageIndex, expectedUri, actualUri }: ITestDto) {
const message = test.item.state.messages[messageIndex];
if (!message?.location) {
return;
}
this.show(message.location.range, hintPeekHeight(message));
if (this.model) {
this.model.dispose();
}
this.show(message.location.range, hintDiffPeekHeight(message));
this.setTitle(message.message.toString(), test.item.label);
if (message.actualOutput !== undefined && message.expectedOutput !== undefined) {
await this.showDiffInEditor(test, messageIndex);
const [original, modified] = await Promise.all([
this.modelService.createModelReference(expectedUri),
this.modelService.createModelReference(actualUri),
]);
const model = this.model.value = new SimpleDiffEditorModel(original, modified);
if (!this.diff.value) {
this.model.value = undefined;
} else {
await this.showMessageInEditor(test, messageIndex);
this.diff.value.setModel(model);
}
}
@ -190,37 +257,58 @@ export class TestingOutputPeek extends PeekViewWidget {
*/
protected _doLayoutBody(height: number, width: number) {
super._doLayoutBody(height, width);
this.dimension = new dom.Dimension(width, height);
this.diff?.layout(this.dimension);
}
private async showMessageInEditor(test: InternalTestItem, messageIndex: number) {
// todo? not sure if this is a useful experience
this.model?.dispose();
this.diff?.setModel(null);
}
private async showDiffInEditor(test: InternalTestItem, messageIndex: number) {
const uriParts = { messageIndex, testId: test.id, providerId: test.providerId };
const [original, modified] = await Promise.all([
this.modelService.createModelReference(buildTestUri({ ...uriParts, type: TestUriType.ExpectedOutput })),
this.modelService.createModelReference(buildTestUri({ ...uriParts, type: TestUriType.ActualOutput })),
]);
this.model?.dispose();
const model = this.model = new SimpleDiffEditorModel(original, modified);
if (!this.diff) {
model.dispose();
} else {
this.diff.setModel(model);
}
this.diff.value?.layout(this.dimension);
}
}
const hintPeekHeight = (message: ITestMessage) => {
const lines = Math.max(count(message.actualOutput || '', '\n'), count(message.expectedOutput || '', '\n'));
return clamp(lines, 5, 20);
};
class TestingMessageOutputPeek extends TestingOutputPeek {
private readonly preview = this._disposables.add(new MutableDisposable<EmbeddedCodeEditorWidget>());
/**
* @override
*/
protected _fillBody(containerElement: HTMLElement): void {
const diffContainer = dom.append(containerElement, dom.$('div.preview.inline'));
const preview = this.preview.value = this.instantiationService.createInstance(EmbeddedCodeEditorWidget, diffContainer, commonEditorOptions, this.editor);
if (this.dimension) {
preview.layout(this.dimension);
}
}
/**
* @override
*/
public async setModel({ test, messageIndex, messageUri }: ITestDto) {
const message = test.item.state.messages[messageIndex];
if (!message?.location) {
return;
}
this.show(message.location.range, hintPeekStrHeight(message.message.toString()));
this.setTitle(message.message.toString(), test.item.label);
const modelRef = this.model.value = await this.modelService.createModelReference(messageUri);
if (this.preview.value) {
this.preview.value.setModel(modelRef.object.textEditorModel);
} else {
this.model.value = undefined;
}
}
/**
* @override
*/
protected _doLayoutBody(height: number, width: number) {
super._doLayoutBody(height, width);
this.preview.value?.layout(this.dimension);
}
}
const hintDiffPeekHeight = (message: ITestMessage) =>
Math.max(hintPeekStrHeight(message.actualOutput), hintPeekStrHeight(message.expectedOutput));
const hintPeekStrHeight = (str: string | undefined) => clamp(count(str || '', '\n'), 5, 20);
class SimpleDiffEditorModel extends EditorModel {
public readonly original = this._original.object.textEditorModel;

View file

@ -4,8 +4,8 @@
*--------------------------------------------------------------------------------------------*/
import { localize } from 'vs/nls';
import { editorErrorForeground, registerColor } from 'vs/platform/theme/common/colorRegistry';
import { TestRunState } from 'vs/workbench/api/common/extHostTypes';
import { editorErrorForeground, editorForeground, editorHintForeground, editorInfoForeground, editorWarningForeground, registerColor } from 'vs/platform/theme/common/colorRegistry';
import { TestMessageSeverity, TestRunState } from 'vs/workbench/api/common/extHostTypes';
export const testingColorIconFailed = registerColor('testing.iconFailed', {
dark: '#f14c4c',
@ -19,13 +19,18 @@ export const testingColorIconErrored = registerColor('testing.iconErrored', {
hc: '#000000'
}, localize('testing.iconErrored', "Color for the 'Errored' icon in the test explorer."));
export const testingColorIconPassed = registerColor('testing.iconPassed', {
dark: '#73c991',
light: '#73c991',
hc: '#000000'
}, localize('testing.iconPassed', "Color for the 'passed' icon in the test explorer."));
export const testingColorRunAction = registerColor('testing.runAction', {
dark: testingColorIconPassed,
light: testingColorIconPassed,
hc: testingColorIconPassed
}, localize('testing.runAction', "Color for 'run' icons in the editor."));
export const testingColorIconQueued = registerColor('testing.iconQueued', {
dark: '#cca700',
light: '#cca700',
@ -50,6 +55,41 @@ export const testingPeekBorder = registerColor('testing.peekBorder', {
hc: editorErrorForeground,
}, localize('testing.peekBorder', 'Color of the peek view borders and arrow.'));
export const testMessageSeverityColors: {
[K in TestMessageSeverity]: {
decorationForeground: string,
};
} = {
[TestMessageSeverity.Error]: {
decorationForeground: registerColor(
'testing.message.error.decorationForeground',
{ dark: editorErrorForeground, light: editorErrorForeground, hc: editorForeground },
localize('testing.message.error.decorationForeground', 'Text color of test error messages shown inline in the editor.')
),
},
[TestMessageSeverity.Warning]: {
decorationForeground: registerColor(
'testing.message.warning.decorationForeground',
{ dark: editorWarningForeground, light: editorWarningForeground, hc: editorForeground },
localize('testing.message.warning.decorationForeground', 'Text color of test warning messages shown inline in the editor.')
),
},
[TestMessageSeverity.Information]: {
decorationForeground: registerColor(
'testing.message.info.decorationForeground',
{ dark: editorInfoForeground, light: editorInfoForeground, hc: editorForeground },
localize('testing.message.info.decorationForeground', 'Text color of test info messages shown inline in the editor.')
),
},
[TestMessageSeverity.Hint]: {
decorationForeground: registerColor(
'testing.message.hint.decorationForeground',
{ dark: editorHintForeground, light: editorHintForeground, hc: editorForeground },
localize('testing.message.hint.decorationForeground', 'Text color of test hint messages shown inline in the editor.')
),
},
};
export const testStatesToIconColors: { [K in TestRunState]?: string } = {
[TestRunState.Errored]: testingColorIconErrored,
[TestRunState.Failed]: testingColorIconFailed,

View file

@ -11,6 +11,7 @@ export const enum Testing {
ViewletId = 'workbench.view.extension.test',
ExplorerViewId = 'workbench.view.testing',
OutputPeekContributionId = 'editor.contrib.testingOutputPeek',
DecorationsContributionId = 'editor.contrib.testingDecorations',
FilterActionId = 'workbench.actions.treeView.testExplorer.filter',
}

View file

@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from 'vs/base/common/event';
import { generateUuid } from 'vs/base/common/uuid';
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { TestRunState } from 'vs/workbench/api/common/extHostTypes';
@ -88,6 +89,11 @@ export class TestResult {
public onChange = this.changeEmitter.event;
public onComplete = this.completeEmitter.event;
/**
* Unique ID for referring to this set of test results.
*/
public readonly id = generateUuid();
/**
* Gets whether the test run has finished.
*/
@ -175,11 +181,16 @@ export interface ITestResultService {
* Adds a new test result to the collection.
*/
push(result: TestResult): TestResult;
/**
* Looks up a set of test results by ID.
*/
lookup(resultId: string): TestResult | undefined;
}
export const ITestResultService = createDecorator<ITestResultService>('testResultService');
const RETAIN_LAST_RESULTS = 10;
const RETAIN_LAST_RESULTS = 16;
export class TestResultService implements ITestResultService {
declare _serviceBrand: undefined;
@ -216,6 +227,13 @@ export class TestResultService implements ITestResultService {
return result;
}
/**
* @inheritdoc
*/
public lookup(id: string) {
return this.results.find(r => r.id === id);
}
private onComplete() {
// move the complete test run down behind any still-running ones
for (let i = 0; i < this.results.length - 2; i++) {

View file

@ -5,7 +5,7 @@
import { CancellationToken } from 'vs/base/common/cancellation';
import { Event } from 'vs/base/common/event';
import { IDisposable } from 'vs/base/common/lifecycle';
import { IDisposable, IReference } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol';
@ -39,6 +39,11 @@ export interface IMainThreadTestCollection extends AbstractIncrementalTestCollec
*/
rootIds: ReadonlySet<string>;
/**
* Iterates over every test in the collection.
*/
all: Iterable<IncrementalTestCollectionItem>;
/**
* Gets a node in the collection by ID.
*/
@ -82,10 +87,7 @@ export interface ITestService {
runTests(req: RunTestsRequest, token?: CancellationToken): Promise<RunTestsResult>;
cancelTestRun(req: RunTestsRequest): void;
publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void;
subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): {
collection: IMainThreadTestCollection;
dispose(): void;
};
subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): IReference<IMainThreadTestCollection>;
/**
* Updates the number of sources who provide test roots when subscription

View file

@ -7,7 +7,7 @@ import { groupBy } from 'vs/base/common/arrays';
import { disposableTimeout } from 'vs/base/common/async';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { Emitter } from 'vs/base/common/event';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { Disposable, IDisposable, IReference } from 'vs/base/common/lifecycle';
import { isDefined } from 'vs/base/common/types';
import { URI, UriComponents } from 'vs/base/common/uri';
import { localize } from 'vs/nls';
@ -131,7 +131,7 @@ export class TestService extends Disposable implements ITestService {
const subscriptions = [...this.testSubscriptions.values()]
.filter(v => req.tests.some(t => v.collection.getNodeById(t.testId)))
.map(s => this.subscribeToDiffs(s.ident.resource, s.ident.uri, () => result?.notifyChanged()));
result = this.testResults.push(TestResult.from(subscriptions.map(s => s.collection), req.tests));
result = this.testResults.push(TestResult.from(subscriptions.map(s => s.object), req.tests));
try {
const tests = groupBy(req.tests, (a, b) => a.providerId === b.providerId ? 0 : 1);
@ -175,7 +175,7 @@ export class TestService extends Disposable implements ITestService {
/**
* @inheritdoc
*/
public subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener) {
public subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): IReference<IMainThreadTestCollection> {
const subscriptionKey = getTestSubscriptionKey(resource, uri);
let subscription = this.testSubscriptions.get(subscriptionKey);
if (!subscription) {
@ -200,7 +200,7 @@ export class TestService extends Disposable implements ITestService {
const listener = acceptDiff && subscription.onDiff.event(acceptDiff);
return {
collection: subscription.collection,
object: subscription.collection,
dispose: () => {
listener?.dispose();
@ -277,6 +277,14 @@ class MainThreadTestCollection extends AbstractIncrementalTestCollection<Increme
return this.roots;
}
/**
* @inheritdoc
*/
public get all() {
return this.getIterator();
}
public readonly onPendingRootProvidersChange = this.pendingRootChangeEmitter.event;
public readonly onBusyProvidersChange = this.busyProvidersChangeEmitter.event;
@ -350,4 +358,15 @@ class MainThreadTestCollection extends AbstractIncrementalTestCollection<Increme
protected createItem(internal: InternalTestItem): IncrementalTestCollectionItem {
return { ...internal, children: new Set() };
}
private *getIterator() {
const queue = [this.rootIds];
while (queue.length) {
for (const id of queue.pop()!) {
const node = this.getNodeById(id)!;
yield node;
queue.push(node.children);
}
}
}
}

View file

@ -9,6 +9,7 @@ import { IModelService } from 'vs/editor/common/services/modelService';
import { ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService';
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
import { parseTestUri, TestUriType, TEST_DATA_SCHEME } from 'vs/workbench/contrib/testing/common/testingUri';
import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService';
import { ITestService } from 'vs/workbench/contrib/testing/common/testService';
/**
@ -20,6 +21,7 @@ export class TestingContentProvider implements IWorkbenchContribution, ITextMode
@ITextModelService textModelResolverService: ITextModelService,
@IModelService private readonly modelService: IModelService,
@ITestService private readonly testService: ITestService,
@ITestService private readonly resultService: ITestResultService,
) {
textModelResolverService.registerTextModelContentProvider(TEST_DATA_SCHEME, this);
}
@ -38,20 +40,26 @@ export class TestingContentProvider implements IWorkbenchContribution, ITextMode
return null;
}
const test = await this.testService.lookupTest({ providerId: parsed.providerId, testId: parsed.testId });
const test = 'providerId' in parsed
? await this.testService.lookupTest({ providerId: parsed.providerId, testId: parsed.testId })
: this.resultService.lookup(parsed.resultId)?.tests.find(t => t.id === parsed.testId);
if (!test) {
return null;
}
let text: string | undefined;
switch (parsed.type) {
case TestUriType.ActualOutput:
case TestUriType.ResultActualOutput:
case TestUriType.LiveActualOutput:
text = test.item.state.messages[parsed.messageIndex]?.actualOutput;
break;
case TestUriType.ExpectedOutput:
case TestUriType.ResultExpectedOutput:
case TestUriType.LiveExpectedOutput:
text = test.item.state.messages[parsed.messageIndex]?.expectedOutput;
break;
case TestUriType.Message:
case TestUriType.ResultMessage:
case TestUriType.LiveMessage:
text = test.item.state.messages[parsed.messageIndex]?.message.toString();
break;
}

View file

@ -22,7 +22,6 @@ export const statePriority: { [K in TestRunState]: number } = {
[TestRunState.Unset]: 0,
};
export const isFailedState = (s: TestRunState) => s === TestRunState.Errored || s === TestRunState.Failed;
export const stateNodes = Object.entries(statePriority).reduce(

View file

@ -8,29 +8,54 @@ import { URI } from 'vs/base/common/uri';
export const TEST_DATA_SCHEME = 'vscode-test-data';
export const enum TestUriType {
Message,
ActualOutput,
ExpectedOutput,
LiveMessage,
LiveActualOutput,
LiveExpectedOutput,
ResultMessage,
ResultActualOutput,
ResultExpectedOutput,
}
interface IGenericTestUri {
interface ILiveTestUri {
providerId: string;
testId: string;
}
interface ITestMessageReference extends IGenericTestUri {
type: TestUriType.Message;
interface ILiveTestMessageReference extends ILiveTestUri {
type: TestUriType.LiveMessage;
messageIndex: number;
}
interface ITestOutputReference extends IGenericTestUri {
type: TestUriType.ActualOutput | TestUriType.ExpectedOutput;
interface ILiveTestOutputReference extends ILiveTestUri {
type: TestUriType.LiveActualOutput | TestUriType.LiveExpectedOutput;
messageIndex: number;
}
export type ParsedTestUri = ITestMessageReference | ITestOutputReference;
interface IResultTestUri {
resultId: string;
testId: string;
}
interface IResultTestMessageReference extends IResultTestUri {
type: TestUriType.ResultMessage;
messageIndex: number;
}
interface IResultTestOutputReference extends IResultTestUri {
type: TestUriType.ResultActualOutput | TestUriType.ResultExpectedOutput;
messageIndex: number;
}
export type ParsedTestUri =
| IResultTestMessageReference
| IResultTestOutputReference
| ILiveTestMessageReference
| ILiveTestOutputReference;
const enum TestUriParts {
Results = 'results',
Live = 'live',
Messages = 'message',
Text = 'text',
ActualOutput = 'actualOutput',
@ -38,21 +63,30 @@ const enum TestUriParts {
}
export const parseTestUri = (uri: URI): ParsedTestUri | undefined => {
const providerId = uri.authority;
const [testId, ...request] = uri.path.slice(1).split('/');
const type = uri.authority;
const [locationId, testId, ...request] = uri.path.slice(1).split('/');
if (request[0] === TestUriParts.Messages) {
const index = Number(request[1]);
const part = request[2];
switch (part) {
case TestUriParts.Text:
return { providerId, testId, messageIndex: index, type: TestUriType.Message };
case TestUriParts.ActualOutput:
return { providerId, testId, messageIndex: index, type: TestUriType.ActualOutput };
case TestUriParts.ExpectedOutput:
return { providerId, testId, messageIndex: index, type: TestUriType.ExpectedOutput };
default:
return undefined;
if (type === TestUriParts.Results) {
switch (part) {
case TestUriParts.Text:
return { resultId: locationId, testId, messageIndex: index, type: TestUriType.ResultMessage };
case TestUriParts.ActualOutput:
return { resultId: locationId, testId, messageIndex: index, type: TestUriType.ResultActualOutput };
case TestUriParts.ExpectedOutput:
return { resultId: locationId, testId, messageIndex: index, type: TestUriType.ResultExpectedOutput };
}
} else if (type === TestUriParts.Live) {
switch (part) {
case TestUriParts.Text:
return { providerId: locationId, testId, messageIndex: index, type: TestUriType.LiveMessage };
case TestUriParts.ActualOutput:
return { providerId: locationId, testId, messageIndex: index, type: TestUriType.LiveActualOutput };
case TestUriParts.ExpectedOutput:
return { providerId: locationId, testId, messageIndex: index, type: TestUriType.LiveExpectedOutput };
}
}
}
@ -60,17 +94,29 @@ export const parseTestUri = (uri: URI): ParsedTestUri | undefined => {
};
export const buildTestUri = (parsed: ParsedTestUri): URI => {
const uriParts = { scheme: TEST_DATA_SCHEME, authority: parsed.testId };
const msgRef = (index: number, ...remaining: string[]) =>
URI.from({ ...uriParts, path: ['', parsed.testId, TestUriParts.Messages, index, ...remaining].join('/') });
const uriParts = {
scheme: TEST_DATA_SCHEME,
authority: 'resultId' in parsed ? TestUriParts.Results : TestUriParts.Live
};
const msgRef = (locationId: string, index: number, ...remaining: string[]) =>
URI.from({
...uriParts,
path: ['', locationId, parsed.testId, TestUriParts.Messages, index, ...remaining].join('/'),
});
switch (parsed.type) {
case TestUriType.ActualOutput:
return msgRef(parsed.messageIndex, TestUriParts.ActualOutput);
case TestUriType.ExpectedOutput:
return msgRef(parsed.messageIndex, TestUriParts.ExpectedOutput);
case TestUriType.Message:
return msgRef(parsed.messageIndex, TestUriParts.Text);
case TestUriType.ResultActualOutput:
return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.ActualOutput);
case TestUriType.ResultExpectedOutput:
return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.ExpectedOutput);
case TestUriType.ResultMessage:
return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.Text);
case TestUriType.LiveActualOutput:
return msgRef(parsed.providerId, parsed.messageIndex, TestUriParts.ActualOutput);
case TestUriType.LiveExpectedOutput:
return msgRef(parsed.providerId, parsed.messageIndex, TestUriParts.ExpectedOutput);
case TestUriType.LiveMessage:
return msgRef(parsed.providerId, parsed.messageIndex, TestUriParts.Text);
default:
throw new Error('Invalid test uri');
}

View file

@ -195,8 +195,8 @@ class TestSubscription extends Disposable {
const folderNode: ITestSubscriptionFolder = {
folder,
getChildren: function* () {
for (const rootId of listener.collection.rootIds) {
const node = listener.collection.getNodeById(rootId);
for (const rootId of ref.object.rootIds) {
const node = ref.object.getNodeById(rootId);
if (node) {
yield node;
}
@ -204,7 +204,7 @@ class TestSubscription extends Disposable {
},
};
const listener = this.testService.subscribeToDiffs(
const ref = this.testService.subscribeToDiffs(
ExtHostTestingResource.Workspace,
folder.uri,
diff => {
@ -215,16 +215,15 @@ class TestSubscription extends Disposable {
);
const disposable = new DisposableStore();
disposable.add(listener);
disposable.add(listener.collection.onBusyProvidersChange(
disposable.add(ref);
disposable.add(ref.object.onBusyProvidersChange(
() => this.pendingRootChangeEmitter.fire(this.pendingRootProviders)));
disposable.add(listener.collection.onBusyProvidersChange(
disposable.add(ref.object.onBusyProvidersChange(
() => this.busyProvidersChangeEmitter.fire(this.busyProviders)));
this.collectionsForWorkspaces.set(folder.uri.toString(), {
listener: disposable,
collection: listener.collection,
collection: ref.object,
folder: folderNode,
});
}

View file

@ -0,0 +1,25 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { buildTestUri, ParsedTestUri, parseTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri';
suite('Workbench - Testing URIs', () => {
test('round trip', () => {
const uris: ParsedTestUri[] = [
{ type: TestUriType.LiveActualOutput, messageIndex: 42, providerId: 'p', testId: 't' },
{ type: TestUriType.LiveExpectedOutput, messageIndex: 42, providerId: 'p', testId: 't' },
{ type: TestUriType.LiveMessage, messageIndex: 42, providerId: 'p', testId: 't' },
{ type: TestUriType.ResultActualOutput, messageIndex: 42, resultId: 'r', testId: 't' },
{ type: TestUriType.ResultExpectedOutput, messageIndex: 42, resultId: 'r', testId: 't' },
{ type: TestUriType.ResultMessage, messageIndex: 42, resultId: 'r', testId: 't' },
];
for (const uri of uris) {
const serialized = buildTestUri(uri);
assert.deepStrictEqual(uri, parseTestUri(serialized));
}
});
});