diff --git a/package.json b/package.json index 887c63a47f5b..63f80480c85c 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "jupyter", "django", "debugger", - "unittest" + "unittest", + "multi-root ready" ], "categories": [ "Languages", @@ -1629,6 +1630,8 @@ "vscode": "^1.1.5" }, "devDependencies": { + "@types/chai": "^4.0.4", + "@types/chai-as-promised": "^7.1.0", "@types/fs-extra": "^4.0.2", "@types/jquery": "^1.10.31", "@types/lodash": "^4.14.74", @@ -1641,19 +1644,22 @@ "@types/socket.io-client": "^1.4.27", "@types/uuid": "^3.3.27", "@types/winreg": "^1.2.30", + "@types/xml2js": "^0.4.0", "babel-core": "^6.14.0", "babel-loader": "^6.2.5", "babel-preset-es2015": "^6.14.0", + "chai": "^4.1.2", + "chai-as-promised": "^7.1.1", "event-stream": "^3.3.4", "gulp": "^3.9.1", "gulp-filter": "^5.0.1", "gulp-typescript": "^3.2.2", "husky": "^0.14.3", "ignore-loader": "^0.1.1", - "retyped-diff-match-patch-tsd-ambient": "^1.0.0-0", - "sinon": "^2.3.6", "mocha": "^2.3.3", "relative": "^3.0.2", + "retyped-diff-match-patch-tsd-ambient": "^1.0.0-0", + "sinon": "^2.3.6", "ts-loader": "^2.3.4", "tslint": "^5.7.0", "tslint-eslint-rules": "^4.1.1", diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index 91f6e5af31b4..5ab6c470e79d 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -96,4 +96,4 @@ export namespace Documentation { export const Home = '/docs/workspaceSymbols/'; export const InstallOnWindows = '/docs/workspaceSymbols/#Install-Windows'; } -} \ No newline at end of file +} diff --git a/src/client/common/installer.ts b/src/client/common/installer.ts index fb8042454455..c78d6bf410be 100644 --- a/src/client/common/installer.ts +++ b/src/client/common/installer.ts @@ -177,6 +177,7 @@ export class Installer implements vscode.Disposable { // tslint:disable-next-line:no-non-null-assertion const productName = ProductNames.get(product)!; const pythonConfig = workspace.getConfiguration('python'); + // tslint:disable-next-line:prefer-type-cast const disablePromptForFeatures = pythonConfig.get('disablePromptForFeatures', [] as string[]); return disablePromptForFeatures.indexOf(productName) === -1; } @@ -185,7 +186,8 @@ export class Installer implements vscode.Disposable { public async promptToInstall(product: Product, resource?: Uri): Promise { // tslint:disable-next-line:no-non-null-assertion const productType = ProductTypes.get(product)!; - const productTypeName = ProductTypeNames.get(productType); + // tslint:disable-next-line:no-non-null-assertion + const productTypeName = ProductTypeNames.get(productType)!; // tslint:disable-next-line:no-non-null-assertion const productName = ProductNames.get(product)!; @@ -199,9 +201,9 @@ export class Installer implements vscode.Disposable { return InstallerResponse.Ignore; } - const installOption = ProductInstallationPrompt.has(product) ? ProductInstallationPrompt.get(product) : 'Install ' + productName; - const disableOption = 'Disable ' + productTypeName; - const dontShowAgain = `Don't show this prompt again`; + const installOption = ProductInstallationPrompt.has(product) ? ProductInstallationPrompt.get(product) : `Install ${productName}`; + const disableOption = `Disable ${productTypeName}`; + const dontShowAgain = 'Don\'t show this prompt again'; const alternateFormatter = product === Product.autopep8 ? 'yapf' : 'autopep8'; const useOtherFormatter = `Use '${alternateFormatter}' formatter`; const options = []; @@ -220,8 +222,8 @@ export class Installer implements vscode.Disposable { case disableOption: { if (Linters.indexOf(product) >= 0) { return this.disableLinter(product, resource).then(() => InstallerResponse.Disabled); - } - else { + } else { + // tslint:disable-next-line:no-non-null-assertion const settingToDisable = SettingToDisableProduct.get(product)!; return this.updateSetting(settingToDisable, false, resource).then(() => InstallerResponse.Disabled); } @@ -232,6 +234,7 @@ export class Installer implements vscode.Disposable { } case dontShowAgain: { const pythonConfig = workspace.getConfiguration('python'); + // tslint:disable-next-line:prefer-type-cast const features = pythonConfig.get('disablePromptForFeatures', [] as string[]); features.push(productName); return pythonConfig.update('disablePromptForFeatures', features, true).then(() => InstallerResponse.Ignore); @@ -255,31 +258,32 @@ export class Installer implements vscode.Disposable { this.outputChannel.appendLine('Option 2: Extract to any folder and add the path to this folder to the command setting.'); this.outputChannel.appendLine('Option 3: Extract to any folder and define that path in the python.workspaceSymbols.ctagsPath setting of your user settings file (settings.json).'); this.outputChannel.show(); - } - else { + } else { window.showInformationMessage('Install Universal Ctags and set it in your path or define the path in your python.workspaceSymbols.ctagsPath settings'); } return InstallerResponse.Ignore; } + // tslint:disable-next-line:no-non-null-assertion let installArgs = ProductInstallScripts.get(product)!; - let pipIndex = installArgs.indexOf('pip'); + const pipIndex = installArgs.indexOf('pip'); if (pipIndex > 0) { installArgs = installArgs.slice(); - let proxy = vscode.workspace.getConfiguration('http').get('proxy', ''); + const proxy = vscode.workspace.getConfiguration('http').get('proxy', ''); if (proxy.length > 0) { installArgs.splice(2, 0, proxy); installArgs.splice(2, 0, '--proxy'); } } + // tslint:disable-next-line:no-any let installationPromise: Promise; if (this.outputChannel && installArgs[0] === '-m') { // Errors are just displayed to the user this.outputChannel.show(); installationPromise = execPythonFile(resource, settings.PythonSettings.getInstance(resource).pythonPath, + // tslint:disable-next-line:no-non-null-assertion installArgs, getCwdForInstallScript(resource), true, (data) => { this.outputChannel!.append(data); }); - } - else { + } else { // When using terminal get the fully qualitified path // Cuz people may launch vs code from terminal when they have activated the appropriate virtual env // Problem is terminal doesn't use the currently activated virtual env @@ -291,12 +295,13 @@ export class Installer implements vscode.Disposable { if (installArgs[0] === '-m') { if (pythonPath.indexOf(' ') >= 0) { installScript = `"${pythonPath}" ${installScript}`; - } - else { + } else { installScript = `${pythonPath} ${installScript}`; } } + // tslint:disable-next-line:no-non-null-assertion Installer.terminal!.sendText(installScript); + // tslint:disable-next-line:no-non-null-assertion Installer.terminal!.show(false); }); } @@ -306,30 +311,33 @@ export class Installer implements vscode.Disposable { .then(isInstalled => isInstalled ? InstallerResponse.Installed : InstallerResponse.Ignore); } + // tslint:disable-next-line:member-ordering public isInstalled(product: Product, resource?: Uri): Promise { return isProductInstalled(product, resource); } + // tslint:disable-next-line:member-ordering no-any public uninstall(product: Product, resource?: Uri): Promise { return uninstallproduct(product, resource); } + // tslint:disable-next-line:member-ordering public disableLinter(product: Product, resource: Uri) { if (resource && !workspace.getWorkspaceFolder(resource)) { + // tslint:disable-next-line:no-non-null-assertion const settingToDisable = SettingToDisableProduct.get(product)!; const pythonConfig = workspace.getConfiguration('python', resource); return pythonConfig.update(settingToDisable, false, ConfigurationTarget.Workspace); - } - else { + } else { const pythonConfig = workspace.getConfiguration('python'); return pythonConfig.update('linting.enabledWithoutWorkspace', false, true); } } + // tslint:disable-next-line:no-any private updateSetting(setting: string, value: any, resource?: Uri) { if (resource && !workspace.getWorkspaceFolder(resource)) { const pythonConfig = workspace.getConfiguration('python', resource); return pythonConfig.update(setting, value, ConfigurationTarget.Workspace); - } - else { + } else { const pythonConfig = workspace.getConfiguration('python'); return pythonConfig.update(setting, value, true); } @@ -351,6 +359,7 @@ async function isProductInstalled(product: Product, resource?: Uri): Promise !isNotInstalledError(reason)); } +// tslint:disable-next-line:no-any function uninstallproduct(product: Product, resource?: Uri): Promise { if (!ProductUninstallScripts.has(product)) { return Promise.resolve(); } + // tslint:disable-next-line:no-non-null-assertion const uninstallArgs = ProductUninstallScripts.get(product)!; return execPythonFile(resource, 'python', uninstallArgs, getCwdForInstallScript(resource), false); } diff --git a/src/client/common/utils.ts b/src/client/common/utils.ts index 2d171de81d19..85930a837a8c 100644 --- a/src/client/common/utils.ts +++ b/src/client/common/utils.ts @@ -165,7 +165,7 @@ function handleResponse(file: string, includeErrorAsResponse: boolean, error: Er // pylint: // In the case of pylint we have some messages (such as config file not found and using default etc...) being returned in stderr - // These error messages are useless when using pylint + // These error messages are useless when using pylint if (includeErrorAsResponse && (stdout.length > 0 || stderr.length > 0)) { return Promise.resolve(stdout + '\n' + stderr); } @@ -189,7 +189,7 @@ function handlePythonModuleResponse(includeErrorAsResponse: boolean, error: Erro // pylint: // In the case of pylint we have some messages (such as config file not found and using default etc...) being returned in stderr - // These error messages are useless when using pylint + // These error messages are useless when using pylint if (includeErrorAsResponse && (stdout.length > 0 || stderr.length > 0)) { return Promise.resolve(stdout + '\n' + stderr); } diff --git a/src/client/linters/baseLinter.ts b/src/client/linters/baseLinter.ts index 551b6bc84622..46c89d16dff9 100644 --- a/src/client/linters/baseLinter.ts +++ b/src/client/linters/baseLinter.ts @@ -144,7 +144,7 @@ export abstract class BaseLinter { }); } - protected handleError(expectedFileName: string, fileName: string, error: Error, resource?: Uri) { + protected handleError(expectedFileName: string, fileName: string, error: Error, resource: Uri) { this._errorHandler.handleError(expectedFileName, fileName, error, resource); } } diff --git a/src/client/linters/errorHandlers/main.ts b/src/client/linters/errorHandlers/main.ts index cc9120627929..e8b9c4d82b35 100644 --- a/src/client/linters/errorHandlers/main.ts +++ b/src/client/linters/errorHandlers/main.ts @@ -2,10 +2,11 @@ import { OutputChannel, Uri } from 'vscode'; import { Installer, Product } from '../../common/installer'; import { InvalidArgumentsErrorHandler } from './invalidArgs'; -import { StandardErrorHandler } from './standard'; import { NotInstalledErrorHandler } from './notInstalled'; +import { StandardErrorHandler } from './standard'; export class ErrorHandler { + // tslint:disable-next-line:variable-name private _errorHandlers: StandardErrorHandler[] = []; constructor(protected id: string, protected product: Product, protected installer: Installer, protected outputChannel: OutputChannel) { this._errorHandlers = [ @@ -15,7 +16,7 @@ export class ErrorHandler { ]; } - public handleError(expectedFileName: string, fileName: string, error: Error, resource?: Uri) { - this._errorHandlers.some(handler => handler.handleError(expectedFileName, fileName, error)); + public handleError(expectedFileName: string, fileName: string, error: Error, resource: Uri) { + this._errorHandlers.some(handler => handler.handleError(expectedFileName, fileName, error, resource)); } -} \ No newline at end of file +} diff --git a/src/client/linters/errorHandlers/standard.ts b/src/client/linters/errorHandlers/standard.ts index e9f665a0d09c..966f8afa41a9 100644 --- a/src/client/linters/errorHandlers/standard.ts +++ b/src/client/linters/errorHandlers/standard.ts @@ -6,7 +6,7 @@ export class StandardErrorHandler { constructor(protected id: string, protected product: Product, protected installer: Installer, protected outputChannel: OutputChannel) { } - private displayLinterError(resource?: Uri) { + private displayLinterError(resource: Uri) { const message = `There was an error in running the linter '${this.id}'`; window.showErrorMessage(message, 'Disable linter', 'View Errors').then(item => { switch (item) { @@ -22,7 +22,7 @@ export class StandardErrorHandler { }); } - public handleError(expectedFileName: string, fileName: string, error: Error, resource?: Uri): boolean { + public handleError(expectedFileName: string, fileName: string, error: Error, resource: Uri): boolean { if (typeof error === 'string' && (error as string).indexOf("OSError: [Errno 2] No such file or directory: '/") > 0) { return false; } diff --git a/src/client/unittests/codeLenses/main.ts b/src/client/unittests/codeLenses/main.ts index 40c4a58754d3..3efd30091227 100644 --- a/src/client/unittests/codeLenses/main.ts +++ b/src/client/unittests/codeLenses/main.ts @@ -1,16 +1,17 @@ import * as vscode from 'vscode'; import * as constants from '../../common/constants'; - -import { TestFileCodeLensProvider } from './testFiles'; import { PythonSymbolProvider } from '../../providers/symbolProvider'; +import { ITestCollectionStorageService } from '../common/types'; +import { TestFileCodeLensProvider } from './testFiles'; + +export function activateCodeLenses(onDidChange: vscode.EventEmitter, + symboldProvider: PythonSymbolProvider, testCollectionStorage: ITestCollectionStorageService): vscode.Disposable { -export function activateCodeLenses(onDidChange: vscode.EventEmitter, symboldProvider: PythonSymbolProvider): vscode.Disposable { const disposables: vscode.Disposable[] = []; - disposables.push(vscode.languages.registerCodeLensProvider(constants.PythonLanguage, new TestFileCodeLensProvider(onDidChange, symboldProvider))); + const codeLensProvider = new TestFileCodeLensProvider(onDidChange, symboldProvider, testCollectionStorage); + disposables.push(vscode.languages.registerCodeLensProvider(constants.PythonLanguage, codeLensProvider)); return { - dispose: function () { - disposables.forEach(d => d.dispose()); - } + dispose: () => { disposables.forEach(d => d.dispose()); } }; -} \ No newline at end of file +} diff --git a/src/client/unittests/codeLenses/testFiles.ts b/src/client/unittests/codeLenses/testFiles.ts index a9976899b74e..bfb3fd550ffe 100644 --- a/src/client/unittests/codeLenses/testFiles.ts +++ b/src/client/unittests/codeLenses/testFiles.ts @@ -1,124 +1,129 @@ 'use strict'; -import * as vscode from 'vscode'; -import { CodeLensProvider, TextDocument, CancellationToken, CodeLens, SymbolInformation } from 'vscode'; -import { TestFile, TestsToRun, TestSuite, TestFunction, TestStatus } from '../common/contracts'; +import { CancellationToken, CancellationTokenSource, CodeLens, CodeLensProvider, Event, EventEmitter, Position, Range, SymbolInformation, SymbolKind, TextDocument, workspace } from 'vscode'; +import { Uri } from 'vscode'; import * as constants from '../../common/constants'; -import { getDiscoveredTests } from '../common/testUtils'; import { PythonSymbolProvider } from '../../providers/symbolProvider'; +import { ITestCollectionStorageService, TestFile, TestFunction, TestStatus, TestsToRun, TestSuite } from '../common/types'; -interface CodeLensData { - symbolKind: vscode.SymbolKind; - symbolName: string; - fileName: string; -} -interface FunctionsAndSuites { +type FunctionsAndSuites = { functions: TestFunction[]; suites: TestSuite[]; -} +}; export class TestFileCodeLensProvider implements CodeLensProvider { - constructor(private _onDidChange: vscode.EventEmitter, private symbolProvider: PythonSymbolProvider) { + // tslint:disable-next-line:variable-name + constructor(private _onDidChange: EventEmitter, + private symbolProvider: PythonSymbolProvider, + private testCollectionStorage: ITestCollectionStorageService) { } - get onDidChangeCodeLenses(): vscode.Event { + get onDidChangeCodeLenses(): Event { return this._onDidChange.event; } - public provideCodeLenses(document: TextDocument, token: CancellationToken): Thenable { - let testItems = getDiscoveredTests(); + public async provideCodeLenses(document: TextDocument, token: CancellationToken) { + const wkspace = workspace.getWorkspaceFolder(document.uri); + if (!wkspace) { + return []; + } + const testItems = this.testCollectionStorage.getTests(wkspace.uri); if (!testItems || testItems.testFiles.length === 0 || testItems.testFunctions.length === 0) { - return Promise.resolve([]); + return []; } - let cancelTokenSrc = new vscode.CancellationTokenSource(); + const cancelTokenSrc = new CancellationTokenSource(); token.onCancellationRequested(() => { cancelTokenSrc.cancel(); }); // Strop trying to build the code lenses if unable to get a list of - // symbols in this file afrer x time + // symbols in this file afrer x time. setTimeout(() => { if (!cancelTokenSrc.token.isCancellationRequested) { cancelTokenSrc.cancel(); } }, constants.Delays.MaxUnitTestCodeLensDelay); - return getCodeLenses(document, token, this.symbolProvider); + return this.getCodeLenses(document, token, this.symbolProvider); } - resolveCodeLens(codeLens: CodeLens, token: CancellationToken): CodeLens | Thenable { + public resolveCodeLens(codeLens: CodeLens, token: CancellationToken): CodeLens | Thenable { codeLens.command = { command: 'python.runtests', title: 'Test' }; return Promise.resolve(codeLens); } -} -function getCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken, symbolProvider: PythonSymbolProvider): Thenable { - const documentUri = document.uri; - const tests = getDiscoveredTests(); - if (!tests) { - return null; - } - const file = tests.testFiles.find(file => file.fullPath === documentUri.fsPath); - if (!file) { - return Promise.resolve([]); + private async getCodeLenses(document: TextDocument, token: CancellationToken, symbolProvider: PythonSymbolProvider) { + const wkspace = workspace.getWorkspaceFolder(document.uri); + if (!wkspace) { + return []; + } + const tests = this.testCollectionStorage.getTests(wkspace.uri); + if (!tests) { + return []; + } + const file = tests.testFiles.find(item => item.fullPath === document.uri.fsPath); + if (!file) { + return []; + } + const allFuncsAndSuites = getAllTestSuitesAndFunctionsPerFile(file); + + return symbolProvider.provideDocumentSymbolsForInternalUse(document, token) + .then((symbols: SymbolInformation[]) => { + return symbols.filter(symbol => { + return symbol.kind === SymbolKind.Function || + symbol.kind === SymbolKind.Method || + symbol.kind === SymbolKind.Class; + }).map(symbol => { + // This is bloody crucial, if the start and end columns are the same + // then vscode goes bonkers when ever you edit a line (start scrolling magically). + const range = new Range(symbol.location.range.start, + new Position(symbol.location.range.end.line, + symbol.location.range.end.character + 1)); + + return this.getCodeLens(document.uri, allFuncsAndSuites, + range, symbol.name, symbol.kind, symbol.containerName); + }).reduce((previous, current) => previous.concat(current), []).filter(codeLens => codeLens !== null); + }, reason => { + if (token.isCancellationRequested) { + return []; + } + return Promise.reject(reason); + }); } - const allFuncsAndSuites = getAllTestSuitesAndFunctionsPerFile(file); - - return symbolProvider.provideDocumentSymbolsForInternalUse(document, token) - .then((symbols: vscode.SymbolInformation[]) => { - return symbols.filter(symbol => { - return symbol.kind === vscode.SymbolKind.Function || - symbol.kind === vscode.SymbolKind.Method || - symbol.kind === vscode.SymbolKind.Class; - }).map(symbol => { - // This is bloody crucial, if the start and end columns are the same - // then vscode goes bonkers when ever you edit a line (start scrolling magically) - const range = new vscode.Range(symbol.location.range.start, - new vscode.Position(symbol.location.range.end.line, - symbol.location.range.end.character + 1)); - - return getCodeLens(documentUri.fsPath, allFuncsAndSuites, - range, symbol.name, symbol.kind, symbol.containerName); - }).reduce((previous, current) => previous.concat(current), []).filter(codeLens => codeLens !== null); - }, reason => { - if (token.isCancellationRequested) { - return []; - } - return Promise.reject(reason); - }); -} -function getCodeLens(fileName: string, allFuncsAndSuites: FunctionsAndSuites, - range: vscode.Range, symbolName: string, symbolKind: vscode.SymbolKind, symbolContainer: string): vscode.CodeLens[] { + private getCodeLens(file: Uri, allFuncsAndSuites: FunctionsAndSuites, + range: Range, symbolName: string, symbolKind: SymbolKind, symbolContainer: string): CodeLens[] { - switch (symbolKind) { - case vscode.SymbolKind.Function: - case vscode.SymbolKind.Method: { - return getFunctionCodeLens(fileName, allFuncsAndSuites, symbolName, range, symbolContainer); - } - case vscode.SymbolKind.Class: { - const cls = allFuncsAndSuites.suites.find(cls => cls.name === symbolName); - if (!cls) { - return null; + switch (symbolKind) { + case SymbolKind.Function: + case SymbolKind.Method: { + return getFunctionCodeLens(file, allFuncsAndSuites, symbolName, range, symbolContainer); + } + case SymbolKind.Class: { + const cls = allFuncsAndSuites.suites.find(item => item.name === symbolName); + if (!cls) { + return null; + } + return [ + new CodeLens(range, { + title: getTestStatusIcon(cls.status) + constants.Text.CodeLensRunUnitTest, + command: constants.Commands.Tests_Run, + arguments: [file, { testSuite: [cls] }] + }), + new CodeLens(range, { + title: getTestStatusIcon(cls.status) + constants.Text.CodeLensDebugUnitTest, + command: constants.Commands.Tests_Debug, + arguments: [file, { testSuite: [cls] }] + }) + ]; + } + default: { + return []; } - return [ - new CodeLens(range, { - title: getTestStatusIcon(cls.status) + constants.Text.CodeLensRunUnitTest, - command: constants.Commands.Tests_Run, - arguments: [{ testSuite: [cls] }] - }), - new CodeLens(range, { - title: getTestStatusIcon(cls.status) + constants.Text.CodeLensDebugUnitTest, - command: constants.Commands.Tests_Debug, - arguments: [{ testSuite: [cls] }] - }) - ]; } } - - return null; } -function getTestStatusIcon(status: TestStatus): string { +function getTestStatusIcon(status?: TestStatus): string { switch (status) { case TestStatus.Pass: { return '✔ '; @@ -137,7 +142,7 @@ function getTestStatusIcon(status: TestStatus): string { } function getTestStatusIcons(fns: TestFunction[]): string { - let statuses: string[] = []; + const statuses: string[] = []; let count = fns.filter(fn => fn.status === TestStatus.Pass).length; if (count > 0) { statuses.push(`✔ ${count}`); @@ -153,15 +158,14 @@ function getTestStatusIcons(fns: TestFunction[]): string { return statuses.join(' '); } -function getFunctionCodeLens(filePath: string, functionsAndSuites: FunctionsAndSuites, - symbolName: string, range: vscode.Range, symbolContainer: string): vscode.CodeLens[] { +function getFunctionCodeLens(file: Uri, functionsAndSuites: FunctionsAndSuites, + symbolName: string, range: Range, symbolContainer: string): CodeLens[] { - let fn: TestFunction; + let fn: TestFunction | undefined; if (symbolContainer.length === 0) { - fn = functionsAndSuites.functions.find(fn => fn.name === symbolName); - } - else { - // Assume single levels for now + fn = functionsAndSuites.functions.find(func => func.name === symbolName); + } else { + // Assume single levels for now. functionsAndSuites.suites .filter(s => s.name === symbolContainer) .forEach(s => { @@ -177,54 +181,55 @@ function getFunctionCodeLens(filePath: string, functionsAndSuites: FunctionsAndS new CodeLens(range, { title: getTestStatusIcon(fn.status) + constants.Text.CodeLensRunUnitTest, command: constants.Commands.Tests_Run, - arguments: [{ testFunction: [fn] }] + arguments: [file, { testFunction: [fn] }] }), new CodeLens(range, { title: getTestStatusIcon(fn.status) + constants.Text.CodeLensDebugUnitTest, command: constants.Commands.Tests_Debug, - arguments: [{ testFunction: [fn] }] + arguments: [file, { testFunction: [fn] }] }) ]; } - // Ok, possible we're dealing with parameterized unit tests - // If we have [ in the name, then this is a parameterized function - let functions = functionsAndSuites.functions.filter(fn => fn.name.startsWith(symbolName + '[') && fn.name.endsWith(']')); + // Ok, possible we're dealing with parameterized unit tests. + // If we have [ in the name, then this is a parameterized function. + const functions = functionsAndSuites.functions.filter(func => func.name.startsWith(`${symbolName}[`) && func.name.endsWith(']')); if (functions.length === 0) { - return null; + return []; } if (functions.length === 0) { return [ new CodeLens(range, { title: constants.Text.CodeLensRunUnitTest, command: constants.Commands.Tests_Run, - arguments: [{ testFunction: functions }] + arguments: [file, { testFunction: functions }] }), new CodeLens(range, { title: constants.Text.CodeLensDebugUnitTest, command: constants.Commands.Tests_Debug, - arguments: [{ testFunction: functions }] + arguments: [file, { testFunction: functions }] }) ]; } - // Find all flattened functions + // Find all flattened functions. return [ new CodeLens(range, { - title: getTestStatusIcons(functions) + constants.Text.CodeLensRunUnitTest + ' (Multiple)', + title: `${getTestStatusIcons(functions)}${constants.Text.CodeLensRunUnitTest} (Multiple)`, command: constants.Commands.Tests_Picker_UI, - arguments: [filePath, functions] + arguments: [file, functions] }), new CodeLens(range, { - title: getTestStatusIcons(functions) + constants.Text.CodeLensDebugUnitTest + ' (Multiple)', + title: `${getTestStatusIcons(functions)}${constants.Text.CodeLensDebugUnitTest} (Multiple)`, command: constants.Commands.Tests_Picker_UI_Debug, - arguments: [filePath, functions] + arguments: [file, functions] }) ]; } function getAllTestSuitesAndFunctionsPerFile(testFile: TestFile): FunctionsAndSuites { - const all = { functions: testFile.functions, suites: [] }; + // tslint:disable-next-line:prefer-type-cast + const all = { functions: testFile.functions, suites: [] as TestSuite[] }; testFile.suites.forEach(suite => { all.suites.push(suite); @@ -247,4 +252,4 @@ function getAllTestSuitesAndFunctions(testSuite: TestSuite): FunctionsAndSuites all.suites.push(...allChildItems.suites); }); return all; -} \ No newline at end of file +} diff --git a/src/client/unittests/common/baseTestManager.ts b/src/client/unittests/common/baseTestManager.ts index b00035890a54..4b4612c7da85 100644 --- a/src/client/unittests/common/baseTestManager.ts +++ b/src/client/unittests/common/baseTestManager.ts @@ -1,37 +1,52 @@ // import {TestFolder, TestsToRun, Tests, TestFile, TestSuite, TestFunction, TestStatus, FlattenedTestFunction, FlattenedTestSuite, CANCELLATION_REASON} from './contracts'; -import { Tests, TestStatus, TestsToRun, CANCELLATION_REASON } from './contracts'; import * as vscode from 'vscode'; -import { resetTestResults, displayTestErrorMessage, storeDiscoveredTests } from './testUtils'; -import { Installer, Product } from '../../common/installer'; -import { isNotInstalledError } from '../../common/helpers'; +import { Uri, workspace } from 'vscode'; import { IPythonSettings, PythonSettings } from '../../common/configSettings'; +import { isNotInstalledError } from '../../common/helpers'; +import { Installer, Product } from '../../common/installer'; +import { CANCELLATION_REASON } from './constants'; +import { displayTestErrorMessage } from './testUtils'; +import { ITestCollectionStorageService, ITestResultsService, ITestsHelper, Tests, TestStatus, TestsToRun } from './types'; enum CancellationTokenType { - testDicovery, + testDiscovery, testRunner } export abstract class BaseTestManager { + public readonly workspace: Uri; + protected readonly settings: IPythonSettings; private tests: Tests; + // tslint:disable-next-line:variable-name private _status: TestStatus = TestStatus.Unknown; private testDiscoveryCancellationTokenSource: vscode.CancellationTokenSource; private testRunnerCancellationTokenSource: vscode.CancellationTokenSource; private installer: Installer; - protected get testDiscoveryCancellationToken(): vscode.CancellationToken { - if (this.testDiscoveryCancellationTokenSource) { - return this.testDiscoveryCancellationTokenSource.token; - } + private discoverTestsPromise: Promise; + constructor(private testProvider: string, private product: Product, protected rootDirectory: string, + protected outputChannel: vscode.OutputChannel, private testCollectionStorage: ITestCollectionStorageService, + protected testResultsService: ITestResultsService, protected testsHelper: ITestsHelper) { + this._status = TestStatus.Unknown; + this.installer = new Installer(); + this.settings = PythonSettings.getInstance(this.rootDirectory ? Uri.file(this.rootDirectory) : undefined); + this.workspace = workspace.getWorkspaceFolder(Uri.file(this.rootDirectory)).uri; } - protected get testRunnerCancellationToken(): vscode.CancellationToken { - if (this.testRunnerCancellationTokenSource) { - return this.testRunnerCancellationTokenSource.token; - } + protected get testDiscoveryCancellationToken(): vscode.CancellationToken | undefined { + return this.testDiscoveryCancellationTokenSource ? this.testDiscoveryCancellationTokenSource.token : undefined; + } + protected get testRunnerCancellationToken(): vscode.CancellationToken | undefined { + return this.testRunnerCancellationTokenSource ? this.testRunnerCancellationTokenSource.token : undefined; } public dispose() { + this.stop(); } public get status(): TestStatus { return this._status; } + public get workingDirectory(): string { + const settings = PythonSettings.getInstance(vscode.Uri.file(this.rootDirectory)); + return settings.unitTest.cwd && settings.unitTest.cwd.length > 0 ? settings.unitTest.cwd : this.rootDirectory; + } public stop() { if (this.testDiscoveryCancellationTokenSource) { this.testDiscoveryCancellationTokenSource.cancel(); @@ -40,12 +55,6 @@ export abstract class BaseTestManager { this.testRunnerCancellationTokenSource.cancel(); } } - protected readonly settings: IPythonSettings; - constructor(private testProvider: string, private product: Product, protected rootDirectory: string, protected outputChannel: vscode.OutputChannel) { - this._status = TestStatus.Unknown; - this.installer = new Installer(); - this.settings = PythonSettings.getInstance(vscode.Uri.file(this.rootDirectory)); - } public reset() { this._status = TestStatus.Unknown; this.tests = null; @@ -55,31 +64,9 @@ export abstract class BaseTestManager { return; } - resetTestResults(this.tests); - } - private createCancellationToken(tokenType: CancellationTokenType) { - this.disposeCancellationToken(tokenType); - if (tokenType === CancellationTokenType.testDicovery) { - this.testDiscoveryCancellationTokenSource = new vscode.CancellationTokenSource(); - } else { - this.testRunnerCancellationTokenSource = new vscode.CancellationTokenSource(); - } - } - private disposeCancellationToken(tokenType: CancellationTokenType) { - if (tokenType === CancellationTokenType.testDicovery) { - if (this.testDiscoveryCancellationTokenSource) { - this.testDiscoveryCancellationTokenSource.dispose(); - } - this.testDiscoveryCancellationTokenSource = null; - } else { - if (this.testRunnerCancellationTokenSource) { - this.testRunnerCancellationTokenSource.dispose(); - } - this.testRunnerCancellationTokenSource = null; - } + this.testResultsService.resetResults(this.tests); } - private discoverTestsPromise: Promise; - discoverTests(ignoreCache: boolean = false, quietMode: boolean = false, isUserInitiated: boolean = true): Promise { + public async discoverTests(ignoreCache: boolean = false, quietMode: boolean = false, userInitiated: boolean = false): Promise { if (this.discoverTestsPromise) { return this.discoverTestsPromise; } @@ -89,10 +76,14 @@ export abstract class BaseTestManager { return Promise.resolve(this.tests); } this._status = TestStatus.Discovering; - if (isUserInitiated) { + + // If ignoreCache is true, its an indication of the fact that its a user invoked operation. + // Hence we can stop the debugger. + if (userInitiated) { this.stop(); } - this.createCancellationToken(CancellationTokenType.testDicovery); + + this.createCancellationToken(CancellationTokenType.testDiscovery); return this.discoverTestsPromise = this.discoverTestsImpl(ignoreCache) .then(tests => { this.tests = tests; @@ -114,13 +105,15 @@ export abstract class BaseTestManager { if (haveErrorsInDiscovering && !quietMode) { displayTestErrorMessage('There were some errors in disovering unit tests'); } - storeDiscoveredTests(tests); - this.disposeCancellationToken(CancellationTokenType.testDicovery); + const wkspace = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(this.rootDirectory)).uri; + this.testCollectionStorage.storeTests(wkspace, tests); + this.disposeCancellationToken(CancellationTokenType.testDiscovery); return tests; }).catch(reason => { if (isNotInstalledError(reason) && !quietMode) { - this.installer.promptToInstall(this.product, vscode.Uri.file(this.rootDirectory)); + // tslint:disable-next-line:no-floating-promises + this.installer.promptToInstall(this.product, this.workspace); } this.tests = null; @@ -128,24 +121,20 @@ export abstract class BaseTestManager { if (this.testDiscoveryCancellationToken && this.testDiscoveryCancellationToken.isCancellationRequested) { reason = CANCELLATION_REASON; this._status = TestStatus.Idle; - } - else { + } else { this._status = TestStatus.Error; this.outputChannel.appendLine('Test Disovery failed: '); + // tslint:disable-next-line:prefer-template this.outputChannel.appendLine('' + reason); } - storeDiscoveredTests(null); - this.disposeCancellationToken(CancellationTokenType.testDicovery); + const wkspace = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(this.rootDirectory)).uri; + this.testCollectionStorage.storeTests(wkspace, null); + this.disposeCancellationToken(CancellationTokenType.testDiscovery); return Promise.reject(reason); }); } - abstract discoverTestsImpl(ignoreCache: boolean, debug?: boolean): Promise; - public runTest(testsToRun?: TestsToRun, debug?: boolean): Promise; - public runTest(runFailedTests?: boolean, debug?: boolean): Promise; - public runTest(args: any, debug?: boolean): Promise { - let runFailedTests = false; - let testsToRun: TestsToRun = null; - let moreInfo = { + public runTest(testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise { + const moreInfo = { Test_Provider: this.testProvider, Run_Failed_Tests: 'false', Run_Specific_File: 'false', @@ -153,12 +142,11 @@ export abstract class BaseTestManager { Run_Specific_Function: 'false' }; - if (typeof args === 'boolean') { - runFailedTests = args === true; + if (runFailedTests === true) { + // tslint:disable-next-line:prefer-template moreInfo.Run_Failed_Tests = runFailedTests + ''; } - if (typeof args === 'object' && args !== null) { - testsToRun = args; + if (testsToRun && typeof testsToRun === 'object') { if (Array.isArray(testsToRun.testFile) && testsToRun.testFile.length > 0) { moreInfo.Run_Specific_File = 'true'; } @@ -175,7 +163,6 @@ export abstract class BaseTestManager { this._status = TestStatus.Running; this.stop(); - this.createCancellationToken(CancellationTokenType.testDicovery); // If running failed tests, then don't clear the previously build UnitTests // If we do so, then we end up re-discovering the unit tests and clearing previously cached list of failed tests // Similarly, if running a specific test or test file, don't clear the cache (possible tests have some state information retained) @@ -187,7 +174,7 @@ export abstract class BaseTestManager { } displayTestErrorMessage('Errors in discovering tests, continuing with tests'); return { - rootTestFolders: [], testFiles: [], testFolders: [], testFunctions: [], testSuits: [], + rootTestFolders: [], testFiles: [], testFolders: [], testFunctions: [], testSuites: [], summary: { errors: 0, failures: 0, passed: 0, skipped: 0 } }; }) @@ -202,13 +189,35 @@ export abstract class BaseTestManager { if (this.testRunnerCancellationToken && this.testRunnerCancellationToken.isCancellationRequested) { reason = CANCELLATION_REASON; this._status = TestStatus.Idle; - } - else { + } else { this._status = TestStatus.Error; } this.disposeCancellationToken(CancellationTokenType.testRunner); return Promise.reject(reason); }); } - abstract runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise; + // tslint:disable-next-line:no-any + protected abstract runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise; + protected abstract discoverTestsImpl(ignoreCache: boolean, debug?: boolean): Promise; + private createCancellationToken(tokenType: CancellationTokenType) { + this.disposeCancellationToken(tokenType); + if (tokenType === CancellationTokenType.testDiscovery) { + this.testDiscoveryCancellationTokenSource = new vscode.CancellationTokenSource(); + } else { + this.testRunnerCancellationTokenSource = new vscode.CancellationTokenSource(); + } + } + private disposeCancellationToken(tokenType: CancellationTokenType) { + if (tokenType === CancellationTokenType.testDiscovery) { + if (this.testDiscoveryCancellationTokenSource) { + this.testDiscoveryCancellationTokenSource.dispose(); + } + this.testDiscoveryCancellationTokenSource = null; + } else { + if (this.testRunnerCancellationTokenSource) { + this.testRunnerCancellationTokenSource.dispose(); + } + this.testRunnerCancellationTokenSource = null; + } + } } diff --git a/src/client/unittests/common/configSettingService.ts b/src/client/unittests/common/configSettingService.ts new file mode 100644 index 000000000000..f6a90619f9fe --- /dev/null +++ b/src/client/unittests/common/configSettingService.ts @@ -0,0 +1,67 @@ +import { ConfigurationTarget, Uri, workspace, WorkspaceConfiguration } from 'vscode'; +import { Product } from '../../common/installer'; +import { ITestConfigSettingsService, UnitTestProduct } from './types'; + +export class TestConfigSettingsService implements ITestConfigSettingsService { + private static getTestArgSetting(product: UnitTestProduct) { + switch (product) { + case Product.unittest: + return 'unitTest.unittestArgs'; + case Product.pytest: + return 'unitTest.pyTestArgs'; + case Product.nosetest: + return 'unitTest.nosetestArgs'; + default: + throw new Error('Invalid Test Product'); + } + } + private static getTestEnablingSetting(product: UnitTestProduct) { + switch (product) { + case Product.unittest: + return 'unitTest.unittestEnabled'; + case Product.pytest: + return 'unitTest.pyTestEnabled'; + case Product.nosetest: + return 'unitTest.nosetestsEnabled'; + default: + throw new Error('Invalid Test Product'); + } + } + // tslint:disable-next-line:no-any + private static async updateSetting(testDirectory: string | Uri, setting: string, value: any) { + let pythonConfig: WorkspaceConfiguration; + let configTarget: ConfigurationTarget; + const resource = typeof testDirectory === 'string' ? Uri.file(testDirectory) : testDirectory; + if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length === 0) { + configTarget = ConfigurationTarget.Workspace; + pythonConfig = workspace.getConfiguration('python'); + } else if (workspace.workspaceFolders.length === 1) { + configTarget = ConfigurationTarget.Workspace; + pythonConfig = workspace.getConfiguration('python', workspace.workspaceFolders[0].uri); + } else { + configTarget = ConfigurationTarget.Workspace; + const workspaceFolder = workspace.getWorkspaceFolder(resource); + if (!workspaceFolder) { + throw new Error(`Test directory does not belong to any workspace (${testDirectory})`); + } + // tslint:disable-next-line:no-non-null-assertion + pythonConfig = workspace.getConfiguration('python', workspaceFolder!.uri); + } + + return pythonConfig.update(setting, value); + } + public async updateTestArgs(testDirectory: string | Uri, product: UnitTestProduct, args: string[]) { + const setting = TestConfigSettingsService.getTestArgSetting(product); + return TestConfigSettingsService.updateSetting(testDirectory, setting, args); + } + + public async enable(testDirectory: string | Uri, product: UnitTestProduct): Promise { + const setting = TestConfigSettingsService.getTestEnablingSetting(product); + return TestConfigSettingsService.updateSetting(testDirectory, setting, true); + } + + public async disable(testDirectory: string | Uri, product: UnitTestProduct): Promise { + const setting = TestConfigSettingsService.getTestEnablingSetting(product); + return TestConfigSettingsService.updateSetting(testDirectory, setting, false); + } +} diff --git a/src/client/unittests/common/constants.ts b/src/client/unittests/common/constants.ts new file mode 100644 index 000000000000..3d428cfad97c --- /dev/null +++ b/src/client/unittests/common/constants.ts @@ -0,0 +1 @@ +export const CANCELLATION_REASON = 'cancelled_user_request'; diff --git a/src/client/unittests/common/contracts.ts b/src/client/unittests/common/contracts.ts deleted file mode 100644 index 02dc03c18980..000000000000 --- a/src/client/unittests/common/contracts.ts +++ /dev/null @@ -1,89 +0,0 @@ -export const CANCELLATION_REASON = 'cancelled_user_request'; - -export interface TestFolder extends TestResult { - name: string; - testFiles: TestFile[]; - nameToRun: string; - status?: TestStatus; - folders: TestFolder[]; -} -export interface TestFile extends TestResult { - name: string; - fullPath: string; - functions: TestFunction[]; - suites: TestSuite[]; - nameToRun: string; - xmlName: string; - status?: TestStatus; - errorsWhenDiscovering?: string; -} -export interface TestSuite extends TestResult { - name: string; - functions: TestFunction[]; - suites: TestSuite[]; - isUnitTest: Boolean; - isInstance: Boolean; - nameToRun: string; - xmlName: string; - status?: TestStatus; -} -export interface TestFunction extends TestResult { - name: string; - nameToRun: string; - status?: TestStatus; -} -export interface TestResult extends Node { - passed?: boolean; - time: number; - line?: number; - message?: string; - traceback?: string; - functionsPassed?: number; - functionsFailed?: number; - functionsDidNotRun?: number; -} -export interface Node { - expanded?: Boolean; -} -export interface FlattenedTestFunction { - testFunction: TestFunction; - parentTestSuite?: TestSuite; - parentTestFile: TestFile; - xmlClassName: string; -} -export interface FlattenedTestSuite { - testSuite: TestSuite; - parentTestSuite?: TestSuite; - parentTestFile: TestFile; - xmlClassName: string; -} -export interface TestSummary { - passed: number; - failures: number; - errors: number; - skipped: number; -} -export interface Tests { - summary: TestSummary; - testFiles: TestFile[]; - testFunctions: FlattenedTestFunction[]; - testSuits: FlattenedTestSuite[]; - testFolders: TestFolder[]; - rootTestFolders: TestFolder[]; -} -export enum TestStatus { - Unknown, - Discovering, - Idle, - Running, - Fail, - Error, - Skipped, - Pass -} -export interface TestsToRun { - testFolder?: TestFolder[]; - testFile?: TestFile[]; - testSuite?: TestSuite[]; - testFunction?: TestFunction[]; -} diff --git a/src/client/unittests/common/debugLauncher.ts b/src/client/unittests/common/debugLauncher.ts index a1d754f20dbd..783dbc24fd7c 100644 --- a/src/client/unittests/common/debugLauncher.ts +++ b/src/client/unittests/common/debugLauncher.ts @@ -1,63 +1,68 @@ import * as os from 'os'; -import { CancellationToken, debug, OutputChannel, workspace, Uri } from 'vscode'; +import { CancellationToken, debug, OutputChannel, Uri, workspace } from 'vscode'; import { PythonSettings } from '../../common/configSettings'; -import { execPythonFile } from './../../common/utils'; import { createDeferred } from './../../common/helpers'; +import { execPythonFile } from './../../common/utils'; +import { ITestDebugLauncher } from './types'; -export function launchDebugger(rootDirectory: string, testArgs: string[], token?: CancellationToken, outChannel?: OutputChannel) { - const pythonSettings = PythonSettings.getInstance(rootDirectory ? Uri.file(rootDirectory) : undefined); - const def = createDeferred(); - const launchDef = createDeferred(); - let outputChannelShown = false; - execPythonFile(rootDirectory, pythonSettings.pythonPath, testArgs, rootDirectory, true, (data: string) => { - if (data.startsWith('READY' + os.EOL)) { - // debug socket server has started. - launchDef.resolve(); - data = data.substring(('READY' + os.EOL).length); - } +export class DebugLauncher implements ITestDebugLauncher { + public async launchDebugger(rootDirectory: string, testArgs: string[], token?: CancellationToken, outChannel?: OutputChannel) { + const pythonSettings = PythonSettings.getInstance(rootDirectory ? Uri.file(rootDirectory) : undefined); + // tslint:disable-next-line:no-any + const def = createDeferred(); + // tslint:disable-next-line:no-any + const launchDef = createDeferred(); + let outputChannelShown = false; + execPythonFile(rootDirectory, pythonSettings.pythonPath, testArgs, rootDirectory, true, (data: string) => { + if (data.startsWith(`READY${os.EOL}`)) { + // debug socket server has started. + launchDef.resolve(); + data = data.substring((`READY${os.EOL}`).length); + } - if (!outputChannelShown) { - outputChannelShown = true; - outChannel.show(); - } - outChannel.append(data); - }, token).catch(reason => { - if (!def.rejected && !def.resolved) { - def.reject(reason); - } - }).then(() => { - if (!def.rejected && !def.resolved) { - def.resolve(); - } - }).catch(reason => { - if (!def.rejected && !def.resolved) { - def.reject(reason); - } - }); + if (!outputChannelShown) { + outputChannelShown = true; + outChannel.show(); + } + outChannel.append(data); + }, token).catch(reason => { + if (!def.rejected && !def.resolved) { + def.reject(reason); + } + }).then(() => { + if (!def.rejected && !def.resolved) { + def.resolve(); + } + }).catch(reason => { + if (!def.rejected && !def.resolved) { + def.reject(reason); + } + }); - launchDef.promise.then(() => { - if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length === 0) { - throw new Error('Please open a workspace'); - } - let workspaceFolder = workspace.getWorkspaceFolder(Uri.file(rootDirectory)); - if (!workspaceFolder) { - workspaceFolder = workspace.workspaceFolders[0]; - } - return debug.startDebugging(workspaceFolder, { - "name": "Debug Unit Test", - "type": "python", - "request": "attach", - "localRoot": rootDirectory, - "remoteRoot": rootDirectory, - "port": pythonSettings.unitTest.debugPort, - "secret": "my_secret", - "host": "localhost" + launchDef.promise.then(() => { + if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length === 0) { + throw new Error('Please open a workspace'); + } + let workspaceFolder = workspace.getWorkspaceFolder(Uri.file(rootDirectory)); + if (!workspaceFolder) { + workspaceFolder = workspace.workspaceFolders[0]; + } + return debug.startDebugging(workspaceFolder, { + name: 'Debug Unit Test', + type: 'python', + request: 'attach', + localRoot: rootDirectory, + remoteRoot: rootDirectory, + port: pythonSettings.unitTest.debugPort, + secret: 'my_secret', + host: 'localhost' + }); + }).catch(reason => { + if (!def.rejected && !def.resolved) { + def.reject(reason); + } }); - }).catch(reason => { - if (!def.rejected && !def.resolved) { - def.reject(reason); - } - }); - return def.promise; + return def.promise; + } } diff --git a/src/client/unittests/common/storageService.ts b/src/client/unittests/common/storageService.ts new file mode 100644 index 000000000000..775750de1fe9 --- /dev/null +++ b/src/client/unittests/common/storageService.ts @@ -0,0 +1,21 @@ +import { Uri, workspace } from 'vscode'; +import { ITestCollectionStorageService, Tests } from './types'; + +export class TestCollectionStorageService implements ITestCollectionStorageService { + private testsIndexedByWorkspaceUri = new Map(); + public getTests(wkspace: Uri): Tests | undefined { + const workspaceFolder = this.getWorkspaceFolderPath(wkspace) || ''; + return this.testsIndexedByWorkspaceUri.has(workspaceFolder) ? this.testsIndexedByWorkspaceUri.get(workspaceFolder) : undefined; + } + public storeTests(wkspace: Uri, tests: Tests | null | undefined): void { + const workspaceFolder = this.getWorkspaceFolderPath(wkspace) || ''; + this.testsIndexedByWorkspaceUri.set(workspaceFolder, tests); + } + public dispose() { + this.testsIndexedByWorkspaceUri.clear(); + } + private getWorkspaceFolderPath(resource: Uri): string | undefined { + const folder = workspace.getWorkspaceFolder(resource); + return folder ? folder.uri.path : undefined; + } +} diff --git a/src/client/unittests/common/testConfigurationManager.ts b/src/client/unittests/common/testConfigurationManager.ts index 8b031b7a63b2..afdad0192c87 100644 --- a/src/client/unittests/common/testConfigurationManager.ts +++ b/src/client/unittests/common/testConfigurationManager.ts @@ -1,14 +1,26 @@ +import * as path from 'path'; import * as vscode from 'vscode'; +import { Uri } from 'vscode'; import { createDeferred } from '../../common/helpers'; +import { Installer } from '../../common/installer'; import { getSubDirectories } from '../../common/utils'; -import * as path from 'path'; +import { ITestConfigSettingsService, UnitTestProduct } from './types'; export abstract class TestConfigurationManager { - public abstract enable(): Thenable; - public abstract disable(): Thenable; - constructor(protected readonly outputChannel: vscode.OutputChannel) { } - public abstract configure(rootDir: string): Promise; - + constructor(protected workspace: Uri, + protected product: UnitTestProduct, + protected readonly outputChannel: vscode.OutputChannel, + protected installer: Installer, + protected testConfigSettingsService: ITestConfigSettingsService) { } + // tslint:disable-next-line:no-any + public abstract configure(wkspace: Uri): Promise; + public async enable() { + return this.testConfigSettingsService.enable(this.workspace, this.product); + } + // tslint:disable-next-line:no-any + public async disable() { + return this.testConfigSettingsService.enable(this.workspace, this.product); + } protected selectTestDir(rootDir: string, subDirs: string[], customOptions: vscode.QuickPickItem[] = []): Promise { const options = { matchOnDescription: true, @@ -22,7 +34,7 @@ export abstract class TestConfigurationManager { } return { label: dirName, - description: '', + description: '' }; }).filter(item => item !== null); @@ -46,12 +58,12 @@ export abstract class TestConfigurationManager { matchOnDetail: true, placeHolder: 'Select the pattern to identify test files' }; - let items: vscode.QuickPickItem[] = [ - { label: '*test.py', description: `Python Files ending with 'test'` }, - { label: '*_test.py', description: `Python Files ending with '_test'` }, - { label: 'test*.py', description: `Python Files begining with 'test'` }, - { label: 'test_*.py', description: `Python Files begining with 'test_'` }, - { label: '*test*.py', description: `Python Files containing the word 'test'` } + const items: vscode.QuickPickItem[] = [ + { label: '*test.py', description: 'Python Files ending with \'test\'' }, + { label: '*_test.py', description: 'Python Files ending with \'_test\'' }, + { label: 'test*.py', description: 'Python Files begining with \'test\'' }, + { label: 'test_*.py', description: 'Python Files begining with \'test_\'' }, + { label: '*test*.py', description: 'Python Files containing the word \'test\'' } ]; const def = createDeferred(); @@ -65,17 +77,17 @@ export abstract class TestConfigurationManager { return def.promise; } - protected getTestDirs(rootDir): Promise { + protected getTestDirs(rootDir: string): Promise { return getSubDirectories(rootDir).then(subDirs => { subDirs.sort(); - // Find out if there are any dirs with the name test and place them on the top - let possibleTestDirs = subDirs.filter(dir => dir.match(/test/i)); - let nonTestDirs = subDirs.filter(dir => possibleTestDirs.indexOf(dir) === -1); + // Find out if there are any dirs with the name test and place them on the top. + const possibleTestDirs = subDirs.filter(dir => dir.match(/test/i)); + const nonTestDirs = subDirs.filter(dir => possibleTestDirs.indexOf(dir) === -1); possibleTestDirs.push(...nonTestDirs); - // The test dirs are now on top + // The test dirs are now on top. return possibleTestDirs; }); } -} \ No newline at end of file +} diff --git a/src/client/unittests/common/testManagerService.ts b/src/client/unittests/common/testManagerService.ts new file mode 100644 index 000000000000..87cfa5d2c1e4 --- /dev/null +++ b/src/client/unittests/common/testManagerService.ts @@ -0,0 +1,63 @@ +import { OutputChannel, Uri } from 'vscode'; +import { PythonSettings } from '../../common/configSettings'; +import { Product } from '../../common/installer'; +import { TestManager as NoseTestManager } from '../nosetest/main'; +import { TestManager as PyTestTestManager } from '../pytest/main'; +import { TestManager as UnitTestTestManager } from '../unittest/main'; +import { BaseTestManager } from './baseTestManager'; +import { ITestCollectionStorageService, ITestDebugLauncher, ITestManagerService, ITestResultsService, ITestsHelper, UnitTestProduct } from './types'; + +type TestManagerInstanceInfo = { instance?: BaseTestManager, create(rootDirectory: string): BaseTestManager }; + +export class TestManagerService implements ITestManagerService { + private testManagers = new Map(); + constructor(private wkspace: Uri, private outChannel: OutputChannel, + testCollectionStorage: ITestCollectionStorageService, testResultsService: ITestResultsService, + testsHelper: ITestsHelper, debugLauncher: ITestDebugLauncher) { + this.testManagers.set(Product.nosetest, { + create: (rootDirectory: string) => new NoseTestManager(rootDirectory, this.outChannel, testCollectionStorage, testResultsService, testsHelper, debugLauncher) + }); + this.testManagers.set(Product.pytest, { + create: (rootDirectory: string) => new PyTestTestManager(rootDirectory, this.outChannel, testCollectionStorage, testResultsService, testsHelper, debugLauncher) + }); + this.testManagers.set(Product.unittest, { + create: (rootDirectory: string) => new UnitTestTestManager(rootDirectory, this.outChannel, testCollectionStorage, testResultsService, testsHelper, debugLauncher) + }); + } + public dispose() { + this.testManagers.forEach(info => { + if (info.instance) { + info.instance.dispose(); + } + }); + } + public getTestManager(): BaseTestManager | undefined { + const preferredTestManager = this.getPreferredTestManager(); + if (typeof preferredTestManager !== 'number') { + return; + } + + // tslint:disable-next-line:no-non-null-assertion + const info = this.testManagers.get(preferredTestManager)!; + if (!info.instance) { + const testDirectory = this.getTestWorkingDirectory(); + info.instance = info.create(testDirectory); + } + return info.instance; + } + public getTestWorkingDirectory() { + const settings = PythonSettings.getInstance(this.wkspace); + return settings.unitTest.cwd && settings.unitTest.cwd.length > 0 ? settings.unitTest.cwd : this.wkspace.fsPath; + } + public getPreferredTestManager(): UnitTestProduct | undefined { + const settings = PythonSettings.getInstance(this.wkspace); + if (settings.unitTest.nosetestsEnabled) { + return Product.nosetest; + } else if (settings.unitTest.pyTestEnabled) { + return Product.pytest; + } else if (settings.unitTest.unittestEnabled) { + return Product.unittest; + } + return undefined; + } +} diff --git a/src/client/unittests/common/testManagerServiceFactory.ts b/src/client/unittests/common/testManagerServiceFactory.ts new file mode 100644 index 000000000000..f10600ef8b45 --- /dev/null +++ b/src/client/unittests/common/testManagerServiceFactory.ts @@ -0,0 +1,12 @@ +import { OutputChannel, Uri } from 'vscode'; +import { TestManagerService } from './testManagerService'; +import { ITestCollectionStorageService, ITestDebugLauncher, ITestManagerService, ITestManagerServiceFactory, ITestResultsService, ITestsHelper } from './types'; + +export class TestManagerServiceFactory implements ITestManagerServiceFactory { + constructor(private outChannel: OutputChannel, private testCollectionStorage: ITestCollectionStorageService, + private testResultsService: ITestResultsService, private testsHelper: ITestsHelper, + private debugLauncher: ITestDebugLauncher) { } + public createTestManagerService(wkspace: Uri): ITestManagerService { + return new TestManagerService(wkspace, this.outChannel, this.testCollectionStorage, this.testResultsService, this.testsHelper, this.debugLauncher); + } +} diff --git a/src/client/unittests/common/testResultsService.ts b/src/client/unittests/common/testResultsService.ts new file mode 100644 index 000000000000..8743b579d879 --- /dev/null +++ b/src/client/unittests/common/testResultsService.ts @@ -0,0 +1,110 @@ +import { TestResultResetVisitor } from './testVisitors/resultResetVisitor'; +import { ITestResultsService, TestFile, TestFolder, Tests, TestStatus, TestSuite } from './types'; + +export class TestResultsService implements ITestResultsService { + public resetResults(tests: Tests): void { + const resultResetVisitor = new TestResultResetVisitor(); + tests.testFolders.forEach(f => resultResetVisitor.visitTestFolder(f)); + tests.testFunctions.forEach(fn => resultResetVisitor.visitTestFunction(fn.testFunction)); + tests.testSuites.forEach(suite => resultResetVisitor.visitTestSuite(suite.testSuite)); + tests.testFiles.forEach(testFile => resultResetVisitor.visitTestFile(testFile)); + } + public updateResults(tests: Tests): void { + tests.testFiles.forEach(test => this.updateTestFileResults(test)); + tests.testFolders.forEach(folder => this.updateTestFolderResults(folder)); + } + private updateTestSuiteResults(test: TestSuite): void { + this.updateTestSuiteAndFileResults(test); + } + private updateTestFileResults(test: TestFile): void { + this.updateTestSuiteAndFileResults(test); + } + private updateTestFolderResults(testFolder: TestFolder): void { + let allFilesPassed = true; + let allFilesRan = true; + + testFolder.testFiles.forEach(fl => { + if (allFilesPassed && typeof fl.passed === 'boolean') { + if (!fl.passed) { + allFilesPassed = false; + } + } else { + allFilesRan = false; + } + + testFolder.functionsFailed += fl.functionsFailed; + testFolder.functionsPassed += fl.functionsPassed; + }); + + let allFoldersPassed = true; + let allFoldersRan = true; + + testFolder.folders.forEach(folder => { + this.updateTestFolderResults(folder); + if (allFoldersPassed && typeof folder.passed === 'boolean') { + if (!folder.passed) { + allFoldersPassed = false; + } + } else { + allFoldersRan = false; + } + + testFolder.functionsFailed += folder.functionsFailed; + testFolder.functionsPassed += folder.functionsPassed; + }); + + if (allFilesRan && allFoldersRan) { + testFolder.passed = allFilesPassed && allFoldersPassed; + testFolder.status = testFolder.passed ? TestStatus.Idle : TestStatus.Fail; + } else { + testFolder.passed = null; + testFolder.status = TestStatus.Unknown; + } + } + private updateTestSuiteAndFileResults(test: TestSuite | TestFile): void { + let totalTime = 0; + let allFunctionsPassed = true; + let allFunctionsRan = true; + + test.functions.forEach(fn => { + totalTime += fn.time; + if (typeof fn.passed === 'boolean') { + if (fn.passed) { + test.functionsPassed += 1; + } else { + test.functionsFailed += 1; + allFunctionsPassed = false; + } + } else { + allFunctionsRan = false; + } + }); + + let allSuitesPassed = true; + let allSuitesRan = true; + + test.suites.forEach(suite => { + this.updateTestSuiteResults(suite); + totalTime += suite.time; + if (allSuitesRan && typeof suite.passed === 'boolean') { + if (!suite.passed) { + allSuitesPassed = false; + } + } else { + allSuitesRan = false; + } + + test.functionsFailed += suite.functionsFailed; + test.functionsPassed += suite.functionsPassed; + }); + + test.time = totalTime; + if (allSuitesRan && allFunctionsRan) { + test.passed = allFunctionsPassed && allSuitesPassed; + test.status = test.passed ? TestStatus.Idle : TestStatus.Error; + } else { + test.passed = null; + test.status = TestStatus.Unknown; + } + } +} diff --git a/src/client/unittests/common/testUtils.ts b/src/client/unittests/common/testUtils.ts index 9df1e2ca73ef..7ce389bdf66f 100644 --- a/src/client/unittests/common/testUtils.ts +++ b/src/client/unittests/common/testUtils.ts @@ -1,9 +1,24 @@ -import {TestFolder, TestsToRun, Tests, TestFile, TestSuite, TestStatus, FlattenedTestFunction, FlattenedTestSuite} from './contracts'; -import * as vscode from 'vscode'; import * as path from 'path'; +import * as vscode from 'vscode'; +import { Uri, workspace } from 'vscode'; +import { window } from 'vscode'; import * as constants from '../../common/constants'; - -let discoveredTests: Tests; +import { TestFlatteningVisitor } from './testVisitors/flatteningVisitor'; +import { TestResultResetVisitor } from './testVisitors/resultResetVisitor'; +import { TestFile, TestFolder, Tests, TestsToRun } from './types'; +import { ITestsHelper } from './types'; + +export async function selectTestWorkspace(): Promise { + if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length === 0) { + return undefined; + } else if (workspace.workspaceFolders.length === 1) { + return workspace.workspaceFolders[0].uri; + } else { + // tslint:disable-next-line:no-any prefer-type-cast + const workspaceFolder = await (window as any).showWorkspaceFolderPick({ placeHolder: 'Select a workspace' }); + return workspaceFolder ? workspaceFolder.uri : undefined; + } +} export function displayTestErrorMessage(message: string) { vscode.window.showErrorMessage(message, constants.Button_Text_Tests_View_Output).then(action => { @@ -13,257 +28,90 @@ export function displayTestErrorMessage(message: string) { }); } -export function getDiscoveredTests(): Tests { - return discoveredTests; -} -export function storeDiscoveredTests(tests: Tests) { - discoveredTests = tests; -} - -export function resolveValueAsTestToRun(name: string, rootDirectory: string): TestsToRun { - // TODO: We need a better way to match (currently we have raw name, name, xmlname, etc = which one do we - // use to identify a file given the full file name, similary for a folder and function - // Perhaps something like a parser or methods like TestFunction.fromString()... something) - let tests = getDiscoveredTests(); - if (!tests) { return null; } - const absolutePath = path.isAbsolute(name) ? name : path.resolve(rootDirectory, name); - let testFolders = tests.testFolders.filter(folder => folder.nameToRun === name || folder.name === name || folder.name === absolutePath); - if (testFolders.length > 0) { return { testFolder: testFolders }; }; - - let testFiles = tests.testFiles.filter(file => file.nameToRun === name || file.name === name || file.fullPath === absolutePath); - if (testFiles.length > 0) { return { testFile: testFiles }; }; - let testFns = tests.testFunctions.filter(fn => fn.testFunction.nameToRun === name || fn.testFunction.name === name).map(fn => fn.testFunction); - if (testFns.length > 0) { return { testFunction: testFns }; }; - - // Just return this as a test file - return { testFile: [{ name: name, nameToRun: name, functions: [], suites: [], xmlName: name, fullPath: '', time: 0 }] }; -} export function extractBetweenDelimiters(content: string, startDelimiter: string, endDelimiter: string): string { content = content.substring(content.indexOf(startDelimiter) + startDelimiter.length); return content.substring(0, content.lastIndexOf(endDelimiter)); } export function convertFileToPackage(filePath: string): string { - let lastIndex = filePath.lastIndexOf('.'); + const lastIndex = filePath.lastIndexOf('.'); return filePath.substring(0, lastIndex).replace(/\//g, '.').replace(/\\/g, '.'); } -export function updateResults(tests: Tests) { - tests.testFiles.forEach(updateResultsUpstream); - tests.testFolders.forEach(updateFolderResultsUpstream); -} +export class TestsHelper implements ITestsHelper { + public flattenTestFiles(testFiles: TestFile[]): Tests { + const flatteningVisitor = new TestFlatteningVisitor(); + testFiles.forEach(testFile => flatteningVisitor.visitTestFile(testFile)); -export function updateFolderResultsUpstream(testFolder: TestFolder) { - let allFilesPassed = true; - let allFilesRan = true; - - testFolder.testFiles.forEach(fl => { - if (allFilesPassed && typeof fl.passed === 'boolean') { - if (!fl.passed) { - allFilesPassed = false; - } - } - else { - allFilesRan = false; - } - - testFolder.functionsFailed += fl.functionsFailed; - testFolder.functionsPassed += fl.functionsPassed; - }); + const tests = { + testFiles: testFiles, + testFunctions: flatteningVisitor.flattenedTestFunctions, + testSuites: flatteningVisitor.flattenedTestSuites, + testFolders: [], + rootTestFolders: [], + summary: { passed: 0, failures: 0, errors: 0, skipped: 0 } + }; - let allFoldersPassed = true; - let allFoldersRan = true; + this.placeTestFilesIntoFolders(tests); - testFolder.folders.forEach(folder => { - updateFolderResultsUpstream(folder); - if (allFoldersPassed && typeof folder.passed === 'boolean') { - if (!folder.passed) { - allFoldersPassed = false; - } - } - else { - allFoldersRan = false; - } - - testFolder.functionsFailed += folder.functionsFailed; - testFolder.functionsPassed += folder.functionsPassed; - }); - - if (allFilesRan && allFoldersRan) { - testFolder.passed = allFilesPassed && allFoldersPassed; - testFolder.status = testFolder.passed ? TestStatus.Idle : TestStatus.Fail; - } - else { - testFolder.passed = null; - testFolder.status = TestStatus.Unknown; + return tests; } -} - -export function updateResultsUpstream(test: TestSuite | TestFile) { - let totalTime = 0; - let allFunctionsPassed = true; - let allFunctionsRan = true; - - test.functions.forEach(fn => { - totalTime += fn.time; - if (typeof fn.passed === 'boolean') { - if (fn.passed) { - test.functionsPassed += 1; - } - else { - test.functionsFailed += 1; - allFunctionsPassed = false; - } - } - else { - allFunctionsRan = false; - } - }); - - let allSuitesPassed = true; - let allSuitesRan = true; - - test.suites.forEach(suite => { - updateResultsUpstream(suite); - totalTime += suite.time; - if (allSuitesRan && typeof suite.passed === 'boolean') { - if (!suite.passed) { - allSuitesPassed = false; + public placeTestFilesIntoFolders(tests: Tests): void { + // First get all the unique folders + const folders: string[] = []; + tests.testFiles.forEach(file => { + const dir = path.dirname(file.name); + if (folders.indexOf(dir) === -1) { + folders.push(dir); } - } - else { - allSuitesRan = false; - } - - test.functionsFailed += suite.functionsFailed; - test.functionsPassed += suite.functionsPassed; - }); - - test.time = totalTime; - if (allSuitesRan && allFunctionsRan) { - test.passed = allFunctionsPassed && allSuitesPassed; - test.status = test.passed ? TestStatus.Idle : TestStatus.Error; - } - else { - test.passed = null; - test.status = TestStatus.Unknown; - } -} - -export function placeTestFilesInFolders(tests: Tests) { - // First get all the unique folders - const folders: string[] = []; - tests.testFiles.forEach(file => { - let dir = path.dirname(file.name); - if (folders.indexOf(dir) === -1) { - folders.push(dir); - } - }); - - tests.testFolders = []; - const folderMap = new Map(); - folders.sort(); + }); - folders.forEach(dir => { - dir.split(path.sep).reduce((parentPath, currentName, index, values) => { - let newPath = currentName; - let parentFolder: TestFolder; - if (parentPath.length > 0) { - parentFolder = folderMap.get(parentPath); - newPath = path.join(parentPath, currentName); - } - if (!folderMap.has(newPath)) { - const testFolder: TestFolder = { name: newPath, testFiles: [], folders: [], nameToRun: newPath, time: 0 }; - folderMap.set(newPath, testFolder); - if (parentFolder) { - parentFolder.folders.push(testFolder); + tests.testFolders = []; + const folderMap = new Map(); + folders.sort(); + + folders.forEach(dir => { + dir.split(path.sep).reduce((parentPath, currentName, index, values) => { + let newPath = currentName; + let parentFolder: TestFolder; + if (parentPath.length > 0) { + parentFolder = folderMap.get(parentPath); + newPath = path.join(parentPath, currentName); } - else { - tests.rootTestFolders.push(testFolder); + if (!folderMap.has(newPath)) { + const testFolder: TestFolder = { name: newPath, testFiles: [], folders: [], nameToRun: newPath, time: 0 }; + folderMap.set(newPath, testFolder); + if (parentFolder) { + parentFolder.folders.push(testFolder); + } else { + tests.rootTestFolders.push(testFolder); + } + tests.testFiles.filter(fl => path.dirname(fl.name) === newPath).forEach(testFile => { + testFolder.testFiles.push(testFile); + }); + tests.testFolders.push(testFolder); } - tests.testFiles.filter(fl => path.dirname(fl.name) === newPath).forEach(testFile => { - testFolder.testFiles.push(testFile); - }); - tests.testFolders.push(testFolder); - } - return newPath; - }, ''); - }); -} -export function flattenTestFiles(testFiles: TestFile[]): Tests { - let fns: FlattenedTestFunction[] = []; - let suites: FlattenedTestSuite[] = []; - testFiles.forEach(testFile => { - // sample test_three (file name without extension and all / replaced with ., meaning this is the package) - const packageName = convertFileToPackage(testFile.name); - - testFile.functions.forEach(fn => { - fns.push({ testFunction: fn, xmlClassName: packageName, parentTestFile: testFile }); + return newPath; + }, ''); }); - - testFile.suites.forEach(suite => { - suites.push({ parentTestFile: testFile, testSuite: suite, xmlClassName: suite.xmlName }); - flattenTestSuites(fns, suites, testFile, suite); - }); - }); - - let tests = { - testFiles: testFiles, - testFunctions: fns, testSuits: suites, - testFolders: [], - rootTestFolders: [], - summary: { passed: 0, failures: 0, errors: 0, skipped: 0 } - }; - - placeTestFilesInFolders(tests); - - return tests; -} -export function flattenTestSuites(flattenedFns: FlattenedTestFunction[], flattenedSuites: FlattenedTestSuite[], testFile: TestFile, testSuite: TestSuite) { - testSuite.functions.forEach(fn => { - flattenedFns.push({ testFunction: fn, xmlClassName: testSuite.xmlName, parentTestFile: testFile, parentTestSuite: testSuite }); - }); - - // We may have child classes - testSuite.suites.forEach(suite => { - flattenedSuites.push({ parentTestFile: testFile, testSuite: suite, xmlClassName: suite.xmlName }); - flattenTestSuites(flattenedFns, flattenedSuites, testFile, suite); - }); + } + public parseTestName(name: string, rootDirectory: string, tests: Tests): TestsToRun { + // TODO: We need a better way to match (currently we have raw name, name, xmlname, etc = which one do we. + // Use to identify a file given the full file name, similarly for a folder and function. + // Perhaps something like a parser or methods like TestFunction.fromString()... something). + if (!tests) { return null; } + const absolutePath = path.isAbsolute(name) ? name : path.resolve(rootDirectory, name); + const testFolders = tests.testFolders.filter(folder => folder.nameToRun === name || folder.name === name || folder.name === absolutePath); + if (testFolders.length > 0) { return { testFolder: testFolders }; } + + const testFiles = tests.testFiles.filter(file => file.nameToRun === name || file.name === name || file.fullPath === absolutePath); + if (testFiles.length > 0) { return { testFile: testFiles }; } + + const testFns = tests.testFunctions.filter(fn => fn.testFunction.nameToRun === name || fn.testFunction.name === name).map(fn => fn.testFunction); + if (testFns.length > 0) { return { testFunction: testFns }; } + + // Just return this as a test file. + return { testFile: [{ name: name, nameToRun: name, functions: [], suites: [], xmlName: name, fullPath: '', time: 0 }] }; + } } - -export function resetTestResults(tests: Tests) { - tests.testFolders.forEach(f => { - f.functionsDidNotRun = 0; - f.functionsFailed = 0; - f.functionsPassed = 0; - f.passed = null; - f.status = TestStatus.Unknown; - }); - tests.testFunctions.forEach(fn => { - fn.testFunction.passed = null; - fn.testFunction.time = 0; - fn.testFunction.message = ''; - fn.testFunction.traceback = ''; - fn.testFunction.status = TestStatus.Unknown; - fn.testFunction.functionsFailed = 0; - fn.testFunction.functionsPassed = 0; - fn.testFunction.functionsDidNotRun = 0; - }); - tests.testSuits.forEach(suite => { - suite.testSuite.passed = null; - suite.testSuite.time = 0; - suite.testSuite.status = TestStatus.Unknown; - suite.testSuite.functionsFailed = 0; - suite.testSuite.functionsPassed = 0; - suite.testSuite.functionsDidNotRun = 0; - }); - tests.testFiles.forEach(testFile => { - testFile.passed = null; - testFile.time = 0; - testFile.status = TestStatus.Unknown; - testFile.functionsFailed = 0; - testFile.functionsPassed = 0; - testFile.functionsDidNotRun = 0; - }); -} \ No newline at end of file diff --git a/src/client/unittests/common/testVisitors/flatteningVisitor.ts b/src/client/unittests/common/testVisitors/flatteningVisitor.ts new file mode 100644 index 000000000000..4a8ed96babbc --- /dev/null +++ b/src/client/unittests/common/testVisitors/flatteningVisitor.ts @@ -0,0 +1,65 @@ +import { convertFileToPackage } from '../testUtils'; +import { + FlattenedTestFunction, + FlattenedTestSuite, + ITestVisitor, + TestFile, + TestFolder, + TestFunction, + TestSuite +} from '../types'; + +export class TestFlatteningVisitor implements ITestVisitor { + // tslint:disable-next-line:variable-name + private _flattedTestFunctions = new Map(); + // tslint:disable-next-line:variable-name + private _flattenedTestSuites = new Map(); + public get flattenedTestFunctions(): FlattenedTestFunction[] { + return [...this._flattedTestFunctions.values()]; + } + public get flattenedTestSuites(): FlattenedTestSuite[] { + return [...this._flattenedTestSuites.values()]; + } + // tslint:disable-next-line:no-empty + public visitTestFunction(testFunction: TestFunction): void { } + // tslint:disable-next-line:no-empty + public visitTestSuite(testSuite: TestSuite): void { } + public visitTestFile(testFile: TestFile): void { + // sample test_three (file name without extension and all / replaced with ., meaning this is the package) + const packageName = convertFileToPackage(testFile.name); + + testFile.functions.forEach(fn => this.addTestFunction(fn, testFile, packageName)); + testFile.suites.forEach(suite => this.visitTestSuiteOfAFile(suite, testFile)); + } + // tslint:disable-next-line:no-empty + public visitTestFolder(testFile: TestFolder) { } + private visitTestSuiteOfAFile(testSuite: TestSuite, parentTestFile: TestFile): void { + testSuite.functions.forEach(fn => this.visitTestFunctionOfASuite(fn, testSuite, parentTestFile)); + testSuite.suites.forEach(suite => this.visitTestSuiteOfAFile(suite, parentTestFile)); + this.addTestSuite(testSuite, parentTestFile); + } + private visitTestFunctionOfASuite(testFunction: TestFunction, parentTestSuite: TestSuite, parentTestFile: TestFile) { + const key = `Function:${testFunction.name},Suite:${parentTestSuite.name},SuiteXmlName:${parentTestSuite.xmlName},ParentFile:${parentTestFile.fullPath}`; + if (this._flattenedTestSuites.has(key)) { + return; + } + const flattenedFunction = { testFunction, xmlClassName: parentTestSuite.xmlName, parentTestFile, parentTestSuite }; + this._flattedTestFunctions.set(key, flattenedFunction); + } + private addTestSuite(testSuite: TestSuite, parentTestFile: TestFile) { + const key = `Suite:${testSuite.name},SuiteXmlName:${testSuite.xmlName},ParentFile:${parentTestFile.fullPath}`; + if (this._flattenedTestSuites.has(key)) { + return; + } + const flattenedSuite = { parentTestFile, testSuite, xmlClassName: testSuite.xmlName }; + this._flattenedTestSuites.set(key, flattenedSuite); + } + private addTestFunction(testFunction: TestFunction, parentTestFile: TestFile, parentTestPackage: string) { + const key = `Function:${testFunction.name},ParentFile:${parentTestFile.fullPath}`; + if (this._flattedTestFunctions.has(key)) { + return; + } + const flattendFunction = { testFunction, xmlClassName: parentTestPackage, parentTestFile }; + this._flattedTestFunctions.set(key, flattendFunction); + } +} diff --git a/src/client/unittests/common/testVisitors/folderGenerationVisitor.ts b/src/client/unittests/common/testVisitors/folderGenerationVisitor.ts new file mode 100644 index 000000000000..46956873edd1 --- /dev/null +++ b/src/client/unittests/common/testVisitors/folderGenerationVisitor.ts @@ -0,0 +1,55 @@ +import * as path from 'path'; +import { ITestVisitor, TestFile, TestFolder, TestFunction, TestSuite } from '../types'; + +export class TestFolderGenerationVisitor implements ITestVisitor { + // tslint:disable-next-line:variable-name + private _testFolders: TestFolder[] = []; + // tslint:disable-next-line:variable-name + private _rootTestFolders: TestFolder[] = []; + private folderMap = new Map(); + public get testFolders(): Readonly { + return [...this._testFolders]; + } + public get rootTestFolders(): Readonly { + return [...this._rootTestFolders]; + } + // tslint:disable-next-line:no-empty + public visitTestFunction(testFunction: TestFunction): void { } + // tslint:disable-next-line:no-empty + public visitTestSuite(testSuite: TestSuite): void { } + public visitTestFile(testFile: TestFile): void { + // First get all the unique folders + const folders: string[] = []; + const dir = path.dirname(testFile.name); + if (this.folderMap.has(dir)) { + const folder = this.folderMap.get(dir); + folder.testFiles.push(testFile); + return; + } + + dir.split(path.sep).reduce((accumulatedPath, currentName, index) => { + let newPath = currentName; + let parentFolder: TestFolder; + if (accumulatedPath.length > 0) { + parentFolder = this.folderMap.get(accumulatedPath); + newPath = path.join(accumulatedPath, currentName); + } + if (!this.folderMap.has(newPath)) { + const testFolder: TestFolder = { name: newPath, testFiles: [], folders: [], nameToRun: newPath, time: 0 }; + this.folderMap.set(newPath, testFolder); + if (parentFolder) { + parentFolder.folders.push(testFolder); + } else { + this._rootTestFolders.push(testFolder); + } + this._testFolders.push(testFolder); + } + return newPath; + }, ''); + + // tslint:disable-next-line:no-non-null-assertion + this.folderMap.get(dir)!.testFiles.push(testFile); + } + // tslint:disable-next-line:no-empty + public visitTestFolder(testFile: TestFolder) { } +} diff --git a/src/client/unittests/common/testVisitors/resultResetVisitor.ts b/src/client/unittests/common/testVisitors/resultResetVisitor.ts new file mode 100644 index 000000000000..0d58c1076b04 --- /dev/null +++ b/src/client/unittests/common/testVisitors/resultResetVisitor.ts @@ -0,0 +1,37 @@ +import { ITestVisitor, TestFile, TestFolder, TestFunction, TestStatus, TestSuite } from '../types'; + +export class TestResultResetVisitor implements ITestVisitor { + public visitTestFunction(testFunction: TestFunction): void { + testFunction.passed = null; + testFunction.time = 0; + testFunction.message = ''; + testFunction.traceback = ''; + testFunction.status = TestStatus.Unknown; + testFunction.functionsFailed = 0; + testFunction.functionsPassed = 0; + testFunction.functionsDidNotRun = 0; + } + public visitTestSuite(testSuite: TestSuite): void { + testSuite.passed = null; + testSuite.time = 0; + testSuite.status = TestStatus.Unknown; + testSuite.functionsFailed = 0; + testSuite.functionsPassed = 0; + testSuite.functionsDidNotRun = 0; + } + public visitTestFile(testFile: TestFile): void { + testFile.passed = null; + testFile.time = 0; + testFile.status = TestStatus.Unknown; + testFile.functionsFailed = 0; + testFile.functionsPassed = 0; + testFile.functionsDidNotRun = 0; + } + public visitTestFolder(testFolder: TestFolder) { + testFolder.functionsDidNotRun = 0; + testFolder.functionsFailed = 0; + testFolder.functionsPassed = 0; + testFolder.passed = null; + testFolder.status = TestStatus.Unknown; + } +} diff --git a/src/client/unittests/common/types.ts b/src/client/unittests/common/types.ts new file mode 100644 index 000000000000..5125b5a4c17c --- /dev/null +++ b/src/client/unittests/common/types.ts @@ -0,0 +1,152 @@ +import { CancellationToken, Disposable, OutputChannel, Uri } from 'vscode'; +import { Product } from '../../common/installer'; +import { BaseTestManager } from './baseTestManager'; + +export type TestFolder = TestResult & { + name: string; + testFiles: TestFile[]; + nameToRun: string; + status?: TestStatus; + folders: TestFolder[]; +}; + +export type TestFile = TestResult & { + name: string; + fullPath: string; + functions: TestFunction[]; + suites: TestSuite[]; + nameToRun: string; + xmlName: string; + status?: TestStatus; + errorsWhenDiscovering?: string; +}; + +export type TestSuite = TestResult & { + name: string; + functions: TestFunction[]; + suites: TestSuite[]; + isUnitTest: Boolean; + isInstance: Boolean; + nameToRun: string; + xmlName: string; + status?: TestStatus; +}; + +export type TestFunction = TestResult & { + name: string; + nameToRun: string; + status?: TestStatus; +}; + +export type TestResult = Node & { + passed?: boolean; + time: number; + line?: number; + message?: string; + traceback?: string; + functionsPassed?: number; + functionsFailed?: number; + functionsDidNotRun?: number; +}; + +export type Node = { + expanded?: Boolean; +}; + +export type FlattenedTestFunction = { + testFunction: TestFunction; + parentTestSuite?: TestSuite; + parentTestFile: TestFile; + xmlClassName: string; +}; + +export type FlattenedTestSuite = { + testSuite: TestSuite; + parentTestSuite?: TestSuite; + parentTestFile: TestFile; + xmlClassName: string; +}; + +export type TestSummary = { + passed: number; + failures: number; + errors: number; + skipped: number; +}; + +export type Tests = { + summary: TestSummary; + testFiles: TestFile[]; + testFunctions: FlattenedTestFunction[]; + testSuites: FlattenedTestSuite[]; + testFolders: TestFolder[]; + rootTestFolders: TestFolder[]; +}; + +export enum TestStatus { + Unknown, + Discovering, + Idle, + Running, + Fail, + Error, + Skipped, + Pass +} + +export type TestsToRun = { + testFolder?: TestFolder[]; + testFile?: TestFile[]; + testSuite?: TestSuite[]; + testFunction?: TestFunction[]; +}; + +export type UnitTestProduct = Product.nosetest | Product.pytest | Product.unittest; + +export interface ITestConfigSettingsService { + updateTestArgs(testDirectory: string, product: UnitTestProduct, args: string[]): Promise; + enable(testDirectory: string | Uri, product: UnitTestProduct): Promise; + disable(testDirectory: string | Uri, product: UnitTestProduct): Promise; +} + +export interface ITestManagerService extends Disposable { + getTestManager(): BaseTestManager | undefined; + getTestWorkingDirectory(): string; + getPreferredTestManager(): UnitTestProduct | undefined; +} + +export interface ITestManagerServiceFactory { + createTestManagerService(wkspace: Uri): ITestManagerService; +} + +export interface IWorkspaceTestManagerService extends Disposable { + getTestManager(resource: Uri): BaseTestManager | undefined; + getTestWorkingDirectory(resource: Uri): string; + getPreferredTestManager(resource: Uri): UnitTestProduct | undefined; +} + +export interface ITestsHelper { + flattenTestFiles(testFiles: TestFile[]): Tests; + placeTestFilesIntoFolders(tests: Tests): void; +} + +export interface ITestVisitor { + visitTestFunction(testFunction: TestFunction): void; + visitTestSuite(testSuite: TestSuite): void; + visitTestFile(testFile: TestFile): void; + visitTestFolder(testFile: TestFolder): void; +} + +export interface ITestCollectionStorageService extends Disposable { + getTests(wkspace: Uri): Tests | undefined; + storeTests(wkspace: Uri, tests: Tests | null | undefined): void; +} + +export interface ITestResultsService { + resetResults(tests: Tests): void; + updateResults(tests: Tests): void; +} + +export interface ITestDebugLauncher { + launchDebugger(rootDirectory: string, testArgs: string[], token?: CancellationToken, outChannel?: OutputChannel): Promise; +} diff --git a/src/client/unittests/common/workspaceTestManagerService.ts b/src/client/unittests/common/workspaceTestManagerService.ts new file mode 100644 index 000000000000..ff7e198cb423 --- /dev/null +++ b/src/client/unittests/common/workspaceTestManagerService.ts @@ -0,0 +1,53 @@ +import { Disposable, OutputChannel, Uri, workspace } from 'vscode'; +import { Product } from '../../common/installer'; +import { BaseTestManager } from './baseTestManager'; +import { TestManagerService } from './testManagerService'; +import { ITestManagerService, ITestManagerServiceFactory, IWorkspaceTestManagerService, UnitTestProduct } from './types'; + +export class WorkspaceTestManagerService implements IWorkspaceTestManagerService, Disposable { + private workspaceTestManagers = new Map(); + private disposables: Disposable[] = []; + constructor(private outChannel: OutputChannel, + private testManagerServiceFactory: ITestManagerServiceFactory) { + } + public dispose() { + this.workspaceTestManagers.forEach(info => info.dispose()); + } + public getTestManager(resource: Uri): BaseTestManager | undefined { + const wkspace = this.getWorkspace(resource); + this.ensureTestManagerService(wkspace); + return this.workspaceTestManagers.get(wkspace.fsPath).getTestManager(); + } + public getTestWorkingDirectory(resource: Uri) { + const wkspace = this.getWorkspace(resource); + this.ensureTestManagerService(wkspace); + return this.workspaceTestManagers.get(wkspace.fsPath).getTestWorkingDirectory(); + } + public getPreferredTestManager(resource: Uri): UnitTestProduct { + const wkspace = this.getWorkspace(resource); + this.ensureTestManagerService(wkspace); + return this.workspaceTestManagers.get(wkspace.fsPath).getPreferredTestManager(); + } + private getWorkspace(resource: Uri): Uri { + if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length === 0) { + const noWkspaceMessage = 'Please open a workspace'; + this.outChannel.appendLine(noWkspaceMessage); + throw new Error(noWkspaceMessage); + } + if (!resource || workspace.workspaceFolders.length === 1) { + return workspace.workspaceFolders[0].uri; + } + const workspaceFolder = workspace.getWorkspaceFolder(resource); + if (workspaceFolder) { + return workspaceFolder.uri; + } + const message = `Resource '${resource.fsPath}' does not belong to any workspace`; + this.outChannel.appendLine(message); + throw new Error(message); + } + private ensureTestManagerService(wkspace: Uri) { + if (!this.workspaceTestManagers.has(wkspace.fsPath)) { + this.workspaceTestManagers.set(wkspace.fsPath, this.testManagerServiceFactory.createTestManagerService(wkspace)); + } + } +} diff --git a/src/client/unittests/common/xUnitParser.ts b/src/client/unittests/common/xUnitParser.ts index 2f02601266a0..6318060a94f7 100644 --- a/src/client/unittests/common/xUnitParser.ts +++ b/src/client/unittests/common/xUnitParser.ts @@ -1,12 +1,12 @@ import * as fs from 'fs'; import * as xml2js from 'xml2js'; -import { Tests, TestStatus } from './contracts'; +import { Tests, TestStatus } from './types'; export enum PassCalculationFormulae { pytest, nosetests } -interface TestSuiteResult { +type TestSuiteResult = { $: { errors: string; failures: string; @@ -17,8 +17,8 @@ interface TestSuiteResult { time: string; }; testcase: TestCaseResult[]; -} -interface TestCaseResult { +}; +type TestCaseResult = { $: { classname: string; file: string; @@ -38,30 +38,32 @@ interface TestCaseResult { _: string; $: { message: string, type: string } }[]; -} +}; +// tslint:disable-next-line:no-any function getSafeInt(value: string, defaultValue: any = 0): number { - const num = parseInt(value); + const num = parseInt(value, 10); if (isNaN(num)) { return defaultValue; } return num; } -export function updateResultsFromXmlLogFile(tests: Tests, outputXmlFile: string, passCalculationFormulae: PassCalculationFormulae): Promise { +export function updateResultsFromXmlLogFile(tests: Tests, outputXmlFile: string, passCalculationFormulae: PassCalculationFormulae): Promise<{}> { + // tslint:disable-next-line:no-any return new Promise((resolve, reject) => { fs.readFile(outputXmlFile, 'utf8', (err, data) => { if (err) { return reject(err); } - xml2js.parseString(data, (err, result) => { - if (err) { - return reject(err); + xml2js.parseString(data, (error, parserResult) => { + if (error) { + return reject(error); } - let testSuiteResult: TestSuiteResult = result.testsuite; + const testSuiteResult: TestSuiteResult = parserResult.testsuite; tests.summary.errors = getSafeInt(testSuiteResult.$.errors); tests.summary.failures = getSafeInt(testSuiteResult.$.failures); tests.summary.skipped = getSafeInt(testSuiteResult.$.skips ? testSuiteResult.$.skips : testSuiteResult.$.skip); - let testCount = getSafeInt(testSuiteResult.$.tests); + const testCount = getSafeInt(testSuiteResult.$.tests); switch (passCalculationFormulae) { case PassCalculationFormulae.pytest: { @@ -73,7 +75,7 @@ export function updateResultsFromXmlLogFile(tests: Tests, outputXmlFile: string, break; } default: { - throw new Error("Unknown Test Pass Calculation"); + throw new Error('Unknown Test Pass Calculation'); } } @@ -83,7 +85,7 @@ export function updateResultsFromXmlLogFile(tests: Tests, outputXmlFile: string, testSuiteResult.testcase.forEach((testcase: TestCaseResult) => { const xmlClassName = testcase.$.classname.replace(/\(\)/g, '').replace(/\.\./g, '.').replace(/\.\./g, '.').replace(/\.+$/, ''); - let result = tests.testFunctions.find(fn => fn.xmlClassName === xmlClassName && fn.testFunction.name === testcase.$.name); + const result = tests.testFunctions.find(fn => fn.xmlClassName === xmlClassName && fn.testFunction.name === testcase.$.name); if (!result) { // Possible we're dealing with nosetests, where the file name isn't returned to us // When dealing with nose tests @@ -94,7 +96,7 @@ export function updateResultsFromXmlLogFile(tests: Tests, outputXmlFile: string, // fn.parentTestSuite && fn.parentTestSuite.name === testcase.$.classname); // Look for failed file test - let fileTest = testcase.$.file && tests.testFiles.find(file => file.nameToRun === testcase.$.file); + const fileTest = testcase.$.file && tests.testFiles.find(file => file.nameToRun === testcase.$.file); if (fileTest && testcase.error) { fileTest.status = TestStatus.Error; fileTest.passed = false; @@ -109,7 +111,6 @@ export function updateResultsFromXmlLogFile(tests: Tests, outputXmlFile: string, result.testFunction.passed = true; result.testFunction.status = TestStatus.Pass; - if (testcase.failure) { result.testFunction.status = TestStatus.Fail; result.testFunction.passed = false; diff --git a/src/client/unittests/configuration.ts b/src/client/unittests/configuration.ts index cd2b7614179e..ecbefeae8d4d 100644 --- a/src/client/unittests/configuration.ts +++ b/src/client/unittests/configuration.ts @@ -1,152 +1,159 @@ 'use strict'; +import * as path from 'path'; import * as vscode from 'vscode'; +import { OutputChannel, Uri } from 'vscode'; import { PythonSettings } from '../common/configSettings'; -import { Product } from '../common/installer'; +import { Installer, Product } from '../common/installer'; +import { getSubDirectories } from '../common/utils'; +import { TestConfigSettingsService } from './common/configSettingService'; import { TestConfigurationManager } from './common/testConfigurationManager'; +import { selectTestWorkspace } from './common/testUtils'; +import { UnitTestProduct } from './common/types'; +import { ConfigurationManager } from './nosetest/testConfigurationManager'; import * as nose from './nosetest/testConfigurationManager'; import * as pytest from './pytest/testConfigurationManager'; import * as unittest from './unittest/testConfigurationManager'; -import { getSubDirectories } from '../common/utils'; -import * as path from 'path'; -function promptToEnableAndConfigureTestFramework(outputChannel: vscode.OutputChannel, messageToDisplay: string = 'Select a test framework/tool to enable', enableOnly: boolean = false): Thenable { - const items = [{ - label: 'unittest', - product: Product.unittest, - description: 'Standard Python test framework', - detail: 'https://docs.python.org/2/library/unittest.html' - }, - { - label: 'pytest', - product: Product.pytest, - description: 'Can run unittest (including trial) and nose test suites out of the box', - detail: 'http://docs.pytest.org/en/latest/' - }, - { - label: 'nose', - product: Product.nosetest, - description: 'nose framework', - detail: 'https://docs.python.org/2/library/unittest.html' - }]; - const options = { - matchOnDescription: true, - matchOnDetail: true, - placeHolder: messageToDisplay - }; - return vscode.window.showQuickPick(items, options).then(item => { - if (!item) { - return Promise.reject(null); +// tslint:disable-next-line:no-any +async function promptToEnableAndConfigureTestFramework(wkspace: Uri, outputChannel: vscode.OutputChannel, messageToDisplay: string = 'Select a test framework/tool to enable', enableOnly: boolean = false) { + const selectedTestRunner = await selectTestRunner(messageToDisplay); + if (typeof selectedTestRunner !== 'number') { + return Promise.reject(null); + } + const configMgr: TestConfigurationManager = createTestConfigurationManager(wkspace, selectedTestRunner, outputChannel); + if (enableOnly) { + // Ensure others are disabled + if (selectedTestRunner !== Product.unittest) { + createTestConfigurationManager(wkspace, Product.unittest, outputChannel).disable(); } - let configMgr: TestConfigurationManager; - switch (item.product) { - case Product.unittest: { - configMgr = new unittest.ConfigurationManager(outputChannel); - break; - } - case Product.pytest: { - configMgr = new pytest.ConfigurationManager(outputChannel); - break; - } - case Product.nosetest: { - configMgr = new nose.ConfigurationManager(outputChannel); - break; - } - default: { - throw new Error('Invalid test configuration'); - } + if (selectedTestRunner !== Product.pytest) { + createTestConfigurationManager(wkspace, Product.pytest, outputChannel).disable(); } - - if (enableOnly) { - // Ensure others are disabled - if (item.product !== Product.unittest) { - (new unittest.ConfigurationManager(outputChannel)).disable(); - } - if (item.product !== Product.pytest) { - (new pytest.ConfigurationManager(outputChannel)).disable(); - } - if (item.product !== Product.nosetest) { - (new nose.ConfigurationManager(outputChannel)).disable(); - } - return configMgr.enable(); + if (selectedTestRunner !== Product.nosetest) { + createTestConfigurationManager(wkspace, Product.nosetest, outputChannel).disable(); } + return configMgr.enable(); + } - // Configure everything before enabling - // Cuz we don't want the test engine (in main.ts file - tests get discovered when config changes are detected) - // to start discovering tests when tests haven't been configured properly - function enableTest(): Thenable { - // TODO: Enable multi workspace root support - const pythonConfig = vscode.workspace.getConfiguration('python'); - if (pythonConfig.get('unitTest.promptToConfigure')) { - return configMgr.enable(); - } - return pythonConfig.update('unitTest.promptToConfigure', undefined).then(() => { - return configMgr.enable(); - }, reason => { - return configMgr.enable().then(() => Promise.reject(reason)); - }); - } - return configMgr.configure(vscode.workspace.rootPath).then(() => { - return enableTest(); - }).catch(reason => { - return enableTest().then(() => Promise.reject(reason)); - }); + return configMgr.configure(wkspace).then(() => { + return enableTest(wkspace, configMgr); + }).catch(reason => { + return enableTest(wkspace, configMgr).then(() => Promise.reject(reason)); }); } -export function displayTestFrameworkError(outputChannel: vscode.OutputChannel): Thenable { - // TODO: Enable multi workspace root support +export function displayTestFrameworkError(wkspace: Uri, outputChannel: vscode.OutputChannel) { const settings = PythonSettings.getInstance(); let enabledCount = settings.unitTest.pyTestEnabled ? 1 : 0; enabledCount += settings.unitTest.nosetestsEnabled ? 1 : 0; enabledCount += settings.unitTest.unittestEnabled ? 1 : 0; if (enabledCount > 1) { - return promptToEnableAndConfigureTestFramework(outputChannel, 'Enable only one of the test frameworks (unittest, pytest or nosetest).', true); - } - else { + return promptToEnableAndConfigureTestFramework(wkspace, outputChannel, 'Enable only one of the test frameworks (unittest, pytest or nosetest).', true); + } else { const option = 'Enable and configure a Test Framework'; return vscode.window.showInformationMessage('No test framework configured (unittest, pytest or nosetest)', option).then(item => { if (item === option) { - return promptToEnableAndConfigureTestFramework(outputChannel); + return promptToEnableAndConfigureTestFramework(wkspace, outputChannel); } return Promise.reject(null); }); } } -export function displayPromptToEnableTests(rootDir: string, outputChannel: vscode.OutputChannel): Thenable { +export async function displayPromptToEnableTests(rootDir: string, outputChannel: vscode.OutputChannel) { const settings = PythonSettings.getInstance(vscode.Uri.file(rootDir)); if (settings.unitTest.pyTestEnabled || settings.unitTest.nosetestsEnabled || settings.unitTest.unittestEnabled) { - return Promise.reject(null); + return; } if (!settings.unitTest.promptToConfigure) { - return Promise.reject(null); + return; } const yes = 'Yes'; - const no = `Later`; - const noNotAgain = `No, don't ask again`; + const no = 'Later'; + const noNotAgain = 'No, don\'t ask again'; - return checkIfHasTestDirs(rootDir).then(hasTests => { - if (!hasTests) { - return Promise.reject(null); - } - return vscode.window.showInformationMessage('You seem to have tests, would you like to enable a test framework?', yes, no, noNotAgain).then(item => { - if (!item || item === no) { - return Promise.reject(null); - } - if (item === yes) { - return promptToEnableAndConfigureTestFramework(outputChannel); - } - else { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.promptToConfigure', false); - } - }); + const hasTests = checkForExistenceOfTests(rootDir); + if (!hasTests) { + return; + } + const item = await vscode.window.showInformationMessage('You seem to have tests, would you like to enable a test framework?', yes, no, noNotAgain); + if (!item || item === no) { + return; + } + if (item === yes) { + await promptToEnableAndConfigureTestFramework(vscode.workspace.getWorkspaceFolder(vscode.Uri.file(rootDir)).uri, outputChannel); + } else { + const pythonConfig = vscode.workspace.getConfiguration('python'); + await pythonConfig.update('unitTest.promptToConfigure', false); + } +} + +// Configure everything before enabling. +// Cuz we don't want the test engine (in main.ts file - tests get discovered when config changes are detected) +// to start discovering tests when tests haven't been configured properly. +function enableTest(wkspace: Uri, configMgr: ConfigurationManager) { + const pythonConfig = vscode.workspace.getConfiguration('python', wkspace); + // tslint:disable-next-line:no-backbone-get-set-outside-model + if (pythonConfig.get('unitTest.promptToConfigure')) { + return configMgr.enable(); + } + return pythonConfig.update('unitTest.promptToConfigure', undefined).then(() => { + return configMgr.enable(); + }, reason => { + return configMgr.enable().then(() => Promise.reject(reason)); }); } -function checkIfHasTestDirs(rootDir: string): Promise { +function checkForExistenceOfTests(rootDir: string): Promise { return getSubDirectories(rootDir).then(subDirs => { return subDirs.map(dir => path.relative(rootDir, dir)).filter(dir => dir.match(/test/i)).length > 0; }); } +function createTestConfigurationManager(wkspace: Uri, product: Product, outputChannel: OutputChannel) { + const installer = new Installer(outputChannel); + const configSettingService = new TestConfigSettingsService(); + switch (product) { + case Product.unittest: { + return new unittest.ConfigurationManager(wkspace, outputChannel, installer, configSettingService); + } + case Product.pytest: { + return new pytest.ConfigurationManager(wkspace, outputChannel, installer, configSettingService); + } + case Product.nosetest: { + return new nose.ConfigurationManager(wkspace, outputChannel, installer, configSettingService); + } + default: { + throw new Error('Invalid test configuration'); + } + } +} +async function selectTestRunner(placeHolderMessage: string): Promise { + const items = [{ + label: 'unittest', + product: Product.unittest, + description: 'Standard Python test framework', + detail: 'https://docs.python.org/2/library/unittest.html' + }, + { + label: 'pytest', + product: Product.pytest, + description: 'Can run unittest (including trial) and nose test suites out of the box', + // tslint:disable-next-line:no-http-string + detail: 'http://docs.pytest.org/en/latest/' + }, + { + label: 'nose', + product: Product.nosetest, + description: 'nose framework', + detail: 'https://docs.python.org/2/library/unittest.html' + }]; + const options = { + matchOnDescription: true, + matchOnDetail: true, + placeHolder: placeHolderMessage + }; + const selectedTestRunner = await vscode.window.showQuickPick(items, options); + // tslint:disable-next-line:prefer-type-cast + return selectedTestRunner ? selectedTestRunner.product as UnitTestProduct : undefined; +} diff --git a/src/client/unittests/display/main.ts b/src/client/unittests/display/main.ts index 7b9ee8a5a534..ab232152e295 100644 --- a/src/client/unittests/display/main.ts +++ b/src/client/unittests/display/main.ts @@ -1,12 +1,18 @@ 'use strict'; import * as vscode from 'vscode'; -import { Tests, CANCELLATION_REASON } from '../common/contracts'; import * as constants from '../../common/constants'; +import { createDeferred, isNotInstalledError } from '../../common/helpers'; +import { CANCELLATION_REASON } from '../common/constants'; import { displayTestErrorMessage } from '../common/testUtils'; -import { isNotInstalledError, createDeferred } from '../../common/helpers'; +import { Tests } from '../common/types'; export class TestResultDisplay { private statusBar: vscode.StatusBarItem; + private discoverCounter = 0; + private ticker = ['|', '/', '-', '|', '/', '-', '\\']; + private progressTimeout; + private progressPrefix: string; + // tslint:disable-next-line:no-any constructor(private outputChannel: vscode.OutputChannel, private onDidChange: vscode.EventEmitter = null) { this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); } @@ -16,19 +22,29 @@ export class TestResultDisplay { public set enabled(enable: boolean) { if (enable) { this.statusBar.show(); - } - else { + } else { this.statusBar.hide(); } } - public DisplayProgressStatus(tests: Promise, debug: boolean = false) { - this.displayProgress('Running Tests', `Running Tests (Click to Stop)`, constants.Commands.Tests_Ask_To_Stop_Test); - tests + public displayProgressStatus(testRunResult: Promise, debug: boolean = false) { + this.displayProgress('Running Tests', 'Running Tests (Click to Stop)', constants.Commands.Tests_Ask_To_Stop_Test); + testRunResult .then(tests => this.updateTestRunWithSuccess(tests, debug)) .catch(this.updateTestRunWithFailure.bind(this)) // We don't care about any other exceptions returned by updateTestRunWithFailure + // tslint:disable-next-line:no-empty .catch(() => { }); } + public displayDiscoverStatus(testDiscovery: Promise) { + this.displayProgress('Discovering Tests', 'Discovering Tests (Click to Stop)', constants.Commands.Tests_Ask_To_Stop_Discovery); + return testDiscovery.then(tests => { + this.updateWithDiscoverSuccess(tests); + return tests; + }).catch(reason => { + this.updateWithDiscoverFailure(reason); + return Promise.reject(reason); + }); + } private updateTestRunWithSuccess(tests: Tests, debug: boolean = false): Tests { this.clearProgressTicker(); @@ -58,7 +74,7 @@ export class TestResultDisplay { toolTip.push(`${tests.summary.errors} Error${tests.summary.errors > 1 ? 's' : ''}`); foreColor = 'yellow'; } - this.statusBar.tooltip = toolTip.length === 0 ? 'No Tests Ran' : toolTip.join(', ') + ' (Tests)'; + this.statusBar.tooltip = toolTip.length === 0 ? 'No Tests Ran' : `${toolTip.join(', ')} (Tests)`; this.statusBar.text = statusText.length === 0 ? 'No Tests Ran' : statusText.join(' '); this.statusBar.color = foreColor; this.statusBar.command = constants.Commands.Tests_View_UI; @@ -71,27 +87,23 @@ export class TestResultDisplay { return tests; } + // tslint:disable-next-line:no-any private updateTestRunWithFailure(reason: any): Promise { this.clearProgressTicker(); this.statusBar.command = constants.Commands.Tests_View_UI; if (reason === CANCELLATION_REASON) { this.statusBar.text = '$(zap) Run Tests'; this.statusBar.tooltip = 'Run Tests'; - } - else { - this.statusBar.text = `$(alert) Tests Failed`; + } else { + this.statusBar.text = '$(alert) Tests Failed'; this.statusBar.tooltip = 'Running Tests Failed'; displayTestErrorMessage('There was an error in running the tests.'); } return Promise.reject(reason); } - private discoverCounter = 0; - private ticker = ['|', '/', '-', '|', '/', '-', '\\']; - private progressTimeout; - private progressPrefix: string; private displayProgress(message: string, tooltip: string, command: string) { - this.progressPrefix = this.statusBar.text = '$(stop) ' + message; + this.progressPrefix = this.statusBar.text = `$(stop) ${message}`; this.statusBar.command = command; this.statusBar.tooltip = tooltip; this.statusBar.show(); @@ -99,7 +111,7 @@ export class TestResultDisplay { this.progressTimeout = setInterval(() => this.updateProgressTicker(), 150); } private updateProgressTicker() { - let text = `${this.progressPrefix} ${this.ticker[this.discoverCounter % 7]}`; + const text = `${this.progressPrefix} ${this.ticker[this.discoverCounter % 7]}`; this.discoverCounter += 1; this.statusBar.text = text; } @@ -111,21 +123,12 @@ export class TestResultDisplay { this.discoverCounter = 0; } - public DisplayDiscoverStatus(tests: Promise) { - this.displayProgress('Discovering Tests', 'Discovering Tests (Click to Stop)', constants.Commands.Tests_Ask_To_Stop_Discovery); - return tests.then(tests => { - this.updateWithDiscoverSuccess(tests); - return tests; - }).catch(reason => { - this.updateWithDiscoverFailure(reason); - return Promise.reject(reason); - }); - } - + // tslint:disable-next-line:no-any private disableTests(): Promise { + // tslint:disable-next-line:no-any const def = createDeferred(); const pythonConfig = vscode.workspace.getConfiguration('python'); - let settingsToDisable = ['unitTest.promptToConfigure', 'unitTest.pyTestEnabled', + const settingsToDisable = ['unitTest.promptToConfigure', 'unitTest.pyTestEnabled', 'unitTest.unittestEnabled', 'unitTest.nosetestsEnabled']; function disableTest() { @@ -154,28 +157,30 @@ export class TestResultDisplay { if (!haveTests) { vscode.window.showInformationMessage('No tests discovered, please check the configuration settings for the tests.', 'Disable Tests').then(item => { if (item === 'Disable Tests') { + // tslint:disable-next-line:no-floating-promises this.disableTests(); } }); } } + // tslint:disable-next-line:no-any private updateWithDiscoverFailure(reason: any) { this.clearProgressTicker(); - this.statusBar.text = `$(zap) Discover Tests`; + this.statusBar.text = '$(zap) Discover Tests'; this.statusBar.tooltip = 'Discover Tests'; this.statusBar.command = constants.Commands.Tests_Discover; this.statusBar.show(); this.statusBar.color = 'yellow'; if (reason !== CANCELLATION_REASON) { - this.statusBar.text = `$(alert) Test discovery failed`; - this.statusBar.tooltip = `Discovering Tests failed (view 'Python Test Log' output panel for details)`; - // TODO: ignore this quitemode, always display the error message (inform the user) + this.statusBar.text = '$(alert) Test discovery failed'; + this.statusBar.tooltip = 'Discovering Tests failed (view \'Python Test Log\' output panel for details)'; + // TODO: ignore this quitemode, always display the error message (inform the user). if (!isNotInstalledError(reason)) { - // TODO: show an option that will invoke a command 'python.test.configureTest' or similar - // This will be hanlded by main.ts that will capture input from user and configure the tests + // TODO: show an option that will invoke a command 'python.test.configureTest' or similar. + // This will be hanlded by main.ts that will capture input from user and configure the tests. vscode.window.showErrorMessage('There was an error in discovering tests, please check the configuration settings for the tests.'); } } } -} \ No newline at end of file +} diff --git a/src/client/unittests/display/picker.ts b/src/client/unittests/display/picker.ts index 67338d2f71c4..61c18840ae51 100644 --- a/src/client/unittests/display/picker.ts +++ b/src/client/unittests/display/picker.ts @@ -1,23 +1,22 @@ -import { QuickPickItem, window } from 'vscode'; +import * as path from 'path'; +import { QuickPickItem, Uri, window } from 'vscode'; import * as vscode from 'vscode'; -import { Tests, TestFile, TestFunction, FlattenedTestFunction, TestStatus } from '../common/contracts'; -import { getDiscoveredTests } from '../common/testUtils'; import * as constants from '../../common/constants'; -import * as path from 'path'; +import { FlattenedTestFunction, ITestCollectionStorageService, TestFile, TestFunction, Tests, TestStatus, TestsToRun } from '../common/types'; export class TestDisplay { - constructor() { - } - public displayStopTestUI(message: string) { + constructor(private testCollectionStorage: ITestCollectionStorageService) { } + public displayStopTestUI(workspace: Uri, message: string) { window.showQuickPick([message]).then(item => { if (item === message) { - vscode.commands.executeCommand(constants.Commands.Tests_Stop); + vscode.commands.executeCommand(constants.Commands.Tests_Stop, workspace); } }); } - public displayTestUI(rootDirectory: string) { - const tests = getDiscoveredTests(); - window.showQuickPick(buildItems(rootDirectory, tests), { matchOnDescription: true, matchOnDetail: true }).then(onItemSelected); + public displayTestUI(wkspace: Uri) { + const tests = this.testCollectionStorage.getTests(wkspace); + window.showQuickPick(buildItems(tests), { matchOnDescription: true, matchOnDetail: true }) + .then(item => onItemSelected(wkspace, item, false)); } public selectTestFunction(rootDirectory: string, tests: Tests): Promise { return new Promise((resolve, reject) => { @@ -41,12 +40,13 @@ export class TestDisplay { }, reject); }); } - public displayFunctionTestPickerUI(rootDirectory: string, fileName: string, testFunctions: TestFunction[], debug?: boolean) { - const tests = getDiscoveredTests(); + public displayFunctionTestPickerUI(wkspace: Uri, rootDirectory: string, file: Uri, testFunctions: TestFunction[], debug?: boolean) { + const tests = this.testCollectionStorage.getTests(wkspace); if (!tests) { return; } - const testFile = tests.testFiles.find(file => file.name === fileName || file.fullPath === fileName); + const fileName = file.fsPath; + const testFile = tests.testFiles.find(item => item.name === fileName || item.fullPath === fileName); if (!testFile) { return; } @@ -57,7 +57,7 @@ export class TestDisplay { window.showQuickPick(buildItemsForFunctions(rootDirectory, flattenedFunctions, undefined, undefined, debug), { matchOnDescription: true, matchOnDetail: true }).then(testItem => { - return onItemSelected(testItem, debug); + return onItemSelected(wkspace, testItem, debug); }); } } @@ -81,14 +81,16 @@ statusIconMapping.set(TestStatus.Fail, constants.Octicons.Test_Fail); statusIconMapping.set(TestStatus.Error, constants.Octicons.Test_Error); statusIconMapping.set(TestStatus.Skipped, constants.Octicons.Test_Skip); -interface TestItem extends QuickPickItem { +type TestItem = QuickPickItem & { type: Type; fn?: FlattenedTestFunction; -} -interface TestFileItem extends QuickPickItem { +}; + +type TestFileItem = QuickPickItem & { type: Type; testFile?: TestFile; -} +}; + function getSummary(tests?: Tests) { if (!tests || !tests.summary) { return ''; @@ -102,20 +104,20 @@ function getSummary(tests?: Tests) { } if (tests.summary.errors > 0) { const plural = tests.summary.errors === 1 ? '' : 's'; - statusText.push(`${constants.Octicons.Test_Error} ${tests.summary.errors} Error` + plural); + statusText.push(`${constants.Octicons.Test_Error} ${tests.summary.errors} Error${plural}`); } if (tests.summary.skipped > 0) { statusText.push(`${constants.Octicons.Test_Skip} ${tests.summary.skipped} Skipped`); } return statusText.join(', ').trim(); } -function buildItems(rootDirectory: string, tests?: Tests): TestItem[] { +function buildItems(tests?: Tests): TestItem[] { const items: TestItem[] = []; items.push({ description: '', label: 'Run All Unit Tests', type: Type.RunAll }); items.push({ description: '', label: 'Discover Unit Tests', type: Type.ReDiscover }); items.push({ description: '', label: 'Run Unit Test Method ...', type: Type.SelectAndRunMethod }); - let summary = getSummary(tests); + const summary = getSummary(tests); items.push({ description: '', label: 'View Unit Test Output', type: Type.ViewTestOutput, detail: summary }); if (tests && tests.summary.failures > 0) { @@ -132,7 +134,7 @@ statusSortPrefix[TestStatus.Skipped] = '3'; statusSortPrefix[TestStatus.Pass] = '4'; function buildItemsForFunctions(rootDirectory: string, tests: FlattenedTestFunction[], sortBasedOnResults: boolean = false, displayStatusIcons: boolean = false, debug: boolean = false): TestItem[] { - let functionItems: TestItem[] = []; + const functionItems: TestItem[] = []; tests.forEach(fn => { let icon = ''; if (displayStatusIcons && statusIconMapping.has(fn.testFunction.status)) { @@ -165,15 +167,15 @@ function buildItemsForFunctions(rootDirectory: string, tests: FlattenedTestFunct return functionItems; } function buildItemsForTestFiles(rootDirectory: string, testFiles: TestFile[]): TestFileItem[] { - let fileItems: TestFileItem[] = testFiles.map(testFile => { + const fileItems: TestFileItem[] = testFiles.map(testFile => { return { description: '', detail: path.relative(rootDirectory, testFile.fullPath), type: Type.RunFile, label: path.basename(testFile.fullPath), testFile: testFile - } - }) + }; + }); fileItems.sort((a, b) => { if (a.detail < b.detail) { return -1; @@ -182,15 +184,16 @@ function buildItemsForTestFiles(rootDirectory: string, testFiles: TestFile[]): T return 1; } return 0; - }) + }); return fileItems; } -function onItemSelected(selection: TestItem, debug?: boolean) { +function onItemSelected(wkspace: Uri, selection: TestItem, debug?: boolean) { if (!selection || typeof selection.type !== 'number') { return; } let cmd = ''; - let args = []; + // tslint:disable-next-line:no-any + const args: any[] = [wkspace]; switch (selection.type) { case Type.Null: { return; @@ -217,15 +220,20 @@ function onItemSelected(selection: TestItem, debug?: boolean) { } case Type.RunMethod: { cmd = constants.Commands.Tests_Run; - args.push(selection.fn); + // tslint:disable-next-line:prefer-type-cast + args.push({ testFunction: [selection.fn.testFunction] } as TestsToRun); break; } case Type.DebugMethod: { cmd = constants.Commands.Tests_Debug; - args.push(selection.fn); + // tslint:disable-next-line:prefer-type-cast + args.push({ testFunction: [selection.fn.testFunction] } as TestsToRun); args.push(true); break; } + default: { + return; + } } vscode.commands.executeCommand(cmd, ...args); diff --git a/src/client/unittests/main.ts b/src/client/unittests/main.ts index bbd4e41b35cc..adb86808d695 100644 --- a/src/client/unittests/main.ts +++ b/src/client/unittests/main.ts @@ -1,71 +1,79 @@ 'use strict'; +import { Uri, window, workspace } from 'vscode'; import * as vscode from 'vscode'; -import * as nosetests from './nosetest/main'; -import * as pytest from './pytest/main'; -import * as unittest from './unittest/main'; +import { IUnitTestSettings, PythonSettings } from '../common/configSettings'; import * as constants from '../common/constants'; -import { - CANCELLATION_REASON, - FlattenedTestFunction, - TestFile, - TestFunction, - TestStatus, - TestsToRun, -} from './common/contracts'; -import { resolveValueAsTestToRun, getDiscoveredTests } from './common/testUtils'; +import { PythonSymbolProvider } from '../providers/symbolProvider'; +import { activateCodeLenses } from './codeLenses/main'; import { BaseTestManager } from './common/baseTestManager'; -import { PythonSettings, IUnitTestSettings } from '../common/configSettings'; +import { CANCELLATION_REASON } from './common/constants'; +import { DebugLauncher } from './common/debugLauncher'; +import { TestCollectionStorageService } from './common/storageService'; +import { TestManagerServiceFactory } from './common/testManagerServiceFactory'; +import { TestResultsService } from './common/testResultsService'; +import { selectTestWorkspace, TestsHelper } from './common/testUtils'; +import { FlattenedTestFunction, ITestCollectionStorageService, IWorkspaceTestManagerService, TestFile, TestFunction, TestStatus, TestsToRun } from './common/types'; +import { WorkspaceTestManagerService } from './common/workspaceTestManagerService'; +import { displayTestFrameworkError } from './configuration'; import { TestResultDisplay } from './display/main'; import { TestDisplay } from './display/picker'; -import { activateCodeLenses } from './codeLenses/main'; -import { displayTestFrameworkError } from './configuration'; -import { PythonSymbolProvider } from '../providers/symbolProvider'; +import * as nosetests from './nosetest/main'; +import * as pytest from './pytest/main'; +import * as unittest from './unittest/main'; -let testManager: BaseTestManager | undefined | null; -let pyTestManager: pytest.TestManager | undefined | null; -let unittestManager: unittest.TestManager | undefined | null; -let nosetestManager: nosetests.TestManager | undefined | null; +let workspaceTestManagerService: IWorkspaceTestManagerService; let testResultDisplay: TestResultDisplay; let testDisplay: TestDisplay; let outChannel: vscode.OutputChannel; -let onDidChange: vscode.EventEmitter = new vscode.EventEmitter(); +const onDidChange: vscode.EventEmitter = new vscode.EventEmitter(); +let testCollectionStorage: ITestCollectionStorageService; export function activate(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, symboldProvider: PythonSymbolProvider) { - // TODO: Add multi workspace support - const settings = PythonSettings.getInstance(); - uniTestSettingsString = JSON.stringify(settings.unitTest); context.subscriptions.push({ dispose: dispose }); outChannel = outputChannel; - let disposables = registerCommands(); + const disposables = registerCommands(); context.subscriptions.push(...disposables); - if (settings.unitTest.nosetestsEnabled || settings.unitTest.pyTestEnabled || settings.unitTest.unittestEnabled) { - // Ignore the exceptions returned - // This function is invoked via a command which will be invoked else where in the extension - discoverTests(true, false).catch(() => { - // Ignore the errors - }); - } + testCollectionStorage = new TestCollectionStorageService(); + const testResultsService = new TestResultsService(); + const testsHelper = new TestsHelper(); + const debugLauncher = new DebugLauncher(); + const testManagerServiceFactory = new TestManagerServiceFactory(outChannel, testCollectionStorage, testResultsService, testsHelper, debugLauncher); + workspaceTestManagerService = new WorkspaceTestManagerService(outChannel, testManagerServiceFactory); - settings.addListener('change', onConfigChanged); - context.subscriptions.push(activateCodeLenses(onDidChange, symboldProvider)); + context.subscriptions.push(autoResetTests()); + context.subscriptions.push(activateCodeLenses(onDidChange, symboldProvider, testCollectionStorage)); context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(onDocumentSaved)); -} -function getTestWorkingDirectory() { - // TODO: Add multi workspace support - const settings = PythonSettings.getInstance(); - return settings.unitTest.cwd && settings.unitTest.cwd.length > 0 ? settings.unitTest.cwd : vscode.workspace.rootPath!; + autoDiscoverTests(); } +async function getTestManager(displayTestNotConfiguredMessage: boolean, resource?: Uri): Promise { + let wkspace: Uri | undefined; + if (resource) { + const wkspaceFolder = workspace.getWorkspaceFolder(resource); + wkspace = wkspaceFolder ? wkspaceFolder.uri : undefined; + } else { + wkspace = await selectTestWorkspace(); + } + if (!wkspace) { + return; + } + const testManager = workspaceTestManagerService.getTestManager(wkspace); + if (testManager) { + return testManager; + } + if (displayTestNotConfiguredMessage) { + await displayTestFrameworkError(wkspace, outChannel); + } +} let timeoutId: number; async function onDocumentSaved(doc: vscode.TextDocument): Promise { - let testManager = getTestRunner(); + const testManager = await getTestManager(false, doc.uri); if (!testManager) { return; } - - let tests = await testManager.discoverTests(false, true, false); + const tests = await testManager.discoverTests(false, true); if (!tests || !Array.isArray(tests.testFiles) || tests.testFiles.length === 0) { return; } @@ -76,125 +84,158 @@ async function onDocumentSaved(doc: vscode.TextDocument): Promise { if (timeoutId) { clearTimeout(timeoutId); } - timeoutId = setTimeout(() => { discoverTests(true, false); }, 1000); + timeoutId = setTimeout(() => discoverTests(doc.uri, true), 1000); } function dispose() { - if (pyTestManager) { - pyTestManager.dispose(); - } - if (nosetestManager) { - nosetestManager.dispose(); - } - if (unittestManager) { - unittestManager.dispose(); - } + workspaceTestManagerService.dispose(); + testCollectionStorage.dispose(); } function registerCommands(): vscode.Disposable[] { const disposables = []; - disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Discover, () => { - // Ignore the exceptions returned - // This command will be invoked else where in the extension - discoverTests(true, true).catch(() => { return null; }); + disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Discover, (resource?: Uri) => { + // Ignore the exceptions returned. + // This command will be invoked else where in the extension. + // tslint:disable-next-line:no-empty + discoverTests(resource, true, true).catch(() => { }); })); - disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Run_Failed, () => runTestsImpl(true))); - disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Run, (testId) => runTestsImpl(testId))); - disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Debug, (testId) => runTestsImpl(testId, true))); + disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Run_Failed, (resource: Uri) => runTestsImpl(resource, undefined, true))); + // tslint:disable-next-line:no-unnecessary-callback-wrapper + disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Run, (file: Uri, testToRun?: TestsToRun) => runTestsImpl(file, testToRun))); + disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Debug, (file: Uri, testToRun: TestsToRun) => runTestsImpl(file, testToRun, false, true))); + // tslint:disable-next-line:no-unnecessary-callback-wrapper disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_View_UI, () => displayUI())); - disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Picker_UI, (file, testFunctions) => displayPickerUI(file, testFunctions))); + // tslint:disable-next-line:no-unnecessary-callback-wrapper + disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Picker_UI, (file: Uri, testFunctions: TestFunction[]) => displayPickerUI(file, testFunctions))); disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Picker_UI_Debug, (file, testFunctions) => displayPickerUI(file, testFunctions, true))); - disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Stop, () => stopTests())); + // tslint:disable-next-line:no-unnecessary-callback-wrapper + disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Stop, (resource: Uri) => stopTests(resource))); disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_ViewOutput, () => outChannel.show())); disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Ask_To_Stop_Discovery, () => displayStopUI('Stop discovering tests'))); disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Ask_To_Stop_Test, () => displayStopUI('Stop running tests'))); - disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Select_And_Run_Method, () => selectAndRunTestMethod())); - disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Select_And_Debug_Method, () => selectAndRunTestMethod(true))); + // tslint:disable-next-line:no-unnecessary-callback-wrapper + disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Select_And_Run_Method, (resource: Uri) => selectAndRunTestMethod(resource))); + disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Select_And_Debug_Method, (resource: Uri) => selectAndRunTestMethod(resource, true))); + // tslint:disable-next-line:no-unnecessary-callback-wrapper disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Select_And_Run_File, () => selectAndRunTestFile())); + // tslint:disable-next-line:no-unnecessary-callback-wrapper disposables.push(vscode.commands.registerCommand(constants.Commands.Tests_Run_Current_File, () => runCurrentTestFile())); return disposables; } -function displayUI() { - let testManager = getTestRunner(); +async function displayUI() { + const testManager = await getTestManager(true); if (!testManager) { - return displayTestFrameworkError(outChannel); + return; } - testDisplay = testDisplay ? testDisplay : new TestDisplay(); - testDisplay.displayTestUI(getTestWorkingDirectory()); + testDisplay = testDisplay ? testDisplay : new TestDisplay(testCollectionStorage); + testDisplay.displayTestUI(testManager.workspace); } -function displayPickerUI(file: string, testFunctions: TestFunction[], debug?: boolean) { - let testManager = getTestRunner(); +async function displayPickerUI(file: Uri, testFunctions: TestFunction[], debug?: boolean) { + const testManager = await getTestManager(true, file); if (!testManager) { - return displayTestFrameworkError(outChannel); + return; } - testDisplay = testDisplay ? testDisplay : new TestDisplay(); - testDisplay.displayFunctionTestPickerUI(getTestWorkingDirectory(), file, testFunctions, debug); + testDisplay = testDisplay ? testDisplay : new TestDisplay(testCollectionStorage); + testDisplay.displayFunctionTestPickerUI(testManager.workspace, testManager.workingDirectory, file, testFunctions, debug); } -function selectAndRunTestMethod(debug?: boolean) { - let testManager = getTestRunner(); +async function selectAndRunTestMethod(resource: Uri, debug?: boolean) { + const testManager = await getTestManager(true, resource); if (!testManager) { - return displayTestFrameworkError(outChannel); - } - testManager.discoverTests(true, true, true).then(() => { - const tests = getDiscoveredTests(); - testDisplay = testDisplay ? testDisplay : new TestDisplay(); - testDisplay.selectTestFunction(getTestWorkingDirectory(), tests).then(testFn => { - runTestsImpl(testFn, debug); - }).catch(() => { }); - }); + return; + } + try { + await testManager.discoverTests(true, true, true); + } catch (ex) { + return; + } + + const tests = testCollectionStorage.getTests(testManager.workspace); + testDisplay = testDisplay ? testDisplay : new TestDisplay(testCollectionStorage); + const selectedTestFn = await testDisplay.selectTestFunction(testManager.workspace.fsPath, tests); + if (!selectedTestFn) { + return; + } + // tslint:disable-next-line:prefer-type-cast + await runTestsImpl(testManager.workspace, { testFunction: [selectedTestFn.testFunction] } as TestsToRun, debug); } -function selectAndRunTestFile() { - let testManager = getTestRunner(); +async function selectAndRunTestFile() { + const testManager = await getTestManager(true); if (!testManager) { - return displayTestFrameworkError(outChannel); - } - testManager.discoverTests(true, true, true).then(() => { - const tests = getDiscoveredTests(); - testDisplay = testDisplay ? testDisplay : new TestDisplay(); - testDisplay.selectTestFile(getTestWorkingDirectory(), tests).then(testFile => { - runTestsImpl({ testFile: [testFile] }); - }).catch(() => { }); - }); + return; + } + try { + await testManager.discoverTests(true, true, true); + } catch (ex) { + return; + } + + const tests = testCollectionStorage.getTests(testManager.workspace); + testDisplay = testDisplay ? testDisplay : new TestDisplay(testCollectionStorage); + const selectedFile = await testDisplay.selectTestFile(testManager.workspace.fsPath, tests); + if (!selectedFile) { + return; + } + // tslint:disable-next-line:prefer-type-cast + await runTestsImpl(testManager.workspace, { testFile: [selectedFile] } as TestsToRun); } -function runCurrentTestFile() { - if (!vscode.window.activeTextEditor) { +async function runCurrentTestFile() { + if (!window.activeTextEditor) { return; } - const currentFilePath = vscode.window.activeTextEditor.document.fileName; - let testManager = getTestRunner(); + const testManager = await getTestManager(true, window.activeTextEditor.document.uri); if (!testManager) { - return displayTestFrameworkError(outChannel); + return; } - testManager.discoverTests(true, true, true).then(() => { - const tests = getDiscoveredTests(); - const testFiles = tests.testFiles.filter(testFile => { - return testFile.fullPath === currentFilePath; - }); - if (testFiles.length < 1) { - return; - } - runTestsImpl({ testFile: [testFiles[0]] }); + try { + await testManager.discoverTests(true, true, true); + } catch (ex) { + return; + } + const tests = testCollectionStorage.getTests(testManager.workspace); + const testFiles = tests.testFiles.filter(testFile => { + return testFile.fullPath === window.activeTextEditor.document.uri.fsPath; }); + if (testFiles.length < 1) { + return; + } + // tslint:disable-next-line:prefer-type-cast + await runTestsImpl(testManager.workspace, { testFile: [testFiles[0]] } as TestsToRun); } -function displayStopUI(message: string) { - let testManager = getTestRunner(); +async function displayStopUI(message: string) { + const testManager = await getTestManager(true); if (!testManager) { - return displayTestFrameworkError(outChannel); + return; } - testDisplay = testDisplay ? testDisplay : new TestDisplay(); - testDisplay.displayStopTestUI(message); + testDisplay = testDisplay ? testDisplay : new TestDisplay(testCollectionStorage); + testDisplay.displayStopTestUI(testManager.workspace, message); } + let uniTestSettingsString: string; +function autoResetTests() { + if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length > 1) { + // tslint:disable-next-line:no-empty + return { dispose: () => { } }; + } + const settings = PythonSettings.getInstance(); + uniTestSettingsString = JSON.stringify(settings.unitTest); + return workspace.onDidChangeConfiguration(() => setTimeout(onConfigChanged, 1000)); +} function onConfigChanged() { - // TODO: Add multi workspace support + // If there's one workspace, then stop the tests and restart, + // else let the user do this manually. + if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length > 1) { + return; + } const settings = PythonSettings.getInstance(); - // Possible that a test framework has been enabled or some settings have changed - // Meaning we need to re-load the discovered tests (as something could have changed) + + // Possible that a test framework has been enabled or some settings have changed. + // Meaning we need to re-load the discovered tests (as something could have changed). const newSettings = JSON.stringify(settings.unitTest); if (uniTestSettingsString === newSettings) { return; @@ -205,71 +246,47 @@ function onConfigChanged() { if (testResultDisplay) { testResultDisplay.enabled = false; } - - if (testManager) { - testManager.stop(); - testManager = null; - } - if (pyTestManager) { - pyTestManager.dispose(); - pyTestManager = null; - } - if (nosetestManager) { - nosetestManager.dispose(); - nosetestManager = null; - } - if (unittestManager) { - unittestManager.dispose(); - unittestManager = null; - } + workspaceTestManagerService.dispose(); return; } - if (testResultDisplay) { testResultDisplay.enabled = true; } - - // No need to display errors - if (settings.unitTest.nosetestsEnabled || settings.unitTest.pyTestEnabled || settings.unitTest.unittestEnabled) { - discoverTests(true, false); - } + autoDiscoverTests(); } -function getTestRunner() { - const rootDirectory = getTestWorkingDirectory(); - const settings = PythonSettings.getInstance(vscode.Uri.file(rootDirectory)); - if (settings.unitTest.nosetestsEnabled) { - return nosetestManager = nosetestManager ? nosetestManager : new nosetests.TestManager(rootDirectory, outChannel); - } - else if (settings.unitTest.pyTestEnabled) { - return pyTestManager = pyTestManager ? pyTestManager : new pytest.TestManager(rootDirectory, outChannel); +function autoDiscoverTests() { + if (!Array.isArray(workspace.workspaceFolders) || workspace.workspaceFolders.length > 1) { + return; } - else if (settings.unitTest.unittestEnabled) { - return unittestManager = unittestManager ? unittestManager : new unittest.TestManager(rootDirectory, outChannel); + const settings = PythonSettings.getInstance(); + if (!settings.unitTest.nosetestsEnabled && !settings.unitTest.pyTestEnabled && !settings.unitTest.unittestEnabled) { + return; } - return null; -} -function stopTests() { - let testManager = getTestRunner(); + // No need to display errors. + // tslint:disable-next-line:no-empty + discoverTests(workspace.workspaceFolders[0].uri, true).catch(() => { }); +} +async function stopTests(resource: Uri) { + const testManager = await getTestManager(true, resource); if (testManager) { testManager.stop(); } } -function discoverTests(ignoreCache?: boolean, isUserInitiated?: boolean) { - let testManager = getTestRunner(); +async function discoverTests(resource?: Uri, ignoreCache?: boolean, userInitiated?: boolean) { + const testManager = await getTestManager(true, resource); if (!testManager) { - displayTestFrameworkError(outChannel); - return Promise.resolve(null); + return; } if (testManager && (testManager.status !== TestStatus.Discovering && testManager.status !== TestStatus.Running)) { testResultDisplay = testResultDisplay ? testResultDisplay : new TestResultDisplay(outChannel, onDidChange); - return testResultDisplay.DisplayDiscoverStatus(testManager.discoverTests(ignoreCache, false, isUserInitiated)); - } - else { - return Promise.resolve(null); + const discoveryPromise = testManager.discoverTests(ignoreCache, false, userInitiated); + testResultDisplay.displayDiscoverStatus(discoveryPromise); + await discoveryPromise; } } +// tslint:disable-next-line:no-any function isTestsToRun(arg: any): arg is TestsToRun { if (arg && arg.testFunction && Array.isArray(arg.testFunction)) { return true; @@ -282,46 +299,21 @@ function isTestsToRun(arg: any): arg is TestsToRun { } return false; } -function isUri(arg: any): arg is vscode.Uri { - return arg && arg.fsPath && typeof arg.fsPath === 'string'; -} -function isFlattenedTestFunction(arg: any): arg is FlattenedTestFunction { - return arg && arg.testFunction && typeof arg.xmlClassName === 'string' && - arg.parentTestFile && typeof arg.testFunction.name === 'string'; -} -function identifyTestType(rootDirectory: string, arg?: vscode.Uri | TestsToRun | boolean | FlattenedTestFunction): TestsToRun | boolean | null | undefined { - if (typeof arg === 'boolean') { - return arg === true; - } - if (isTestsToRun(arg)) { - return arg; - } - if (isFlattenedTestFunction(arg)) { - return { testFunction: [arg.testFunction] }; - } - if (isUri(arg)) { - return resolveValueAsTestToRun(arg.fsPath, rootDirectory); - } - return null; -} -function runTestsImpl(arg?: vscode.Uri | TestsToRun | boolean | FlattenedTestFunction, debug: boolean = false) { - let testManager = getTestRunner(); +async function runTestsImpl(resource?: Uri, testsToRun?: TestsToRun, runFailedTests?: boolean, debug: boolean = false) { + const testManager = await getTestManager(true, resource); if (!testManager) { - return displayTestFrameworkError(outChannel); + return; } - // lastRanTests = testsToRun; - const runInfo = identifyTestType(getTestWorkingDirectory(), arg); - testResultDisplay = testResultDisplay ? testResultDisplay : new TestResultDisplay(outChannel, onDidChange); + const promise = testManager.runTest(testsToRun, runFailedTests, debug) + .catch(reason => { + if (reason !== CANCELLATION_REASON) { + outChannel.appendLine(`Error: ${reason}`); + } + return Promise.reject(reason); + }); - const ret = typeof runInfo === 'boolean' ? testManager.runTest(runInfo, debug) : testManager.runTest(runInfo as TestsToRun, debug); - let runPromise = ret.catch(reason => { - if (reason !== CANCELLATION_REASON) { - outChannel.appendLine('Error: ' + reason); - } - return Promise.reject(reason); - }); - - testResultDisplay.DisplayProgressStatus(runPromise, debug); + testResultDisplay.displayProgressStatus(promise, debug); + await promise; } diff --git a/src/client/unittests/nosetest/collector.ts b/src/client/unittests/nosetest/collector.ts index c78d1fd86f9f..3a202654e6fd 100644 --- a/src/client/unittests/nosetest/collector.ts +++ b/src/client/unittests/nosetest/collector.ts @@ -1,12 +1,12 @@ 'use strict'; -import * as path from 'path'; -import { execPythonFile } from './../../common/utils'; -import { TestFile, TestSuite, TestFunction, Tests } from '../common/contracts'; import * as os from 'os'; -import { extractBetweenDelimiters, convertFileToPackage, flattenTestFiles } from '../common/testUtils'; +import * as path from 'path'; import { CancellationToken } from 'vscode'; -import { PythonSettings } from '../../common/configSettings'; import { OutputChannel, Uri } from 'vscode'; +import { PythonSettings } from '../../common/configSettings'; +import { convertFileToPackage, extractBetweenDelimiters } from '../common/testUtils'; +import { ITestsHelper, TestFile, TestFunction, Tests, TestSuite } from '../common/types'; +import { execPythonFile } from './../../common/utils'; const NOSE_WANT_FILE_PREFIX = 'nose.selector: DEBUG: wantFile '; const NOSE_WANT_FILE_SUFFIX = '.py? True'; @@ -20,7 +20,7 @@ const argsToExcludeForDiscovery = ['-v', '--verbose', '--failed', '--process-restartworker', '--with-xunit']; const settingsInArgsToExcludeForDiscovery = ['--verbosity']; -export function discoverTests(rootDirectory: string, args: string[], token: CancellationToken, ignoreCache: boolean, outChannel: OutputChannel): Promise { +export function discoverTests(rootDirectory: string, args: string[], token: CancellationToken, ignoreCache: boolean, outChannel: OutputChannel, testsHelper: ITestsHelper): Promise { let logOutputLines: string[] = ['']; let testFiles: TestFile[] = []; @@ -44,8 +44,7 @@ export function discoverTests(rootDirectory: string, args: string[], token: Canc // and starts with nose.selector: DEBUG: want if (logOutputLines[lastLineIndex].endsWith('? True')) { logOutputLines.push(''); - } - else { + } else { // We don't need this line logOutputLines[lastLineIndex] = ''; } @@ -84,7 +83,7 @@ export function discoverTests(rootDirectory: string, args: string[], token: Canc // Exclude tests that don't have any functions or test suites testFiles = testFiles.filter(testFile => testFile.suites.length > 0 || testFile.functions.length > 0); - return flattenTestFiles(testFiles); + return testsHelper.flattenTestFiles(testFiles); }); } @@ -115,10 +114,10 @@ function parseNoseTestModuleCollectionResult(rootDirectory: string, lines: strin } if (line.startsWith('nose.selector: DEBUG: wantClass ? True'); + const name = extractBetweenDelimiters(line, 'nose.selector: DEBUG: wantClass ? True'); const clsName = path.extname(name).substring(1); const testSuite: TestSuite = { - name: clsName, nameToRun: fileName + `:${clsName}`, + name: clsName, nameToRun: `${fileName}:${clsName}`, functions: [], suites: [], xmlName: name, time: 0, isUnitTest: false, isInstance: false, functionsFailed: 0, functionsPassed: 0 }; @@ -126,7 +125,7 @@ function parseNoseTestModuleCollectionResult(rootDirectory: string, lines: strin return; } if (line.startsWith('nose.selector: DEBUG: wantClass ')) { - let name = extractBetweenDelimiters(line, 'nose.selector: DEBUG: wantClass ', '? True'); + const name = extractBetweenDelimiters(line, 'nose.selector: DEBUG: wantClass ', '? True'); const testSuite: TestSuite = { name: path.extname(name).substring(1), nameToRun: `${fileName}:.${name}`, functions: [], suites: [], xmlName: name, time: 0, isUnitTest: false, @@ -144,6 +143,7 @@ function parseNoseTestModuleCollectionResult(rootDirectory: string, lines: strin time: 0, functionsFailed: 0, functionsPassed: 0 }; + // tslint:disable-next-line:no-non-null-assertion const cls = testFile.suites.find(suite => suite.name === clsName)!; cls.functions.push(fn); return; diff --git a/src/client/unittests/nosetest/main.ts b/src/client/unittests/nosetest/main.ts index 3533fd00c0ce..f670fb2333a3 100644 --- a/src/client/unittests/nosetest/main.ts +++ b/src/client/unittests/nosetest/main.ts @@ -1,29 +1,32 @@ 'use strict'; -import { PythonSettings } from '../../common/configSettings'; import { OutputChannel } from 'vscode'; -import { TestsToRun, Tests } from '../common/contracts'; import * as vscode from 'vscode'; -import { discoverTests } from './collector'; +import { PythonSettings } from '../../common/configSettings'; +import { Product } from '../../common/installer'; import { BaseTestManager } from '../common/baseTestManager'; +import { ITestCollectionStorageService, ITestDebugLauncher, ITestResultsService, ITestsHelper, Tests, TestsToRun } from '../common/types'; +import { discoverTests } from './collector'; import { runTest } from './runner'; -import { Product } from '../../common/installer'; export class TestManager extends BaseTestManager { - constructor(rootDirectory: string, outputChannel: vscode.OutputChannel) { - super('nosetest', Product.nosetest, rootDirectory, outputChannel); + constructor(rootDirectory: string, outputChannel: vscode.OutputChannel, + testCollectionStorage: ITestCollectionStorageService, + testResultsService: ITestResultsService, testsHelper: ITestsHelper, private debugLauncher: ITestDebugLauncher) { + super('nosetest', Product.nosetest, rootDirectory, outputChannel, testCollectionStorage, testResultsService, testsHelper); } - discoverTestsImpl(ignoreCache: boolean): Promise { - let args = this.settings.unitTest.nosetestArgs.slice(0); - return discoverTests(this.rootDirectory, args, this.testDiscoveryCancellationToken, ignoreCache, this.outputChannel); + public discoverTestsImpl(ignoreCache: boolean): Promise { + const args = this.settings.unitTest.nosetestArgs.slice(0); + return discoverTests(this.rootDirectory, args, this.testDiscoveryCancellationToken, ignoreCache, this.outputChannel, this.testsHelper); } - runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise { - let args = this.settings.unitTest.nosetestArgs.slice(0); + // tslint:disable-next-line:no-any + public runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise { + const args = this.settings.unitTest.nosetestArgs.slice(0); if (runFailedTests === true && args.indexOf('--failed') === -1) { args.push('--failed'); } if (!runFailedTests && args.indexOf('--with-id') === -1) { args.push('--with-id'); } - return runTest(this.rootDirectory, tests, args, testsToRun, this.testRunnerCancellationToken, this.outputChannel, debug); + return runTest(this.testResultsService, this.debugLauncher, this.rootDirectory, tests, args, testsToRun, this.testRunnerCancellationToken, this.outputChannel, debug); } } diff --git a/src/client/unittests/nosetest/runner.ts b/src/client/unittests/nosetest/runner.ts index 3f6f73bb2532..ec67b2adc656 100644 --- a/src/client/unittests/nosetest/runner.ts +++ b/src/client/unittests/nosetest/runner.ts @@ -1,18 +1,17 @@ 'use strict'; +import * as path from 'path'; +import { CancellationToken, OutputChannel, Uri } from 'vscode'; +import { PythonSettings } from '../../common/configSettings'; import { createTemporaryFile } from '../../common/helpers'; -import { OutputChannel, CancellationToken, Uri } from 'vscode'; -import { TestsToRun, Tests } from '../common/contracts'; -import { updateResults } from '../common/testUtils'; -import { updateResultsFromXmlLogFile, PassCalculationFormulae } from '../common/xUnitParser'; import { run } from '../common/runner'; -import { PythonSettings } from '../../common/configSettings'; -import * as path from 'path'; -import { launchDebugger } from '../common/debugLauncher'; +import { ITestDebugLauncher, ITestResultsService, Tests, TestsToRun } from '../common/types'; +import { PassCalculationFormulae, updateResultsFromXmlLogFile } from '../common/xUnitParser'; const WITH_XUNIT = '--with-xunit'; const XUNIT_FILE = '--xunit-file'; -export function runTest(rootDirectory: string, tests: Tests, args: string[], testsToRun?: TestsToRun, token?: CancellationToken, outChannel?: OutputChannel, debug?: boolean): Promise { +// tslint:disable-next-line:no-any +export function runTest(testResultsService: ITestResultsService, debugLauncher: ITestDebugLauncher, rootDirectory: string, tests: Tests, args: string[], testsToRun?: TestsToRun, token?: CancellationToken, outChannel?: OutputChannel, debug?: boolean): Promise { let testPaths = []; if (testsToRun && testsToRun.testFolder) { testPaths = testPaths.concat(testsToRun.testFolder.map(f => f.nameToRun)); @@ -28,6 +27,7 @@ export function runTest(rootDirectory: string, tests: Tests, args: string[], tes } let xmlLogFile = ''; + // tslint:disable-next-line:no-empty let xmlLogFileCleanup: Function = () => { }; // Check if '--with-xunit' is in args list @@ -37,7 +37,7 @@ export function runTest(rootDirectory: string, tests: Tests, args: string[], tes } // Check if '--xunit-file' exists, if not generate random xml file - let indexOfXUnitFile = noseTestArgs.findIndex(value => value.indexOf(XUNIT_FILE) === 0); + const indexOfXUnitFile = noseTestArgs.findIndex(value => value.indexOf(XUNIT_FILE) === 0); let promiseToGetXmlLogFile: Promise; if (indexOfXUnitFile === -1) { promiseToGetXmlLogFile = createTemporaryFile('.xml').then(xmlLogResult => { @@ -47,12 +47,10 @@ export function runTest(rootDirectory: string, tests: Tests, args: string[], tes noseTestArgs.push(`${XUNIT_FILE}=${xmlLogFile}`); return xmlLogResult.filePath; }); - } - else { + } else { if (noseTestArgs[indexOfXUnitFile].indexOf('=') === -1) { xmlLogFile = noseTestArgs[indexOfXUnitFile + 1]; - } - else { + } else { xmlLogFile = noseTestArgs[indexOfXUnitFile].substring(noseTestArgs[indexOfXUnitFile].indexOf('=') + 1).trim(); } @@ -64,14 +62,15 @@ export function runTest(rootDirectory: string, tests: Tests, args: string[], tes if (debug === true) { const testLauncherFile = path.join(__dirname, '..', '..', '..', '..', 'pythonFiles', 'PythonTools', 'testlauncher.py'); const nosetestlauncherargs = [rootDirectory, 'my_secret', pythonSettings.unitTest.debugPort.toString(), 'nose']; - const args = [testLauncherFile].concat(nosetestlauncherargs).concat(noseTestArgs.concat(testPaths)); - return launchDebugger(rootDirectory, args, token, outChannel); - } - else { - return run(pythonSettings.unitTest.nosetestPath, noseTestArgs.concat(testPaths), rootDirectory, token, outChannel); + const debuggerArgs = [testLauncherFile].concat(nosetestlauncherargs).concat(noseTestArgs.concat(testPaths)); + // tslint:disable-next-line:prefer-type-cast no-any + return debugLauncher.launchDebugger(rootDirectory, debuggerArgs, token, outChannel) as Promise; + } else { + // tslint:disable-next-line:prefer-type-cast no-any + return run(pythonSettings.unitTest.nosetestPath, noseTestArgs.concat(testPaths), rootDirectory, token, outChannel) as Promise; } }).then(() => { - return updateResultsFromLogFiles(tests, xmlLogFile); + return updateResultsFromLogFiles(tests, xmlLogFile, testResultsService); }).then(result => { xmlLogFileCleanup(); return result; @@ -81,9 +80,10 @@ export function runTest(rootDirectory: string, tests: Tests, args: string[], tes }); } -export function updateResultsFromLogFiles(tests: Tests, outputXmlFile: string): Promise { +// tslint:disable-next-line:no-any +export function updateResultsFromLogFiles(tests: Tests, outputXmlFile: string, testResultsService: ITestResultsService): Promise { return updateResultsFromXmlLogFile(tests, outputXmlFile, PassCalculationFormulae.nosetests).then(() => { - updateResults(tests); + testResultsService.updateResults(tests); return tests; }); } diff --git a/src/client/unittests/nosetest/testConfigurationManager.ts b/src/client/unittests/nosetest/testConfigurationManager.ts index 48826176ab89..7a8b0bedf152 100644 --- a/src/client/unittests/nosetest/testConfigurationManager.ts +++ b/src/client/unittests/nosetest/testConfigurationManager.ts @@ -1,54 +1,44 @@ -import * as vscode from 'vscode'; -import { TestConfigurationManager } from '../common/testConfigurationManager'; import * as fs from 'fs'; import * as path from 'path'; +import * as vscode from 'vscode'; +import { Uri } from 'vscode'; import { Installer, Product } from '../../common/installer'; +import { TestConfigurationManager } from '../common/testConfigurationManager'; +import { ITestConfigSettingsService } from '../common/types'; export class ConfigurationManager extends TestConfigurationManager { - public enable(): Thenable { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.nosetestsEnabled', true); - } - public disable(): Thenable { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.nosetestsEnabled', false); + constructor(workspace: Uri, outputChannel: vscode.OutputChannel, + installer: Installer, testConfigSettingsService: ITestConfigSettingsService) { + super(workspace, Product.nosetest, outputChannel, installer, testConfigSettingsService); } - - private static configFilesExist(rootDir: string): Promise { + private static async configFilesExist(rootDir: string): Promise { const promises = ['.noserc', 'nose.cfg'].map(cfg => { return new Promise(resolve => { fs.exists(path.join(rootDir, cfg), exists => { resolve(exists ? cfg : ''); }); }); }); - return Promise.all(promises).then(values => { - return values.filter(exists => exists.length > 0); - }); + const values = await Promise.all(promises); + return values.filter(exists => exists.length > 0); } - public configure(rootDir: string): Promise { + // tslint:disable-next-line:no-any + public async configure(wkspace: Uri): Promise { const args: string[] = []; const configFileOptionLabel = 'Use existing config file'; - let installer = new Installer(this.outputChannel); - return ConfigurationManager.configFilesExist(rootDir).then(configFiles => { - if (configFiles.length > 0) { - return Promise.resolve(); - } + const configFiles = await ConfigurationManager.configFilesExist(wkspace.fsPath); + // If a config file exits, there's nothing to be configured. + if (configFiles.length > 0) { + return; + } - return this.getTestDirs(rootDir).then(subDirs => { - return this.selectTestDir(rootDir, subDirs); - }).then(testDir => { - if (typeof testDir === 'string' && testDir !== configFileOptionLabel) { - args.push(testDir); - } - }); - }).then(() => { - return installer.isInstalled(Product.nosetest); - }).then(installed => { - if (!installed) { - return installer.install(Product.nosetest); - } - }).then(() => { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.nosetestArgs', args); - }); + const subDirs = await this.getTestDirs(wkspace.fsPath); + const testDir = await this.selectTestDir(wkspace.fsPath, subDirs); + if (typeof testDir === 'string' && testDir !== configFileOptionLabel) { + args.push(testDir); + } + const installed = await this.installer.isInstalled(Product.nosetest); + if (!installed) { + await this.installer.install(Product.nosetest); + } + await this.testConfigSettingsService.updateTestArgs(wkspace.fsPath, Product.nosetest, args); } -} \ No newline at end of file +} diff --git a/src/client/unittests/pytest/collector.ts b/src/client/unittests/pytest/collector.ts index 175bf16414d5..4507f1fdb64c 100644 --- a/src/client/unittests/pytest/collector.ts +++ b/src/client/unittests/pytest/collector.ts @@ -1,12 +1,12 @@ 'use strict'; -import { execPythonFile } from './../../common/utils'; -import { TestFile, TestSuite, TestFunction, Tests } from '../common/contracts'; import * as os from 'os'; -import { extractBetweenDelimiters, flattenTestFiles, convertFileToPackage } from '../common/testUtils'; -import * as vscode from 'vscode'; import * as path from 'path'; -import { PythonSettings } from '../../common/configSettings'; +import * as vscode from 'vscode'; import { OutputChannel } from 'vscode'; +import { PythonSettings } from '../../common/configSettings'; +import { convertFileToPackage, extractBetweenDelimiters } from '../common/testUtils'; +import { ITestsHelper, TestFile, TestFunction, Tests, TestSuite } from '../common/types'; +import { execPythonFile } from './../../common/utils'; const argsToExcludeForDiscovery = ['-x', '--exitfirst', '--fixtures-per-test', '--pdb', '--runxfail', @@ -16,10 +16,10 @@ const argsToExcludeForDiscovery = ['-x', '--exitfirst', '--disable-pytest-warnings', '-l', '--showlocals']; const settingsInArgsToExcludeForDiscovery = []; -export function discoverTests(rootDirectory: string, args: string[], token: vscode.CancellationToken, ignoreCache: boolean, outChannel: OutputChannel): Promise { +export function discoverTests(rootDirectory: string, args: string[], token: vscode.CancellationToken, ignoreCache: boolean, outChannel: OutputChannel, testsHelper: ITestsHelper): Promise { let logOutputLines: string[] = ['']; - let testFiles: TestFile[] = []; - let parentNodes: { indent: number, item: TestFile | TestSuite }[] = []; + const testFiles: TestFile[] = []; + const parentNodes: { indent: number, item: TestFile | TestSuite }[] = []; const errorLine = /==*( *)ERRORS( *)=*/; const errorFileLine = /__*( *)ERROR collecting (.*)/; const lastLineWithErrors = /==*.*/; @@ -90,7 +90,7 @@ export function discoverTests(rootDirectory: string, args: string[], token: vsco if (token && token.isCancellationRequested) { return Promise.reject('cancelled'); } - return flattenTestFiles(testFiles); + return testsHelper.flattenTestFiles(testFiles); }); } @@ -104,7 +104,7 @@ function parsePyTestModuleCollectionError(rootDirectory: string, lines: string[] return; } - let errorFileLine = lines[0]; + const errorFileLine = lines[0]; let fileName = errorFileLine.substring(errorFileLine.indexOf('ERROR collecting') + 'ERROR collecting'.length).trim(); fileName = fileName.substr(0, fileName.lastIndexOf(' ')); @@ -144,22 +144,23 @@ function parsePyTestModuleCollectionResult(rootDirectory: string, lines: string[ if (trimmedLine.startsWith(' 0) { - let parentNode = parentNodes[parentNodes.length - 1]; + const parentNode = parentNodes[parentNodes.length - 1]; if (parentNode.indent < indentOfCurrentItem) { return parentNode; } diff --git a/src/client/unittests/pytest/main.ts b/src/client/unittests/pytest/main.ts index 7234c52898ef..ee53c93de207 100644 --- a/src/client/unittests/pytest/main.ts +++ b/src/client/unittests/pytest/main.ts @@ -1,24 +1,26 @@ 'use strict'; -import { TestsToRun, Tests } from '../common/contracts'; -import { runTest } from './runner'; import * as vscode from 'vscode'; -import { discoverTests } from './collector'; -import { BaseTestManager } from '../common/baseTestManager'; import { Product } from '../../common/installer'; +import { BaseTestManager } from '../common/baseTestManager'; +import { ITestCollectionStorageService, ITestDebugLauncher, ITestResultsService, ITestsHelper, Tests, TestsToRun } from '../common/types'; +import { discoverTests } from './collector'; +import { runTest } from './runner'; export class TestManager extends BaseTestManager { - constructor(rootDirectory: string, outputChannel: vscode.OutputChannel) { - super('pytest', Product.pytest, rootDirectory, outputChannel); + constructor(rootDirectory: string, outputChannel: vscode.OutputChannel, + testCollectionStorage: ITestCollectionStorageService, + testResultsService: ITestResultsService, testsHelper: ITestsHelper, private debugLauncher: ITestDebugLauncher) { + super('pytest', Product.pytest, rootDirectory, outputChannel, testCollectionStorage, testResultsService, testsHelper); } - discoverTestsImpl(ignoreCache: boolean): Promise { - let args = this.settings.unitTest.pyTestArgs.slice(0); - return discoverTests(this.rootDirectory, args, this.testDiscoveryCancellationToken, ignoreCache, this.outputChannel); + public discoverTestsImpl(ignoreCache: boolean): Promise { + const args = this.settings.unitTest.pyTestArgs.slice(0); + return discoverTests(this.rootDirectory, args, this.testDiscoveryCancellationToken, ignoreCache, this.outputChannel, this.testsHelper); } - runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise { - let args = this.settings.unitTest.pyTestArgs.slice(0); + public runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise<{}> { + const args = this.settings.unitTest.pyTestArgs.slice(0); if (runFailedTests === true && args.indexOf('--lf') === -1 && args.indexOf('--last-failed') === -1) { args.push('--last-failed'); } - return runTest(this.rootDirectory, tests, args, testsToRun, this.testRunnerCancellationToken, this.outputChannel, debug); + return runTest(this.testResultsService, this.debugLauncher, this.rootDirectory, tests, args, testsToRun, this.testRunnerCancellationToken, this.outputChannel, debug); } } diff --git a/src/client/unittests/pytest/runner.ts b/src/client/unittests/pytest/runner.ts index 963ad150b5c9..3e314b305cab 100644 --- a/src/client/unittests/pytest/runner.ts +++ b/src/client/unittests/pytest/runner.ts @@ -1,17 +1,13 @@ -/// - 'use strict'; -import { createTemporaryFile } from '../../common/helpers'; -import { TestsToRun, Tests } from '../common/contracts'; -import { updateResults } from '../common/testUtils'; +import * as path from 'path'; import { CancellationToken, OutputChannel, Uri } from 'vscode'; -import { updateResultsFromXmlLogFile, PassCalculationFormulae } from '../common/xUnitParser'; -import { run } from '../common/runner'; import { PythonSettings } from '../../common/configSettings'; -import * as path from 'path'; -import { launchDebugger } from '../common/debugLauncher'; +import { createTemporaryFile } from '../../common/helpers'; +import { run } from '../common/runner'; +import { ITestDebugLauncher, ITestResultsService, Tests, TestsToRun } from '../common/types'; +import { PassCalculationFormulae, updateResultsFromXmlLogFile } from '../common/xUnitParser'; -export function runTest(rootDirectory: string, tests: Tests, args: string[], testsToRun?: TestsToRun, token?: CancellationToken, outChannel?: OutputChannel, debug?: boolean): Promise { +export function runTest(testResultsService: ITestResultsService, debugLauncher: ITestDebugLauncher, rootDirectory: string, tests: Tests, args: string[], testsToRun?: TestsToRun, token?: CancellationToken, outChannel?: OutputChannel, debug?: boolean): Promise { let testPaths = []; if (testsToRun && testsToRun.testFolder) { testPaths = testPaths.concat(testsToRun.testFolder.map(f => f.nameToRun)); @@ -41,14 +37,15 @@ export function runTest(rootDirectory: string, tests: Tests, args: string[], tes if (debug) { const testLauncherFile = path.join(__dirname, '..', '..', '..', '..', 'pythonFiles', 'PythonTools', 'testlauncher.py'); const pytestlauncherargs = [rootDirectory, 'my_secret', pythonSettings.unitTest.debugPort.toString(), 'pytest']; - const args = [testLauncherFile].concat(pytestlauncherargs).concat(testArgs); - return launchDebugger(rootDirectory, args, token, outChannel); - } - else { - return run(pythonSettings.unitTest.pyTestPath, testArgs, rootDirectory, token, outChannel); + const debuggerArgs = [testLauncherFile].concat(pytestlauncherargs).concat(testArgs); + // tslint:disable-next-line:prefer-type-cast no-any + return debugLauncher.launchDebugger(rootDirectory, debuggerArgs, token, outChannel) as Promise; + } else { + // tslint:disable-next-line:prefer-type-cast no-any + return run(pythonSettings.unitTest.pyTestPath, testArgs, rootDirectory, token, outChannel) as Promise; } }).then(() => { - return updateResultsFromLogFiles(tests, xmlLogFile); + return updateResultsFromLogFiles(tests, xmlLogFile, testResultsService); }).then(result => { xmlLogFileCleanup(); return result; @@ -58,9 +55,9 @@ export function runTest(rootDirectory: string, tests: Tests, args: string[], tes }); } -export function updateResultsFromLogFiles(tests: Tests, outputXmlFile: string): Promise { +export function updateResultsFromLogFiles(tests: Tests, outputXmlFile: string, testResultsService: ITestResultsService): Promise { return updateResultsFromXmlLogFile(tests, outputXmlFile, PassCalculationFormulae.pytest).then(() => { - updateResults(tests); + testResultsService.updateResults(tests); return tests; }); } diff --git a/src/client/unittests/pytest/testConfigurationManager.ts b/src/client/unittests/pytest/testConfigurationManager.ts index 09ae098ede9a..8017835d7b95 100644 --- a/src/client/unittests/pytest/testConfigurationManager.ts +++ b/src/client/unittests/pytest/testConfigurationManager.ts @@ -1,61 +1,51 @@ -import * as vscode from 'vscode'; -import { TestConfigurationManager } from '../common/testConfigurationManager'; import * as fs from 'fs'; import * as path from 'path'; +import * as vscode from 'vscode'; +import { Uri } from 'vscode'; import { Installer, Product } from '../../common/installer'; +import { TestConfigurationManager } from '../common/testConfigurationManager'; +import { ITestConfigSettingsService } from '../common/types'; export class ConfigurationManager extends TestConfigurationManager { - public enable(): Thenable { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.pyTestEnabled', true); + constructor(workspace: Uri, outputChannel: vscode.OutputChannel, + installer: Installer, testConfigSettingsService: ITestConfigSettingsService) { + super(workspace, Product.pytest, outputChannel, installer, testConfigSettingsService); } - public disable(): Thenable { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.pyTestEnabled', false); - } - - private static configFilesExist(rootDir: string): Promise { + private static async configFilesExist(rootDir: string): Promise { const promises = ['pytest.ini', 'tox.ini', 'setup.cfg'].map(cfg => { return new Promise(resolve => { fs.exists(path.join(rootDir, cfg), exists => { resolve(exists ? cfg : ''); }); }); }); - return Promise.all(promises).then(values => { - return values.filter(exists => exists.length > 0); - }); + const values = await Promise.all(promises); + return values.filter(exists => exists.length > 0); } - public configure(rootDir: string): Promise { + // tslint:disable-next-line:no-any + public async configure(wkspace: Uri) { const args = []; const configFileOptionLabel = 'Use existing config file'; const options: vscode.QuickPickItem[] = []; - let installer = new Installer(this.outputChannel); - return ConfigurationManager.configFilesExist(rootDir).then(configFiles => { - if (configFiles.length > 0 && configFiles.length !== 1 && configFiles[0] !== 'setup.cfg') { - return Promise.resolve(); - } + const configFiles = await ConfigurationManager.configFilesExist(wkspace.fsPath); + // If a config file exits, there's nothing to be configured. + if (configFiles.length > 0 && configFiles.length !== 1 && configFiles[0] !== 'setup.cfg') { + return; + } - if (configFiles.length === 1 && configFiles[0] === 'setup.cfg') { - options.push({ - label: configFileOptionLabel, - description: 'setup.cfg' - }); - } - return this.getTestDirs(rootDir).then(subDirs => { - return this.selectTestDir(rootDir, subDirs, options); - }).then(testDir => { - if (typeof testDir === 'string' && testDir !== configFileOptionLabel) { - args.push(testDir); - } + if (configFiles.length === 1 && configFiles[0] === 'setup.cfg') { + options.push({ + label: configFileOptionLabel, + description: 'setup.cfg' }); - }).then(() => { - return installer.isInstalled(Product.pytest); - }).then(installed => { - if (!installed) { - return installer.install(Product.pytest); - } - }).then(() => { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.pyTestArgs', args); - }); + } + const subDirs = await this.getTestDirs(wkspace.fsPath); + const testDir = await this.selectTestDir(wkspace.fsPath, subDirs, options); + if (typeof testDir === 'string' && testDir !== configFileOptionLabel) { + args.push(testDir); + } + const installed = await this.installer.isInstalled(Product.pytest); + if (!installed) { + await this.installer.install(Product.pytest); + } + await this.testConfigSettingsService.updateTestArgs(wkspace.fsPath, Product.pytest, args); } -} \ No newline at end of file +} diff --git a/src/client/unittests/unittest/collector.ts b/src/client/unittests/unittest/collector.ts index 633cbe4bc671..50c6edc19dec 100644 --- a/src/client/unittests/unittest/collector.ts +++ b/src/client/unittests/unittest/collector.ts @@ -1,13 +1,12 @@ 'use strict'; -import { execPythonFile } from './../../common/utils'; -import { TestFile, Tests, TestStatus } from '../common/contracts'; -import { flattenTestFiles } from '../common/testUtils'; -import * as vscode from 'vscode'; import * as path from 'path'; -import { PythonSettings } from '../../common/configSettings'; +import * as vscode from 'vscode'; import { OutputChannel } from 'vscode'; +import { PythonSettings } from '../../common/configSettings'; +import { ITestsHelper, TestFile, TestFunction, Tests, TestStatus, TestSuite } from '../common/types'; +import { execPythonFile } from './../../common/utils'; -export function discoverTests(rootDirectory: string, args: string[], token: vscode.CancellationToken, ignoreCache: boolean, outChannel: OutputChannel): Promise { +export function discoverTests(rootDirectory: string, args: string[], token: vscode.CancellationToken, ignoreCache: boolean, outChannel: OutputChannel, testsHelper: ITestsHelper): Promise { let startDirectory = '.'; let pattern = 'test*.py'; const indexOfStartDir = args.findIndex(arg => arg.indexOf('-s') === 0); @@ -16,8 +15,7 @@ export function discoverTests(rootDirectory: string, args: string[], token: vsco if (startDir.trim() === '-s' && args.length >= indexOfStartDir) { // Assume the next items is the directory startDirectory = args[indexOfStartDir + 1]; - } - else { + } else { startDirectory = startDir.substring(2).trim(); if (startDirectory.startsWith('=') || startDirectory.startsWith(' ')) { startDirectory = startDirectory.substring(1); @@ -30,8 +28,7 @@ export function discoverTests(rootDirectory: string, args: string[], token: vsco if (patternValue.trim() === '-p' && args.length >= indexOfPattern) { // Assume the next items is the directory pattern = args[indexOfPattern + 1]; - } - else { + } else { pattern = patternValue.substring(2).trim(); if (pattern.startsWith('=')) { pattern = pattern.substring(1); @@ -51,7 +48,7 @@ for suite in suites._tests: pass`; let startedCollecting = false; - let testItems: string[] = []; + const testItems: string[] = []; function processOutput(output: string) { output.split(/\r?\n/g).forEach((line, index, lines) => { if (token && token.isCancellationRequested) { @@ -83,17 +80,17 @@ for suite in suites._tests: if (startDirectory.length > 1) { testsDirectory = path.isAbsolute(startDirectory) ? startDirectory : path.resolve(rootDirectory, startDirectory); } - return parseTestIds(testsDirectory, testItems); + return parseTestIds(testsDirectory, testItems, testsHelper); }); } -function parseTestIds(rootDirectory: string, testIds: string[]): Tests { +function parseTestIds(rootDirectory: string, testIds: string[], testsHelper: ITestsHelper): Tests { const testFiles: TestFile[] = []; testIds.forEach(testId => { addTestId(rootDirectory, testId, testFiles); }); - return flattenTestFiles(testFiles); + return testsHelper.flattenTestFiles(testFiles); } function addTestId(rootDirectory: string, testId: string, testFiles: TestFile[]) { @@ -104,7 +101,7 @@ function addTestId(rootDirectory: string, testId: string, testFiles: TestFile[]) } const paths = testIdParts.slice(0, testIdParts.length - 2); - const filePath = path.join(rootDirectory, ...paths) + '.py'; + const filePath = `${path.join(rootDirectory, ...paths)}.py`; const functionName = testIdParts.pop(); const className = testIdParts.pop(); @@ -114,8 +111,10 @@ function addTestId(rootDirectory: string, testId: string, testFiles: TestFile[]) testFile = { name: path.basename(filePath), fullPath: filePath, - functions: [], - suites: [], + // tslint:disable-next-line:prefer-type-cast + functions: [] as TestFunction[], + // tslint:disable-next-line:prefer-type-cast + suites: [] as TestSuite[], nameToRun: `${className}.${functionName}`, xmlName: '', status: TestStatus.Idle, @@ -130,8 +129,10 @@ function addTestId(rootDirectory: string, testId: string, testFiles: TestFile[]) if (!testSuite) { testSuite = { name: className, - functions: [], - suites: [], + // tslint:disable-next-line:prefer-type-cast + functions: [] as TestFunction[], + // tslint:disable-next-line:prefer-type-cast + suites: [] as TestSuite[], isUnitTest: true, isInstance: false, nameToRun: classNameToRun, @@ -142,7 +143,7 @@ function addTestId(rootDirectory: string, testId: string, testFiles: TestFile[]) testFile.suites.push(testSuite); } - const testFunction = { + const testFunction: TestFunction = { name: functionName, nameToRun: testId, status: TestStatus.Idle, diff --git a/src/client/unittests/unittest/main.ts b/src/client/unittests/unittest/main.ts index 6d5bcb918c87..162ec396b6ef 100644 --- a/src/client/unittests/unittest/main.ts +++ b/src/client/unittests/unittest/main.ts @@ -1,29 +1,32 @@ 'use strict'; -import { PythonSettings } from '../../common/configSettings'; -import { TestsToRun, Tests, TestStatus } from '../common/contracts'; -import { runTest } from './runner'; import * as vscode from 'vscode'; -import { discoverTests } from './collector'; -import { BaseTestManager } from '../common/baseTestManager'; +import { PythonSettings } from '../../common/configSettings'; import { Product } from '../../common/installer'; +import { BaseTestManager } from '../common/baseTestManager'; +import { ITestCollectionStorageService, ITestDebugLauncher, ITestResultsService, ITestsHelper, Tests, TestStatus, TestsToRun } from '../common/types'; +import { discoverTests } from './collector'; +import { runTest } from './runner'; export class TestManager extends BaseTestManager { - constructor(rootDirectory: string, outputChannel: vscode.OutputChannel) { - super('unitest', Product.unittest, rootDirectory, outputChannel); + constructor(rootDirectory: string, outputChannel: vscode.OutputChannel, + testCollectionStorage: ITestCollectionStorageService, + testResultsService: ITestResultsService, testsHelper: ITestsHelper, private debugLauncher: ITestDebugLauncher) { + super('unitest', Product.unittest, rootDirectory, outputChannel, testCollectionStorage, testResultsService, testsHelper); } - configure() { + // tslint:disable-next-line:no-empty + public configure() { } - discoverTestsImpl(ignoreCache: boolean): Promise { - let args = this.settings.unitTest.unittestArgs.slice(0); - return discoverTests(this.rootDirectory, args, this.testDiscoveryCancellationToken, ignoreCache, this.outputChannel); + public discoverTestsImpl(ignoreCache: boolean): Promise { + const args = this.settings.unitTest.unittestArgs.slice(0); + return discoverTests(this.rootDirectory, args, this.testDiscoveryCancellationToken, ignoreCache, this.outputChannel, this.testsHelper); } - runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise { - let args = this.settings.unitTest.unittestArgs.slice(0); + public runTestImpl(tests: Tests, testsToRun?: TestsToRun, runFailedTests?: boolean, debug?: boolean): Promise<{}> { + const args = this.settings.unitTest.unittestArgs.slice(0); if (runFailedTests === true) { testsToRun = { testFile: [], testFolder: [], testSuite: [], testFunction: [] }; testsToRun.testFunction = tests.testFunctions.filter(fn => { return fn.testFunction.status === TestStatus.Error || fn.testFunction.status === TestStatus.Fail; }).map(fn => fn.testFunction); } - return runTest(this, this.rootDirectory, tests, args, testsToRun, this.testRunnerCancellationToken, this.outputChannel, debug); + return runTest(this, this.testResultsService, this.debugLauncher, this.rootDirectory, tests, args, testsToRun, this.testRunnerCancellationToken, this.outputChannel, debug); } } diff --git a/src/client/unittests/unittest/runner.ts b/src/client/unittests/unittest/runner.ts index 77ee4bee9f5c..db72c77b14ef 100644 --- a/src/client/unittests/unittest/runner.ts +++ b/src/client/unittests/unittest/runner.ts @@ -1,24 +1,24 @@ -/// - 'use strict'; import * as path from 'path'; -import { TestsToRun, Tests, TestStatus } from '../common/contracts'; -import { updateResults } from '../common/testUtils'; -import { BaseTestManager } from '../common/baseTestManager'; import { CancellationToken, OutputChannel, Uri } from 'vscode'; +import { PythonSettings } from '../../common/configSettings'; +import { BaseTestManager } from '../common/baseTestManager'; import { run } from '../common/runner'; +import { ITestDebugLauncher, ITestResultsService, Tests, TestStatus, TestsToRun } from '../common/types'; import { Server } from './socketServer'; -import { PythonSettings } from '../../common/configSettings'; -import { launchDebugger } from '../common/debugLauncher'; -interface TestStatusMap { +type TestStatusMap = { status: TestStatus; summaryProperty: string; -} +}; const outcomeMapping = new Map(); +// tslint:disable-next-line:no-backbone-get-set-outside-model outcomeMapping.set('passed', { status: TestStatus.Pass, summaryProperty: 'passed' }); +// tslint:disable-next-line:no-backbone-get-set-outside-model outcomeMapping.set('failed', { status: TestStatus.Fail, summaryProperty: 'failures' }); +// tslint:disable-next-line:no-backbone-get-set-outside-model outcomeMapping.set('error', { status: TestStatus.Error, summaryProperty: 'errors' }); +// tslint:disable-next-line:no-backbone-get-set-outside-model outcomeMapping.set('skipped', { status: TestStatus.Skipped, summaryProperty: 'skipped' }); interface ITestData { @@ -28,7 +28,8 @@ interface ITestData { traceback: string; } -export function runTest(testManager: BaseTestManager, rootDirectory: string, tests: Tests, args: string[], testsToRun?: TestsToRun, token?: CancellationToken, outChannel?: OutputChannel, debug?: boolean): Promise { +// tslint:disable-next-line:max-func-body-length +export function runTest(testManager: BaseTestManager, testResultsService: ITestResultsService, debugLauncher: ITestDebugLauncher, rootDirectory: string, tests: Tests, args: string[], testsToRun?: TestsToRun, token?: CancellationToken, outChannel?: OutputChannel, debug?: boolean): Promise { tests.summary.errors = 0; tests.summary.failures = 0; tests.summary.passed = 0; @@ -37,12 +38,16 @@ export function runTest(testManager: BaseTestManager, rootDirectory: string, tes const testLauncherFile = path.join(__dirname, '..', '..', '..', '..', 'pythonFiles', 'PythonTools', 'visualstudio_py_testlauncher.py'); const server = new Server(); server.on('error', (message: string, ...data: string[]) => { + // tslint:disable-next-line:no-console console.log(`${message} ${data.join(' ')}`); }); + // tslint:disable-next-line:no-empty server.on('log', (message: string, ...data: string[]) => { }); + // tslint:disable-next-line:no-empty server.on('connect', (data) => { }); + // tslint:disable-next-line:no-empty server.on('start', (data: { test: string }) => { }); server.on('result', (data: ITestData) => { @@ -57,24 +62,24 @@ export function runTest(testManager: BaseTestManager, rootDirectory: string, tes if (failFast && (statusDetails.summaryProperty === 'failures' || statusDetails.summaryProperty === 'errors')) { testManager.stop(); } - } - else { + } else { if (statusDetails) { tests.summary[statusDetails.summaryProperty] += 1; } } }); + // tslint:disable-next-line:no-empty server.on('socket.disconnected', (data) => { }); return server.start().then(port => { - let testPaths: string[] = getIdsOfTestsToRun(tests, testsToRun); - for (let counter = 0; counter < testPaths.length; counter++) { - testPaths[counter] = '-t' + testPaths[counter].trim(); + const testPaths: string[] = getIdsOfTestsToRun(tests, testsToRun); + for (let counter = 0; counter < testPaths.length; counter += 1) { + testPaths[counter] = `-t${testPaths[counter].trim()}`; } const startTestDiscoveryDirectory = getStartDirectory(args); - function runTest(testFile: string = '', testId: string = '') { + function runTestInternal(testFile: string = '', testId: string = '') { let testArgs = buildTestArgs(args); failFast = testArgs.indexOf('--uf') >= 0; testArgs = testArgs.filter(arg => arg !== '--uf'); @@ -82,7 +87,7 @@ export function runTest(testManager: BaseTestManager, rootDirectory: string, tes testArgs.push(`--result-port=${port}`); if (debug === true) { const debugPort = PythonSettings.getInstance(Uri.file(rootDirectory)).unitTest.debugPort; - testArgs.push(...[`--secret=my_secret`, `--port=${debugPort}`]); + testArgs.push(...['--secret=my_secret', `--port=${debugPort}`]); } testArgs.push(`--us=${startTestDiscoveryDirectory}`); if (testId.length > 0) { @@ -92,40 +97,45 @@ export function runTest(testManager: BaseTestManager, rootDirectory: string, tes testArgs.push(`--testFile=${testFile}`); } if (debug === true) { - return launchDebugger(rootDirectory, [testLauncherFile].concat(testArgs), token, outChannel); - } - else { + // tslint:disable-next-line:prefer-type-cast no-any + return debugLauncher.launchDebugger(rootDirectory, [testLauncherFile].concat(testArgs), token, outChannel); + } else { + // tslint:disable-next-line:prefer-type-cast no-any return run(PythonSettings.getInstance(Uri.file(rootDirectory)).pythonPath, [testLauncherFile].concat(testArgs), rootDirectory, token, outChannel); } } // Test everything if (testPaths.length === 0) { - return runTest(); + return runTestInternal(); } // Ok, the ptvs test runner can only work with one test at a time let promise = Promise.resolve(''); if (Array.isArray(testsToRun.testFile)) { testsToRun.testFile.forEach(testFile => { - promise = promise.then(() => runTest(testFile.fullPath, testFile.nameToRun)); + // tslint:disable-next-line:prefer-type-cast no-any + promise = promise.then(() => runTestInternal(testFile.fullPath, testFile.nameToRun) as Promise); }); } if (Array.isArray(testsToRun.testSuite)) { testsToRun.testSuite.forEach(testSuite => { - const testFileName = tests.testSuits.find(t => t.testSuite === testSuite).parentTestFile.fullPath; - promise = promise.then(() => runTest(testFileName, testSuite.nameToRun)); + const testFileName = tests.testSuites.find(t => t.testSuite === testSuite).parentTestFile.fullPath; + // tslint:disable-next-line:prefer-type-cast no-any + promise = promise.then(() => runTestInternal(testFileName, testSuite.nameToRun) as Promise); }); } if (Array.isArray(testsToRun.testFunction)) { testsToRun.testFunction.forEach(testFn => { const testFileName = tests.testFunctions.find(t => t.testFunction === testFn).parentTestFile.fullPath; - promise = promise.then(() => runTest(testFileName, testFn.nameToRun)); + // tslint:disable-next-line:prefer-type-cast no-any + promise = promise.then(() => runTestInternal(testFileName, testFn.nameToRun) as Promise); }); } - return promise; + // tslint:disable-next-line:prefer-type-cast no-any + return promise as Promise; }).then(() => { - updateResults(tests); + testResultsService.updateResults(tests); return tests; }).catch(reason => { return Promise.reject(reason); @@ -140,8 +150,7 @@ function getStartDirectory(args: string[]): string { if ((startDir.trim() === '-s' || startDir.trim() === '--start-directory') && args.length >= indexOfStartDir) { // Assume the next items is the directory startDirectory = args[indexOfStartDir + 1]; - } - else { + } else { const lenToStartFrom = startDir.startsWith('-s') ? '-s'.length : '--start-directory'.length; startDirectory = startDir.substring(lenToStartFrom).trim(); if (startDirectory.startsWith('=')) { @@ -160,8 +169,7 @@ function buildTestArgs(args: string[]): string[] { if ((patternValue.trim() === '-p' || patternValue.trim() === '--pattern') && args.length >= indexOfPattern) { // Assume the next items is the directory pattern = args[indexOfPattern + 1]; - } - else { + } else { const lenToStartFrom = patternValue.startsWith('-p') ? '-p'.length : '--pattern'.length; pattern = patternValue.substring(lenToStartFrom).trim(); if (pattern.startsWith('=')) { diff --git a/src/client/unittests/unittest/socketServer.ts b/src/client/unittests/unittest/socketServer.ts index 6c4d3bc6166b..334ae6b85c4e 100644 --- a/src/client/unittests/unittest/socketServer.ts +++ b/src/client/unittests/unittest/socketServer.ts @@ -1,16 +1,19 @@ 'use strict'; -import * as net from 'net'; +import { EventEmitter } from 'events'; import * as fs from 'fs'; +import * as net from 'net'; import * as os from 'os'; -import { Disposable } from 'vscode' +import { Disposable } from 'vscode'; import { createDeferred, Deferred } from '../../common/helpers'; -import { EventEmitter } from 'events'; +// tslint:disable-next-line:variable-name const MaxConnections = 100; function getIPType() { const networkInterfaces = os.networkInterfaces(); + // tslint:disable-next-line:variable-name let IPType = ''; + // tslint:disable-next-line:prefer-type-cast no-any if (networkInterfaces && Array.isArray(networkInterfaces) && (networkInterfaces as any).length > 0) { // getting the family of first network interface available IPType = networkInterfaces[Object.keys(networkInterfaces)[0]][0].family; @@ -41,7 +44,7 @@ export class Server extends EventEmitter implements Disposable { } } public start(): Promise { - this.startedDef = createDeferred() + this.startedDef = createDeferred(); fs.unlink(this.path, () => { this.server = net.createServer(this.connectionListener.bind(this)); this.server.maxConnections = MaxConnections; @@ -72,16 +75,17 @@ export class Server extends EventEmitter implements Disposable { this.emit('error', err); }); socket.on('data', (data) => { - let sock = socket; + const sock = socket; // Assume we have just one client socket connection let dataStr = this.ipcBuffer += data; + // tslint:disable-next-line:no-constant-condition while (true) { const startIndex = dataStr.indexOf('{'); if (startIndex === -1) { return; } - const lengthOfMessage = parseInt(dataStr.slice(dataStr.indexOf(':') + 1, dataStr.indexOf('{')).trim()); + const lengthOfMessage = parseInt(dataStr.slice(dataStr.indexOf(':') + 1, dataStr.indexOf('{')).trim(), 10); if (dataStr.length < startIndex + lengthOfMessage) { return; } @@ -97,13 +101,16 @@ export class Server extends EventEmitter implements Disposable { this.emit('log', message, ...data); } private onCloseSocket() { - for (let i = 0, count = this.sockets.length; i < count; i++) { - let socket = this.sockets[i]; + // tslint:disable-next-line:one-variable-per-declaration + for (let i = 0, count = this.sockets.length; i < count; i += 1) { + const socket = this.sockets[i]; let destroyedSocketId = false; if (socket && socket.readable) { continue; } + // tslint:disable-next-line:no-any prefer-type-cast if ((socket as any).id) { + // tslint:disable-next-line:no-any prefer-type-cast destroyedSocketId = (socket as any).id; } this.log('socket disconnected', destroyedSocketId.toString()); @@ -115,4 +122,4 @@ export class Server extends EventEmitter implements Disposable { return; } } -} \ No newline at end of file +} diff --git a/src/client/unittests/unittest/testConfigurationManager.ts b/src/client/unittests/unittest/testConfigurationManager.ts index 85041676e06b..87334cd72746 100644 --- a/src/client/unittests/unittest/testConfigurationManager.ts +++ b/src/client/unittests/unittest/testConfigurationManager.ts @@ -1,42 +1,33 @@ -import * as vscode from 'vscode'; import * as path from 'path'; +import { OutputChannel, Uri } from 'vscode'; +import { Installer, Product } from '../../common/installer'; import { TestConfigurationManager } from '../common/testConfigurationManager'; +import { ITestConfigSettingsService } from '../common/types'; export class ConfigurationManager extends TestConfigurationManager { - public enable(): Thenable { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.unittestEnabled', true); + constructor(workspace: Uri, outputChannel: OutputChannel, + installer: Installer, testConfigSettingsService: ITestConfigSettingsService) { + super(workspace, Product.unittest, outputChannel, installer, testConfigSettingsService); } - public disable(): Thenable { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.unittestEnabled', false); - } - - public configure(rootDir: string): Promise { + // tslint:disable-next-line:no-any + public async configure(wkspace: Uri) { const args = ['-v']; - return this.getTestDirs(rootDir).then(subDirs => { - return this.selectTestDir(rootDir, subDirs); - }).then(testDir => { - args.push('-s'); - if (typeof testDir === 'string' && testDir !== '.') { - args.push(`.${path.sep}${testDir}`); - } - else { - args.push('.'); - } + const subDirs = await this.getTestDirs(wkspace.fsPath); + const testDir = await this.selectTestDir(wkspace.fsPath, subDirs); + args.push('-s'); + if (typeof testDir === 'string' && testDir !== '.') { + args.push(`./${testDir}`); + } else { + args.push('.'); + } - return this.selectTestFilePattern(); - }).then(testfilePattern => { - args.push('-p'); - if (typeof testfilePattern === 'string') { - args.push(testfilePattern); - } - else { - args.push('test*.py'); - } - }).then(() => { - const pythonConfig = vscode.workspace.getConfiguration('python'); - return pythonConfig.update('unitTest.unittestArgs', args); - }); + const testfilePattern = await this.selectTestFilePattern(); + args.push('-p'); + if (typeof testfilePattern === 'string') { + args.push(testfilePattern); + } else { + args.push('test*.py'); + } + await this.testConfigSettingsService.updateTestArgs(wkspace.fsPath, Product.unittest, args); } -} \ No newline at end of file +} diff --git a/src/client/workspaceSymbols/main.ts b/src/client/workspaceSymbols/main.ts index 3a5c64e55b4e..1be92e608df4 100644 --- a/src/client/workspaceSymbols/main.ts +++ b/src/client/workspaceSymbols/main.ts @@ -5,6 +5,7 @@ import { fsExistsAsync } from '../common/utils'; import { isNotInstalledError } from '../common/helpers'; import { PythonLanguage, Commands } from '../common/constants'; import { WorkspaceSymbolProvider } from './provider'; +import { workspace } from 'vscode'; const MAX_NUMBER_OF_ATTEMPTS_TO_INSTALL_AND_BUILD = 2; @@ -101,7 +102,7 @@ export class WorkspaceSymbols implements vscode.Disposable { continue; } else { - promptPromise = this.installer.promptToInstall(Product.ctags); + promptPromise = this.installer.promptToInstall(Product.ctags, workspace.workspaceFolders[0].uri); promptResponse = await promptPromise; } if (promptResponse !== InstallerResponse.Installed || (!token || token.isCancellationRequested)) { diff --git a/src/test/.vscode/settings.json b/src/test/.vscode/settings.json index 1d54a0da6b81..2218e2cecd87 100644 --- a/src/test/.vscode/settings.json +++ b/src/test/.vscode/settings.json @@ -5,10 +5,12 @@ "python.unitTest.nosetestArgs": [], "python.unitTest.pyTestArgs": [], "python.unitTest.unittestArgs": [ - "-s=./tests", - "-p=test_*.py" + "-v", + "-s", + ".", + "-p", + "*test*.py" ], - "python.pythonPath": "python", "python.formatting.formatOnSave": false, "python.sortImports.args": [], "python.linting.lintOnSave": false, diff --git a/src/test/common.ts b/src/test/common.ts index 00ef1caa14f2..fe8bc2b35547 100644 --- a/src/test/common.ts +++ b/src/test/common.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs-extra'; import * as path from 'path'; import { ConfigurationTarget, Uri, workspace } from 'vscode'; import { PythonSettings } from '../client/common/configSettings'; @@ -12,7 +13,8 @@ export type PythonSettingKeys = 'workspaceSymbols.enabled' | 'pythonPath' | 'linting.flake8Enabled' | 'linting.pep8Enabled' | 'linting.pylamaEnabled' | 'linting.prospectorEnabled' | 'linting.pydocstyleEnabled' | 'linting.mypyEnabled' | 'unitTest.nosetestArgs' | 'unitTest.pyTestArgs' | 'unitTest.unittestArgs' | - 'formatting.formatOnSave' | 'formatting.provider' | 'sortImports.args'; + 'formatting.formatOnSave' | 'formatting.provider' | 'sortImports.args' | + 'unitTest.nosetestsEnabled' | 'unitTest.pyTestEnabled' | 'unitTest.unittestEnabled'; export async function updateSetting(setting: PythonSettingKeys, value: {}, resource: Uri, configTarget: ConfigurationTarget) { const settings = workspace.getConfiguration('python', resource); @@ -91,6 +93,19 @@ async function restoreGlobalPythonPathSetting(): Promise { PythonSettings.dispose(); } +export async function deleteDirectory(dir: string) { + const exists = await fs.pathExists(dir); + if (exists) { + await fs.remove(dir); + } +} +export async function deleteFile(file: string) { + const exists = await fs.pathExists(file); + if (exists) { + await fs.remove(file); + } +} + const globalPythonPathSetting = workspace.getConfiguration('python').inspect('pythonPath').globalValue; export const clearPythonPathInWorkspaceFolder = async (resource: string | Uri) => retryAsync(setPythonPathInWorkspace)(resource, ConfigurationTarget.WorkspaceFolder); export const setPythonPathInWorkspaceRoot = async (pythonPath: string) => retryAsync(setPythonPathInWorkspace)(undefined, ConfigurationTarget.Workspace, pythonPath); diff --git a/src/test/index.ts b/src/test/index.ts index 9c4896f1901c..f14ba247bb92 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1,6 +1,6 @@ +import * as testRunner from 'vscode/lib/testrunner'; import { initializePython, IS_MULTI_ROOT_TEST } from './initialize'; -const testRunner = require('vscode/lib/testrunner'); -process.env['VSC_PYTHON_CI_TEST'] = '1'; +process.env.VSC_PYTHON_CI_TEST = '1'; // You can directly control Mocha options by uncommenting the following lines. // See https://github.com/mochajs/mocha/wiki/Using-mocha-programmatically#set-options for more info. @@ -8,8 +8,6 @@ testRunner.configure({ ui: 'tdd', useColors: true, timeout: 25000, - retries: 3, - grep: 'Jupyter', - invert: 'invert' + retries: 3 }); module.exports = testRunner; diff --git a/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_one.py b/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_one.py new file mode 100644 index 000000000000..db18d3885488 --- /dev/null +++ b/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_one.py @@ -0,0 +1,8 @@ +import unittest + +class Test_test_one_1(unittest.TestCase): + def test_1_1_1(self): + self.assertEqual(1,1,'Not equal') + +if __name__ == '__main__': + unittest.main() diff --git a/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.py b/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.py new file mode 100644 index 000000000000..4e1a6151deb1 --- /dev/null +++ b/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.py @@ -0,0 +1,8 @@ +import unittest + +class Test_test_two_2(unittest.TestCase): + def test_2_1_1(self): + self.assertEqual(1,1,'Not equal') + +if __name__ == '__main__': + unittest.main() diff --git a/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.txt b/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.txt new file mode 100644 index 000000000000..4e1a6151deb1 --- /dev/null +++ b/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.txt @@ -0,0 +1,8 @@ +import unittest + +class Test_test_two_2(unittest.TestCase): + def test_2_1_1(self): + self.assertEqual(1,1,'Not equal') + +if __name__ == '__main__': + unittest.main() diff --git a/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.updated.txt b/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.updated.txt new file mode 100644 index 000000000000..b70c80df1619 --- /dev/null +++ b/src/test/pythonFiles/testFiles/debuggerTest/tests/test_debugger_two.updated.txt @@ -0,0 +1,14 @@ +import unittest + +class Test_test_two_2(unittest.TestCase): + def test_2_1_1(self): + self.assertEqual(1,1,'Not equal') + + def test_2_1_2(self): + self.assertEqual(1,1,'Not equal') + + def test_2_1_3(self): + self.assertEqual(1,1,'Not equal') + +if __name__ == '__main__': + unittest.main() diff --git a/src/test/unittests/debugger.test.ts b/src/test/unittests/debugger.test.ts new file mode 100644 index 000000000000..c908a7f7080e --- /dev/null +++ b/src/test/unittests/debugger.test.ts @@ -0,0 +1,197 @@ +import { assert, expect, should, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { ConfigurationTarget } from 'vscode'; +import { createDeferred } from '../../client/common/helpers'; +import { BaseTestManager } from '../../client/unittests/common/baseTestManager'; +import { CANCELLATION_REASON } from '../../client/unittests/common/constants'; +import { TestCollectionStorageService } from '../../client/unittests/common/storageService'; +import { TestResultsService } from '../../client/unittests/common/testResultsService'; +import { TestsHelper } from '../../client/unittests/common/testUtils'; +import { ITestCollectionStorageService, ITestResultsService, ITestsHelper, TestsToRun } from '../../client/unittests/common/types'; +import { TestResultDisplay } from '../../client/unittests/display/main'; +import { TestManager as NosetestManager } from '../../client/unittests/nosetest/main'; +import { TestManager as PytestManager } from '../../client/unittests/pytest/main'; +import { TestManager as UnitTestManager } from '../../client/unittests/unittest/main'; +import { deleteDirectory, rootWorkspaceUri, updateSetting } from '../common'; +import { initialize, initializeTest, IS_MULTI_ROOT_TEST } from './../initialize'; +import { MockOutputChannel } from './../mockClasses'; +import { MockDebugLauncher } from './mocks'; + +use(chaiAsPromised); + +const testFilesPath = path.join(__dirname, '..', '..', '..', 'src', 'test', 'pythonFiles', 'testFiles', 'debuggerTest'); +const defaultUnitTestArgs = [ + '-v', + '-s', + '.', + '-p', + '*test*.py' +]; + +// tslint:disable-next-line:max-func-body-length +suite('Unit Tests Debugging', () => { + let testManager: BaseTestManager; + let testResultDisplay: TestResultDisplay; + let outChannel: MockOutputChannel; + let storageService: ITestCollectionStorageService; + let resultsService: ITestResultsService; + let mockDebugLauncher: MockDebugLauncher; + let testsHelper: ITestsHelper; + const configTarget = IS_MULTI_ROOT_TEST ? ConfigurationTarget.WorkspaceFolder : ConfigurationTarget.Workspace; + suiteSetup(async function () { + // Test disvovery is where the delay is, hence give 10 seconds (as we discover tests at least twice in each test). + // tslint:disable-next-line:no-invalid-this + this.timeout(10000); + await initialize(); + await updateSetting('unitTest.unittestArgs', defaultUnitTestArgs, rootWorkspaceUri, configTarget); + await updateSetting('unitTest.nosetestArgs', [], rootWorkspaceUri, configTarget); + await updateSetting('unitTest.pyTestArgs', [], rootWorkspaceUri, configTarget); + }); + setup(async () => { + outChannel = new MockOutputChannel('Python Test Log'); + testResultDisplay = new TestResultDisplay(outChannel); + await deleteDirectory(path.join(testFilesPath, '.cache')); + await initializeTest(); + }); + teardown(async () => { + await updateSetting('unitTest.unittestArgs', defaultUnitTestArgs, rootWorkspaceUri, configTarget); + await updateSetting('unitTest.nosetestArgs', [], rootWorkspaceUri, configTarget); + await updateSetting('unitTest.pyTestArgs', [], rootWorkspaceUri, configTarget); + outChannel.dispose(); + mockDebugLauncher.dispose(); + if (testManager) { + testManager.dispose(); + } + testResultDisplay.dispose(); + }); + + function createTestManagerDepedencies() { + storageService = new TestCollectionStorageService(); + resultsService = new TestResultsService(); + testsHelper = new TestsHelper(); + mockDebugLauncher = new MockDebugLauncher(); + } + + async function testStartingDebugger() { + const tests = await testManager.discoverTests(true, true); + assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); + assert.equal(tests.testFunctions.length, 2, 'Incorrect number of test functions'); + assert.equal(tests.testSuites.length, 2, 'Incorrect number of test suites'); + + const testFunction = [tests.testFunctions[0].testFunction]; + testManager.runTest({ testFunction }, false, true); + const launched = await mockDebugLauncher.launched; + assert.isTrue(launched, 'Debugger not launched'); + } + + test('Debugger should start (unittest)', async () => { + await updateSetting('unitTest.unittestArgs', ['-s=./tests', '-p=test_*.py'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new UnitTestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testStartingDebugger(); + }); + + test('Debugger should start (pytest)', async () => { + await updateSetting('unitTest.pyTestArgs', ['-k=test_'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new PytestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testStartingDebugger(); + }); + + test('Debugger should start (nosetest)', async () => { + await updateSetting('unitTest.nosetestArgs', ['-m', 'test'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new NosetestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testStartingDebugger(); + }); + + async function testStoppingDebugger() { + const tests = await testManager.discoverTests(true, true); + assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); + assert.equal(tests.testFunctions.length, 2, 'Incorrect number of test functions'); + assert.equal(tests.testSuites.length, 2, 'Incorrect number of test suites'); + + const testFunction = [tests.testFunctions[0].testFunction]; + const runningPromise = testManager.runTest({ testFunction }, false, true); + const launched = await mockDebugLauncher.launched; + assert.isTrue(launched, 'Debugger not launched'); + + const discoveryPromise = testManager.discoverTests(true, true, true); + + expect(runningPromise).eventually.throws(CANCELLATION_REASON, 'Incorrect reason for ending the debugger'); + } + + test('Debugger should stop when user invokes a test discovery (unittest)', async () => { + await updateSetting('unitTest.unittestArgs', ['-s=./tests', '-p=test_*.py'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new UnitTestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testStoppingDebugger(); + }); + + test('Debugger should stop when user invokes a test discovery (pytest)', async () => { + await updateSetting('unitTest.pyTestArgs', ['-k=test_'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new PytestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testStoppingDebugger(); + }); + + test('Debugger should stop when user invokes a test discovery (nosetest)', async () => { + await updateSetting('unitTest.nosetestArgs', ['-m', 'test'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new NosetestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testStoppingDebugger(); + }); + + async function testDebuggerWhenRediscoveringTests() { + const tests = await testManager.discoverTests(true, true); + assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); + assert.equal(tests.testFunctions.length, 2, 'Incorrect number of test functions'); + assert.equal(tests.testSuites.length, 2, 'Incorrect number of test suites'); + + const testFunction = [tests.testFunctions[0].testFunction]; + const runningPromise = testManager.runTest({ testFunction }, false, true); + const launched = await mockDebugLauncher.launched; + assert.isTrue(launched, 'Debugger not launched'); + + const discoveryPromise = testManager.discoverTests(false, true); + const deferred = createDeferred(); + + discoveryPromise + .then(() => deferred.resolve('')) + .catch(ex => deferred.reject(ex)); + + // This promise should never resolve nor reject. + runningPromise + .then(() => 'Debugger stopped when it shouldn\'t have') + .catch(() => 'Debugger crashed when it shouldn\'t have') + .then(error => { + deferred.reject(error); + }); + + // Should complete without any errors + await deferred.promise; + } + + test('Debugger should not stop when test discovery is invoked automatically by extension (unittest)', async () => { + await updateSetting('unitTest.unittestArgs', ['-s=./tests', '-p=test_*.py'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new UnitTestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testDebuggerWhenRediscoveringTests(); + }); + + test('Debugger should not stop when test discovery is invoked automatically by extension (pytest)', async () => { + await updateSetting('unitTest.pyTestArgs', ['-k=test_'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new PytestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testDebuggerWhenRediscoveringTests(); + }); + + test('Debugger should not stop when test discovery is invoked automatically by extension (nosetest)', async () => { + await updateSetting('unitTest.nosetestArgs', ['-m', 'test'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new NosetestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await testDebuggerWhenRediscoveringTests(); + }); +}); diff --git a/src/test/unittests/mocks.ts b/src/test/unittests/mocks.ts new file mode 100644 index 000000000000..036c0ffa14d1 --- /dev/null +++ b/src/test/unittests/mocks.ts @@ -0,0 +1,40 @@ +import { CancellationToken, Disposable, OutputChannel } from 'vscode'; +import { createDeferred, Deferred } from '../../client/common/helpers'; +import { ITestDebugLauncher, Tests } from '../../client/unittests/common/types'; + +export class MockDebugLauncher implements ITestDebugLauncher, Disposable { + public get launched(): Promise { + return this._launched.promise; + } + public get debuggerPromise(): Deferred { + return this._promise; + } + public get cancellationToken(): CancellationToken { + return this._token; + } + // tslint:disable-next-line:variable-name + private _launched: Deferred; + // tslint:disable-next-line:variable-name + private _promise?: Deferred; + // tslint:disable-next-line:variable-name + private _token: CancellationToken; + constructor() { + this._launched = createDeferred(); + } + public async launchDebugger(rootDirectory: string, testArgs: string[], token?: CancellationToken, outChannel?: OutputChannel): Promise { + this._launched.resolve(true); + // tslint:disable-next-line:no-non-null-assertion + this._token = token!; + this._promise = createDeferred(); + // tslint:disable-next-line:no-non-null-assertion + token!.onCancellationRequested(() => { + if (this._promise) { + this._promise.reject('Mock-User Cancelled'); + } + }); + return this._promise.promise; + } + public dispose() { + this._promise = undefined; + } +} diff --git a/src/test/unittests/nosetest.test.ts b/src/test/unittests/nosetest.test.ts index d61e4e0d9944..9105c483fec3 100644 --- a/src/test/unittests/nosetest.test.ts +++ b/src/test/unittests/nosetest.test.ts @@ -2,12 +2,16 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import { Tests, TestsToRun } from '../../client/unittests/common/contracts'; +import { TestCollectionStorageService } from '../../client/unittests/common/storageService'; +import { TestResultsService } from '../../client/unittests/common/testResultsService'; +import { TestsHelper } from '../../client/unittests/common/testUtils'; +import { ITestCollectionStorageService, ITestResultsService, ITestsHelper, Tests, TestsToRun } from '../../client/unittests/common/types'; import { TestResultDisplay } from '../../client/unittests/display/main'; import * as nose from '../../client/unittests/nosetest/main'; import { rootWorkspaceUri, updateSetting } from '../common'; import { initialize, initializeTest, IS_MULTI_ROOT_TEST } from './../initialize'; import { MockOutputChannel } from './../mockClasses'; +import { MockDebugLauncher } from './mocks'; const UNITTEST_TEST_FILES_PATH = path.join(__dirname, '..', '..', '..', 'src', 'test', 'pythonFiles', 'testFiles', 'noseFiles'); const UNITTEST_SINGLE_TEST_FILE_PATH = path.join(__dirname, '..', '..', '..', 'src', 'test', 'pythonFiles', 'testFiles', 'single'); @@ -23,6 +27,10 @@ suite('Unit Tests (nosetest)', () => { let testManager: nose.TestManager; let testResultDisplay: TestResultDisplay; let outChannel: vscode.OutputChannel; + let storageService: ITestCollectionStorageService; + let resultsService: ITestResultsService; + let testsHelper: ITestsHelper; + suiteSetup(async () => { filesToDelete.forEach(file => { if (fs.existsSync(file)) { @@ -32,7 +40,8 @@ suite('Unit Tests (nosetest)', () => { await updateSetting('unitTest.nosetestArgs', [], rootWorkspaceUri, configTarget); await initialize(); }); - suiteTeardown(() => { + suiteTeardown(async () => { + await updateSetting('unitTest.nosetestArgs', [], rootWorkspaceUri, configTarget); filesToDelete.forEach(file => { if (fs.existsSync(file)) { fs.unlinkSync(file); @@ -51,7 +60,10 @@ suite('Unit Tests (nosetest)', () => { await updateSetting('unitTest.nosetestArgs', [], rootWorkspaceUri, configTarget); }); function createTestManager(rootDir: string = rootDirectory) { - testManager = new nose.TestManager(rootDir, outChannel); + storageService = new TestCollectionStorageService(); + resultsService = new TestResultsService(); + testsHelper = new TestsHelper(); + testManager = new nose.TestManager(rootDir, outChannel, storageService, resultsService, testsHelper, new MockDebugLauncher()); } test('Discover Tests (single test file)', async () => { @@ -59,17 +71,17 @@ suite('Unit Tests (nosetest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 6, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 2, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 2, 'Incorrect number of test suites'); assert.equal(tests.testFiles.some(t => t.name === path.join('tests', 'test_one.py') && t.nameToRun === t.name), true, 'Test File not found'); }); - test('Check that nameToRun in testSuits has class name after : (single test file)', async () => { + test('Check that nameToRun in testSuites has class name after : (single test file)', async () => { createTestManager(UNITTEST_SINGLE_TEST_FILE_PATH); const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 6, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 2, 'Incorrect number of test suites'); - assert.equal(tests.testSuits.every(t => t.testSuite.name === t.testSuite.nameToRun.split(':')[1]), true, 'Suite name does not match class name'); + assert.equal(tests.testSuites.length, 2, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.every(t => t.testSuite.name === t.testSuite.nameToRun.split(':')[1]), true, 'Suite name does not match class name'); }); function lookForTestFile(tests: Tests, testFile: string) { @@ -82,7 +94,7 @@ suite('Unit Tests (nosetest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 5, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 16, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 6, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 6, 'Incorrect number of test suites'); lookForTestFile(tests, path.join('tests', 'test_unittest_one.py')); lookForTestFile(tests, path.join('tests', 'test_unittest_two.py')); lookForTestFile(tests, path.join('tests', 'unittest_three_test.py')); @@ -96,7 +108,7 @@ suite('Unit Tests (nosetest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 6, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 2, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 2, 'Incorrect number of test suites'); lookForTestFile(tests, path.join('specific', 'tst_unittest_one.py')); lookForTestFile(tests, path.join('specific', 'tst_unittest_two.py')); }); @@ -107,7 +119,7 @@ suite('Unit Tests (nosetest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 1, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 3, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 1, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 1, 'Incorrect number of test suites'); lookForTestFile(tests, 'test_root.py'); }); @@ -130,7 +142,7 @@ suite('Unit Tests (nosetest)', () => { assert.equal(results.summary.passed, 6, 'Passed'); assert.equal(results.summary.skipped, 2, 'skipped'); - results = await testManager.runTest(true); + results = await testManager.runTest(undefined, true); assert.equal(results.summary.errors, 1, 'Errors again'); assert.equal(results.summary.failures, 7, 'Failures again'); assert.equal(results.summary.passed, 0, 'Passed again'); @@ -156,7 +168,7 @@ suite('Unit Tests (nosetest)', () => { await updateSetting('unitTest.nosetestArgs', ['-m', 'test'], rootWorkspaceUri, configTarget); createTestManager(); const tests = await testManager.discoverTests(true, true); - const testSuiteToRun = tests.testSuits.find(s => s.xmlClassName === 'test_root.Test_Root_test1'); + const testSuiteToRun = tests.testSuites.find(s => s.xmlClassName === 'test_root.Test_Root_test1'); assert.ok(testSuiteToRun, 'Test suite not found'); // tslint:disable-next-line:no-non-null-assertion const testSuite: TestsToRun = { testFile: [], testFolder: [], testFunction: [], testSuite: [testSuiteToRun!.testSuite] }; diff --git a/src/test/unittests/pytest.test.ts b/src/test/unittests/pytest.test.ts index c0fb8dc64540..cf0ba4130ca8 100644 --- a/src/test/unittests/pytest.test.ts +++ b/src/test/unittests/pytest.test.ts @@ -1,12 +1,16 @@ import * as assert from 'assert'; import * as path from 'path'; import * as vscode from 'vscode'; -import { TestFile, TestsToRun } from '../../client/unittests/common/contracts'; +import { TestCollectionStorageService } from '../../client/unittests/common/storageService'; +import { TestResultsService } from '../../client/unittests/common/testResultsService'; +import { TestsHelper } from '../../client/unittests/common/testUtils'; +import { ITestCollectionStorageService, ITestResultsService, ITestsHelper, TestFile, TestsToRun } from '../../client/unittests/common/types'; import { TestResultDisplay } from '../../client/unittests/display/main'; import * as pytest from '../../client/unittests/pytest/main'; import { rootWorkspaceUri, updateSetting } from '../common'; import { initialize, initializeTest, IS_MULTI_ROOT_TEST } from './../initialize'; import { MockOutputChannel } from './../mockClasses'; +import { MockDebugLauncher } from './mocks'; const UNITTEST_TEST_FILES_PATH = path.join(__dirname, '..', '..', '..', 'src', 'test', 'pythonFiles', 'testFiles', 'standard'); const UNITTEST_SINGLE_TEST_FILE_PATH = path.join(__dirname, '..', '..', '..', 'src', 'test', 'pythonFiles', 'testFiles', 'single'); @@ -19,6 +23,9 @@ suite('Unit Tests (PyTest)', () => { let testManager: pytest.TestManager; let testResultDisplay: TestResultDisplay; let outChannel: vscode.OutputChannel; + let storageService: ITestCollectionStorageService; + let resultsService: ITestResultsService; + let testsHelper: ITestsHelper; const configTarget = IS_MULTI_ROOT_TEST ? vscode.ConfigurationTarget.WorkspaceFolder : vscode.ConfigurationTarget.Workspace; suiteSetup(async () => { await initialize(); @@ -37,15 +44,21 @@ suite('Unit Tests (PyTest)', () => { await updateSetting('unitTest.pyTestArgs', [], rootWorkspaceUri, configTarget); }); function createTestManager(rootDir: string = rootDirectory) { - testManager = new pytest.TestManager(rootDir, outChannel); + storageService = new TestCollectionStorageService(); + resultsService = new TestResultsService(); + testsHelper = new TestsHelper(); + testManager = new pytest.TestManager(rootDir, outChannel, storageService, resultsService, testsHelper, new MockDebugLauncher()); } test('Discover Tests (single test file)', async () => { - testManager = new pytest.TestManager(UNITTEST_SINGLE_TEST_FILE_PATH, outChannel); + storageService = new TestCollectionStorageService(); + resultsService = new TestResultsService(); + testsHelper = new TestsHelper(); + testManager = new pytest.TestManager(UNITTEST_SINGLE_TEST_FILE_PATH, outChannel, storageService, resultsService, testsHelper, new MockDebugLauncher()); const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 6, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 2, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 2, 'Incorrect number of test suites'); assert.equal(tests.testFiles.some(t => t.name === 'tests/test_one.py' && t.nameToRun === t.name), true, 'Test File not found'); assert.equal(tests.testFiles.some(t => t.name === 'test_root.py' && t.nameToRun === t.name), true, 'Test File not found'); }); @@ -56,7 +69,7 @@ suite('Unit Tests (PyTest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 6, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 29, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 8, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 8, 'Incorrect number of test suites'); assert.equal(tests.testFiles.some(t => t.name === 'tests/test_unittest_one.py' && t.nameToRun === t.name), true, 'Test File not found'); assert.equal(tests.testFiles.some(t => t.name === 'tests/test_unittest_two.py' && t.nameToRun === t.name), true, 'Test File not found'); assert.equal(tests.testFiles.some(t => t.name === 'tests/unittest_three_test.py' && t.nameToRun === t.name), true, 'Test File not found'); @@ -71,7 +84,7 @@ suite('Unit Tests (PyTest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 1, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 2, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 1, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 1, 'Incorrect number of test suites'); assert.equal(tests.testFiles.some(t => t.name === 'tests/unittest_three_test.py' && t.nameToRun === t.name), true, 'Test File not found'); }); @@ -82,7 +95,7 @@ suite('Unit Tests (PyTest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 14, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 4, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 4, 'Incorrect number of test suites'); assert.equal(tests.testFiles.some(t => t.name === 'other/test_unittest_one.py' && t.nameToRun === t.name), true, 'Test File not found'); assert.equal(tests.testFiles.some(t => t.name === 'other/test_pytest.py' && t.nameToRun === t.name), true, 'Test File not found'); }); @@ -106,7 +119,7 @@ suite('Unit Tests (PyTest)', () => { assert.equal(results.summary.passed, 17, 'Passed'); assert.equal(results.summary.skipped, 3, 'skipped'); - results = await testManager.runTest(true); + results = await testManager.runTest(undefined, true); assert.equal(results.summary.errors, 0, 'Failed Errors'); assert.equal(results.summary.failures, 9, 'Failed Failures'); assert.equal(results.summary.passed, 0, 'Failed Passed'); @@ -138,7 +151,7 @@ suite('Unit Tests (PyTest)', () => { await updateSetting('unitTest.pyTestArgs', ['-k=test_'], rootWorkspaceUri, configTarget); createTestManager(); const tests = await testManager.discoverTests(true, true); - const testSuite: TestsToRun = { testFile: [], testFolder: [], testFunction: [], testSuite: [tests.testSuits[0].testSuite] }; + const testSuite: TestsToRun = { testFile: [], testFolder: [], testFunction: [], testSuite: [tests.testSuites[0].testSuite] }; const results = await testManager.runTest(testSuite); assert.equal(results.summary.errors, 0, 'Errors'); assert.equal(results.summary.failures, 1, 'Failures'); @@ -166,6 +179,6 @@ suite('Unit Tests (PyTest)', () => { assert.equal(tests.testFiles.length, 1, 'Incorrect number of test files'); assert.equal(tests.testFolders.length, 1, 'Incorrect number of test folders'); assert.equal(tests.testFunctions.length, 1, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 1, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 1, 'Incorrect number of test suites'); }); }); diff --git a/src/test/unittests/rediscover.test.ts b/src/test/unittests/rediscover.test.ts new file mode 100644 index 000000000000..8478b9b8e14b --- /dev/null +++ b/src/test/unittests/rediscover.test.ts @@ -0,0 +1,108 @@ +import { assert } from 'chai'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { ConfigurationTarget, Position, Range, Uri, window, workspace } from 'vscode'; +import { BaseTestManager } from '../../client/unittests/common/baseTestManager'; +import { CANCELLATION_REASON } from '../../client/unittests/common/constants'; +import { TestCollectionStorageService } from '../../client/unittests/common/storageService'; +import { TestResultsService } from '../../client/unittests/common/testResultsService'; +import { TestsHelper } from '../../client/unittests/common/testUtils'; +import { ITestCollectionStorageService, ITestResultsService, ITestsHelper, TestsToRun } from '../../client/unittests/common/types'; +import { TestResultDisplay } from '../../client/unittests/display/main'; +import { TestManager as NosetestManager } from '../../client/unittests/nosetest/main'; +import { TestManager as PytestManager } from '../../client/unittests/pytest/main'; +import { TestManager as UnitTestManager } from '../../client/unittests/unittest/main'; +import { deleteDirectory, deleteFile, rootWorkspaceUri, updateSetting } from '../common'; +import { initialize, initializeTest, IS_MULTI_ROOT_TEST } from './../initialize'; +import { MockOutputChannel } from './../mockClasses'; +import { MockDebugLauncher } from './mocks'; + +const testFilesPath = path.join(__dirname, '..', '..', '..', 'src', 'test', 'pythonFiles', 'testFiles', 'debuggerTest'); +const testFile = path.join(testFilesPath, 'tests', 'test_debugger_two.py'); +const testFileWithFewTests = path.join(testFilesPath, 'tests', 'test_debugger_two.txt'); +const testFileWithMoreTests = path.join(testFilesPath, 'tests', 'test_debugger_two.updated.txt'); +const defaultUnitTestArgs = [ + '-v', + '-s', + '.', + '-p', + '*test*.py' +]; + +// tslint:disable-next-line:max-func-body-length +suite('Unit Tests Discovery', () => { + let testManager: BaseTestManager; + let testResultDisplay: TestResultDisplay; + let outChannel: MockOutputChannel; + let storageService: ITestCollectionStorageService; + let resultsService: ITestResultsService; + let mockDebugLauncher: MockDebugLauncher; + let testsHelper: ITestsHelper; + const configTarget = IS_MULTI_ROOT_TEST ? ConfigurationTarget.WorkspaceFolder : ConfigurationTarget.Workspace; + suiteSetup(async () => { + await initialize(); + }); + setup(async () => { + outChannel = new MockOutputChannel('Python Test Log'); + testResultDisplay = new TestResultDisplay(outChannel); + await fs.copy(testFileWithFewTests, testFile, { overwrite: true }); + await deleteDirectory(path.join(testFilesPath, '.cache')); + await resetSettings(); + await initializeTest(); + }); + teardown(async () => { + await resetSettings(); + await fs.copy(testFileWithFewTests, testFile, { overwrite: true }); + await deleteFile(path.join(path.dirname(testFile), `${path.basename(testFile, '.py')}.pyc`)); + outChannel.dispose(); + if (testManager) { + testManager.dispose(); + } + testResultDisplay.dispose(); + }); + + async function resetSettings() { + await updateSetting('unitTest.unittestArgs', defaultUnitTestArgs, rootWorkspaceUri, configTarget); + await updateSetting('unitTest.nosetestArgs', [], rootWorkspaceUri, configTarget); + await updateSetting('unitTest.pyTestArgs', [], rootWorkspaceUri, configTarget); + } + + function createTestManagerDepedencies() { + storageService = new TestCollectionStorageService(); + resultsService = new TestResultsService(); + testsHelper = new TestsHelper(); + mockDebugLauncher = new MockDebugLauncher(); + } + + async function discoverUnitTests() { + let tests = await testManager.discoverTests(true, true); + assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); + assert.equal(tests.testSuites.length, 2, 'Incorrect number of test suites'); + assert.equal(tests.testFunctions.length, 2, 'Incorrect number of test functions'); + await deleteFile(path.join(path.dirname(testFile), `${path.basename(testFile, '.py')}.pyc`)); + await fs.copy(testFileWithMoreTests, testFile, { overwrite: true }); + tests = await testManager.discoverTests(true, true); + assert.equal(tests.testFunctions.length, 4, 'Incorrect number of updated test functions'); + } + + test('Re-discover tests (unittest)', async () => { + await updateSetting('unitTest.unittestArgs', ['-s=./tests', '-p=test_*.py'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new UnitTestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await discoverUnitTests(); + }); + + test('Re-discover tests (pytest)', async () => { + await updateSetting('unitTest.pyTestArgs', ['-k=test_'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new PytestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await discoverUnitTests(); + }); + + test('Re-discover tests (nosetest)', async () => { + await updateSetting('unitTest.nosetestArgs', ['-m', 'test'], rootWorkspaceUri, configTarget); + createTestManagerDepedencies(); + testManager = new NosetestManager(testFilesPath, outChannel, storageService, resultsService, testsHelper, mockDebugLauncher); + await discoverUnitTests(); + }); +}); diff --git a/src/test/unittests/unittest.test.ts b/src/test/unittests/unittest.test.ts index 478111c1d620..bcf201bcc3dc 100644 --- a/src/test/unittests/unittest.test.ts +++ b/src/test/unittests/unittest.test.ts @@ -2,12 +2,16 @@ import * as assert from 'assert'; import * as fs from 'fs-extra'; import * as path from 'path'; import { ConfigurationTarget } from 'vscode'; -import { TestsToRun } from '../../client/unittests/common/contracts'; +import { TestCollectionStorageService } from '../../client/unittests/common/storageService'; +import { TestResultsService } from '../../client/unittests/common/testResultsService'; +import { TestsHelper } from '../../client/unittests/common/testUtils'; +import { ITestCollectionStorageService, ITestResultsService, ITestsHelper, TestsToRun } from '../../client/unittests/common/types'; import { TestResultDisplay } from '../../client/unittests/display/main'; import * as unittest from '../../client/unittests/unittest/main'; import { rootWorkspaceUri, updateSetting } from '../common'; import { initialize, initializeTest, IS_MULTI_ROOT_TEST } from './../initialize'; import { MockOutputChannel } from './../mockClasses'; +import { MockDebugLauncher } from './mocks'; const testFilesPath = path.join(__dirname, '..', '..', '..', 'src', 'test', 'pythonFiles', 'testFiles'); const UNITTEST_TEST_FILES_PATH = path.join(testFilesPath, 'standard'); @@ -27,6 +31,9 @@ suite('Unit Tests (unittest)', () => { let testManager: unittest.TestManager; let testResultDisplay: TestResultDisplay; let outChannel: MockOutputChannel; + let storageService: ITestCollectionStorageService; + let resultsService: ITestResultsService; + let testsHelper: ITestsHelper; const rootDirectory = UNITTEST_TEST_FILES_PATH; const configTarget = IS_MULTI_ROOT_TEST ? ConfigurationTarget.WorkspaceFolder : ConfigurationTarget.Workspace; suiteSetup(async () => { @@ -42,22 +49,29 @@ suite('Unit Tests (unittest)', () => { } await initializeTest(); }); - teardown(() => { + teardown(async () => { outChannel.dispose(); testManager.dispose(); testResultDisplay.dispose(); + await updateSetting('unitTest.unittestArgs', defaultUnitTestArgs, rootWorkspaceUri, configTarget); }); function createTestManager(rootDir: string = rootDirectory) { - testManager = new unittest.TestManager(rootDir, outChannel); + storageService = new TestCollectionStorageService(); + resultsService = new TestResultsService(); + testsHelper = new TestsHelper(); + testManager = new unittest.TestManager(rootDir, outChannel, storageService, resultsService, testsHelper, new MockDebugLauncher()); } test('Discover Tests (single test file)', async () => { await updateSetting('unitTest.unittestArgs', ['-s=./tests', '-p=test_*.py'], rootWorkspaceUri, configTarget); - testManager = new unittest.TestManager(UNITTEST_SINGLE_TEST_FILE_PATH, outChannel); + storageService = new TestCollectionStorageService(); + resultsService = new TestResultsService(); + testsHelper = new TestsHelper(); + testManager = new unittest.TestManager(UNITTEST_SINGLE_TEST_FILE_PATH, outChannel, storageService, resultsService, testsHelper, new MockDebugLauncher()); const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 1, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 3, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 1, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 1, 'Incorrect number of test suites'); assert.equal(tests.testFiles.some(t => t.name === 'test_one.py' && t.nameToRun === 'Test_test1.test_A'), true, 'Test File not found'); }); @@ -67,7 +81,7 @@ suite('Unit Tests (unittest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 2, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 9, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 3, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 3, 'Incorrect number of test suites'); assert.equal(tests.testFiles.some(t => t.name === 'test_unittest_one.py' && t.nameToRun === 'Test_test1.test_A'), true, 'Test File not found'); assert.equal(tests.testFiles.some(t => t.name === 'test_unittest_two.py' && t.nameToRun === 'Test_test2.test_A2'), true, 'Test File not found'); }); @@ -78,7 +92,7 @@ suite('Unit Tests (unittest)', () => { const tests = await testManager.discoverTests(true, true); assert.equal(tests.testFiles.length, 1, 'Incorrect number of test files'); assert.equal(tests.testFunctions.length, 2, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 1, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 1, 'Incorrect number of test suites'); assert.equal(tests.testFiles.some(t => t.name === 'unittest_three_test.py' && t.nameToRun === 'Test_test3.test_A'), true, 'Test File not found'); }); @@ -101,7 +115,7 @@ suite('Unit Tests (unittest)', () => { assert.equal(results.summary.passed, 3, 'Passed'); assert.equal(results.summary.skipped, 1, 'skipped'); - results = await testManager.runTest(true); + results = await testManager.runTest(undefined, true); assert.equal(results.summary.errors, 1, 'Failed Errors'); assert.equal(results.summary.failures, 4, 'Failed Failures'); assert.equal(results.summary.passed, 0, 'Failed Passed'); @@ -130,7 +144,7 @@ suite('Unit Tests (unittest)', () => { const tests = await testManager.discoverTests(true, true); // tslint:disable-next-line:no-non-null-assertion - const testSuiteToTest = tests.testSuits.find(s => s.testSuite.name === 'Test_test_one_1')!.testSuite; + const testSuiteToTest = tests.testSuites.find(s => s.testSuite.name === 'Test_test_one_1')!.testSuite; const testSuite: TestsToRun = { testFile: [], testFolder: [], testFunction: [], testSuite: [testSuiteToTest] }; const results = await testManager.runTest(testSuite); @@ -160,6 +174,6 @@ suite('Unit Tests (unittest)', () => { assert.equal(tests.testFiles.length, 1, 'Incorrect number of test files'); assert.equal(tests.testFolders.length, 1, 'Incorrect number of test folders'); assert.equal(tests.testFunctions.length, 1, 'Incorrect number of test functions'); - assert.equal(tests.testSuits.length, 1, 'Incorrect number of test suites'); + assert.equal(tests.testSuites.length, 1, 'Incorrect number of test suites'); }); }); diff --git a/typings.json b/typings.json deleted file mode 100644 index 9f6c5ea9c4a9..000000000000 --- a/typings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "globalDependencies": { - "fs-extra": "registry:dt/fs-extra#0.0.0+20160319124112", - "xml2js": "registry:dt/xml2js#0.0.0+20160317120654" - } -} diff --git a/typings/globals/xml2js/index.d.ts b/typings/globals/xml2js/index.d.ts deleted file mode 100644 index a3feea4d5ab8..000000000000 --- a/typings/globals/xml2js/index.d.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Generated by typings -// Source: https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/7de6c3dd94feaeb21f20054b9f30d5dabc5efabd/xml2js/xml2js.d.ts -declare module 'xml2js' { - - export = xml2js; - - namespace xml2js { - function parseString(xml: string, callback: (err: any, result: any) => void): void; - function parseString(xml: string, options: Options, callback: (err: any, result: any) => void): void; - - var defaults: { - '0.1': Options; - '0.2': OptionsV2; - } - - class Builder { - constructor(options?: BuilderOptions); - buildObject(rootObj: any): string; - } - - class Parser { - constructor(options?: Options); - processAsync(): any; - assignOrPush(obj: any, key: string, newValue: any): any; - reset(): any; - parseString(str: string , cb?: Function): void; - } - - interface RenderOptions { - indent?: string; - newline?: string; - pretty?: boolean; - } - - interface XMLDeclarationOptions { - encoding?: string; - standalone?: boolean; - version?: string; - } - - interface BuilderOptions { - doctype?: any; - headless?: boolean; - indent?: string; - newline?: string; - pretty?: boolean; - renderOpts?: RenderOptions; - rootName?: string; - xmldec?: XMLDeclarationOptions; - } - - interface Options { - async?: boolean; - attrkey?: string; - attrNameProcessors?: [(name: string) => string]; - attrValueProcessors?: [(name: string) => string]; - charkey?: string; - charsAsChildren?: boolean; - childkey?: string; - emptyTag?: any; - explicitArray?: boolean; - explicitCharkey?: boolean; - explicitChildren?: boolean; - explicitRoot?: boolean; - ignoreAttrs?: boolean; - mergeAttrs?: boolean; - normalize?: boolean; - normalizeTags?: boolean; - strict?: boolean; - tagNameProcessors?: [(name: string) => string]; - trim?: boolean; - validator?: Function; - valueProcessors?: [(name: string) => string]; - xmlns?: boolean; - } - - interface OptionsV2 extends Options { - preserveChildrenOrder?: boolean; - rootName?: string; - xmldec?: { - version: string; - encoding?: string; - standalone?: boolean; - }; - doctype?: any; - renderOpts?: { - pretty?: boolean; - indent?: string; - newline?: string; - }; - headless?: boolean; - chunkSize?: number; - cdata?: boolean; - } - } -} diff --git a/typings/globals/xml2js/typings.json b/typings/globals/xml2js/typings.json deleted file mode 100644 index 4f70efbc7f27..000000000000 --- a/typings/globals/xml2js/typings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "resolution": "main", - "tree": { - "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/7de6c3dd94feaeb21f20054b9f30d5dabc5efabd/xml2js/xml2js.d.ts", - "raw": "registry:dt/xml2js#0.0.0+20160317120654", - "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/7de6c3dd94feaeb21f20054b9f30d5dabc5efabd/xml2js/xml2js.d.ts" - } -} diff --git a/typings/index.d.ts b/typings/index.d.ts deleted file mode 100644 index 3e9f3c454ee6..000000000000 --- a/typings/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// \ No newline at end of file diff --git a/typings/node.d.ts b/typings/node.d.ts deleted file mode 100644 index 457fd135e6f6..000000000000 --- a/typings/node.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// \ No newline at end of file diff --git a/typings/vscode-typings.d.ts b/typings/vscode-typings.d.ts deleted file mode 100644 index e9d47fd5a066..000000000000 --- a/typings/vscode-typings.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// \ No newline at end of file