diff --git a/package.json b/package.json
index 25cec4bba639..8136475744d0 100644
--- a/package.json
+++ b/package.json
@@ -143,6 +143,30 @@
"title": "%python.command.python.buildWorkspaceSymbols.title%",
"category": "Python"
},
+ {
+ "command": "python.openTestNodeInEditor",
+ "title": "Open",
+ "icon": {
+ "light": "resources/light/open-file.svg",
+ "dark": "resources/dark/open-file.svg"
+ }
+ },
+ {
+ "command": "python.runTestNode",
+ "title": "Run",
+ "icon": {
+ "light": "resources/light/start.svg",
+ "dark": "resources/dark/start.svg"
+ }
+ },
+ {
+ "command": "python.debugTestNode",
+ "title": "Debug",
+ "icon": {
+ "light": "resources/light/debug.svg",
+ "dark": "resources/dark/debug.svg"
+ }
+ },
{
"command": "python.runtests",
"title": "%python.command.python.runtests.title%",
@@ -439,6 +463,24 @@
}
],
"commandPalette": [
+ {
+ "command": "python.runTestNode",
+ "title": "Run",
+ "category": "Python",
+ "when": "config.noExists"
+ },
+ {
+ "command": "python.debugTestNode",
+ "title": "Debug",
+ "category": "Python",
+ "when": "config.noExists"
+ },
+ {
+ "command": "python.openTestNodeInEditor",
+ "title": "Open",
+ "category": "Python",
+ "when": "config.noExists"
+ },
{
"command": "python.datascience.runcurrentcell",
"title": "%python.command.python.datascience.runcurrentcell.title%",
@@ -540,6 +582,53 @@
"command": "python.runtests",
"group": "navigation"
}
+ ],
+ "view/item/context": [
+ {
+ "command": "python.openTestNodeInEditor",
+ "when": "view == python_tests && viewItem == testFunction",
+ "group": "inline"
+ },
+ {
+ "command": "python.debugTestNode",
+ "when": "view == python_tests && viewItem == testFunction",
+ "group": "inline"
+ },
+ {
+ "command": "python.runTestNode",
+ "when": "view == python_tests && viewItem == testFunction",
+ "group": "inline"
+ },
+ {
+ "command": "python.openTestNodeInEditor",
+ "when": "view == python_tests && viewItem == testFile",
+ "group": "inline"
+ },
+ {
+ "command": "python.debugTestNode",
+ "when": "view == python_tests && viewItem == testFile",
+ "group": "inline"
+ },
+ {
+ "command": "python.runTestNode",
+ "when": "view == python_tests && viewItem == testFile",
+ "group": "inline"
+ },
+ {
+ "command": "python.openTestNodeInEditor",
+ "when": "view == python_tests && viewItem == testSuite",
+ "group": "inline"
+ },
+ {
+ "command": "python.debugTestNode",
+ "when": "view == python_tests && viewItem == testSuite",
+ "group": "inline"
+ },
+ {
+ "command": "python.runTestNode",
+ "when": "view == python_tests && viewItem == testSuite",
+ "group": "inline"
+ }
]
},
"debuggers": [
diff --git a/pythonFiles/symbolProvider.py b/pythonFiles/symbolProvider.py
index 7f21ee4ba353..0e69d06b227f 100644
--- a/pythonFiles/symbolProvider.py
+++ b/pythonFiles/symbolProvider.py
@@ -47,7 +47,7 @@ def getDataObject(self, node, namespace=""):
"name": node.name,
"range": {
"start": {
- "line": node.lineno,
+ "line": node.lineno - 1,
"character": node.col_offset
},
"end": {
@@ -59,7 +59,7 @@ def getDataObject(self, node, namespace=""):
def getEndPosition(self, node):
if not hasattr(node, 'body') or len(node.body) == 0:
- return (node.lineno, node.col_offset)
+ return (node.lineno - 1, node.col_offset)
return self.getEndPosition(node.body[-1])
diff --git a/resources/dark/debug.svg b/resources/dark/debug.svg
new file mode 100644
index 000000000000..e211df43ef6c
--- /dev/null
+++ b/resources/dark/debug.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/resources/dark/open-file.svg b/resources/dark/open-file.svg
new file mode 100644
index 000000000000..8a9a087704bc
--- /dev/null
+++ b/resources/dark/open-file.svg
@@ -0,0 +1 @@
+
diff --git a/resources/dark/start.svg b/resources/dark/start.svg
new file mode 100644
index 000000000000..9b0a10da4304
--- /dev/null
+++ b/resources/dark/start.svg
@@ -0,0 +1 @@
+
diff --git a/resources/light/debug.svg b/resources/light/debug.svg
new file mode 100644
index 000000000000..b8efb1c8f77e
--- /dev/null
+++ b/resources/light/debug.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/resources/light/open-file.svg b/resources/light/open-file.svg
new file mode 100644
index 000000000000..8a9a087704bc
--- /dev/null
+++ b/resources/light/open-file.svg
@@ -0,0 +1 @@
+
diff --git a/resources/light/start.svg b/resources/light/start.svg
new file mode 100644
index 000000000000..9b0a10da4304
--- /dev/null
+++ b/resources/light/start.svg
@@ -0,0 +1 @@
+
diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts
index a92f29dd4ba8..b3bf641bf670 100644
--- a/src/client/common/constants.ts
+++ b/src/client/common/constants.ts
@@ -45,6 +45,9 @@ export namespace Commands {
export const navigateToTestFunction = 'navigateToTestFunction';
export const navigateToTestSuite = 'navigateToTestSuite';
export const navigateToTestFile = 'navigateToTestFile';
+ export const openTestNodeInEditor = 'python.openTestNodeInEditor';
+ export const runTestNode = 'python.runTestNode';
+ export const debugTestNode = 'python.debugTestNode';
}
export namespace Octicons {
export const Test_Pass = '$(check)';
diff --git a/src/client/extension.ts b/src/client/extension.ts
index 5e59846aaa58..b43834a9099d 100644
--- a/src/client/extension.ts
+++ b/src/client/extension.ts
@@ -101,7 +101,7 @@ import { EditorLoadTelemetry } from './telemetry/types';
import { registerTypes as commonRegisterTerminalTypes } from './terminals/serviceRegistry';
import { ICodeExecutionManager, ITerminalAutoActivation } from './terminals/types';
import { TEST_OUTPUT_CHANNEL } from './unittests/common/constants';
-import { ITestCodeNavigatorCommandHandler } from './unittests/navigation/types';
+import { ITestCodeNavigatorCommandHandler, ITestExplorerCommandHandler } from './unittests/navigation/types';
import { registerTypes as unitTestsRegisterTypes } from './unittests/serviceRegistry';
durations.codeLoadingTime = stopWatch.elapsedTime;
@@ -293,6 +293,7 @@ function initializeServices(context: ExtensionContext, serviceManager: ServiceMa
serviceContainer.get(IInterpreterLocatorProgressService).register();
serviceContainer.get(IApplicationDiagnostics).register();
serviceContainer.get(ITestCodeNavigatorCommandHandler).register();
+ serviceContainer.get(ITestExplorerCommandHandler).register();
}
// tslint:disable-next-line:no-any
diff --git a/src/client/telemetry/types.ts b/src/client/telemetry/types.ts
index 48515a15dc7b..fc8cca7045e0 100644
--- a/src/client/telemetry/types.ts
+++ b/src/client/telemetry/types.ts
@@ -97,7 +97,7 @@ export type TestRunTelemetry = {
tool: 'nosetest' | 'pytest' | 'unittest';
scope: 'currentFile' | 'all' | 'file' | 'class' | 'function' | 'failed';
debugging: boolean;
- triggerSource: 'ui' | 'codelens' | 'commandpalette' | 'auto';
+ triggerSource: 'ui' | 'codelens' | 'commandpalette' | 'auto' | 'testExplorer';
failed: boolean;
};
export type TestDiscoverytTelemetry = {
diff --git a/src/client/unittests/common/constants.ts b/src/client/unittests/common/constants.ts
index e361f4479bcf..fb4843eb9f5f 100644
--- a/src/client/unittests/common/constants.ts
+++ b/src/client/unittests/common/constants.ts
@@ -5,7 +5,8 @@ export enum CommandSource {
auto = 'auto',
ui = 'ui',
codelens = 'codelens',
- commandPalette = 'commandpalette'
+ commandPalette = 'commandpalette',
+ testExplorer = 'testExplorer'
}
export const TEST_OUTPUT_CHANNEL = 'TEST_OUTPUT_CHANNEL';
export const NOSETEST_PROVIDER: TestProvider = 'nosetest';
diff --git a/src/client/unittests/common/testUtils.ts b/src/client/unittests/common/testUtils.ts
index b7f0fdcbfeb3..e092f23e11a7 100644
--- a/src/client/unittests/common/testUtils.ts
+++ b/src/client/unittests/common/testUtils.ts
@@ -7,7 +7,7 @@ import { IUnitTestSettings, Product } from '../../common/types';
import { IServiceContainer } from '../../ioc/types';
import { CommandSource } from './constants';
import { TestFlatteningVisitor } from './testVisitors/flatteningVisitor';
-import { ITestsHelper, ITestVisitor, TestFile, TestFolder, TestFunction, TestProvider, Tests, TestSettingsPropertyNames, TestsToRun, TestSuite, UnitTestProduct } from './types';
+import { ITestsHelper, ITestVisitor, TestFile, TestFolder, TestFunction, TestProvider, Tests, TestSettingsPropertyNames, TestsToRun, TestSuite, TestType, UnitTestProduct } from './types';
export async function selectTestWorkspace(appShell: IApplicationShell): Promise {
if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length === 0) {
@@ -39,6 +39,51 @@ export class TestsHelper implements ITestsHelper {
this.appShell = serviceContainer.get(IApplicationShell);
this.commandManager = serviceContainer.get(ICommandManager);
}
+ public static getTestType(test: TestFile | TestFolder | TestSuite | TestFunction): TestType {
+ if (TestsHelper.getTestFile(test)) {
+ return TestType.testFile;
+ }
+ if (TestsHelper.getTestFolder(test)) {
+ return TestType.testFolder;
+ }
+ if (TestsHelper.getTestSuite(test)) {
+ return TestType.testSuite;
+ }
+ if (TestsHelper.getTestFunction(test)) {
+ return TestType.testFunction;
+ }
+ throw new Error('Unknown test type');
+ }
+ public static getTestFile(test: TestFile | TestFolder | TestSuite | TestFunction): TestFile | undefined {
+ if (!test) {
+ return;
+ }
+ // Only TestFile has a `fullPath` property.
+ return typeof (test as TestFile).fullPath === 'string' ? test as TestFile : undefined;
+ }
+ public static getTestSuite(test: TestFile | TestFolder | TestSuite | TestFunction): TestSuite | undefined {
+ if (!test) {
+ return;
+ }
+ // Only TestSuite has a `suites` property.
+ return Array.isArray((test as TestSuite).suites) ? test as TestSuite : undefined;
+ }
+ public static getTestFolder(test: TestFile | TestFolder | TestSuite | TestFunction): TestFolder | undefined {
+ if (!test) {
+ return;
+ }
+ // Only TestFolder has a `folders` property.
+ return Array.isArray((test as TestFolder).folders) ? test as TestFolder : undefined;
+ }
+ public static getTestFunction(test: TestFile | TestFolder | TestSuite | TestFunction): TestFunction | undefined {
+ if (!test) {
+ return;
+ }
+ if (TestsHelper.getTestFile(test) || TestsHelper.getTestSuite(test) || TestsHelper.getTestSuite(test)) {
+ return;
+ }
+ return test as TestFunction;
+ }
public parseProviderName(product: UnitTestProduct): TestProvider {
switch (product) {
case Product.nosetest: return 'nosetest';
@@ -205,22 +250,4 @@ export class TestsHelper implements ITestsHelper {
return true;
}
- public getTestFile(test: TestFile | TestFolder | TestSuite | TestFunction): TestFile | undefined {
- // Only TestFile has a `fullPath` property.
- return Array.isArray((test as TestFile).fullPath) ? test as TestFile : undefined;
- }
- public getTestSuite(test: TestFile | TestFolder | TestSuite | TestFunction): TestSuite | undefined {
- // Only TestSuite has a `suites` property.
- return Array.isArray((test as TestSuite).suites) ? test as TestSuite : undefined;
- }
- public getTestFolder(test: TestFile | TestFolder | TestSuite | TestFunction): TestFolder | undefined {
- // Only TestFolder has a `folders` property.
- return Array.isArray((test as TestFolder).folders) ? test as TestFolder : undefined;
- }
- public getTestFunction(test: TestFile | TestFolder | TestSuite | TestFunction): TestFunction | undefined {
- if (this.getTestFile(test) || this.getTestSuite(test) || this.getTestSuite(test)) {
- return;
- }
- return test as TestFunction;
- }
}
diff --git a/src/client/unittests/navigation/types.ts b/src/client/unittests/navigation/types.ts
index 03727709276a..3fab281aa170 100644
--- a/src/client/unittests/navigation/types.ts
+++ b/src/client/unittests/navigation/types.ts
@@ -29,3 +29,8 @@ export interface ITestNavigatorHelper {
findSymbol(doc: TextDocument, predicate: SymbolSearch, token: CancellationToken): Promise;
}
export type SymbolSearch = (item: SymbolInformation) => boolean;
+
+export const ITestExplorerCommandHandler = Symbol('ITestExplorerCommandHandler');
+export interface ITestExplorerCommandHandler extends IDisposable {
+ register(): void;
+}
diff --git a/src/client/unittests/providers/commandHandlers.ts b/src/client/unittests/providers/commandHandlers.ts
new file mode 100644
index 000000000000..ad27313a00a5
--- /dev/null
+++ b/src/client/unittests/providers/commandHandlers.ts
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+'use strict';
+
+import { inject, injectable } from 'inversify';
+import { ICommandManager } from '../../common/application/types';
+import { Commands } from '../../common/constants';
+import { traceDecorators } from '../../common/logger';
+import { IDisposable } from '../../common/types';
+import { swallowExceptions } from '../../common/utils/decorators';
+import { CommandSource } from '../common/constants';
+import { TestFile, TestFolder, TestFunction, TestsToRun, TestSuite, TestType } from '../common/types';
+import { ITestExplorerCommandHandler } from '../navigation/types';
+import { TestTreeItem } from './testTreeViewItem';
+
+const testNavigationCommandMapping = {
+ [TestType.testFile]: Commands.navigateToTestFile,
+ [TestType.testFunction]: Commands.navigateToTestFunction,
+ [TestType.testSuite]: Commands.navigateToTestSuite
+};
+
+@injectable()
+export class TestExplorerCommandHandler implements ITestExplorerCommandHandler {
+ private readonly disposables: IDisposable[] = [];
+ constructor(@inject(ICommandManager) private readonly cmdManager: ICommandManager) { }
+ public register(): void {
+ this.disposables.push(this.cmdManager.registerCommand(Commands.runTestNode, this.onRunTestNode, this));
+ this.disposables.push(this.cmdManager.registerCommand(Commands.debugTestNode, this.onDebugTestNode, this));
+ this.disposables.push(this.cmdManager.registerCommand(Commands.openTestNodeInEditor, this.onOpenTestNodeInEditor, this));
+ }
+ public dispose(): void {
+ this.disposables.forEach(item => item.dispose());
+ }
+ @swallowExceptions('Run test node')
+ @traceDecorators.error('Run test node failed')
+ protected async onRunTestNode(item: TestTreeItem): Promise {
+ await this.runDebugTestNode(item, 'run');
+ }
+ @swallowExceptions('Debug test node')
+ @traceDecorators.error('Debug test node failed')
+ protected async onDebugTestNode(item: TestTreeItem): Promise {
+ await this.runDebugTestNode(item, 'debug');
+ }
+ @swallowExceptions('Open test node in Editor')
+ @traceDecorators.error('Open test node in editor failed')
+ protected async onOpenTestNodeInEditor(item: TestTreeItem): Promise {
+ const command = testNavigationCommandMapping[item.testType];
+ if (!command) {
+ throw new Error('Unknown Test Type');
+ }
+
+ this.cmdManager.executeCommand(command, item.resource, item.data);
+ }
+ protected async runDebugTestNode(item: TestTreeItem, runType: 'run' | 'debug'): Promise {
+ let testToRun: TestsToRun;
+ switch (item.testType) {
+ case TestType.testFile: {
+ testToRun = { testFile: [item.data as TestFile] };
+ break;
+ }
+ case TestType.testFolder: {
+ testToRun = { testFolder: [item.data as TestFolder] };
+ break;
+ }
+ case TestType.testSuite: {
+ testToRun = { testSuite: [item.data as TestSuite] };
+ break;
+ }
+ case TestType.testFunction: {
+ testToRun = { testFunction: [item.data as TestFunction] };
+ break;
+ }
+ default:
+ throw new Error('Unknown Test Type');
+ }
+
+ const args = [undefined, CommandSource.testExplorer, item.resource, testToRun];
+ const cmd = runType === 'run' ? Commands.Tests_Run : Commands.Tests_Debug;
+ this.cmdManager.executeCommand(cmd, ...args);
+ }
+}
diff --git a/src/client/unittests/providers/testTreeViewItem.ts b/src/client/unittests/providers/testTreeViewItem.ts
index 9e2ce267ec9c..c2ad9e066b28 100644
--- a/src/client/unittests/providers/testTreeViewItem.ts
+++ b/src/client/unittests/providers/testTreeViewItem.ts
@@ -4,11 +4,12 @@
'use strict';
import {
- TreeItem, TreeItemCollapsibleState
+ TreeItem, TreeItemCollapsibleState, Uri
} from 'vscode';
+import { TestsHelper } from '../common/testUtils';
import {
TestFile, TestFolder, TestFunction,
- TestStatus, TestSuite
+ TestStatus, TestSuite, TestType
} from '../common/types';
export enum TestTreeItemType {
@@ -20,34 +21,38 @@ export enum TestTreeItemType {
}
export class TestTreeItem extends TreeItem {
-
+ public readonly testType: TestType;
constructor(
+ public readonly resource: Uri,
kind: TestTreeItemType,
- private readonly myParent: TestTreeItem,
+ private readonly myParent: TestTreeItem | undefined,
private readonly myChildren: TestTreeItem[],
runId: string,
- name: string,
+ label: string,
testStatus: TestStatus = TestStatus.Unknown,
- // tslint:disable-next-line:no-unused-variable
- private readonly data: TestFolder | TestFile | TestSuite | TestFunction
+ public readonly data: Readonly | Readonly | Readonly | Readonly
) {
super(
- `[${kind}] ${name}`,
+ `[${kind}] ${label}`,
kind === TestTreeItemType.Function ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed
);
this.contextValue = kind;
this.id = runId;
this.tooltip = `Status: ${testStatus}`;
+ this.testType = TestsHelper.getTestType(this.data);
+ this.contextValue = TestsHelper.getTestType(this.data);
}
public static createFromFolder(
+ resource: Uri,
folder: TestFolder,
parent?: TestTreeItem
): TestTreeItem {
const folderItem = new TestTreeItem(
+ resource,
TestTreeItemType.Package,
parent,
[],
@@ -58,18 +63,20 @@ export class TestTreeItem extends TreeItem {
);
folder.testFiles.forEach((testFile: TestFile) => {
- folderItem.children.push(TestTreeItem.createFromFile(testFile, folderItem));
+ folderItem.children.push(TestTreeItem.createFromFile(resource, testFile, folderItem));
});
return folderItem;
}
public static createFromFile(
+ resource: Uri,
testFile: TestFile,
parent?: TestTreeItem
): TestTreeItem {
const fileItem = new TestTreeItem(
+ resource,
TestTreeItemType.File,
parent,
[],
@@ -80,21 +87,23 @@ export class TestTreeItem extends TreeItem {
);
testFile.functions.forEach((fn: TestFunction) => {
- fileItem.children.push(TestTreeItem.createFromFunction(fn, fileItem));
+ fileItem.children.push(TestTreeItem.createFromFunction(resource, fn, fileItem));
});
testFile.suites.forEach((suite: TestSuite) => {
- fileItem.children.push(TestTreeItem.createFromSuite(suite, fileItem));
+ fileItem.children.push(TestTreeItem.createFromSuite(resource, suite, fileItem));
});
return fileItem;
}
public static createFromSuite(
+ resource: Uri,
suite: TestSuite,
parent: TestTreeItem
): TestTreeItem {
const suiteItem = new TestTreeItem(
+ resource,
TestTreeItemType.Suite,
parent,
[],
@@ -105,25 +114,27 @@ export class TestTreeItem extends TreeItem {
);
suite.suites.forEach((subSuite: TestSuite) => {
- suiteItem.children.push(TestTreeItem.createFromSuite(subSuite, suiteItem));
+ suiteItem.children.push(TestTreeItem.createFromSuite(resource, subSuite, suiteItem));
});
suite.functions.forEach((fn: TestFunction) => {
- suiteItem.children.push(TestTreeItem.createFromFunction(fn, suiteItem));
+ suiteItem.children.push(TestTreeItem.createFromFunction(resource, fn, suiteItem));
});
return suiteItem;
}
public static createFromFunction(
+ resource: Uri,
fn: TestFunction,
parent: TestTreeItem
): TestTreeItem {
// tslint:disable-next-line:no-unnecessary-local-variable
const funcItem = new TestTreeItem(
+ resource,
TestTreeItemType.Function,
parent,
- undefined,
+ [],
fn.nameToRun,
fn.name,
fn.status,
@@ -137,7 +148,7 @@ export class TestTreeItem extends TreeItem {
return this.myChildren;
}
- public get parent(): TestTreeItem {
+ public get parent(): TestTreeItem | undefined {
return this.myParent;
}
}
diff --git a/src/client/unittests/providers/testTreeViewProvider.ts b/src/client/unittests/providers/testTreeViewProvider.ts
index 49d0494f8f40..c769df9da720 100644
--- a/src/client/unittests/providers/testTreeViewProvider.ts
+++ b/src/client/unittests/providers/testTreeViewProvider.ts
@@ -5,12 +5,12 @@
import { inject, injectable } from 'inversify';
import {
- Event, EventEmitter, ProviderResult, Uri
+ Event, EventEmitter, ProviderResult, TreeItem, Uri
} from 'vscode';
import { IWorkspaceService } from '../../common/application/types';
import { traceDecorators } from '../../common/logger';
import {
- IDisposable, IDisposableRegistry, Resource
+ IDisposable, IDisposableRegistry
} from '../../common/types';
import { ITestTreeViewProvider } from '../../providers/types';
import {
@@ -18,7 +18,7 @@ import {
} from '../common/types';
import { IUnitTestManagementService, WorkspaceTestStatus } from '../types';
import {
- TestTreeItem, TestTreeItemType
+ TestTreeItem
} from './testTreeViewItem';
@injectable()
@@ -41,8 +41,9 @@ export class TestTreeViewProvider implements ITestTreeViewProvider, IDisposable
@inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry
) {
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
- this.root = [new TestTreeItem(TestTreeItemType.Root, undefined, undefined, '*', 'no tests discovered yet', TestStatus.Unknown, undefined)];
- if (this.workspace.workspaceFolders.length > 0) {
+ // tslint:disable-next-line:no-any
+ this.root = [new TreeItem('no tests discovered yet') as any];
+ if (Array.isArray(this.workspace.workspaceFolders) && this.workspace.workspaceFolders.length > 0) {
this.refresh(this.workspace.workspaceFolders[0].uri);
}
disposableRegistry.push(this);
@@ -95,7 +96,7 @@ export class TestTreeViewProvider implements ITestTreeViewProvider, IDisposable
* Refresh the view by rebuilding the model and signalling the tree view to update itself.
*
*/
- public refresh(resource: Resource, tests?: Tests): void {
+ public refresh(resource: Uri, tests?: Tests): void {
if (tests === undefined) {
tests = this.testStore.getTests(resource);
@@ -103,7 +104,7 @@ export class TestTreeViewProvider implements ITestTreeViewProvider, IDisposable
if (tests && tests.testFolders) {
const newRoot: TestTreeItem[] = [];
tests.testFolders.forEach((tf: TestFolder) => {
- newRoot.push(TestTreeItem.createFromFolder(tf));
+ newRoot.push(TestTreeItem.createFromFolder(resource, tf));
});
this.root = newRoot;
this._onDidChangeTreeData.fire();
diff --git a/src/client/unittests/serviceRegistry.ts b/src/client/unittests/serviceRegistry.ts
index 07179353295d..7d5b3b173142 100644
--- a/src/client/unittests/serviceRegistry.ts
+++ b/src/client/unittests/serviceRegistry.ts
@@ -32,11 +32,13 @@ import { TestResultDisplay } from './display/main';
import { TestDisplay } from './display/picker';
import { UnitTestManagementService } from './main';
import { registerTypes as registerNavigationTypes } from './navigation/serviceRegistry';
+import { ITestExplorerCommandHandler } from './navigation/types';
import { TestManager as NoseTestManager } from './nosetest/main';
import { TestManagerRunner as NoseTestManagerRunner } from './nosetest/runner';
import { ArgumentsService as NoseTestArgumentsService } from './nosetest/services/argsService';
import { TestDiscoveryService as NoseTestDiscoveryService } from './nosetest/services/discoveryService';
import { TestsParser as NoseTestTestsParser } from './nosetest/services/parserService';
+import { TestExplorerCommandHandler } from './providers/commandHandlers';
import { TestTreeViewProvider } from './providers/testTreeViewProvider';
import { TestManager as PyTestTestManager } from './pytest/main';
import { TestManagerRunner as PytestManagerRunner } from './pytest/runner';
@@ -103,6 +105,7 @@ export function registerTypes(serviceManager: IServiceManager) {
serviceManager.addSingleton(IUnitTestDiagnosticService, UnitTestDiagnosticService);
serviceManager.addSingleton(ITestMessageService, TestMessageService, PYTEST_PROVIDER);
serviceManager.addSingleton(ITestTreeViewProvider, TestTreeViewProvider);
+ serviceManager.addSingleton(ITestExplorerCommandHandler, TestExplorerCommandHandler);
serviceManager.addFactory(ITestManagerFactory, (context) => {
return (testProvider: TestProvider, workspaceFolder: Uri, rootDirectory: string) => {
diff --git a/src/test/unittests/explorer/testExplorerCommandHandler.unit.test.ts b/src/test/unittests/explorer/testExplorerCommandHandler.unit.test.ts
new file mode 100644
index 000000000000..3f288b742441
--- /dev/null
+++ b/src/test/unittests/explorer/testExplorerCommandHandler.unit.test.ts
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+'use strict';
+
+import { IDisposable } from '@phosphor/disposable';
+import { anything, capture, deepEqual, instance, mock, verify, when } from 'ts-mockito';
+import * as typemoq from 'typemoq';
+import { Uri } from 'vscode';
+import { CommandManager } from '../../../client/common/application/commandManager';
+import { ICommandManager } from '../../../client/common/application/types';
+import { Commands } from '../../../client/common/constants';
+import { CommandSource } from '../../../client/unittests/common/constants';
+import { TestsHelper } from '../../../client/unittests/common/testUtils';
+import { TestFile, TestFunction, TestsToRun, TestSuite } from '../../../client/unittests/common/types';
+import { ITestExplorerCommandHandler } from '../../../client/unittests/navigation/types';
+import { TestExplorerCommandHandler } from '../../../client/unittests/providers/commandHandlers';
+import { TestTreeItem } from '../../../client/unittests/providers/testTreeViewItem';
+
+// tslint:disable:no-any max-func-body-length
+suite('Unit Tests - Test Explorer Command Hanlder', () => {
+ let commandHandler: ITestExplorerCommandHandler;
+ let cmdManager: ICommandManager;
+
+ setup(() => {
+ cmdManager = mock(CommandManager);
+ commandHandler = new TestExplorerCommandHandler(instance(cmdManager));
+ });
+ test('Commands are registered', () => {
+ commandHandler.register();
+
+ verify(cmdManager.registerCommand(Commands.runTestNode, anything(), commandHandler)).once();
+ verify(cmdManager.registerCommand(Commands.debugTestNode, anything(), commandHandler)).once();
+ verify(cmdManager.registerCommand(Commands.openTestNodeInEditor, anything(), commandHandler)).once();
+ });
+ test('Handlers are disposed', () => {
+ const disposable1 = typemoq.Mock.ofType();
+ const disposable2 = typemoq.Mock.ofType();
+ const disposable3 = typemoq.Mock.ofType();
+
+ when(cmdManager.registerCommand(Commands.runTestNode, anything(), commandHandler)).thenReturn(disposable1.object);
+ when(cmdManager.registerCommand(Commands.debugTestNode, anything(), commandHandler)).thenReturn(disposable2.object);
+ when(cmdManager.registerCommand(Commands.openTestNodeInEditor, anything(), commandHandler)).thenReturn(disposable3.object);
+
+ commandHandler.register();
+ commandHandler.dispose();
+
+ disposable1.verify(d => d.dispose(), typemoq.Times.once());
+ disposable2.verify(d => d.dispose(), typemoq.Times.once());
+ disposable3.verify(d => d.dispose(), typemoq.Times.once());
+ });
+ async function testOpeningTestNode(data: TestFile | TestSuite | TestFunction, expectedCommand: string) {
+ const treeItem = mock(TestTreeItem);
+ const resource = Uri.file(__filename);
+ when(treeItem.data).thenReturn(data);
+ when(treeItem.resource).thenReturn(resource);
+ when(treeItem.testType).thenReturn(TestsHelper.getTestType(data));
+
+ commandHandler.register();
+
+ const handler = capture(cmdManager.registerCommand).last()[1];
+ await handler.bind(commandHandler)(instance(treeItem));
+
+ verify(cmdManager.executeCommand(expectedCommand, resource, data)).once();
+ }
+ test('Opening a file will invoke correct command', async () => {
+ const testFilePath = 'some file path';
+ const data: TestFile = { fullPath: testFilePath } as any;
+ await testOpeningTestNode(data, Commands.navigateToTestFile);
+ });
+ test('Opening a test suite will invoke correct command', async () => {
+ const data: TestSuite = { suites: [] } as any;
+ await testOpeningTestNode(data, Commands.navigateToTestSuite);
+ });
+ test('Opening a test function will invoke correct command', async () => {
+ const data: TestFunction = { name: 'hello' } as any;
+ await testOpeningTestNode(data, Commands.navigateToTestFunction);
+ });
+ async function testRunOrDebugTestNode(data: TestFile | TestSuite | TestFunction,
+ expectedTestRun: TestsToRun, runType: 'run' | 'debug') {
+ const treeItem = mock(TestTreeItem);
+ const resource = Uri.file(__filename);
+ when(treeItem.data).thenReturn(data);
+ when(treeItem.testType).thenReturn(TestsHelper.getTestType(data));
+ when(treeItem.resource).thenReturn(resource);
+
+ commandHandler.register();
+
+ const capturedCommand = capture(cmdManager.registerCommand);
+ const handler = runType === 'run' ? capturedCommand.first()[1] : capturedCommand.second()[1];
+ await handler.bind(commandHandler)(instance(treeItem));
+
+ const cmd = runType === 'run' ? Commands.Tests_Run : Commands.Tests_Debug;
+ verify(cmdManager.executeCommand(cmd, undefined, CommandSource.testExplorer, resource, deepEqual(expectedTestRun))).once();
+ }
+ test('Running a file will invoke correct command', async () => {
+ const testFilePath = 'some file path';
+ const data: TestFile = { fullPath: testFilePath } as any;
+ await testRunOrDebugTestNode(data, { testFile: [data] }, 'run');
+ });
+ test('Running a suite will invoke correct command', async () => {
+ const data: TestSuite = { suites: [] } as any;
+ await testRunOrDebugTestNode(data, { testSuite: [data] }, 'run');
+ });
+ test('Running a function will invoke correct command', async () => {
+ const data: TestSuite = { suites: [] } as any;
+ await testRunOrDebugTestNode(data, { testSuite: [data] }, 'run');
+ });
+ test('Debugging a file will invoke correct command', async () => {
+ const testFilePath = 'some file path';
+ const data: TestFile = { fullPath: testFilePath } as any;
+ await testRunOrDebugTestNode(data, { testFile: [data] }, 'debug');
+ });
+ test('Debugging a suite will invoke correct command', async () => {
+ const data: TestSuite = { suites: [] } as any;
+ await testRunOrDebugTestNode(data, { testSuite: [data] }, 'debug');
+ });
+ test('Debugging a function will invoke correct command', async () => {
+ const data: TestSuite = { suites: [] } as any;
+ await testRunOrDebugTestNode(data, { testSuite: [data] }, 'debug');
+ });
+});