testing: commands to run tests at current cursor and in file

Refs https://github.com/microsoft/vscode/issues/116589
This commit is contained in:
Connor Peet 2021-02-12 16:08:57 -08:00
parent 07e3bcf7ea
commit a0e0324a8d
No known key found for this signature in database
GPG key ID: CF8FD2EA0DBC61BD
9 changed files with 223 additions and 28 deletions

View file

@ -6,6 +6,7 @@
import { CancellationToken } from 'vs/base/common/cancellation';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { URI, UriComponents } from 'vs/base/common/uri';
import { Range } from 'vs/editor/common/core/range';
import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers';
import { getTestSubscriptionKey, ITestState, RunTestsRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { ITestResultService, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResultService';
@ -18,6 +19,7 @@ const reviveDiff = (diff: TestsDiff) => {
const item = entry[1];
if (item.item.location) {
item.item.location.uri = URI.revive(item.item.location.uri);
item.item.location.range = Range.lift(item.item.location.range);
}
}
}
@ -71,6 +73,7 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh
for (const message of state.messages) {
if (message.location) {
message.location.uri = URI.revive(message.location.uri);
message.location.range = Range.lift(message.location.range);
}
}

View file

@ -1507,7 +1507,7 @@ export namespace TestState {
severity: message.severity,
expectedOutput: message.expectedOutput,
actualOutput: message.actualOutput,
location: message.location ? location.from(message.location) : undefined,
location: message.location ? location.from(message.location) as any : undefined,
})) ?? [],
};
}
@ -1536,7 +1536,7 @@ export namespace TestItem {
return {
extId: item.id ?? (parentExtId ? `${parentExtId}\0${item.label}` : item.label),
label: item.label,
location: item.location ? location.from(item.location) : undefined,
location: item.location ? location.from(item.location) as any : undefined,
debuggable: item.debuggable ?? false,
description: item.description,
runnable: item.runnable ?? true,

View file

@ -6,25 +6,22 @@
import { findFirstInSorted } from 'vs/base/common/arrays';
import { URI } from 'vs/base/common/uri';
import { Position } from 'vs/editor/common/core/position';
import { Location as ModeLocation } from 'vs/editor/common/modes';
import { Range } from 'vs/editor/common/core/range';
import { IRichLocation } from 'vs/workbench/contrib/testing/common/testCollection';
export const locationsEqual = (a: ModeLocation | undefined, b: ModeLocation | undefined) => {
export const locationsEqual = (a: IRichLocation | undefined, b: IRichLocation | undefined) => {
if (a === undefined || b === undefined) {
return b === a;
}
return a.uri.toString() === b.uri.toString()
&& a.range.startLineNumber === b.range.startLineNumber
&& a.range.startColumn === b.range.startColumn
&& a.range.endLineNumber === b.range.endLineNumber
&& a.range.endColumn === b.range.endColumn;
return a.uri.toString() === b.uri.toString() && a.range.equalsRange(b.range);
};
/**
* Stores and looks up test-item-like-objects by their uri/range. Used to
* implement the 'reveal' action efficiently.
*/
export class TestLocationStore<T extends { location?: ModeLocation, depth: number }> {
export class TestLocationStore<T extends { location?: IRichLocation, depth: number }> {
private readonly itemsByUri = new Map<string, T[]>();
public hasTestInDocument(uri: URI) {
@ -39,12 +36,7 @@ export class TestLocationStore<T extends { location?: ModeLocation, depth: numbe
return tests.find(test => {
const range = test.location?.range;
return range
&& new Position(range.startLineNumber, range.startColumn).isBeforeOrEqual(position)
&& position.isBeforeOrEqual(new Position(
range.endLineNumber ?? range.startLineNumber,
range.endColumn ?? range.startColumn,
));
return range && Range.lift(range).containsPosition(position);
});
}

View file

@ -26,8 +26,9 @@ import { InternalTestItem, TestIdWithProvider } from 'vs/workbench/contrib/testi
import { ITestingAutoRun } from 'vs/workbench/contrib/testing/common/testingAutoRun';
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
import { ITestResult, ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService';
import { ITestService, waitForAllRoots } from 'vs/workbench/contrib/testing/common/testService';
import { ITestService, waitForAllRoots, waitForAllTests } from 'vs/workbench/contrib/testing/common/testService';
import { IWorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
const category = localize('testing.category', 'Test');
@ -94,7 +95,7 @@ export class RunAction extends Action {
}
}
abstract class RunOrDebugAction extends ViewAction<TestingExplorerView> {
abstract class RunOrDebugSelectedAction extends ViewAction<TestingExplorerView> {
constructor(id: string, title: string, icon: ThemeIcon, private readonly debug: boolean) {
super({
id,
@ -103,6 +104,7 @@ abstract class RunOrDebugAction extends ViewAction<TestingExplorerView> {
viewId: Testing.ExplorerViewId,
f1: true,
category,
precondition: FocusedViewContext.isEqualTo(Testing.ExplorerViewId),
});
}
@ -143,7 +145,7 @@ abstract class RunOrDebugAction extends ViewAction<TestingExplorerView> {
protected abstract filter(item: InternalTestItem): boolean;
}
export class RunSelectedAction extends RunOrDebugAction {
export class RunSelectedAction extends RunOrDebugSelectedAction {
constructor(
) {
super(
@ -162,7 +164,7 @@ export class RunSelectedAction extends RunOrDebugAction {
}
}
export class DebugSelectedAction extends RunOrDebugAction {
export class DebugSelectedAction extends RunOrDebugSelectedAction {
constructor() {
super(
'testing.debugSelected',
@ -502,3 +504,166 @@ export class ToggleAutoRun extends Action2 {
accessor.get(ITestingAutoRun).toggle();
}
}
abstract class RunOrDebugAtCursor extends Action2 {
/**
* @override
*/
public async run(accessor: ServicesAccessor) {
const control = accessor.get(IEditorService).activeTextEditorControl;
const position = control?.getPosition();
const model = control?.getModel();
if (!position || !model || !('uri' in model)) {
return;
}
const testService = accessor.get(ITestService);
const collection = testService.subscribeToDiffs(ExtHostTestingResource.TextDocument, model.uri);
let bestDepth = -1;
let bestNode: InternalTestItem | undefined;
try {
await waitForAllTests(collection.object);
const queue: [depth: number, nodes: Iterable<string>][] = [[0, collection.object.rootIds]];
while (queue.length > 0) {
const [depth, candidates] = queue.pop()!;
for (const id of candidates) {
const candidate = collection.object.getNodeById(id);
if (candidate) {
if (depth > bestDepth && this.filter(candidate) && candidate.item.location?.range.containsPosition(position)) {
bestDepth = depth;
bestNode = candidate;
}
queue.push([depth + 1, candidate.children]);
}
}
}
if (bestNode) {
await this.runTest(testService, bestNode);
}
} finally {
collection.dispose();
}
}
protected abstract filter(node: InternalTestItem): boolean;
protected abstract runTest(service: ITestService, node: InternalTestItem): Promise<ITestResult>;
}
export class RunAtCursor extends RunOrDebugAtCursor {
constructor() {
super({
id: 'testing.runAtCursor',
title: localize('testing.runAtCursor', "Run Test at Cursor"),
f1: true,
category,
});
}
protected filter(node: InternalTestItem): boolean {
return node.item.runnable;
}
protected runTest(service: ITestService, node: InternalTestItem): Promise<ITestResult> {
return service.runTests({ debug: false, tests: [{ testId: node.id, providerId: node.providerId }] });
}
}
export class DebugAtCursor extends RunOrDebugAtCursor {
constructor() {
super({
id: 'testing.debugAtCursor',
title: localize('testing.debugAtCursor', "Debug Test at Cursor"),
f1: true,
category,
});
}
protected filter(node: InternalTestItem): boolean {
return node.item.debuggable;
}
protected runTest(service: ITestService, node: InternalTestItem): Promise<ITestResult> {
return service.runTests({ debug: true, tests: [{ testId: node.id, providerId: node.providerId }] });
}
}
abstract class RunOrDebugCurrentFile extends Action2 {
/**
* @override
*/
public async run(accessor: ServicesAccessor) {
const control = accessor.get(IEditorService).activeTextEditorControl;
const position = control?.getPosition();
const model = control?.getModel();
if (!position || !model || !('uri' in model)) {
return;
}
const testService = accessor.get(ITestService);
const collection = testService.subscribeToDiffs(ExtHostTestingResource.TextDocument, model.uri);
try {
await waitForAllTests(collection.object);
const roots = [...collection.object.rootIds]
.map(r => collection.object.getNodeById(r))
.filter(isDefined)
.filter(n => this.filter(n));
if (roots.length) {
await this.runTest(testService, roots);
}
} finally {
collection.dispose();
}
}
protected abstract filter(node: InternalTestItem): boolean;
protected abstract runTest(service: ITestService, node: InternalTestItem[]): Promise<ITestResult>;
}
export class RunCurrentFile extends RunOrDebugCurrentFile {
constructor() {
super({
id: 'testing.runCurrentFile',
title: localize('testing.runCurrentFile', "Run Tests in Current File"),
f1: true,
category,
});
}
protected filter(node: InternalTestItem): boolean {
return node.item.runnable;
}
protected runTest(service: ITestService, nodes: InternalTestItem[]): Promise<ITestResult> {
return service.runTests({ debug: false, tests: nodes.map(node => ({ testId: node.id, providerId: node.providerId })) });
}
}
export class DebugCurrentFile extends RunOrDebugCurrentFile {
constructor() {
super({
id: 'testing.debugCurrentFile',
title: localize('testing.debugCurrentFile', "Debug Tests in Current File"),
f1: true,
category,
});
}
protected filter(node: InternalTestItem): boolean {
return node.item.debuggable;
}
protected runTest(service: ITestService, nodes: InternalTestItem[]): Promise<ITestResult> {
return service.runTests({ debug: true, tests: nodes.map(node => ({ testId: node.id, providerId: node.providerId })) });
}
}

View file

@ -97,6 +97,10 @@ registerAction2(Action.DebugAllAction);
registerAction2(Action.EditFocusedTest);
registerAction2(Action.ClearTestResultsAction);
registerAction2(Action.ToggleAutoRun);
registerAction2(Action.DebugAtCursor);
registerAction2(Action.RunAtCursor);
registerAction2(Action.DebugCurrentFile);
registerAction2(Action.RunCurrentFile);
registerAction2(CloseTestPeek);
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingContentProvider, LifecyclePhase.Eventually);

View file

@ -14,7 +14,6 @@ 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';
@ -27,7 +26,7 @@ import { BREAKPOINT_EDITOR_CONTRIBUTION_ID, IBreakpointEditorContribution } from
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 { IncrementalTestCollectionItem, IRichLocation, ITestMessage } from 'vs/workbench/contrib/testing/common/testCollection';
import { buildTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri';
import { ITestResultService, TestResultItem } from 'vs/workbench/contrib/testing/common/testResultService';
import { IMainThreadTestCollection, ITestService } from 'vs/workbench/contrib/testing/common/testService';
@ -144,7 +143,7 @@ interface ITestDecoration extends IDisposable {
click(e: IEditorMouseEvent): boolean;
}
const hasValidLocation = <T extends { location?: ModeLocation }>(editorUri: URI, t: T): t is T & { location: ModeLocation } =>
const hasValidLocation = <T extends { location?: IRichLocation }>(editorUri: URI, t: T): t is T & { location: IRichLocation } =>
t.location?.uri.toString() === editorUri.toString();
const firstLineRange = (originalRange: IRange) => ({
@ -170,7 +169,7 @@ class RunTestDecoration extends Disposable implements ITestDecoration {
constructor(
private readonly test: IncrementalTestCollectionItem,
private readonly collection: IMainThreadTestCollection,
private readonly location: ModeLocation,
private readonly location: IRichLocation,
private readonly editor: ICodeEditor,
stateItem: TestResultItem | undefined,
@ITestService private readonly testService: ITestService,
@ -282,7 +281,7 @@ class TestMessageDecoration implements ITestDecoration {
constructor(
{ message, severity }: ITestMessage,
private readonly messageUri: URI,
location: ModeLocation,
location: IRichLocation,
private readonly editor: ICodeEditor,
@ICodeEditorService private readonly editorService: ICodeEditorService,
@IThemeService themeService: IThemeService,

View file

@ -5,7 +5,7 @@
import { IMarkdownString } from 'vs/base/common/htmlContent';
import { URI } from 'vs/base/common/uri';
import { Location as ModeLocation } from 'vs/editor/common/modes';
import { Range } from 'vs/editor/common/core/range';
import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol';
import { TestMessageSeverity, TestRunState } from 'vs/workbench/api/common/extHostTypes';
@ -33,12 +33,20 @@ export interface RunTestForProviderRequest {
debug: boolean;
}
/**
* Location with a fully-instantiated Range and URI.
*/
export interface IRichLocation {
range: Range;
uri: URI;
}
export interface ITestMessage {
message: string | IMarkdownString;
severity: TestMessageSeverity | undefined;
expectedOutput: string | undefined;
actualOutput: string | undefined;
location: ModeLocation | undefined;
location: IRichLocation | undefined;
}
export interface ITestState {
@ -55,7 +63,7 @@ export interface ITestItem {
extId: string;
label: string;
children?: never;
location: ModeLocation | undefined;
location: IRichLocation | undefined;
description: string | undefined;
runnable: boolean;
debuggable: boolean;

View file

@ -6,6 +6,7 @@
import { Emitter, Event } from 'vs/base/common/event';
import { URI } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
import { Range } from 'vs/editor/common/core/range';
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
@ -426,9 +427,15 @@ class HydratedTestResult implements ITestResult {
for (const item of serialized.items) {
const cast: TestResultItem = { ...item, retired: true, children: new Set(item.children) };
if (cast.item.location) {
cast.item.location.uri = URI.revive(cast.item.location.uri);
cast.item.location.range = Range.lift(cast.item.location.range);
}
for (const message of cast.state.messages) {
if (message.location) {
message.location.uri = URI.revive(message.location.uri);
message.location.range = Range.lift(message.location.range);
}
}

View file

@ -74,6 +74,23 @@ export const waitForAllRoots = (collection: IMainThreadTestCollection, timeout =
}).finally(() => listener.dispose());
};
export const waitForAllTests = (collection: IMainThreadTestCollection, timeout = 3000) => {
if (collection.busyProviders === 0) {
return Promise.resolve();
}
let listener: IDisposable;
return new Promise<void>(resolve => {
listener = collection.onBusyProvidersChange(count => {
if (count === 0) {
resolve();
}
});
setTimeout(resolve, timeout);
}).finally(() => listener.dispose());
};
export interface ITestService {
readonly _serviceBrand: undefined;
readonly onShouldSubscribe: Event<{ resource: ExtHostTestingResource, uri: URI; }>;