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": { "search.exclude": {
"**/node_modules": true, "**/node_modules": true,
"**/bower_components": true, "**/bower_components": true,
"cli/target/**": true,
".build/**": true, ".build/**": true,
"out/**": true, "out/**": true,
"out-build/**": true, "out-build/**": true,

View file

@ -727,7 +727,7 @@ interface MirroredCollectionTestItem extends IncrementalTestCollectionItem {
depth: number; depth: number;
} }
class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollectionTestItem> { class MirroredChangeCollector implements IncrementalChangeCollector<MirroredCollectionTestItem> {
private readonly added = new Set<MirroredCollectionTestItem>(); private readonly added = new Set<MirroredCollectionTestItem>();
private readonly updated = new Set<MirroredCollectionTestItem>(); private readonly updated = new Set<MirroredCollectionTestItem>();
private readonly removed = 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>) { 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); 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)); Object.assign(node.revived, Convert.TestItem.toPlain(node.item));
if (!this.added.has(node)) { if (!this.added.has(node)) {
this.updated.add(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)) { if (this.added.has(node)) {
this.added.delete(node); this.added.delete(node);
return; return;
@ -780,7 +779,7 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
} }
/** /**
* @override * @inheritdoc
*/ */
public getChangeEvent(): vscode.TestsChangeEvent { public getChangeEvent(): vscode.TestsChangeEvent {
const { added, updated, removed } = this; const { added, updated, removed } = this;
@ -791,7 +790,7 @@ class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollect
}; };
} }
public override complete() { public complete() {
if (!this.isEmpty) { if (!this.isEmpty) {
this.emitter.fire(this.getChangeEvent()); 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 { IPaneCompositePartService } from 'vs/workbench/services/panecomposite/browser/panecomposite';
import { getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration'; import { getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration';
import { IConfigurationService } from 'vs/platform/configuration/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; const category = Categories.Test;
@ -669,10 +671,15 @@ abstract class ExecuteTestAtCursor extends Action2 {
constructor(options: IAction2Options, protected readonly group: TestRunProfileBitset) { constructor(options: IAction2Options, protected readonly group: TestRunProfileBitset) {
super({ super({
...options, ...options,
menu: { menu: [{
id: MenuId.CommandPalette, id: MenuId.CommandPalette,
when: hasAnyTestProvider, 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, group: this.group,
tests: bestNodes.length ? bestNodes : bestNodesBefore, 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) { constructor(options: IAction2Options, protected readonly group: TestRunProfileBitset) {
super({ super({
...options, ...options,
menu: { menu: [{
id: MenuId.CommandPalette, id: MenuId.CommandPalette,
when: TestingContextKeys.capabilityToContextKey[group].isEqualTo(true), 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; return undefined;
} }
} }

View file

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

View file

@ -5,10 +5,14 @@
import { Emitter } from 'vs/base/common/event'; import { Emitter } from 'vs/base/common/event';
import { Iterable } from 'vs/base/common/iterator'; 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 { 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 { export class MainThreadTestCollection extends AbstractIncrementalTestCollection<IncrementalTestCollectionItem> implements IMainThreadTestCollection {
private testsByUrl = new ResourceMap<Set<IncrementalTestCollectionItem>>();
private busyProvidersChangeEmitter = new Emitter<number>(); private busyProvidersChangeEmitter = new Emitter<number>();
private expandPromises = new WeakMap<IncrementalTestCollectionItem, { private expandPromises = new WeakMap<IncrementalTestCollectionItem, {
pendingLvl: number; pendingLvl: number;
@ -78,6 +82,13 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection<
return this.items.get(id); return this.items.get(id);
} }
/**
* @inheritdoc
*/
public getNodeByUrl(uri: URI): Iterable<IncrementalTestCollectionItem> {
return this.testsByUrl.get(uri) || Iterable.empty();
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -138,6 +149,40 @@ export class MainThreadTestCollection extends AbstractIncrementalTestCollection<
return { ...internal, children: new Set() }; 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() { private *getIterator() {
const queue = [this.rootIds]; const queue = [this.rootIds];
while (queue.length) { while (queue.length) {

View file

@ -61,6 +61,12 @@ export interface IMainThreadTestCollection extends AbstractIncrementalTestCollec
*/ */
getNodeById(id: string): IncrementalTestCollectionItem | undefined; 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 * Requests that children be revealed for the given test. "Levels" may
* be infinite. * be infinite.

View file

@ -41,6 +41,8 @@ export class TestService extends Disposable implements ITestService {
private readonly providerCount: IContextKey<number>; private readonly providerCount: IContextKey<number>;
private readonly canRefreshTests: IContextKey<boolean>; private readonly canRefreshTests: IContextKey<boolean>;
private readonly isRefreshingTests: IContextKey<boolean>; private readonly isRefreshingTests: IContextKey<boolean>;
private readonly activeEditorHasTests: IContextKey<boolean>;
/** /**
* Cancellation for runs requested by the user being managed by the UI. * Cancellation for runs requested by the user being managed by the UI.
* Test runs initiated by extensions are not included here. * 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.providerCount = TestingContextKeys.providerCount.bindTo(contextKeyService);
this.canRefreshTests = TestingContextKeys.canRefreshTests.bindTo(contextKeyService); this.canRefreshTests = TestingContextKeys.canRefreshTests.bindTo(contextKeyService);
this.isRefreshingTests = TestingContextKeys.isRefreshingTests.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) { public publishDiff(_controllerId: string, diff: TestsDiff) {
this.willProcessDiffEmitter.fire(diff); this.willProcessDiffEmitter.fire(diff);
this.collection.apply(diff); this.collection.apply(diff);
this.updateEditorContextKeys();
this.didProcessDiffEmitter.fire(diff); this.didProcessDiffEmitter.fire(diff);
} }
@ -313,6 +319,15 @@ export class TestService extends Disposable implements ITestService {
return disposable; 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> { private async saveAllBeforeTest(req: ResolvedTestRunRequest, configurationService: IConfigurationService = this.configurationService, editorService: IEditorService = this.editorService): Promise<void> {
if (req.isUiTriggered === false) { if (req.isUiTriggered === false) {
return; 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 * 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. * ext host to create a cohesive change event from a diff.
*/ */
export class IncrementalChangeCollector<T> { export interface IncrementalChangeCollector<T> {
/** /**
* A node was added. * A node was added.
*/ */
public add(node: T): void { } add?(node: T): void;
/** /**
* A node in the collection was updated. * A node in the collection was updated.
*/ */
public update(node: T): void { } update?(node: T): void;
/** /**
* A node was removed. * A node was removed.
*/ */
public remove(node: T, isNestedOperation: boolean): void { } remove?(node: T, isNestedOperation: boolean): void;
/** /**
* Called when the diff has been applied. * 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) { for (const op of diff) {
switch (op.op) { switch (op.op) {
case TestDiffOpType.Add: { case TestDiffOpType.Add:
const internalTest = InternalTestItem.deserialize(op.item); this.add(InternalTestItem.deserialize(op.item), changes);
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++;
}
break; break;
}
case TestDiffOpType.Update: { case TestDiffOpType.Update:
const patch = ITestItemUpdate.deserialize(op.item); this.update(ITestItemUpdate.deserialize(op.item), changes);
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);
break; break;
}
case TestDiffOpType.Remove: { case TestDiffOpType.Remove:
const toRemove = this.items.get(op.itemId); this.remove(op.itemId, changes);
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--;
}
}
}
}
break; break;
}
case TestDiffOpType.Retire: case TestDiffOpType.Retire:
this.retireTest(op.itemId); 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. * Called before a diff is applied to create a new change collector.
*/ */
protected createChangeCollector() { protected createChangeCollector(): IncrementalChangeCollector<T> {
return new 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 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 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 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> } = { export const capabilityToContextKey: { [K in TestRunProfileBitset]: RawContextKey<boolean> } = {
[TestRunProfileBitset.Run]: hasRunnableTests, [TestRunProfileBitset.Run]: hasRunnableTests,