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 @@ +continue 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 @@ +continue 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'); + }); +});