testing: add test actions to editor context menus (#167091)

* testing: add test actions to editor context menus

Fixes #130548

* add an efficient test by uri lookup, and editor context key
This commit is contained in:
Connor Peet 2022-11-23 15:32:57 -05:00 committed by GitHub
parent afe8a3475b
commit 387bab6993
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 195 additions and 94 deletions

View file

@ -18,6 +18,7 @@
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
"cli/target/**": true,
".build/**": true,
"out/**": true,
"out-build/**": true,

View file

@ -727,7 +727,7 @@ interface MirroredCollectionTestItem extends IncrementalTestCollectionItem {
depth: number;
}
class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollectionTestItem> {
class MirroredChangeCollector implements IncrementalChangeCollector<MirroredCollectionTestItem> {
private readonly added = new Set<MirroredCollectionTestItem>();
private readonly updated = new Set<MirroredCollectionTestItem>();
private readonly removed = new Set<MirroredCollectionTestItem>();
@ -739,20 +739,19 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
}
constructor(private readonly emitter: Emitter<vscode.TestsChangeEvent>) {
super();
}
/**
* @override
* @inheritdoc
*/
public override add(node: MirroredCollectionTestItem): void {
public add(node: MirroredCollectionTestItem): void {
this.added.add(node);
}
/**
* @override
* @inheritdoc
*/
public override update(node: MirroredCollectionTestItem): void {
public update(node: MirroredCollectionTestItem): void {
Object.assign(node.revived, Convert.TestItem.toPlain(node.item));
if (!this.added.has(node)) {
this.updated.add(node);
@ -760,9 +759,9 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
}
/**
* @override
* @inheritdoc
*/
public override remove(node: MirroredCollectionTestItem): void {
public remove(node: MirroredCollectionTestItem): void {
if (this.added.has(node)) {
this.added.delete(node);
return;
@ -780,7 +779,7 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
}
/**
* @override
* @inheritdoc
*/
public getChangeEvent(): vscode.TestsChangeEvent {
const { added, updated, removed } = this;
@ -791,7 +790,7 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
};
}
public override complete() {
public complete() {
if (!this.isEmpty) {
this.emitter.fire(this.getChangeEvent());
}

View file

@ -41,6 +41,8 @@ import { IEditorService } from 'vs/workbench/services/editor/common/editorServic
import { IPaneCompositePartService } from 'vs/workbench/services/panecomposite/browser/panecomposite';
import { getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { MessageController } from 'vs/editor/contrib/message/browser/messageController';
import { isCodeEditor } from 'vs/editor/browser/editorBrowser';
const category = Categories.Test;
@ -669,10 +671,15 @@ abstract class ExecuteTestAtCursor extends Action2 {
constructor(options: IAction2Options, protected readonly group: TestRunProfileBitset) {
super({
...options,
menu: {
menu: [{
id: MenuId.CommandPalette,
when: hasAnyTestProvider,
},
}, {
id: MenuId.EditorContext,
group: 'testing',
order: group === TestRunProfileBitset.Run ? ActionOrder.Run : ActionOrder.Debug,
when: ContextKeyExpr.and(TestingContextKeys.activeEditorHasTests, TestingContextKeys.capabilityToContextKey[group]),
}]
});
}
@ -749,6 +756,8 @@ abstract class ExecuteTestAtCursor extends Action2 {
group: this.group,
tests: bestNodes.length ? bestNodes : bestNodesBefore,
});
} else if (isCodeEditor(activeControl)) {
MessageController.get(activeControl)?.showMessage(localize('noTestsAtCursor', "No tests found here"), position);
}
}
}
@ -787,10 +796,16 @@ abstract class ExecuteTestsInCurrentFile extends Action2 {
constructor(options: IAction2Options, protected readonly group: TestRunProfileBitset) {
super({
...options,
menu: {
menu: [{
id: MenuId.CommandPalette,
when: TestingContextKeys.capabilityToContextKey[group].isEqualTo(true),
},
}, {
id: MenuId.EditorContext,
group: 'testing',
// add 0.1 to be after the "at cursor" commands
order: (group === TestRunProfileBitset.Run ? ActionOrder.Run : ActionOrder.Debug) + 0.1,
when: ContextKeyExpr.and(TestingContextKeys.activeEditorHasTests, TestingContextKeys.capabilityToContextKey[group]),
}],
});
}
@ -830,6 +845,10 @@ abstract class ExecuteTestsInCurrentFile extends Action2 {
});
}
if (isCodeEditor(control)) {
MessageController.get(control)?.showMessage(localize('noTestsInFile', "No tests found in this file"), position);
}
return undefined;
}
}

View file

@ -200,8 +200,8 @@ export class TestingDecorationService extends Disposable implements ITestingDeco
const map = model.changeDecorations(accessor => {
const newDecorations: ITestDecoration[] = [];
const runDecorations = new TestDecorations<{ line: number; id: ''; test: IncrementalTestCollectionItem; resultItem: TestResultItem | undefined }>();
for (const test of this.testService.collection.all) {
if (!test.item.range || test.item.uri?.toString() !== uriStr) {
for (const test of this.testService.collection.getNodeByUrl(model.uri)) {
if (!test.item.range) {
continue;
}

View file

@ -5,10 +5,14 @@
import { Emitter } from 'vs/base/common/event';
import { Iterable } from 'vs/base/common/iterator';
import { AbstractIncrementalTestCollection, IncrementalTestCollectionItem, InternalTestItem, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testTypes';
import { AbstractIncrementalTestCollection, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testTypes';
import { IMainThreadTestCollection } from 'vs/workbench/contrib/testing/common/testService';
import { ResourceMap } from 'vs/base/common/map';
import { URI } from 'vs/base/common/uri';
export class MainThreadTestCollection extends AbstractIncrementalTestCollection<IncrementalTestCollectionItem> implements IMainThreadTestCollection {
private testsByUrl = new ResourceMap<Set<IncrementalTestCollectionItem>>();
private busyProvidersChangeEmitter = new Emitter<number>();
private expandPromises = new WeakMap<IncrementalTestCollectionItem, {
pendingLvl: number;
@ -78,6 +82,13 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection<
return this.items.get(id);
}
/**
* @inheritdoc
*/
public getNodeByUrl(uri: URI): Iterable<IncrementalTestCollectionItem> {
return this.testsByUrl.get(uri) || Iterable.empty();
}
/**
* @inheritdoc
*/
@ -138,6 +149,40 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection<
return { ...internal, children: new Set() };
}
private readonly changeCollector: IncrementalChangeCollector<IncrementalTestCollectionItem> = {
add: node => {
if (!node.item.uri) {
return;
}
const s = this.testsByUrl.get(node.item.uri);
if (!s) {
this.testsByUrl.set(node.item.uri, new Set([node]));
} else {
s.add(node);
}
},
remove: node => {
if (!node.item.uri) {
return;
}
const s = this.testsByUrl.get(node.item.uri);
if (!s) {
return;
}
s.delete(node);
if (s.size === 0) {
this.testsByUrl.delete(node.item.uri);
}
},
};
protected override createChangeCollector(): IncrementalChangeCollector<IncrementalTestCollectionItem> {
return this.changeCollector;
}
private *getIterator() {
const queue = [this.rootIds];
while (queue.length) {

View file

@ -61,6 +61,12 @@ export interface IMainThreadTestCollection extends AbstractIncrementalTestCollec
*/
getNodeById(id: string): IncrementalTestCollectionItem | undefined;
/**
* Gets all tests that have the given URL. Tests returned from this
* method are *not* in any particular order.
*/
getNodeByUrl(uri: URI): Iterable<IncrementalTestCollectionItem>;
/**
* Requests that children be revealed for the given test. "Levels" may
* be infinite.

View file

@ -41,6 +41,8 @@ export class TestService extends Disposable implements ITestService {
private readonly providerCount: IContextKey<number>;
private readonly canRefreshTests: IContextKey<boolean>;
private readonly isRefreshingTests: IContextKey<boolean>;
private readonly activeEditorHasTests: IContextKey<boolean>;
/**
* Cancellation for runs requested by the user being managed by the UI.
* Test runs initiated by extensions are not included here.
@ -97,6 +99,9 @@ export class TestService extends Disposable implements ITestService {
this.providerCount = TestingContextKeys.providerCount.bindTo(contextKeyService);
this.canRefreshTests = TestingContextKeys.canRefreshTests.bindTo(contextKeyService);
this.isRefreshingTests = TestingContextKeys.isRefreshingTests.bindTo(contextKeyService);
this.activeEditorHasTests = TestingContextKeys.activeEditorHasTests.bindTo(contextKeyService);
this._register(editorService.onDidActiveEditorChange(() => this.updateEditorContextKeys()));
}
/**
@ -228,6 +233,7 @@ export class TestService extends Disposable implements ITestService {
public publishDiff(_controllerId: string, diff: TestsDiff) {
this.willProcessDiffEmitter.fire(diff);
this.collection.apply(diff);
this.updateEditorContextKeys();
this.didProcessDiffEmitter.fire(diff);
}
@ -313,6 +319,15 @@ export class TestService extends Disposable implements ITestService {
return disposable;
}
private updateEditorContextKeys() {
const uri = this.editorService.activeEditor?.resource;
if (uri) {
this.activeEditorHasTests.set(!Iterable.isEmpty(this.collection.getNodeByUrl(uri)));
} else {
this.activeEditorHasTests.set(false);
}
}
private async saveAllBeforeTest(req: ResolvedTestRunRequest, configurationService: IConfigurationService = this.configurationService, editorService: IEditorService = this.editorService): Promise<void> {
if (req.isUiTriggered === false) {
return;

View file

@ -626,26 +626,26 @@ export interface IncrementalTestCollectionItem extends InternalTestItem {
* and called with diff changes as they're applied. This is used in the
* ext host to create a cohesive change event from a diff.
*/
export class IncrementalChangeCollector<T> {
export interface IncrementalChangeCollector<T> {
/**
* A node was added.
*/
public add(node: T): void { }
add?(node: T): void;
/**
* A node in the collection was updated.
*/
public update(node: T): void { }
update?(node: T): void;
/**
* A node was removed.
*/
public remove(node: T, isNestedOperation: boolean): void { }
remove?(node: T, isNestedOperation: boolean): void;
/**
* Called when the diff has been applied.
*/
public complete(): void { }
complete?(): void;
}
/**
@ -687,80 +687,17 @@ export abstract class AbstractIncrementalTestCollection<T extends IncrementalTes
for (const op of diff) {
switch (op.op) {
case TestDiffOpType.Add: {
const internalTest = InternalTestItem.deserialize(op.item);
const parentId = TestId.parentId(internalTest.item.extId)?.toString();
if (!parentId) {
const created = this.createItem(internalTest);
this.roots.add(created);
this.items.set(internalTest.item.extId, created);
changes.add(created);
} else if (this.items.has(parentId)) {
const parent = this.items.get(parentId)!;
parent.children.add(internalTest.item.extId);
const created = this.createItem(internalTest, parent);
this.items.set(internalTest.item.extId, created);
changes.add(created);
}
if (internalTest.expand === TestItemExpandState.BusyExpanding) {
this.busyControllerCount++;
}
case TestDiffOpType.Add:
this.add(InternalTestItem.deserialize(op.item), changes);
break;
}
case TestDiffOpType.Update: {
const patch = ITestItemUpdate.deserialize(op.item);
const existing = this.items.get(patch.extId);
if (!existing) {
break;
}
if (patch.expand !== undefined) {
if (existing.expand === TestItemExpandState.BusyExpanding) {
this.busyControllerCount--;
}
if (patch.expand === TestItemExpandState.BusyExpanding) {
this.busyControllerCount++;
}
}
applyTestItemUpdate(existing, patch);
changes.update(existing);
case TestDiffOpType.Update:
this.update(ITestItemUpdate.deserialize(op.item), changes);
break;
}
case TestDiffOpType.Remove: {
const toRemove = this.items.get(op.itemId);
if (!toRemove) {
break;
}
const parentId = TestId.parentId(toRemove.item.extId)?.toString();
if (parentId) {
const parent = this.items.get(parentId)!;
parent.children.delete(toRemove.item.extId);
} else {
this.roots.delete(toRemove);
}
const queue: Iterable<string>[] = [[op.itemId]];
while (queue.length) {
for (const itemId of queue.pop()!) {
const existing = this.items.get(itemId);
if (existing) {
queue.push(existing.children);
this.items.delete(itemId);
changes.remove(existing, existing !== toRemove);
if (existing.expand === TestItemExpandState.BusyExpanding) {
this.busyControllerCount--;
}
}
}
}
case TestDiffOpType.Remove:
this.remove(op.itemId, changes);
break;
}
case TestDiffOpType.Retire:
this.retireTest(op.itemId);
@ -780,7 +717,85 @@ export abstract class AbstractIncrementalTestCollection<T extends IncrementalTes
}
}
changes.complete();
changes.complete?.();
}
protected add(item: InternalTestItem, changes: IncrementalChangeCollector<T>
) {
const parentId = TestId.parentId(item.item.extId)?.toString();
let created: T;
if (!parentId) {
created = this.createItem(item);
this.roots.add(created);
this.items.set(item.item.extId, created);
} else if (this.items.has(parentId)) {
const parent = this.items.get(parentId)!;
parent.children.add(item.item.extId);
created = this.createItem(item, parent);
this.items.set(item.item.extId, created);
} else {
console.error(`Test with unknown parent ID: ${JSON.stringify(item)}`);
return;
}
changes.add?.(created);
if (item.expand === TestItemExpandState.BusyExpanding) {
this.busyControllerCount++;
}
return created;
}
protected update(patch: ITestItemUpdate, changes: IncrementalChangeCollector<T>
) {
const existing = this.items.get(patch.extId);
if (!existing) {
return;
}
if (patch.expand !== undefined) {
if (existing.expand === TestItemExpandState.BusyExpanding) {
this.busyControllerCount--;
}
if (patch.expand === TestItemExpandState.BusyExpanding) {
this.busyControllerCount++;
}
}
applyTestItemUpdate(existing, patch);
changes.update?.(existing);
return existing;
}
protected remove(itemId: string, changes: IncrementalChangeCollector<T>) {
const toRemove = this.items.get(itemId);
if (!toRemove) {
return;
}
const parentId = TestId.parentId(toRemove.item.extId)?.toString();
if (parentId) {
const parent = this.items.get(parentId)!;
parent.children.delete(toRemove.item.extId);
} else {
this.roots.delete(toRemove);
}
const queue: Iterable<string>[] = [[itemId]];
while (queue.length) {
for (const itemId of queue.pop()!) {
const existing = this.items.get(itemId);
if (existing) {
queue.push(existing.children);
this.items.delete(itemId);
changes.remove?.(existing, existing !== toRemove);
if (existing.expand === TestItemExpandState.BusyExpanding) {
this.busyControllerCount--;
}
}
}
}
}
/**
@ -802,8 +817,8 @@ export abstract class AbstractIncrementalTestCollection<T extends IncrementalTes
/**
* Called before a diff is applied to create a new change collector.
*/
protected createChangeCollector() {
return new IncrementalChangeCollector<T>();
protected createChangeCollector(): IncrementalChangeCollector<T> {
return {};
}
/**

View file

@ -17,6 +17,7 @@ export namespace TestingContextKeys {
export const hasCoverableTests = new RawContextKey('testing.hasCoverableTests', false, { type: 'boolean', description: localize('testing.hasCoverableTests', 'Indicates whether any test controller has registered a coverage configuration') });
export const hasNonDefaultProfile = new RawContextKey('testing.hasNonDefaultProfile', false, { type: 'boolean', description: localize('testing.hasNonDefaultConfig', 'Indicates whether any test controller has registered a non-default configuration') });
export const hasConfigurableProfile = new RawContextKey('testing.hasConfigurableProfile', false, { type: 'boolean', description: localize('testing.hasConfigurableConfig', 'Indicates whether any test configuration can be configured') });
export const activeEditorHasTests = new RawContextKey('testing.activeEditorHasTests', false, { type: 'boolean', description: localize('testing.activeEditorHasTests', 'Indicates whether any tests are present in the current editor') });
export const capabilityToContextKey: { [K in TestRunProfileBitset]: RawContextKey<boolean> } = {
[TestRunProfileBitset.Run]: hasRunnableTests,