Skip to content

Commit

Permalink
testing: menu contribution points around messages (#190298)
Browse files Browse the repository at this point in the history
- Implements the proposal in #190277 by adding a `contextValue` to
  TestMessages added to test runs.
- Make the `FloatingClickMenu` reusable outside the editor, and uses
  it to implement a `testing/message/content` contribution point.

With this extensions can do things like:

![](https://memes.peet.io/img/23-08-68e2f9db-abc4-4717-9da6-698b002c481c.png)
  • Loading branch information
connor4312 authored Aug 12, 2023
1 parent 1b87291 commit 2d9cc42
Show file tree
Hide file tree
Showing 21 changed files with 488 additions and 244 deletions.
1 change: 1 addition & 0 deletions extensions/vscode-api-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"envShellEvent",
"testCoverage",
"testObserver",
"testMessageContextValue",
"textSearchProvider",
"timeline",
"tokenInformation",
Expand Down
3 changes: 2 additions & 1 deletion src/vs/base/common/marshallingIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export const enum MarshalledId {
NotebookCellActionContext,
NotebookActionContext,
TestItemContext,
Date
Date,
TestMessageMenuArgs,
}
129 changes: 129 additions & 0 deletions src/vs/platform/actions/browser/floatingMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { $, append, clearNode } from 'vs/base/browser/dom';
import { Widget } from 'vs/base/browser/ui/widget';
import { IAction } from 'vs/base/common/actions';
import { Emitter } from 'vs/base/common/event';
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
import { IMenu, IMenuService, MenuId } from 'vs/platform/actions/common/actions';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { asCssVariable, asCssVariableWithDefault, buttonBackground, buttonForeground, contrastBorder, editorBackground, editorForeground } from 'vs/platform/theme/common/colorRegistry';

export class FloatingClickWidget extends Widget {

private readonly _onClick = this._register(new Emitter<void>());
readonly onClick = this._onClick.event;

private _domNode: HTMLElement;

constructor(private label: string) {
super();

this._domNode = $('.floating-click-widget');
this._domNode.style.padding = '6px 11px';
this._domNode.style.borderRadius = '2px';
this._domNode.style.cursor = 'pointer';
this._domNode.style.zIndex = '1';
}

getDomNode(): HTMLElement {
return this._domNode;
}

render() {
clearNode(this._domNode);
this._domNode.style.backgroundColor = asCssVariableWithDefault(buttonBackground, asCssVariable(editorBackground));
this._domNode.style.color = asCssVariableWithDefault(buttonForeground, asCssVariable(editorForeground));
this._domNode.style.border = `1px solid ${asCssVariable(contrastBorder)}`;

append(this._domNode, $('')).textContent = this.label;

this.onclick(this._domNode, () => this._onClick.fire());
}
}

export abstract class AbstractFloatingClickMenu extends Disposable {
private readonly renderEmitter = new Emitter<FloatingClickWidget>();
protected readonly onDidRender = this.renderEmitter.event;
private readonly menu: IMenu;

constructor(
menuId: MenuId,
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService
) {
super();
this.menu = this._register(menuService.createMenu(menuId, contextKeyService));
}

/** Should be called in implementation constructors after they initialized */
protected render() {
const menuDisposables = this._register(new DisposableStore());
const renderMenuAsFloatingClickBtn = () => {
menuDisposables.clear();
if (!this.isVisible()) {
return;
}
const actions: IAction[] = [];
createAndFillInActionBarActions(this.menu, { renderShortTitle: true, shouldForwardArgs: true }, actions);
if (actions.length === 0) {
return;
}
// todo@jrieken find a way to handle N actions, like showing a context menu
const [first] = actions;
const widget = this.createWidget(first, menuDisposables);
menuDisposables.add(widget);
menuDisposables.add(widget.onClick(() => first.run(this.getActionArg())));
widget.render();
};
this._register(this.menu.onDidChange(renderMenuAsFloatingClickBtn));
renderMenuAsFloatingClickBtn();
}

protected abstract createWidget(action: IAction, disposables: DisposableStore): FloatingClickWidget;

protected getActionArg(): unknown {
return undefined;
}

protected isVisible() {
return true;
}
}

export class FloatingClickMenu extends AbstractFloatingClickMenu {

constructor(
private readonly options: {
/** Element the menu should be rendered into. */
container: HTMLElement;
/** Menu to show. If no actions are present, the button is hidden. */
menuId: MenuId;
/** Argument provided to the menu action */
getActionArg: () => void;
},
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService
) {
super(options.menuId, menuService, contextKeyService);
this.render();
}

protected override createWidget(action: IAction, disposable: DisposableStore): FloatingClickWidget {
const w = this.instantiationService.createInstance(FloatingClickWidget, action.label);
const node = w.getDomNode();
this.options.container.appendChild(node);
disposable.add(toDisposable(() => this.options.container.removeChild(node)));
return w;
}

protected override getActionArg(): unknown {
return this.options.getActionArg();
}
}
2 changes: 2 additions & 0 deletions src/vs/platform/actions/common/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export class MenuId {
static readonly StickyScrollContext = new MenuId('StickyScrollContext');
static readonly TestItem = new MenuId('TestItem');
static readonly TestItemGutter = new MenuId('TestItemGutter');
static readonly TestMessageContext = new MenuId('TestMessageContext');
static readonly TestMessageContent = new MenuId('TestMessageContent');
static readonly TestPeekElement = new MenuId('TestPeekElement');
static readonly TestPeekTitle = new MenuId('TestPeekTitle');
static readonly TouchBarContext = new MenuId('TouchBarContext');
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/api/common/extHost.api.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1521,8 +1521,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
LinkedEditingRanges: extHostTypes.LinkedEditingRanges,
TestResultState: extHostTypes.TestResultState,
TestRunRequest: extHostTypes.TestRunRequest,
TestRunRequest2: extHostTypes.TestRunRequest2,
TestMessage: extHostTypes.TestMessage,
TestMessage2: extHostTypes.TestMessage,
TestTag: extHostTypes.TestTag,
TestRunProfileKind: extHostTypes.TestRunProfileKind,
TextSearchCompleteMessageType: TextSearchCompleteMessageType,
Expand Down
58 changes: 39 additions & 19 deletions src/vs/workbench/api/common/extHostTesting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { MarshalledId } from 'vs/base/common/marshallingIds';
import { deepFreeze } from 'vs/base/common/objects';
import { isDefined } from 'vs/base/common/types';
import { generateUuid } from 'vs/base/common/uuid';
import { IExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { IExtensionDescription, IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { ExtHostTestingShape, ILocationDto, MainContext, MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol';
import { ExtHostCommands } from 'vs/workbench/api/common/extHostCommands';
import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors';
Expand All @@ -28,13 +28,15 @@ import { TestRunProfileKind, TestRunRequest } from 'vs/workbench/api/common/extH
import { TestCommandId } from 'vs/workbench/contrib/testing/common/constants';
import { TestId, TestIdPathParts, TestPosition } from 'vs/workbench/contrib/testing/common/testId';
import { InvalidTestItemError } from 'vs/workbench/contrib/testing/common/testItemCollection';
import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestItem, ITestItemContext, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes';
import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes';
import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions';
import type * as vscode from 'vscode';

interface ControllerInfo {
controller: vscode.TestController;
profiles: Map<number, vscode.TestRunProfile>;
collection: ExtHostTestItemCollection;
extension: Readonly<IRelaxedExtensionDescription>;
}

export class ExtHostTesting implements ExtHostTestingShape {
Expand All @@ -58,14 +60,22 @@ export class ExtHostTesting implements ExtHostTestingShape {

commands.registerArgumentProcessor({
processArgument: arg => {
if (arg?.$mid !== MarshalledId.TestItemContext) {
return arg;
switch (arg?.$mid) {
case MarshalledId.TestItemContext: {
const cast = arg as ITestItemContext;
const targetTest = cast.tests[cast.tests.length - 1].item.extId;
const controller = this.controllers.get(TestId.root(targetTest));
return controller?.collection.tree.get(targetTest)?.actual ?? toItemFromContext(arg);
}
case MarshalledId.TestMessageMenuArgs: {
const { extId, message } = arg as ITestMessageMenuArgs;
return {
test: this.controllers.get(TestId.root(extId))?.collection.tree.get(extId)?.actual,
message: Convert.TestMessage.to(message as ITestErrorMessage.Serialized),
};
}
default: return arg;
}

const cast = arg as ITestItemContext;
const targetTest = cast.tests[cast.tests.length - 1].item.extId;
const controller = this.controllers.get(TestId.root(targetTest));
return controller?.collection.tree.get(targetTest)?.actual ?? toItemFromContext(arg);
}
});

Expand Down Expand Up @@ -137,7 +147,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
return new TestItemImpl(controllerId, id, label, uri);
},
createTestRun: (request, name, persist = true) => {
return this.runTracker.createTestRun(controllerId, collection, request, name, persist);
return this.runTracker.createTestRun(extension, controllerId, collection, request, name, persist);
},
invalidateTestResults: items => {
if (items === undefined) {
Expand All @@ -161,7 +171,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
proxy.$registerTestController(controllerId, label, !!refreshHandler);
disposable.add(toDisposable(() => proxy.$unregisterTestController(controllerId)));

const info: ControllerInfo = { controller, collection, profiles: profiles };
const info: ControllerInfo = { controller, collection, profiles: profiles, extension };
this.controllers.set(controllerId, info);
disposable.add(toDisposable(() => this.controllers.delete(controllerId)));

Expand Down Expand Up @@ -310,7 +320,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
return {};
}

const { collection, profiles } = lookup;
const { collection, profiles, extension } = lookup;
const profile = profiles.get(req.profileId);
if (!profile) {
return {};
Expand Down Expand Up @@ -341,6 +351,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
const tracker = isStartControllerTests(req) && this.runTracker.prepareForMainThreadTestRun(
publicReq,
TestRunDto.fromInternal(req, lookup.collection),
extension,
token,
);

Expand Down Expand Up @@ -410,7 +421,12 @@ class TestRunTracker extends Disposable {
return this.dto.id;
}

constructor(private readonly dto: TestRunDto, private readonly proxy: MainThreadTestingShape, parentToken?: CancellationToken) {
constructor(
private readonly dto: TestRunDto,
private readonly proxy: MainThreadTestingShape,
private readonly extension: Readonly<IRelaxedExtensionDescription>,
parentToken?: CancellationToken,
) {
super();
this.cts = this._register(new CancellationTokenSource(parentToken));

Expand Down Expand Up @@ -460,6 +476,10 @@ class TestRunTracker extends Disposable {
? messages.map(Convert.TestMessage.from)
: [Convert.TestMessage.from(messages)];

if (converted.some(c => c.contextValue !== undefined)) {
checkProposedApiEnabled(this.extension, 'testMessageContextValue');
}

if (test.uri && test.range) {
const defaultLocation: ILocationDto = { range: Convert.Range.from(test.range), uri: test.uri };
for (const message of converted) {
Expand Down Expand Up @@ -606,8 +626,8 @@ export class TestRunCoordinator {
* `$startedExtensionTestRun` is not invoked. The run must eventually
* be cancelled manually.
*/
public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, token: CancellationToken) {
return this.getTracker(req, dto, token);
public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, extension: Readonly<IRelaxedExtensionDescription>, token: CancellationToken) {
return this.getTracker(req, dto, extension, token);
}

/**
Expand Down Expand Up @@ -635,7 +655,7 @@ export class TestRunCoordinator {
/**
* Implements the public `createTestRun` API.
*/
public createTestRun(controllerId: string, collection: ExtHostTestItemCollection, request: vscode.TestRunRequest, name: string | undefined, persist: boolean): vscode.TestRun {
public createTestRun(extension: IRelaxedExtensionDescription, controllerId: string, collection: ExtHostTestItemCollection, request: vscode.TestRunRequest, name: string | undefined, persist: boolean): vscode.TestRun {
const existing = this.tracked.get(request);
if (existing) {
return existing.createRun(name);
Expand All @@ -655,7 +675,7 @@ export class TestRunCoordinator {
persist
});

const tracker = this.getTracker(request, dto);
const tracker = this.getTracker(request, dto, extension);
tracker.onEnd(() => {
this.proxy.$finishedExtensionTestRun(dto.id);
tracker.dispose();
Expand All @@ -664,8 +684,8 @@ export class TestRunCoordinator {
return tracker.createRun(name);
}

private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, token?: CancellationToken) {
const tracker = new TestRunTracker(dto, this.proxy, token);
private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, extension: IRelaxedExtensionDescription, token?: CancellationToken) {
const tracker = new TestRunTracker(dto, this.proxy, extension, token);
this.tracked.set(req, tracker);
tracker.onEnd(() => this.tracked.delete(req));
return tracker;
Expand Down
6 changes: 4 additions & 2 deletions src/vs/workbench/api/common/extHostTypeConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1799,20 +1799,22 @@ export namespace NotebookRendererScript {
}

export namespace TestMessage {
export function from(message: vscode.TestMessage): ITestErrorMessage.Serialized {
export function from(message: vscode.TestMessage2): ITestErrorMessage.Serialized {
return {
message: MarkdownString.fromStrict(message.message) || '',
type: TestMessageType.Error,
expected: message.expectedOutput,
actual: message.actualOutput,
contextValue: message.contextValue,
location: message.location && ({ range: Range.from(message.location.range), uri: message.location.uri }),
};
}

export function to(item: ITestErrorMessage.Serialized): vscode.TestMessage {
export function to(item: ITestErrorMessage.Serialized): vscode.TestMessage2 {
const message = new types.TestMessage(typeof item.message === 'string' ? item.message : MarkdownString.to(item.message));
message.actualOutput = item.actual;
message.expectedOutput = item.expected;
message.contextValue = item.contextValue;
message.location = item.location ? location.to(item.location) : undefined;
return message;
}
Expand Down
6 changes: 2 additions & 4 deletions src/vs/workbench/api/common/extHostTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3884,15 +3884,13 @@ export class TestRunRequest implements vscode.TestRunRequest {
) { }
}

/** Back-compat for proposed API users */
@es5ClassCompat
export class TestRunRequest2 extends TestRunRequest { }

@es5ClassCompat
export class TestMessage implements vscode.TestMessage {
public expectedOutput?: string;
public actualOutput?: string;
public location?: vscode.Location;
/** proposed: */
public contextValue?: string;

public static diff(message: string | vscode.MarkdownString, expected: string, actual: string) {
const msg = new TestMessage(message);
Expand Down
Loading

0 comments on commit 2d9cc42

Please sign in to comment.