Skip to content

Commit 64b7e22

Browse files
committed
Support multi-root folders in test explorer
1 parent 72369e2 commit 64b7e22

File tree

13 files changed

+217
-188
lines changed

13 files changed

+217
-188
lines changed

news/1 Enhancements/4268.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support mult-root workspaces in test explorer.

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,21 @@
679679
],
680680
"view/item/context": [
681681
{
682+
"command": "python.runtests",
683+
"when": "view == python_tests && viewItem == testWorkspaceFolder && !busyTests",
684+
"group": "inline@0"
685+
},
686+
{
687+
"command": "python.debugtests",
688+
"when": "view == python_tests && viewItem == testWorkspaceFolder && !busyTests",
689+
"group": "inline@1"
690+
},
691+
{
692+
"command": "python.discoverTests",
693+
"when": "view == python_tests && viewItem == testWorkspaceFolder && !busyTests",
694+
"group": "inline@2"
695+
},
696+
{
682697
"command": "python.openTestNodeInEditor",
683698
"when": "view == python_tests && viewItem == testFunction",
684699
"group": "inline@2"

src/client/unittests/common/testUtils.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { IApplicationShell, ICommandManager } from '../../common/application/typ
55
import * as constants from '../../common/constants';
66
import { IUnitTestSettings, Product } from '../../common/types';
77
import { IServiceContainer } from '../../ioc/types';
8-
import { TestDataItem } from '../types';
8+
import { TestDataItem, TestWorkspaceFolder } from '../types';
99
import { CommandSource } from './constants';
1010
import { TestFlatteningVisitor } from './testVisitors/flatteningVisitor';
1111
import {
@@ -198,8 +198,7 @@ export class TestsHelper implements ITestsHelper {
198198
}
199199

200200
// Just return this as a test file.
201-
// tslint:disable-next-line:no-object-literal-type-assertion
202-
return <TestsToRun>{ testFile: [{ name: name, nameToRun: name, functions: [], suites: [], xmlName: name, fullPath: '', time: 0 }] };
201+
return { testFile: [{ resource: Uri.file(rootDirectory), name: name, nameToRun: name, functions: [], suites: [], xmlName: name, fullPath: '', time: 0 }] };
203202
}
204203
public displayTestErrorMessage(message: string) {
205204
this.appShell.showErrorMessage(message, constants.Button_Text_Tests_View_Output).then(action => {
@@ -246,6 +245,9 @@ export class TestsHelper implements ITestsHelper {
246245
}
247246

248247
export function getTestType(test: TestDataItem): TestType {
248+
if (test instanceof TestWorkspaceFolder) {
249+
return TestType.testWorkspaceFolder;
250+
}
249251
if (getTestFile(test)) {
250252
return TestType.testFile;
251253
}

src/client/unittests/common/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export enum TestType {
4343
testFile = 'testFile',
4444
testFolder = 'testFolder',
4545
testSuite = 'testSuite',
46-
testFunction = 'testFunction'
46+
testFunction = 'testFunction',
47+
testWorkspaceFolder = 'testWorkspaceFolder'
4748
}
4849
export type TestFile = TestResult & {
4950
resource: Uri;

src/client/unittests/explorer/testTreeViewItem.ts

Lines changed: 11 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,15 @@
55

66
// tslint:disable:max-classes-per-file
77

8-
import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, WorkspaceFolder } from 'vscode';
8+
import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode';
99
import { Commands } from '../../common/constants';
1010
import { getIcon } from '../../common/utils/icons';
1111
import { noop } from '../../common/utils/misc';
1212
import { Icons } from '../common/constants';
1313
import { getTestType } from '../common/testUtils';
14-
import { TestFile, TestStatus, TestType } from '../common/types';
14+
import { TestStatus, TestType } from '../common/types';
1515
import { TestDataItem } from '../types';
1616

17-
export class TestWorkspaceFolder {
18-
constructor(public readonly workspaceFolder: WorkspaceFolder) { }
19-
public get resource(): Uri {
20-
return this.workspaceFolder.uri;
21-
}
22-
}
23-
24-
export class TestWorkspaceFolderTreeItem extends TreeItem {
25-
constructor(
26-
public readonly resource: Uri,
27-
public readonly data: Readonly<TestWorkspaceFolder>,
28-
label: string
29-
) {
30-
super(label, TreeItemCollapsibleState.Collapsed);
31-
}
32-
public get contextValue(): string {
33-
return 'workspaceFolder';
34-
}
35-
public get iconPath(): string | Uri | { light: string | Uri; dark: string | Uri } | ThemeIcon {
36-
return ThemeIcon.Folder;
37-
}
38-
}
39-
4017
/**
4118
* Class that represents a visual node on the
4219
* Test Explorer tree view. Is essentially a wrapper for the underlying
@@ -47,21 +24,23 @@ export class TestTreeItem extends TreeItem {
4724

4825
constructor(
4926
public readonly resource: Uri,
50-
public readonly data: Readonly<TestDataItem>,
51-
private readonly parentData: TestWorkspaceFolder | TestDataItem
27+
public readonly data: Readonly<TestDataItem>
5228
) {
53-
super(data.name, TreeItemCollapsibleState.Collapsed);
29+
super(data.name, TestTreeItem.getCollapsibleState(data));
5430
this.testType = getTestType(this.data);
5531
this.setCommand();
56-
if (this.testType === TestType.testFile) {
57-
this.resourceUri = Uri.file((this.data as TestFile).fullPath);
58-
}
32+
}
33+
private static getCollapsibleState(data: TestDataItem): TreeItemCollapsibleState {
34+
return getTestType(data) === TestType.testFunction ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed;
5935
}
6036
public get contextValue(): string {
6137
return this.testType;
6238
}
6339

6440
public get iconPath(): string | Uri | { light: string | Uri; dark: string | Uri } | ThemeIcon {
41+
if (this.testType === TestType.testWorkspaceFolder) {
42+
return ThemeIcon.Folder;
43+
}
6544
if (!this.data) {
6645
return '';
6746
}
@@ -79,27 +58,10 @@ export class TestTreeItem extends TreeItem {
7958
return getIcon(Icons.discovering);
8059
}
8160
default: {
82-
switch (this.testType) {
83-
case TestType.testFile: {
84-
return ThemeIcon.File;
85-
}
86-
case TestType.testFolder: {
87-
return ThemeIcon.Folder;
88-
}
89-
default: {
90-
return getIcon(Icons.unknown);
91-
}
92-
}
61+
return getIcon(Icons.unknown);
9362
}
9463
}
9564
}
96-
/**
97-
* Parent is an extension to the TreeItem, to make it trivial to discover the node's parent.
98-
*/
99-
public get parent(): TestWorkspaceFolder | TestDataItem {
100-
return this.parentData;
101-
}
102-
10365
/**
10466
* Tooltip for our tree nodes is the test status
10567
*/
@@ -127,14 +89,3 @@ export class TestTreeItem extends TreeItem {
12789
}
12890
}
12991
}
130-
131-
/**
132-
* Create a TreView node from a given TestDataItem without having to specify the exact test item type.
133-
*
134-
* @param resource The workspace resource that this test item exists within.
135-
* @param testData The data item being represented in this tree view node.
136-
* @param parent The parent (or undefined, if the item is a root folder) of the test item.
137-
*/
138-
export function createTreeViewItemFrom(resource: Uri, testData: Readonly<TestDataItem>, parent?: TestWorkspaceFolder | TestDataItem): TreeItem {
139-
return new TestTreeItem(resource, testData, parent!);
140-
}

src/client/unittests/explorer/testTreeViewProvider.ts

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ import { IWorkspaceService } from '../../common/application/types';
99
import { IDisposable, IDisposableRegistry } from '../../common/types';
1010
import { getChildren, getParent } from '../common/testUtils';
1111
import { ITestCollectionStorageService, TestStatus } from '../common/types';
12-
import { ITestDataItemResource, ITestTreeViewProvider, IUnitTestManagementService, TestDataItem, WorkspaceTestStatus } from '../types';
13-
import { createTreeViewItemFrom, TestWorkspaceFolder, TestWorkspaceFolderTreeItem } from './testTreeViewItem';
12+
import { ITestDataItemResource, ITestTreeViewProvider, IUnitTestManagementService, TestDataItem, TestWorkspaceFolder, WorkspaceTestStatus } from '../types';
13+
import { TestTreeItem } from './testTreeViewItem';
1414

1515
@injectable()
16-
export class TestTreeViewProvider implements ITestTreeViewProvider<TestWorkspaceFolder | TestDataItem>, ITestDataItemResource, IDisposable {
17-
public readonly onDidChangeTreeData: Event<TestWorkspaceFolder | TestDataItem | undefined>;
16+
export class TestTreeViewProvider implements ITestTreeViewProvider, ITestDataItemResource, IDisposable {
17+
public readonly onDidChangeTreeData: Event<TestDataItem | undefined>;
1818
public readonly testsAreBeingDiscovered: Map<string, boolean>;
1919

20-
private _onDidChangeTreeData = new EventEmitter<TestWorkspaceFolder | TestDataItem | undefined>();
20+
private _onDidChangeTreeData = new EventEmitter<TestDataItem | undefined>();
2121
private disposables: IDisposable[] = [];
2222

2323
constructor(
@@ -46,7 +46,7 @@ export class TestTreeViewProvider implements ITestTreeViewProvider<TestWorkspace
4646
* @param testData Test data item to map to a Uri
4747
* @returns A Uri representing the workspace that the test data item exists within
4848
*/
49-
public getResource(testData: Readonly<TestWorkspaceFolder | TestDataItem>): Uri {
49+
public getResource(testData: Readonly<TestDataItem>): Uri {
5050
return testData.resource;
5151
}
5252

@@ -65,12 +65,8 @@ export class TestTreeViewProvider implements ITestTreeViewProvider<TestWorkspace
6565
* @param element The element for which [TreeItem](#TreeItem) representation is asked for.
6666
* @return [TreeItem](#TreeItem) representation of the element
6767
*/
68-
public async getTreeItem(element: TestWorkspaceFolder | TestDataItem): Promise<TreeItem> {
69-
if (element instanceof TestWorkspaceFolder) {
70-
return new TestWorkspaceFolderTreeItem(element.resource, element, element.workspaceFolder.name);
71-
}
72-
const parent = await this.getParent!(element);
73-
return createTreeViewItemFrom(element.resource, element, parent);
68+
public async getTreeItem(element: TestDataItem): Promise<TreeItem> {
69+
return new TestTreeItem(element.resource, element);
7470
}
7571

7672
/**
@@ -79,23 +75,30 @@ export class TestTreeViewProvider implements ITestTreeViewProvider<TestWorkspace
7975
* @param element The element from which the provider gets children. Can be `undefined`.
8076
* @return Children of `element` or root if no element is passed.
8177
*/
82-
public getChildren(element?: TestWorkspaceFolder | TestDataItem): TestWorkspaceFolder[] | TestDataItem[] {
83-
if (!element && Array.isArray(this.workspace.workspaceFolders) && this.workspace.workspaceFolders.length > 0) {
84-
return this.workspace.workspaceFolders
85-
.filter(workspaceFolder => this.testStore.getTests(workspaceFolder.uri))
86-
.map(workspaceFolder => new TestWorkspaceFolder(workspaceFolder));
87-
}
88-
if (element instanceof TestWorkspaceFolder) {
89-
const tests = this.testStore.getTests(element.workspaceFolder.uri);
90-
return tests ? tests.rootTestFolders : [];
91-
} else {
92-
const tests = this.testStore.getTests(element.resource);
93-
if (element === undefined) {
94-
return tests && tests.testFolders ? tests.rootTestFolders : [];
78+
public getChildren(element?: TestDataItem): TestDataItem[] {
79+
if (element) {
80+
if (element instanceof TestWorkspaceFolder) {
81+
const tests = this.testStore.getTests(element.workspaceFolder.uri);
82+
return tests ? tests.rootTestFolders : [];
9583
}
84+
return getChildren(element!);
85+
}
9686

97-
return getChildren(element);
87+
if (!Array.isArray(this.workspace.workspaceFolders) || this.workspace.workspaceFolders.length === 0) {
88+
return [];
9889
}
90+
91+
// If we are in a single workspace
92+
if (this.workspace.workspaceFolders.length === 1) {
93+
const tests = this.testStore.getTests(this.workspace.workspaceFolders[0].uri);
94+
return tests ? tests.rootTestFolders : [];
95+
}
96+
97+
// If we are in a mult-root workspace, then nest the test data within a
98+
// virtual node, represending the workspace folder.
99+
return this.workspace.workspaceFolders
100+
.filter(workspaceFolder => this.testStore.getTests(workspaceFolder.uri))
101+
.map(workspaceFolder => new TestWorkspaceFolder(workspaceFolder));
99102
}
100103

101104
/**
@@ -107,12 +110,12 @@ export class TestTreeViewProvider implements ITestTreeViewProvider<TestWorkspace
107110
* @param element The element for which the parent has to be returned.
108111
* @return Parent of `element`.
109112
*/
110-
public async getParent?(element: TestWorkspaceFolder | TestDataItem): Promise<TestWorkspaceFolder | TestDataItem> {
113+
public async getParent(element: TestDataItem): Promise<TestDataItem | undefined> {
111114
if (element instanceof TestWorkspaceFolder) {
112115
return;
113116
}
114117
const tests = this.testStore.getTests(element.resource);
115-
return tests ? getParent(tests, element)! : undefined;
118+
return tests ? getParent(tests, element) : undefined;
116119
}
117120

118121
/**

src/client/unittests/main.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@ import {
3434
import {
3535
ITestDisplay, ITestResultDisplay, ITestTreeViewProvider,
3636
IUnitTestConfigurationService, IUnitTestManagementService,
37+
TestWorkspaceFolder,
3738
WorkspaceTestStatus
3839
} from './types';
3940

41+
// tslint:disable:no-any
42+
4043
@injectable()
4144
export class UnitTestManagementService implements IUnitTestManagementService, Disposable {
4245
private readonly outputChannel: OutputChannel;
@@ -78,8 +81,7 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di
7881
this.registerHandlers();
7982
this.registerCommands();
8083

81-
// tslint:disable-next-line:no-any
82-
const testViewProvider = this.serviceContainer.get<ITestTreeViewProvider<any>>(ITestTreeViewProvider);
84+
const testViewProvider = this.serviceContainer.get<ITestTreeViewProvider>(ITestTreeViewProvider);
8385
const disposable = window.registerTreeDataProvider('python_tests', testViewProvider);
8486
disposablesRegistry.push(disposable);
8587

@@ -354,7 +356,10 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di
354356
const commandManager = this.serviceContainer.get<ICommandManager>(ICommandManager);
355357

356358
const disposables = [
357-
commandManager.registerCommand(constants.Commands.Tests_Discover, (_, cmdSource: CommandSource = CommandSource.commandPalette, resource?: Uri) => {
359+
commandManager.registerCommand(constants.Commands.Tests_Discover, (treeNode: TestWorkspaceFolder | any, cmdSource: CommandSource = CommandSource.commandPalette, resource?: Uri) => {
360+
if (treeNode && treeNode instanceof TestWorkspaceFolder) {
361+
resource = treeNode.resource;
362+
}
358363
// Ignore the exceptions returned.
359364
// This command will be invoked from other places of the extension.
360365
this.discoverTests(cmdSource, resource, true, true)
@@ -367,8 +372,18 @@ export class UnitTestManagementService implements IUnitTestManagementService, Di
367372
.ignoreErrors();
368373
}),
369374
commandManager.registerCommand(constants.Commands.Tests_Run_Failed, (_, cmdSource: CommandSource = CommandSource.commandPalette, resource: Uri) => this.runTestsImpl(cmdSource, resource, undefined, true)),
370-
commandManager.registerCommand(constants.Commands.Tests_Run, (_, cmdSource: CommandSource = CommandSource.commandPalette, file: Uri, testToRun?: TestsToRun) => this.runTestsImpl(cmdSource, file, testToRun)),
371-
commandManager.registerCommand(constants.Commands.Tests_Debug, (_, cmdSource: CommandSource = CommandSource.commandPalette, file: Uri, testToRun: TestsToRun) => this.runTestsImpl(cmdSource, file, testToRun, false, true)),
375+
commandManager.registerCommand(constants.Commands.Tests_Run, (treeNode: TestWorkspaceFolder | any, cmdSource: CommandSource = CommandSource.commandPalette, resource?: Uri, testToRun?: TestsToRun) => {
376+
if (treeNode && treeNode instanceof TestWorkspaceFolder) {
377+
resource = treeNode.resource;
378+
}
379+
return this.runTestsImpl(cmdSource, resource, testToRun);
380+
}),
381+
commandManager.registerCommand(constants.Commands.Tests_Debug, (treeNode: TestWorkspaceFolder | any, cmdSource: CommandSource = CommandSource.commandPalette, resource: Uri, testToRun: TestsToRun) => {
382+
if (treeNode && treeNode instanceof TestWorkspaceFolder) {
383+
resource = treeNode.resource;
384+
}
385+
return this.runTestsImpl(cmdSource, resource, testToRun, false, true);
386+
}),
372387
commandManager.registerCommand(constants.Commands.Tests_View_UI, () => this.displayUI(CommandSource.commandPalette)),
373388
commandManager.registerCommand(constants.Commands.Tests_Picker_UI, (_, cmdSource: CommandSource = CommandSource.commandPalette, file: Uri, testFunctions: TestFunction[]) => this.displayPickerUI(cmdSource, file, testFunctions)),
374389
commandManager.registerCommand(constants.Commands.Tests_Picker_UI_Debug, (_, cmdSource: CommandSource = CommandSource.commandPalette, file: Uri, testFunctions: TestFunction[]) => this.displayPickerUI(cmdSource, file, testFunctions, true)),

src/client/unittests/serviceRegistry.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,7 @@ export function registerTypes(serviceManager: IServiceManager) {
106106

107107
serviceManager.addSingleton<IUnitTestDiagnosticService>(IUnitTestDiagnosticService, UnitTestDiagnosticService);
108108
serviceManager.addSingleton<ITestMessageService>(ITestMessageService, TestMessageService, PYTEST_PROVIDER);
109-
// tslint:disable-next-line:no-any
110-
serviceManager.addSingleton<ITestTreeViewProvider<any>>(ITestTreeViewProvider, TestTreeViewProvider);
109+
serviceManager.addSingleton<ITestTreeViewProvider>(ITestTreeViewProvider, TestTreeViewProvider);
111110
serviceManager.addSingleton<ITestDataItemResource>(ITestDataItemResource, TestTreeViewProvider);
112111
serviceManager.addSingleton<ITestExplorerCommandHandler>(ITestExplorerCommandHandler, TestExplorerCommandHandler);
113112

src/client/unittests/types.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import {
88
DiagnosticSeverity, Disposable, DocumentSymbolProvider,
99
Event, Location, ProviderResult, TextDocument,
10-
TreeDataProvider, TreeItem, Uri
10+
TreeDataProvider, TreeItem, Uri, WorkspaceFolder
1111
} from 'vscode';
1212
import { Product, Resource } from '../common/types';
1313
import { CommandSource } from './common/constants';
@@ -16,7 +16,6 @@ import {
1616
TestFile, TestFolder, TestFunction, TestRunOptions, Tests,
1717
TestStatus, TestsToRun, TestSuite, UnitTestProduct
1818
} from './common/types';
19-
import { TestTreeItem } from './explorer/testTreeViewItem';
2019

2120
export const IUnitTestConfigurationService = Symbol('IUnitTestConfigurationService');
2221
export interface IUnitTestConfigurationService {
@@ -157,13 +156,24 @@ export interface ILocationStackFrameDetails {
157156

158157
export type WorkspaceTestStatus = { workspace: Uri; status: TestStatus };
159158

160-
export type TestDataItem = TestFolder | TestFile | TestSuite | TestFunction;
159+
export type TestDataItem = TestWorkspaceFolder | TestFolder | TestFile | TestSuite | TestFunction;
160+
161+
export class TestWorkspaceFolder {
162+
public status?: TestStatus;
163+
constructor(public readonly workspaceFolder: WorkspaceFolder) { }
164+
public get resource(): Uri {
165+
return this.workspaceFolder.uri;
166+
}
167+
public get name(): string {
168+
return this.workspaceFolder.name;
169+
}
170+
}
161171

162172
export const ITestTreeViewProvider = Symbol('ITestTreeViewProvider');
163-
export interface ITestTreeViewProvider<T> extends TreeDataProvider<T> {
164-
onDidChangeTreeData: Event<T | undefined>;
165-
getTreeItem(element: T): Promise<TreeItem>;
166-
getChildren(element?: T): ProviderResult<T[]>;
173+
export interface ITestTreeViewProvider extends TreeDataProvider<TestDataItem> {
174+
onDidChangeTreeData: Event<TestDataItem | undefined>;
175+
getTreeItem(element: TestDataItem): Promise<TreeItem>;
176+
getChildren(element?: TestDataItem): ProviderResult<TestDataItem[]>;
167177
refresh(resource: Uri): void;
168178
}
169179

0 commit comments

Comments
 (0)