diff --git a/news/1 Enhancements/3321.md b/news/1 Enhancements/3321.md new file mode 100644 index 000000000000..a51cefe4a567 --- /dev/null +++ b/news/1 Enhancements/3321.md @@ -0,0 +1 @@ +Prompt user to select a debug configuration when generating the `launch.json`. diff --git a/package.json b/package.json index fd73169b41a4..ee4e3f376bcd 100644 --- a/package.json +++ b/package.json @@ -887,65 +887,7 @@ } } } - }, - "initialConfigurations": [ - { - "name": "Python: Current File (Integrated Terminal)", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal" - }, - { - "name": "Python: Attach", - "type": "python", - "request": "attach", - "port": 5678, - "host": "localhost" - }, - { - "name": "Python: Module", - "type": "python", - "request": "launch", - "module": "enter-your-module-name-here", - "console": "integratedTerminal" - }, - { - "name": "Python: Django", - "type": "python", - "request": "launch", - "program": "${workspaceFolder}/manage.py", - "console": "integratedTerminal", - "args": [ - "runserver", - "--noreload", - "--nothreading" - ], - "django": true - }, - { - "name": "Python: Flask", - "type": "python", - "request": "launch", - "module": "flask", - "env": { - "FLASK_APP": "app.py" - }, - "args": [ - "run", - "--no-debugger", - "--no-reload" - ], - "jinja": true - }, - { - "name": "Python: Current File (External Terminal)", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "externalTerminal" - } - ] + } } ], "configuration": { diff --git a/package.nls.de.json b/package.nls.de.json index 6744ee1cf235..4c6af269cd11 100644 --- a/package.nls.de.json +++ b/package.nls.de.json @@ -35,7 +35,7 @@ "python.snippet.launch.externalTerminal.description": "Python-Programm mit externem Terminal/Konsole debuggen", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "Django-Anwendung debuggen", - "python.snippet.launch.flask.label": "Python: Flask (0.11.x oder neuer)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "Flask-Anwendung debuggen", "python.snippet.launch.flaskOld.label": "Python: Flask (0.10.x oder früher)", "python.snippet.launch.flaskOld.description": "Ältere Flask-Anwendung debuggen", diff --git a/package.nls.es.json b/package.nls.es.json index d71abd699922..9160b00c73ee 100644 --- a/package.nls.es.json +++ b/package.nls.es.json @@ -35,7 +35,7 @@ "python.snippet.launch.externalTerminal.description": "Depurar una aplicación Python usando una terminal externa", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "Depurar una aplicación de Django", - "python.snippet.launch.flask.label": "Python: Flask (Versión 0.11.x o posterior)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "Depurar una aplicación de Flask", "python.snippet.launch.flaskOld.label": "Python: Flask (Versión 0.10.x o anterior)", "python.snippet.launch.flaskOld.description": "Depurar una aplicación de Flask de estilo antiguo", diff --git a/package.nls.fr.json b/package.nls.fr.json index c764405ca1da..196c4ac8db06 100644 --- a/package.nls.fr.json +++ b/package.nls.fr.json @@ -34,7 +34,7 @@ "python.snippet.launch.externalTerminal.description": "Déboguer un programme Python avec une console externe", "python.snippet.launch.django.label": "Python : Django", "python.snippet.launch.django.description": "Déboguer une application Django", - "python.snippet.launch.flask.label": "Python : Flask (0.11.x ou supérieur)", + "python.snippet.launch.flask.label": "Python : Flask", "python.snippet.launch.flask.description": "Déboguer une application Flask", "python.snippet.launch.flaskOld.label": "Python : Flask (0.10.x ou antérieur)", "python.snippet.launch.flaskOld.description": "Déboguer une application Flask (0.10.x ou antérieur)", diff --git a/package.nls.it.json b/package.nls.it.json index 01b231682a19..1643731c81f3 100644 --- a/package.nls.it.json +++ b/package.nls.it.json @@ -34,7 +34,7 @@ "python.snippet.launch.externalTerminal.description": "Esegui debug di un programma Python nel terminale esterno", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "Esegui debug applicazione Django", - "python.snippet.launch.flask.label": "Python: Flask (0.11.x o successiva)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "Esegui debug applicazione Flask", "python.snippet.launch.flaskOld.label": "Python: Flask (0.10.x o precedente)", "python.snippet.launch.flaskOld.description": "Esegui debug applicazione Flask in vecchio stile", diff --git a/package.nls.ja.json b/package.nls.ja.json index 8b8deec49c7d..7768ca8d097f 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -30,7 +30,7 @@ "python.snippet.launch.externalTerminal.description": "外部のターミナル/コンソールで Python プログラムをデバッグ", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "Django アプリケーションをデバッグ", - "python.snippet.launch.flask.label": "Python: Flask (0.11.x 以降)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "Flask アプリケーションをデバッグ", "python.snippet.launch.flaskOld.label": "Python: Flask (0.10.x 以前)", "python.snippet.launch.flaskOld.description": "旧式の Flask アプリケーションをデバッグ", diff --git a/package.nls.json b/package.nls.json index cdc3df624adf..8d8c2362f4c3 100644 --- a/package.nls.json +++ b/package.nls.json @@ -54,7 +54,7 @@ "python.snippet.launch.externalTerminal.description": "Debug a Python program with External Terminal/Console", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "Debug a Django Application", - "python.snippet.launch.flask.label": "Python: Flask (0.11.x or later)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "Debug a Flask Application", "python.snippet.launch.flaskOld.label": "Python: Flask (0.10.x or earlier)", "python.snippet.launch.flaskOld.description": "Debug an older styled Flask Application", @@ -135,9 +135,41 @@ "Common.canceled": "Canceled", "DataScience.importChangeDirectoryComment": "#%% Change working directory from the workspace root to the ipynb file location. Turn this addition off with the DataSciece.changeDirOnImportExport setting", "DataScience.exportChangeDirectoryComment": "# Change directory to VSCode workspace root so that relative path loads work correctly. Turn this addition off with the DataSciece.changeDirOnImportExport setting", - "DataScience.interruptKernelStatus" : "Interrupting iPython Kernel", - "DataScience.restartKernelAfterInterruptMessage" : "Interrupting the kernel timed out. Do you want to restart the kernel instead? All variables will be lost.", - "DataScience.pythonInterruptFailedHeader" : "Keyboard interrupt crashed the kernel. Kernel restarted.", - "DataScience.sysInfoURILabel" : "Jupyter Server URI: ", - "Common.loadingPythonExtension": "Python extension loading..." + "DataScience.interruptKernelStatus": "Interrupting iPython Kernel", + "DataScience.restartKernelAfterInterruptMessage": "Interrupting the kernel timed out. Do you want to restart the kernel instead? All variables will be lost.", + "DataScience.pythonInterruptFailedHeader": "Keyboard interrupt crashed the kernel. Kernel restarted.", + "DataScience.sysInfoURILabel": "Jupyter Server URI: ", + "Common.loadingPythonExtension": "Python extension loading...", + "debug.selectConfigurationTitle": "Select a debug configuration", + "debug.selectConfigurationPlaceholder": "Debug Configuration", + "debug.debugFileConfigurationLabel": "Python File", + "debug.debugFileConfigurationDescription": "Debug python file", + "debug.debugModuleConfigurationLabel": "Module", + "debug.debugModuleConfigurationDescription": "Debug Python module/package", + "debug.remoteAttachConfigurationLabel": "Remote Attach", + "debug.remoteAttachConfigurationDescription": "Debug a remote python program", + "debug.debugDjangoConfigurationLabel": "Django", + "debug.debugDjangoConfigurationDescription": "Web Application", + "debug.debugFlaskConfigurationLabel": "Flask", + "debug.debugFlaskConfigurationDescription": "Web Application", + "debug.debugPyramidConfigurationLabel": "Pyramid", + "debug.debugPyramidConfigurationDescription": "Web Application", + "debug.djangoEnterManagePyPathTitle": "Debug Django", + "debug.djangoEnterManagePyPathPrompt": "Enter path to manage.py", + "debug.djangoEnterManagePyPathInvalidFilePathError": "Enter a valid python file path", + "debug.flaskEnterAppPathOrNamePathTitle": "Debug Flask", + "debug.flaskEnterAppPathOrNamePathPrompt": "Enter path to Application, e.g. 'app.py' or 'app'", + "debug.flaskEnterAppPathOrNamePathInvalidNameError": "Enter a valid name", + "debug.moduleEnterModuleTitle": "Debug Module", + "debug.moduleEnterModulePrompt": "Enter Python module/package name", + "debug.moduleEnterModuleInvalidNameError": "Enter a valid name", + "debug.pyramidEnterDevelopmentIniPathTitle": "Debug Pyramid", + "debug.pyramidEnterDevelopmentIniPathPrompt": "`Enter path to development.ini ('${workspaceFolderToken}' points to the root of the current workspace folder)`", + "debug.pyramidEnterDevelopmentIniPathInvalidFilePathError": "Enter a valid file path", + "debug.attachRemotePortTitle": "Remote Debugging", + "debug.attachRemotePortPrompt": "Enter Port Number", + "debug.attachRemotePortValidationError": "Enter a valid Port Number", + "debug.attachRemoteHostTitle": "Remote Debugging", + "debug.attachRemoteHostPrompt": "Enter Host Name", + "debug.attachRemoteHostValidationError": "Enter a Host Name or IP Address" } diff --git a/package.nls.ko-kr.json b/package.nls.ko-kr.json index c668625209f2..5534ed4bef06 100644 --- a/package.nls.ko-kr.json +++ b/package.nls.ko-kr.json @@ -30,7 +30,7 @@ "python.snippet.launch.externalTerminal.description": "외부 터미널/콘솔에서 Python 프로그램 디버그", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "Django 응용 프로그램 디버그", - "python.snippet.launch.flask.label": "Python: Flask (0.11.x 또는 이후 버전)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "Flask 응용 프로그램 디버그", "python.snippet.launch.flaskOld.label": "Python: Flask (0.10.x 또는 이전 버전)", "python.snippet.launch.flaskOld.description": "이전 스타일의 Flask 응용 프로그램 디버그", diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index 5b6e4dc2b539..5b9505e81c9b 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -35,7 +35,7 @@ "python.snippet.launch.externalTerminal.description": "Depurar um Programa Python com Terminal/Console Externo", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "Depurar uma Aplicação Django", - "python.snippet.launch.flask.label": "Python: Flask (0.11.x ou superior)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "Depurar uma Aplicação Flask", "python.snippet.launch.flaskOld.label": "Python: Flask (0.10.x ou inferior)", "python.snippet.launch.flaskOld.description": "Depurar uma Aplicação Flask no Estilo Antigo", diff --git a/package.nls.ru.json b/package.nls.ru.json index 8208b0e44e1d..31247aa54535 100644 --- a/package.nls.ru.json +++ b/package.nls.ru.json @@ -34,7 +34,7 @@ "python.snippet.launch.externalTerminal.description": "Отладка программы Python во внешней консоли", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "Отладка приложения Django", - "python.snippet.launch.flask.label": "Python: Flask (0.11.x или новее)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "Отладка приложения Flask", "python.snippet.launch.flaskOld.label": "Python: Flask (0.10.x или старее)", "python.snippet.launch.flaskOld.description": "Отладка приложения Flask (старый стиль)", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 4580d1fa4b10..f7100b8ae56f 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -24,7 +24,6 @@ "python.command.python.enableLinting.title": "启用 Linting", "python.command.python.runLinting.title": "运行 Linting", "python.snippet.launch.standard.label": "Python: 当前文件", - "python.snippet.launch.standard.label": "Python: Current File", "python.snippet.launch.standard.description": "使用标准输出调试 Python 应用", "python.snippet.launch.pyspark.label": "Python: PySpark", "python.snippet.launch.pyspark.description": "调试 PySpark", @@ -36,7 +35,7 @@ "python.snippet.launch.externalTerminal.description": "使用外部终端调试 Python 程序", "python.snippet.launch.django.label": "Python: Django", "python.snippet.launch.django.description": "调试 Django 应用", - "python.snippet.launch.flask.label": "Python: Flask (0.11.x 或以后)", + "python.snippet.launch.flask.label": "Python: Flask", "python.snippet.launch.flask.description": "调试 Flask 应用", "python.snippet.launch.flaskOld.label": "Python: Flask (0.10.x 或之前)", "python.snippet.launch.flaskOld.description": "调试旧式 Flask 应用", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index caa905e79b3d..388527016a2c 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -34,7 +34,7 @@ "python.snippet.launch.externalTerminal.description": "使用外部終端機偵錯 Python 程式", "python.snippet.launch.django.label": "Python:Django", "python.snippet.launch.django.description": "偵錯 Django 程式", - "python.snippet.launch.flask.label": "Python:Flask(0.11.x 或以後)", + "python.snippet.launch.flask.label": "Python:Flask", "python.snippet.launch.flask.description": "偵錯 Flask 程式", "python.snippet.launch.flaskOld.label": "Python:Flask(0.10.x 或之前)", "python.snippet.launch.flaskOld.description": "偵錯舊式 Flask 程式", @@ -49,4 +49,4 @@ "python.command.python.discoverTests.title": "探索 Unit 測試項目", "python.snippet.launch.gevent.label": "Python: Gevent", "python.snippet.launch.gevent.description": "偵錯 Gevent 應用程式" -} \ No newline at end of file +} diff --git a/src/client/common/application/applicationShell.ts b/src/client/common/application/applicationShell.ts index a66e5c4e9594..ae4ff32cb017 100644 --- a/src/client/common/application/applicationShell.ts +++ b/src/client/common/application/applicationShell.ts @@ -6,7 +6,7 @@ const opn = require('opn'); import { injectable } from 'inversify'; -import { CancellationToken, Disposable, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, Progress, ProgressOptions, QuickPickItem, QuickPickOptions, SaveDialogOptions, StatusBarAlignment, StatusBarItem, Uri, window, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode'; +import { CancellationToken, Disposable, InputBox, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, Progress, ProgressOptions, QuickPick, QuickPickItem, QuickPickOptions, SaveDialogOptions, StatusBarAlignment, StatusBarItem, Uri, window, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode'; import { IApplicationShell } from './types'; @injectable() @@ -70,4 +70,10 @@ export class ApplicationShell implements IApplicationShell { public withProgress(options: ProgressOptions, task: (progress: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => Thenable): Thenable { return window.withProgress(options, task); } + public createQuickPick(): QuickPick { + return window.createQuickPick(); + } + public createInputBox(): InputBox { + return window.createInputBox(); + } } diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index c2f62db37405..f3e342226412 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -15,12 +15,14 @@ import { Event, FileSystemWatcher, GlobPattern, + InputBox, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, Progress, ProgressOptions, + QuickPick, QuickPickItem, QuickPickOptions, SaveDialogOptions, @@ -227,6 +229,28 @@ export interface IApplicationShell { */ showInputBox(options?: InputBoxOptions, token?: CancellationToken): Thenable; + /** + * Creates a [QuickPick](#QuickPick) to let the user pick an item from a list + * of items of type T. + * + * Note that in many cases the more convenient [window.showQuickPick](#window.showQuickPick) + * is easier to use. [window.createQuickPick](#window.createQuickPick) should be used + * when [window.showQuickPick](#window.showQuickPick) does not offer the required flexibility. + * + * @return A new [QuickPick](#QuickPick). + */ + createQuickPick(): QuickPick; + + /** + * Creates a [InputBox](#InputBox) to let the user enter some text input. + * + * Note that in many cases the more convenient [window.showInputBox](#window.showInputBox) + * is easier to use. [window.createInputBox](#window.createInputBox) should be used + * when [window.showInputBox](#window.showInputBox) does not offer the required flexibility. + * + * @return A new [InputBox](#InputBox). + */ + createInputBox(): InputBox; /** * Opens URL in a default browser. * diff --git a/src/client/common/platform/pathUtils.ts b/src/client/common/platform/pathUtils.ts index 16fc16c530de..87b2b5c22354 100644 --- a/src/client/common/platform/pathUtils.ts +++ b/src/client/common/platform/pathUtils.ts @@ -14,6 +14,9 @@ export class PathUtils implements IPathUtils { public get delimiter(): string { return path.delimiter; } + public get separator(): string { + return path.sep; + } // TO DO: Deprecate in favor of IPlatformService public getPathVariableName() { return this.isWindows ? WINDOWS_PATH_VARIABLE_NAME : NON_WINDOWS_PATH_VARIABLE_NAME; diff --git a/src/client/common/serviceRegistry.ts b/src/client/common/serviceRegistry.ts index e5c8f59fcac1..fea49c7b2c24 100644 --- a/src/client/common/serviceRegistry.ts +++ b/src/client/common/serviceRegistry.ts @@ -62,6 +62,7 @@ import { IRandom, IsWindows } from './types'; +import { IMultiStepInputFactory, MultiStepInputFactory } from './utils/multiStepInput'; import { Random } from './utils/random'; export function registerTypes(serviceManager: IServiceManager) { @@ -100,4 +101,5 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IFeatureDeprecationManager, FeatureDeprecationManager); serviceManager.addSingleton(IAsyncDisposableRegistry, AsyncDisposableRegistry); + serviceManager.addSingleton(IMultiStepInputFactory, MultiStepInputFactory); } diff --git a/src/client/common/types.ts b/src/client/common/types.ts index 0416a9529cfa..fa56d0ab27ba 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -101,6 +101,12 @@ export const IPathUtils = Symbol('IPathUtils'); export interface IPathUtils { readonly delimiter: string; readonly home: string; + /** + * The platform-specific file separator. '\\' or '/'. + * @type {string} + * @memberof IPathUtils + */ + readonly separator: string; getPathVariableName(): 'Path' | 'PATH'; basename(pathValue: string, ext?: string): string; getDisplayName(pathValue: string, cwd?: string): string; diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 16ab6158a610..eb98f1e0d4f0 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -105,6 +105,43 @@ export namespace DataScience { export const sysInfoURILabel = localize('DataScience.sysInfoURILabel', 'Jupyter Server URI: '); } +export namespace Debug { + export const selectConfigurationTitle = localize('debug.selectConfigurationTitle', 'Select a debug configuration'); + export const selectConfigurationPlaceholder = localize('debug.selectConfigurationPlaceholder', 'Debug Configuration'); + export const debugFileConfigurationLabel = localize('debug.debugFileConfigurationLabel', 'Python File'); + export const debugFileConfigurationDescription = localize('debug.debugFileConfigurationDescription', 'Debug python file'); + export const debugModuleConfigurationLabel = localize('debug.debugModuleConfigurationLabel', 'Module'); + export const debugModuleConfigurationDescription = localize('debug.debugModuleConfigurationDescription', 'Debug Python module/package'); + export const remoteAttachConfigurationLabel = localize('debug.remoteAttachConfigurationLabel', 'Remote Attach'); + export const remoteAttachConfigurationDescription = localize('debug.remoteAttachConfigurationDescription', 'Debug a remote python program'); + export const debugDjangoConfigurationLabel = localize('debug.debugDjangoConfigurationLabel', 'Django'); + export const debugDjangoConfigurationDescription = localize('debug.debugDjangoConfigurationDescription', 'Web Application'); + export const debugFlaskConfigurationLabel = localize('debug.debugFlaskConfigurationLabel', 'Flask'); + export const debugFlaskConfigurationDescription = localize('debug.debugFlaskConfigurationDescription', 'Web Application'); + export const debugPyramidConfigurationLabel = localize('debug.debugPyramidConfigurationLabel', 'Pyramid'); + export const debugPyramidConfigurationDescription = localize('debug.debugPyramidConfigurationDescription', 'Web Application'); + export const djangoEnterManagePyPathTitle = localize('debug.djangoEnterManagePyPathTitle', 'Debug Django'); + export const djangoEnterManagePyPathPrompt = localize('debug.djangoEnterManagePyPathPrompt', 'Enter path to manage.py'); + export const djangoEnterManagePyPathInvalidFilePathError = localize('debug.djangoEnterManagePyPathInvalidFilePathError', 'Enter a valid python file path'); + export const flaskEnterAppPathOrNamePathTitle = localize('debug.flaskEnterAppPathOrNamePathTitle', 'Debug Flask'); + export const flaskEnterAppPathOrNamePathPrompt = localize('debug.flaskEnterAppPathOrNamePathPrompt', 'Enter path to Application, e.g. \'app.py\' or \'app\''); + export const flaskEnterAppPathOrNamePathInvalidNameError = localize('debug.flaskEnterAppPathOrNamePathInvalidNameError', 'Enter a valid name'); + + export const moduleEnterModuleTitle = localize('debug.moduleEnterModuleTitle', 'Debug Module'); + export const moduleEnterModulePrompt = localize('debug.moduleEnterModulePrompt', 'Enter Python module/package name'); + export const moduleEnterModuleInvalidNameError = localize('debug.moduleEnterModuleInvalidNameError', 'Enter a valid name'); + export const pyramidEnterDevelopmentIniPathTitle = localize('debug.pyramidEnterDevelopmentIniPathTitle', 'Debug Pyramid'); + // tslint:disable-next-line:no-invalid-template-strings + export const pyramidEnterDevelopmentIniPathPrompt = localize('debug.pyramidEnterDevelopmentIniPathPrompt', '`Enter path to development.ini (\'${workspaceFolderToken}\' points to the root of the current workspace folder)`'); + export const pyramidEnterDevelopmentIniPathInvalidFilePathError = localize('debug.pyramidEnterDevelopmentIniPathInvalidFilePathError', 'Enter a valid file path'); + export const attachRemotePortTitle = localize('debug.attachRemotePortTitle', 'Remote Debugging'); + export const attachRemotePortPrompt = localize('debug.attachRemotePortPrompt', 'Enter Port Number'); + export const attachRemotePortValidationError = localize('debug.attachRemotePortValidationError', 'Enter a valid Port Number'); + export const attachRemoteHostTitle = localize('debug.attachRemoteHostTitle', 'Remote Debugging'); + export const attachRemoteHostPrompt = localize('debug.attachRemoteHostPrompt', 'Enter Host Name'); + export const attachRemoteHostValidationError = localize('debug.attachRemoteHostValidationError', 'Enter a Host Name or IP Address'); +} + // Skip using vscode-nls and instead just compute our strings based on key values. Key values // can be loaded out of the nls..json files let loadedCollection: { [index: string]: string } | undefined; diff --git a/src/client/common/utils/multiStepInput.ts b/src/client/common/utils/multiStepInput.ts new file mode 100644 index 000000000000..90e3a6192a8a --- /dev/null +++ b/src/client/common/utils/multiStepInput.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:max-func-body-length no-any no-unnecessary-class + +import { inject, injectable } from 'inversify'; +import { Disposable, QuickInput, QuickInputButton, QuickInputButtons, QuickPickItem } from 'vscode'; +import { IApplicationShell } from '../application/types'; + +// Borrowed from https://github.com/Microsoft/vscode-extension-samples/blob/master/quickinput-sample/src/multiStepInput.ts +// Why re-invent the wheel :) + +export class InputFlowAction { + public static back = new InputFlowAction(); + public static cancel = new InputFlowAction(); + public static resume = new InputFlowAction(); + private constructor() { } +} + +export type InputStep = (input: MultiStepInput, state: T) => Promise | void>; + +export interface IQuickPickParameters { + title: string; + step?: number; + totalSteps?: number; + canGoBack?: boolean; + items: T[]; + activeItem?: T; + placeholder: string; + buttons?: QuickInputButton[]; + shouldResume?(): Promise; +} + +export interface InputBoxParameters { + title: string; + step?: number; + totalSteps?: number; + value: string; + prompt: string; + buttons?: QuickInputButton[]; + validate(value: string): Promise; + shouldResume?(): Promise; +} + +type MultiStepInputQuickPicResponseType = T | (P extends { buttons: (infer I)[] } ? I : never); +type MultiStepInputInputBoxResponseType

= string | (P extends { buttons: (infer I)[] } ? I : never); +export interface IMultiStepInput { + run(start: InputStep, state: S): Promise; + showQuickPick>({ title, step, totalSteps, items, activeItem, placeholder, buttons, shouldResume }: P): Promise>; + showInputBox

({ title, step, totalSteps, value, prompt, validate, buttons, shouldResume }: P): Promise>; +} + +export class MultiStepInput implements IMultiStepInput { + private current?: QuickInput; + private steps: InputStep[] = []; + constructor(private readonly shell: IApplicationShell) { } + public run(start: InputStep, state: S) { + return this.stepThrough(start, state); + } + + public async showQuickPick>({ title, step, totalSteps, items, activeItem, placeholder, buttons, shouldResume }: P): Promise> { + const disposables: Disposable[] = []; + try { + return await new Promise>((resolve, reject) => { + const input = this.shell.createQuickPick(); + input.title = title; + input.step = step; + input.totalSteps = totalSteps; + input.placeholder = placeholder; + input.ignoreFocusOut = true; + input.items = items; + if (activeItem) { + input.activeItems = [activeItem]; + } + input.buttons = [ + ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), + ...(buttons || []) + ]; + disposables.push( + input.onDidTriggerButton(item => { + if (item === QuickInputButtons.Back) { + reject(InputFlowAction.back); + } else { + resolve(item); + } + }), + input.onDidChangeSelection(selectedItems => resolve(selectedItems[0])), + input.onDidHide(() => { + (async () => { + reject(shouldResume && await shouldResume() ? InputFlowAction.resume : InputFlowAction.cancel); + })() + .catch(reject); + }) + ); + if (this.current) { + this.current.dispose(); + } + this.current = input; + this.current.show(); + }); + } finally { + disposables.forEach(d => d.dispose()); + } + } + + public async showInputBox

({ title, step, totalSteps, value, prompt, validate, buttons, shouldResume }: P): Promise> { + const disposables: Disposable[] = []; + try { + return await new Promise>((resolve, reject) => { + const input = this.shell.createInputBox(); + input.title = title; + input.step = step; + input.totalSteps = totalSteps; + input.value = value || ''; + input.prompt = prompt; + input.ignoreFocusOut = true; + input.buttons = [ + ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), + ...(buttons || []) + ]; + let validating = validate(''); + disposables.push( + input.onDidTriggerButton(item => { + if (item === QuickInputButtons.Back) { + reject(InputFlowAction.back); + } else { + resolve(item); + } + }), + input.onDidAccept(async () => { + const inputValue = input.value; + input.enabled = false; + input.busy = true; + if (!(await validate(inputValue))) { + resolve(inputValue); + } + input.enabled = true; + input.busy = false; + }), + input.onDidChangeValue(async text => { + const current = validate(text); + validating = current; + const validationMessage = await current; + if (current === validating) { + input.validationMessage = validationMessage; + } + }), + input.onDidHide(() => { + (async () => { + reject(shouldResume && await shouldResume() ? InputFlowAction.resume : InputFlowAction.cancel); + })() + .catch(reject); + }) + ); + if (this.current) { + this.current.dispose(); + } + this.current = input; + this.current.show(); + }); + } finally { + disposables.forEach(d => d.dispose()); + } + } + + private async stepThrough(start: InputStep, state: S) { + let step: InputStep | void = start; + while (step) { + this.steps.push(step); + if (this.current) { + this.current.enabled = false; + this.current.busy = true; + } + try { + step = await step(this, state); + } catch (err) { + if (err === InputFlowAction.back) { + this.steps.pop(); + step = this.steps.pop(); + } else if (err === InputFlowAction.resume) { + step = this.steps.pop(); + } else if (err === InputFlowAction.cancel) { + step = undefined; + } else { + throw err; + } + } + } + if (this.current) { + this.current.dispose(); + } + } +} +export const IMultiStepInputFactory = Symbol('IMultiStepInputFactory'); +export interface IMultiStepInputFactory { + create(): IMultiStepInput; +} +@injectable() +export class MultiStepInputFactory { + constructor(@inject(IApplicationShell) private readonly shell: IApplicationShell) { } + public create(): IMultiStepInput { + return new MultiStepInput(this.shell); + } +} diff --git a/src/client/debugger/extension/configuration/debugConfigurationService.ts b/src/client/debugger/extension/configuration/debugConfigurationService.ts new file mode 100644 index 000000000000..4196c68ac55c --- /dev/null +++ b/src/client/debugger/extension/configuration/debugConfigurationService.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable, named } from 'inversify'; +import { CancellationToken, DebugConfiguration, QuickPickItem, WorkspaceFolder } from 'vscode'; +import { Debug } from '../../../common/utils/localize'; +import { IMultiStepInput, IMultiStepInputFactory, InputStep, IQuickPickParameters } from '../../../common/utils/multiStepInput'; +import { AttachRequestArguments, DebugConfigurationArguments, LaunchRequestArguments } from '../../types'; +import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationService } from '../types'; +import { IDebugConfigurationProviderFactory, IDebugConfigurationResolver } from './types'; + +@injectable() +export class PythonDebugConfigurationService implements IDebugConfigurationService { + constructor(@inject(IDebugConfigurationResolver) @named('attach') private readonly attachResolver: IDebugConfigurationResolver, + @inject(IDebugConfigurationResolver) @named('launch') private readonly launchResolver: IDebugConfigurationResolver, + @inject(IDebugConfigurationProviderFactory) private readonly providerFactory: IDebugConfigurationProviderFactory, + @inject(IMultiStepInputFactory) private readonly multiStepFactory: IMultiStepInputFactory) { + } + public async provideDebugConfigurations?(folder: WorkspaceFolder | undefined, token?: CancellationToken): Promise { + const config: Partial = {}; + const state = { config, folder, token }; + const multiStep = this.multiStepFactory.create(); + await multiStep.run((input, s) => this.pickDebugConfiguration(input, s), state); + return state.config as DebugConfiguration[]; + } + public async resolveDebugConfiguration(folder: WorkspaceFolder | undefined, debugConfiguration: DebugConfiguration, token?: CancellationToken): Promise { + if (debugConfiguration.request === 'attach') { + return this.attachResolver.resolveDebugConfiguration(folder, debugConfiguration as AttachRequestArguments, token); + } else { + return this.launchResolver.resolveDebugConfiguration(folder, debugConfiguration as LaunchRequestArguments, token); + } + } + protected async pickDebugConfiguration(input: IMultiStepInput, state: DebugConfigurationState): Promise | void> { + type DebugConfigurationQuickPickItem = QuickPickItem & { type: DebugConfigurationType }; + const items: DebugConfigurationQuickPickItem[] = [ + { label: Debug.debugFileConfigurationLabel(), type: DebugConfigurationType.launchFile, description: Debug.debugFileConfigurationDescription() }, + { label: Debug.debugModuleConfigurationLabel(), type: DebugConfigurationType.launchModule, description: Debug.debugModuleConfigurationDescription() }, + { label: Debug.remoteAttachConfigurationLabel(), type: DebugConfigurationType.remoteAttach, description: Debug.remoteAttachConfigurationDescription() }, + { label: Debug.debugDjangoConfigurationLabel(), type: DebugConfigurationType.launchDjango, description: Debug.debugDjangoConfigurationDescription() }, + { label: Debug.debugFlaskConfigurationLabel(), type: DebugConfigurationType.launchFlask, description: Debug.debugFlaskConfigurationDescription() }, + { label: Debug.debugPyramidConfigurationLabel(), type: DebugConfigurationType.launchPyramid, description: Debug.debugPyramidConfigurationDescription() } + ]; + state.config = {}; + const pick = await input.showQuickPick>({ + title: Debug.selectConfigurationTitle(), + placeholder: Debug.selectConfigurationPlaceholder(), + activeItem: items[0], + items: items + }); + if (pick) { + const provider = this.providerFactory.create(pick.type); + return provider.buildConfiguration.bind(provider); + } + } +} diff --git a/src/client/debugger/extension/configuration/providers/djangoLaunch.ts b/src/client/debugger/extension/configuration/providers/djangoLaunch.ts new file mode 100644 index 000000000000..f0fc850d0876 --- /dev/null +++ b/src/client/debugger/extension/configuration/providers/djangoLaunch.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import * as path from 'path'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { IWorkspaceService } from '../../../../common/application/types'; +import { IFileSystem } from '../../../../common/platform/types'; +import { IPathUtils } from '../../../../common/types'; +import { Debug, localize } from '../../../../common/utils/localize'; +import { MultiStepInput } from '../../../../common/utils/multiStepInput'; +import { SystemVariables } from '../../../../common/variables/systemVariables'; +import { sendTelemetryEvent } from '../../../../telemetry'; +import { DEBUGGER_CONFIGURATION_PROMPTS } from '../../../../telemetry/constants'; +import { DebuggerTypeName } from '../../../constants'; +import { LaunchRequestArguments } from '../../../types'; +import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationProvider } from '../../types'; + +// tslint:disable-next-line:no-invalid-template-strings +const workspaceFolderToken = '${workspaceFolder}'; + +@injectable() +export class DjangoLaunchDebugConfigurationProvider implements IDebugConfigurationProvider { + constructor(@inject(IFileSystem) private fs: IFileSystem, + @inject(IWorkspaceService) private readonly workspace: IWorkspaceService, + @inject(IPathUtils) private pathUtils: IPathUtils) { } + public async buildConfiguration(input: MultiStepInput, state: DebugConfigurationState) { + const program = await this.getManagePyPath(state.folder); + let manuallyEnteredAValue: boolean | undefined; + const defaultProgram = `${workspaceFolderToken}${this.pathUtils.separator}manage.py`; + const config: Partial = { + name: localize('python.snippet.launch.django.label', 'Python: Django')(), + type: DebuggerTypeName, + request: 'launch', + program: program || defaultProgram, + args: [ + 'runserver', + '--noreload', + '--nothreading' + ], + django: true + }; + if (!program) { + const selectedProgram = await input.showInputBox({ + title: Debug.djangoEnterManagePyPathTitle(), + value: defaultProgram, + prompt: Debug.djangoEnterManagePyPathPrompt(), + validate: value => this.validateManagePy(state.folder, defaultProgram, value) + }); + if (selectedProgram) { + manuallyEnteredAValue = true; + config.program = selectedProgram; + } + } + + sendTelemetryEvent(DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchDjango, autoDetectedDjangoManagePyPath: !!program, manuallyEnteredAValue }); + Object.assign(state.config, config); + } + public async validateManagePy(folder: WorkspaceFolder | undefined, defaultValue: string, selected?: string): Promise { + const error = Debug.djangoEnterManagePyPathInvalidFilePathError(); + if (!selected || selected.trim().length === 0) { + return error; + } + const resolvedPath = this.resolveVariables(selected, folder ? folder.uri : undefined); + if (selected !== defaultValue && !await this.fs.fileExists(resolvedPath)) { + return error; + } + if (!resolvedPath.trim().toLowerCase().endsWith('.py')) { + return error; + } + return; + } + protected resolveVariables(pythonPath: string, resource: Uri | undefined): string { + const workspaceFolder = resource ? this.workspace.getWorkspaceFolder(resource) : undefined; + const systemVariables = new SystemVariables(workspaceFolder ? workspaceFolder.uri.fsPath : undefined); + return systemVariables.resolveAny(pythonPath); + } + + protected async getManagePyPath(folder: WorkspaceFolder | undefined): Promise { + if (!folder) { + return; + } + const defaultLocationOfManagePy = path.join(folder.uri.fsPath, 'manage.py'); + if (await this.fs.fileExists(defaultLocationOfManagePy)) { + return `${workspaceFolderToken}${this.pathUtils.separator}manage.py`; + } + } +} diff --git a/src/client/debugger/extension/configuration/providers/fileLaunch.ts b/src/client/debugger/extension/configuration/providers/fileLaunch.ts new file mode 100644 index 000000000000..7718778ff24e --- /dev/null +++ b/src/client/debugger/extension/configuration/providers/fileLaunch.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { injectable } from 'inversify'; +import { localize } from '../../../../common/utils/localize'; +import { MultiStepInput } from '../../../../common/utils/multiStepInput'; +import { captureTelemetry } from '../../../../telemetry'; +import { DEBUGGER_CONFIGURATION_PROMPTS } from '../../../../telemetry/constants'; +import { DebuggerTypeName } from '../../../constants'; +import { LaunchRequestArguments } from '../../../types'; +import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationProvider } from '../../types'; + +@injectable() +export class FileLaunchDebugConfigurationProvider implements IDebugConfigurationProvider { + @captureTelemetry(DEBUGGER_CONFIGURATION_PROMPTS, { configurationType: DebugConfigurationType.launchFile }, false) + public async buildConfiguration(_input: MultiStepInput, state: DebugConfigurationState) { + const config: Partial = { + name: localize('python.snippet.launch.standard.label', 'Python: Current File')(), + type: DebuggerTypeName, + request: 'launch', + // tslint:disable-next-line:no-invalid-template-strings + program: '${file}', + console: 'integratedTerminal' + }; + Object.assign(state.config, config); + } +} diff --git a/src/client/debugger/extension/configuration/providers/flaskLaunch.ts b/src/client/debugger/extension/configuration/providers/flaskLaunch.ts new file mode 100644 index 000000000000..539b5cf13843 --- /dev/null +++ b/src/client/debugger/extension/configuration/providers/flaskLaunch.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import * as path from 'path'; +import { WorkspaceFolder } from 'vscode'; +import { IFileSystem } from '../../../../common/platform/types'; +import { Debug, localize } from '../../../../common/utils/localize'; +import { MultiStepInput } from '../../../../common/utils/multiStepInput'; +import { sendTelemetryEvent } from '../../../../telemetry'; +import { DEBUGGER_CONFIGURATION_PROMPTS } from '../../../../telemetry/constants'; +import { DebuggerTypeName } from '../../../constants'; +import { LaunchRequestArguments } from '../../../types'; +import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationProvider } from '../../types'; + +@injectable() +export class FlaskLaunchDebugConfigurationProvider implements IDebugConfigurationProvider { + constructor(@inject(IFileSystem) private fs: IFileSystem) { } + public isSupported(debugConfigurationType: DebugConfigurationType): boolean { + return debugConfigurationType === DebugConfigurationType.launchFlask; + } + public async buildConfiguration(input: MultiStepInput, state: DebugConfigurationState) { + const application = await this.getApplicationPath(state.folder); + let manuallyEnteredAValue: boolean | undefined; + const config: Partial = { + name: localize('python.snippet.launch.flask.label', 'Python: Flask')(), + type: DebuggerTypeName, + request: 'launch', + module: 'flask', + env: { + FLASK_APP: application || 'app.py', + FLASK_ENV: 'development', + FLASK_DEBUG: '0' + }, + args: [ + 'run', + '--no-debugger', + '--no-reload' + ], + jinja: true + }; + + if (!application) { + const selectedApp = await input.showInputBox({ + title: Debug.flaskEnterAppPathOrNamePathTitle(), + value: 'app.py', + prompt: Debug.debugFlaskConfigurationDescription(), + validate: value => Promise.resolve((value && value.trim().length > 0) ? undefined : Debug.flaskEnterAppPathOrNamePathInvalidNameError()) + }); + if (selectedApp) { + manuallyEnteredAValue = true; + config.env!.FLASK_APP = selectedApp; + } + } + + sendTelemetryEvent(DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchFlask, autoDetectedFlaskAppPyPath: !!application, manuallyEnteredAValue }); + Object.assign(state.config, config); + } + protected async getApplicationPath(folder: WorkspaceFolder | undefined): Promise { + if (!folder) { + return; + } + const defaultLocationOfManagePy = path.join(folder.uri.fsPath, 'app.py'); + if (await this.fs.fileExists(defaultLocationOfManagePy)) { + return 'app.py'; + } + } +} diff --git a/src/client/debugger/extension/configuration/providers/moduleLaunch.ts b/src/client/debugger/extension/configuration/providers/moduleLaunch.ts new file mode 100644 index 000000000000..f5fd5c1577cc --- /dev/null +++ b/src/client/debugger/extension/configuration/providers/moduleLaunch.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { injectable } from 'inversify'; +import { Debug, localize } from '../../../../common/utils/localize'; +import { MultiStepInput } from '../../../../common/utils/multiStepInput'; +import { sendTelemetryEvent } from '../../../../telemetry'; +import { DEBUGGER_CONFIGURATION_PROMPTS } from '../../../../telemetry/constants'; +import { DebuggerTypeName } from '../../../constants'; +import { LaunchRequestArguments } from '../../../types'; +import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationProvider } from '../../types'; + +@injectable() +export class ModuleLaunchDebugConfigurationProvider implements IDebugConfigurationProvider { + public async buildConfiguration(input: MultiStepInput, state: DebugConfigurationState) { + let manuallyEnteredAValue: boolean | undefined; + const config: Partial = { + name: localize('python.snippet.launch.module.label', 'Python: Module')(), + type: DebuggerTypeName, + request: 'launch', + module: 'enter-your-module-name-here' + }; + const selectedModule = await input.showInputBox({ + title: Debug.moduleEnterModuleTitle(), + value: config.module || 'enter-your-module-name-here', + prompt: Debug.moduleEnterModulePrompt(), + validate: value => Promise.resolve((value && value.trim().length > 0) ? undefined : Debug.moduleEnterModuleInvalidNameError()) + }); + if (selectedModule) { + manuallyEnteredAValue = true; + config.module = selectedModule; + } + + sendTelemetryEvent(DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchModule, manuallyEnteredAValue }); + Object.assign(state.config, config); + } +} diff --git a/src/client/debugger/extension/configuration/providers/providerFactory.ts b/src/client/debugger/extension/configuration/providers/providerFactory.ts new file mode 100644 index 000000000000..61f808d1e9e1 --- /dev/null +++ b/src/client/debugger/extension/configuration/providers/providerFactory.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable, named } from 'inversify'; +import { DebugConfigurationType, IDebugConfigurationProvider } from '../../types'; +import { IDebugConfigurationProviderFactory } from '../types'; + +@injectable() +export class DebugConfigurationProviderFactory implements IDebugConfigurationProviderFactory { + private readonly providers: Map; + constructor( + @inject(IDebugConfigurationProvider) @named(DebugConfigurationType.launchFlask) flaskProvider: IDebugConfigurationProvider, + @inject(IDebugConfigurationProvider) @named(DebugConfigurationType.launchDjango) djangoProvider: IDebugConfigurationProvider, + @inject(IDebugConfigurationProvider) @named(DebugConfigurationType.launchModule) moduleProvider: IDebugConfigurationProvider, + @inject(IDebugConfigurationProvider) @named(DebugConfigurationType.launchFile) fileProvider: IDebugConfigurationProvider, + @inject(IDebugConfigurationProvider) @named(DebugConfigurationType.launchPyramid) pyramidProvider: IDebugConfigurationProvider, + @inject(IDebugConfigurationProvider) @named(DebugConfigurationType.remoteAttach) remoteAttachProvider: IDebugConfigurationProvider + ) { + this.providers = new Map(); + this.providers.set(DebugConfigurationType.launchDjango, djangoProvider); + this.providers.set(DebugConfigurationType.launchFlask, flaskProvider); + this.providers.set(DebugConfigurationType.launchFile, fileProvider); + this.providers.set(DebugConfigurationType.launchModule, moduleProvider); + this.providers.set(DebugConfigurationType.launchPyramid, pyramidProvider); + this.providers.set(DebugConfigurationType.remoteAttach, remoteAttachProvider); + } + public create(configurationType: DebugConfigurationType): IDebugConfigurationProvider { + return this.providers.get(configurationType)!; + } +} diff --git a/src/client/debugger/extension/configuration/providers/pyramidLaunch.ts b/src/client/debugger/extension/configuration/providers/pyramidLaunch.ts new file mode 100644 index 000000000000..6a854319f0c3 --- /dev/null +++ b/src/client/debugger/extension/configuration/providers/pyramidLaunch.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import * as path from 'path'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { IWorkspaceService } from '../../../../common/application/types'; +import { IFileSystem } from '../../../../common/platform/types'; +import { IPathUtils } from '../../../../common/types'; +import { Debug, localize } from '../../../../common/utils/localize'; +import { MultiStepInput } from '../../../../common/utils/multiStepInput'; +import { SystemVariables } from '../../../../common/variables/systemVariables'; +import { sendTelemetryEvent } from '../../../../telemetry'; +import { DEBUGGER_CONFIGURATION_PROMPTS } from '../../../../telemetry/constants'; +import { DebuggerTypeName } from '../../../constants'; +import { LaunchRequestArguments } from '../../../types'; +import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationProvider } from '../../types'; + +// tslint:disable-next-line:no-invalid-template-strings +const workspaceFolderToken = '${workspaceFolder}'; + +@injectable() +export class PyramidLaunchDebugConfigurationProvider implements IDebugConfigurationProvider { + constructor(@inject(IFileSystem) private fs: IFileSystem, + @inject(IWorkspaceService) private readonly workspace: IWorkspaceService, + @inject(IPathUtils) private pathUtils: IPathUtils) { } + public async buildConfiguration(input: MultiStepInput, state: DebugConfigurationState) { + const iniPath = await this.getDevelopmentIniPath(state.folder); + const defaultIni = `${workspaceFolderToken}${this.pathUtils.separator}development.ini`; + let manuallyEnteredAValue: boolean | undefined; + + const config: Partial = { + name: localize('python.snippet.launch.pyramid.label', 'Python: Pyramid Application')(), + type: DebuggerTypeName, + request: 'launch', + args: [ + iniPath || defaultIni + ], + pyramid: true, + jinja: true + }; + + if (!iniPath) { + const selectedIniPath = await input.showInputBox({ + title: Debug.pyramidEnterDevelopmentIniPathTitle(), + value: defaultIni, + prompt: Debug.pyramidEnterDevelopmentIniPathPrompt(), + validate: value => this.validateIniPath(state ? state.folder : undefined, defaultIni, value) + }); + if (selectedIniPath) { + manuallyEnteredAValue = true; + config.args = [selectedIniPath]; + } + } + + sendTelemetryEvent(DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.launchPyramid, autoDetectedPyramidIniPath: !!iniPath, manuallyEnteredAValue }); + Object.assign(state.config, config); + } + public async validateIniPath(folder: WorkspaceFolder | undefined, defaultValue: string, selected?: string): Promise { + if (!folder) { + return; + } + const error = Debug.pyramidEnterDevelopmentIniPathInvalidFilePathError(); + if (!selected || selected.trim().length === 0) { + return error; + } + const resolvedPath = this.resolveVariables(selected, folder.uri); + if (selected !== defaultValue && !await this.fs.fileExists(resolvedPath)) { + return error; + } + if (!resolvedPath.trim().toLowerCase().endsWith('.ini')) { + return error; + } + } + protected resolveVariables(pythonPath: string, resource: Uri | undefined): string { + const workspaceFolder = resource ? this.workspace.getWorkspaceFolder(resource) : undefined; + const systemVariables = new SystemVariables(workspaceFolder ? workspaceFolder.uri.fsPath : undefined); + return systemVariables.resolveAny(pythonPath); + } + + protected async getDevelopmentIniPath(folder: WorkspaceFolder | undefined): Promise { + if (!folder) { + return; + } + const defaultLocationOfManagePy = path.join(folder.uri.fsPath, 'development.ini'); + if (await this.fs.fileExists(defaultLocationOfManagePy)) { + return `${workspaceFolderToken}${this.pathUtils.separator}development.ini`; + } + } +} diff --git a/src/client/debugger/extension/configuration/providers/remoteAttach.ts b/src/client/debugger/extension/configuration/providers/remoteAttach.ts new file mode 100644 index 000000000000..4cbbd9281c92 --- /dev/null +++ b/src/client/debugger/extension/configuration/providers/remoteAttach.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { injectable } from 'inversify'; +import { Debug, localize } from '../../../../common/utils/localize'; +import { InputStep, MultiStepInput } from '../../../../common/utils/multiStepInput'; +import { sendTelemetryEvent } from '../../../../telemetry'; +import { DEBUGGER_CONFIGURATION_PROMPTS } from '../../../../telemetry/constants'; +import { DebuggerTypeName } from '../../../constants'; +import { AttachRequestArguments } from '../../../types'; +import { DebugConfigurationState, DebugConfigurationType, IDebugConfigurationProvider } from '../../types'; + +const defaultHost = 'localhost'; +const defaultPort = 5678; + +@injectable() +export class RemoteAttachDebugConfigurationProvider implements IDebugConfigurationProvider { + public async buildConfiguration(input: MultiStepInput, state: DebugConfigurationState): Promise | void> { + const config: Partial = { + name: localize('python.snippet.launch.attach.label', 'Python: Attach')(), + type: DebuggerTypeName, + request: 'attach', + port: defaultPort, + host: defaultHost + }; + + config.host = await input.showInputBox({ + title: Debug.attachRemoteHostTitle(), + step: 1, + totalSteps: 2, + value: config.host || defaultHost, + prompt: Debug.attachRemoteHostPrompt(), + validate: value => Promise.resolve((value && value.trim().length > 0) ? undefined : Debug.attachRemoteHostValidationError()) + }); + if (!config.host) { + config.host = defaultHost; + } + + sendTelemetryEvent(DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.remoteAttach, manuallyEnteredAValue: config.host !== defaultHost }); + Object.assign(state.config, config); + return _ => this.configurePort(input, state.config); + } + protected async configurePort(input: MultiStepInput, config: Partial) { + const port = await input.showInputBox({ + title: Debug.attachRemotePortTitle(), + step: 2, + totalSteps: 2, + value: (config.port || defaultPort).toString(), + prompt: Debug.attachRemotePortPrompt(), + validate: value => Promise.resolve((value && /^\d+$/.test(value.trim())) ? undefined : Debug.attachRemotePortValidationError()) + }); + if (port && /^\d+$/.test(port.trim())) { + config.port = parseInt(port, 10); + } + if (!config.port) { + config.port = defaultPort; + } + sendTelemetryEvent(DEBUGGER_CONFIGURATION_PROMPTS, undefined, { configurationType: DebugConfigurationType.remoteAttach, manuallyEnteredAValue: config.port !== defaultPort }); + } +} diff --git a/src/client/debugger/extension/configuration/resolvers/base.ts b/src/client/debugger/extension/configuration/resolvers/base.ts index d8e9ad63550f..dffff2e0c6f5 100644 --- a/src/client/debugger/extension/configuration/resolvers/base.ts +++ b/src/client/debugger/extension/configuration/resolvers/base.ts @@ -70,6 +70,8 @@ export abstract class BaseConfigurationResolver im return (debugConfiguration.module && debugConfiguration.module.toUpperCase() === 'FLASK') ? true : false; } protected sendTelemetry(trigger: 'launch' | 'attach', debugConfiguration: Partial) { + const name = debugConfiguration.name || ''; + const moduleName = debugConfiguration.module || ''; const telemetryProps: DebuggerTelemetry = { trigger, console: debugConfiguration.console, @@ -78,13 +80,17 @@ export abstract class BaseConfigurationResolver im flask: this.isDebuggingFlask(debugConfiguration), hasArgs: Array.isArray(debugConfiguration.args) && debugConfiguration.args.length > 0, isLocalhost: this.isLocalHost(debugConfiguration.host), - isModule: typeof debugConfiguration.module === 'string' && debugConfiguration.module.length > 0, + isModule: moduleName.length > 0, isSudo: !!debugConfiguration.sudo, jinja: !!debugConfiguration.jinja, pyramid: !!debugConfiguration.pyramid, stopOnEntry: !!debugConfiguration.stopOnEntry, showReturnValue: !!debugConfiguration.showReturnValue, - subProcess: !!debugConfiguration.subProcess + subProcess: !!debugConfiguration.subProcess, + watson: name.toLowerCase().indexOf('watson') >= 0, + pyspark: name.toLowerCase().indexOf('pyspark') >= 0, + gevent: name.toLowerCase().indexOf('gevent') >= 0, + scrapy: moduleName.toLowerCase() === 'scrapy' }; sendTelemetryEvent(DEBUGGER, undefined, telemetryProps); } diff --git a/src/client/debugger/extension/configuration/types.ts b/src/client/debugger/extension/configuration/types.ts index db8ba18046c4..8a5a7473249c 100644 --- a/src/client/debugger/extension/configuration/types.ts +++ b/src/client/debugger/extension/configuration/types.ts @@ -4,6 +4,7 @@ 'use strict'; import { CancellationToken, DebugConfiguration, Uri, WorkspaceFolder } from 'vscode'; +import { DebugConfigurationType, IDebugConfigurationProvider } from '../types'; export const IConfigurationProviderUtils = Symbol('IConfigurationProviderUtils'); @@ -15,3 +16,8 @@ export const IDebugConfigurationResolver = Symbol('IDebugConfigurationResolver') export interface IDebugConfigurationResolver { resolveDebugConfiguration(folder: WorkspaceFolder | undefined, debugConfiguration: T, token?: CancellationToken): Promise; } + +export const IDebugConfigurationProviderFactory = Symbol('IDebugConfigurationProviderFactory'); +export interface IDebugConfigurationProviderFactory { + create(configurationType: DebugConfigurationType): IDebugConfigurationProvider; +} diff --git a/src/client/debugger/extension/serviceRegistry.ts b/src/client/debugger/extension/serviceRegistry.ts index 548fb1a070be..a3fed3aab68e 100644 --- a/src/client/debugger/extension/serviceRegistry.ts +++ b/src/client/debugger/extension/serviceRegistry.ts @@ -3,26 +3,39 @@ 'use strict'; -import { DebugConfigurationProvider } from 'vscode'; import { IServiceManager } from '../../ioc/types'; import { AttachRequestArguments, LaunchRequestArguments } from '../types'; import { DebuggerBanner } from './banner'; import { ConfigurationProviderUtils } from './configuration/configurationProviderUtils'; -import { PythonDebugConfigurationProvider } from './configuration/debugConfigurationProvider'; +import { PythonDebugConfigurationService } from './configuration/debugConfigurationService'; +import { DjangoLaunchDebugConfigurationProvider } from './configuration/providers/djangoLaunch'; +import { FileLaunchDebugConfigurationProvider } from './configuration/providers/fileLaunch'; +import { FlaskLaunchDebugConfigurationProvider } from './configuration/providers/flaskLaunch'; +import { ModuleLaunchDebugConfigurationProvider } from './configuration/providers/moduleLaunch'; +import { DebugConfigurationProviderFactory } from './configuration/providers/providerFactory'; +import { PyramidLaunchDebugConfigurationProvider } from './configuration/providers/pyramidLaunch'; +import { RemoteAttachDebugConfigurationProvider } from './configuration/providers/remoteAttach'; import { AttachConfigurationResolver } from './configuration/resolvers/attach'; import { LaunchConfigurationResolver } from './configuration/resolvers/launch'; -import { IConfigurationProviderUtils, IDebugConfigurationResolver } from './configuration/types'; +import { IConfigurationProviderUtils, IDebugConfigurationProviderFactory, IDebugConfigurationResolver } from './configuration/types'; import { ChildProcessAttachEventHandler } from './hooks/childProcessAttachHandler'; import { ChildProcessAttachService } from './hooks/childProcessAttachService'; import { IChildProcessAttachService, IDebugSessionEventHandlers } from './hooks/types'; -import { IDebugConfigurationProvider, IDebuggerBanner } from './types'; +import { DebugConfigurationType, IDebugConfigurationProvider, IDebugConfigurationService, IDebuggerBanner } from './types'; export function registerTypes(serviceManager: IServiceManager) { - serviceManager.addSingleton(IDebugConfigurationProvider, PythonDebugConfigurationProvider); + serviceManager.addSingleton(IDebugConfigurationService, PythonDebugConfigurationService); serviceManager.addSingleton(IConfigurationProviderUtils, ConfigurationProviderUtils); serviceManager.addSingleton(IDebuggerBanner, DebuggerBanner); serviceManager.addSingleton(IChildProcessAttachService, ChildProcessAttachService); serviceManager.addSingleton(IDebugSessionEventHandlers, ChildProcessAttachEventHandler); serviceManager.addSingleton>(IDebugConfigurationResolver, LaunchConfigurationResolver, 'launch'); serviceManager.addSingleton>(IDebugConfigurationResolver, AttachConfigurationResolver, 'attach'); + serviceManager.addSingleton(IDebugConfigurationProviderFactory, DebugConfigurationProviderFactory); + serviceManager.addSingleton(IDebugConfigurationProvider, FileLaunchDebugConfigurationProvider, DebugConfigurationType.launchFile); + serviceManager.addSingleton(IDebugConfigurationProvider, DjangoLaunchDebugConfigurationProvider, DebugConfigurationType.launchDjango); + serviceManager.addSingleton(IDebugConfigurationProvider, FlaskLaunchDebugConfigurationProvider, DebugConfigurationType.launchFlask); + serviceManager.addSingleton(IDebugConfigurationProvider, RemoteAttachDebugConfigurationProvider, DebugConfigurationType.remoteAttach); + serviceManager.addSingleton(IDebugConfigurationProvider, ModuleLaunchDebugConfigurationProvider, DebugConfigurationType.launchModule); + serviceManager.addSingleton(IDebugConfigurationProvider, PyramidLaunchDebugConfigurationProvider, DebugConfigurationType.launchPyramid); } diff --git a/src/client/debugger/extension/types.ts b/src/client/debugger/extension/types.ts index 419f5ca16131..63d8691252ff 100644 --- a/src/client/debugger/extension/types.ts +++ b/src/client/debugger/extension/types.ts @@ -3,8 +3,28 @@ 'use strict'; -export const IDebugConfigurationProvider = Symbol('DebugConfigurationProvider'); +import { CancellationToken, DebugConfigurationProvider, WorkspaceFolder } from 'vscode'; +import { InputStep, MultiStepInput } from '../../common/utils/multiStepInput'; +import { DebugConfigurationArguments } from '../types'; + +export const IDebugConfigurationService = Symbol('IDebugConfigurationService'); +export interface IDebugConfigurationService extends DebugConfigurationProvider { } export const IDebuggerBanner = Symbol('IDebuggerBanner'); export interface IDebuggerBanner { initialize(): void; } + +export const IDebugConfigurationProvider = Symbol('IDebugConfigurationProvider'); +export type DebugConfigurationState = { config: Partial; folder?: WorkspaceFolder; token?: CancellationToken }; +export interface IDebugConfigurationProvider { + buildConfiguration(input: MultiStepInput, state: DebugConfigurationState): Promise | void>; +} + +export enum DebugConfigurationType { + launchFile = 'launchFile', + remoteAttach = 'remoteAttach', + launchDjango = 'launchDjango', + launchFlask = 'launchFlask', + launchModule = 'launchModule', + launchPyramid = 'launchPyramid' +} diff --git a/src/client/debugger/types.ts b/src/client/debugger/types.ts index 3c7ccb2f2f35..50eec0f94a92 100644 --- a/src/client/debugger/types.ts +++ b/src/client/debugger/types.ts @@ -58,9 +58,9 @@ export interface IKnownLaunchRequestArguments extends ICommonDebugArguments { args: string[]; cwd?: string; debugOptions?: DebugOptions[]; - env?: Object; + env?: { [key: string]: string | undefined }; envFile: string; - console?: 'none' | 'integratedTerminal' | 'externalTerminal'; + console?: ConsoleType; } // tslint:disable-next-line:interface-name export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArguments, IKnownLaunchRequestArguments, DebugConfiguration { @@ -71,3 +71,8 @@ export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArgum export interface AttachRequestArguments extends DebugProtocol.AttachRequestArguments, IKnownAttachDebugArguments, DebugConfiguration { type: typeof DebuggerTypeName; } + +// tslint:disable-next-line:interface-name +export interface DebugConfigurationArguments extends LaunchRequestArguments, AttachRequestArguments { } + +export type ConsoleType = 'none' | 'integratedTerminal' | 'externalTerminal'; diff --git a/src/client/extension.ts b/src/client/extension.ts index 741b79dcc180..2ce85e65649f 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -61,7 +61,7 @@ import { DebuggerTypeName } from './debugger/constants'; import { DebugSessionEventDispatcher } from './debugger/extension/hooks/eventHandlerDispatcher'; import { IDebugSessionEventHandlers } from './debugger/extension/hooks/types'; import { registerTypes as debugConfigurationRegisterTypes } from './debugger/extension/serviceRegistry'; -import { IDebugConfigurationProvider, IDebuggerBanner } from './debugger/extension/types'; +import { IDebugConfigurationService, IDebuggerBanner } from './debugger/extension/types'; import { registerTypes as formattersRegisterTypes } from './formatters/serviceRegistry'; import { IInterpreterSelector } from './interpreter/configuration/types'; import { @@ -189,8 +189,8 @@ export async function activate(context: ExtensionContext): Promise(IDebugConfigurationProvider).forEach(debugConfig => { - context.subscriptions.push(debug.registerDebugConfigurationProvider(DebuggerTypeName, debugConfig)); + serviceContainer.getAll(IDebugConfigurationService).forEach(debugConfigProvider => { + context.subscriptions.push(debug.registerDebugConfigurationProvider(DebuggerTypeName, debugConfigProvider)); }); serviceContainer.get(IDebuggerBanner).initialize(); diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index 9d512a989c2c..fbb26ff51b2a 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -29,6 +29,7 @@ export const EXECUTION_DJANGO = 'EXECUTION_DJANGO'; export const DEBUGGER = 'DEBUGGER'; export const DEBUGGER_ATTACH_TO_CHILD_PROCESS = 'DEBUGGER.ATTACH_TO_CHILD_PROCESS'; export const DEBUGGER_PERFORMANCE = 'DEBUGGER.PERFORMANCE'; +export const DEBUGGER_CONFIGURATION_PROMPTS = 'DEBUGGER.CONFIGURATION.PROMPTS'; export const UNITTEST_STOP = 'UNITTEST.STOP'; export const UNITTEST_RUN = 'UNITTEST.RUN'; export const UNITTEST_DISCOVER = 'UNITTEST.DISCOVER'; diff --git a/src/client/telemetry/types.ts b/src/client/telemetry/types.ts index 2ca12126e513..89b3eeeb8d43 100644 --- a/src/client/telemetry/types.ts +++ b/src/client/telemetry/types.ts @@ -3,6 +3,7 @@ 'use strict'; import { TerminalShellType } from '../common/terminal/types'; +import { DebugConfigurationType } from '../debugger/extension/types'; import { InterpreterType } from '../interpreter/contracts'; import { LinterId } from '../linters/types'; import { PlatformErrors } from './constants'; @@ -63,6 +64,10 @@ export type DebuggerTelemetry = { showReturnValue: boolean; pyramid: boolean; subProcess: boolean; + watson: boolean; + pyspark: boolean; + gevent: boolean; + scrapy: boolean; }; export type DebuggerPerformanceTelemetry = { duration: number; @@ -72,7 +77,7 @@ export type TestRunTelemetry = { tool: 'nosetest' | 'pytest' | 'unittest'; scope: 'currentFile' | 'all' | 'file' | 'class' | 'function' | 'failed'; debugging: boolean; - triggeredBy: 'ui' | 'codelens' | 'commandpalette' | 'auto'; + triggerSource: 'ui' | 'codelens' | 'commandpalette' | 'auto'; failed: boolean; }; export type TestDiscoverytTelemetry = { @@ -92,6 +97,13 @@ export type TerminalTelemetry = { pythonVersion?: string; interpreterType?: InterpreterType; }; +export type DebuggerConfigurationPromtpsTelemetry = { + configurationType: DebugConfigurationType; + autoDetectedDjangoManagePyPath?: boolean; + autoDetectedPyramidIniPath?: boolean; + autoDetectedFlaskAppPyPath?: boolean; + manuallyEnteredAValue?: boolean; +}; export type DiagnosticsAction = { /** * Diagnostics command executed. @@ -147,4 +159,5 @@ export type TelemetryProperties = FormatTelemetry | DiagnosticsMessages | ImportNotebook | Platform - | LanguageServePlatformSupported; + | LanguageServePlatformSupported + | DebuggerConfigurationPromtpsTelemetry; diff --git a/src/client/unittests/common/managers/baseTestManager.ts b/src/client/unittests/common/managers/baseTestManager.ts index 89a54b12e40e..9fc61c5f9126 100644 --- a/src/client/unittests/common/managers/baseTestManager.ts +++ b/src/client/unittests/common/managers/baseTestManager.ts @@ -2,6 +2,7 @@ import { CancellationToken, CancellationTokenSource, Disposable, OutputChannel, import { IWorkspaceService } from '../../../common/application/types'; import { isNotInstalledError } from '../../../common/helpers'; import { IConfigurationService, IDisposableRegistry, IInstaller, IOutputChannel, IPythonSettings, Product } from '../../../common/types'; +import { getNamesAndValues } from '../../../common/utils/enum'; import { IServiceContainer } from '../../../ioc/types'; import { UNITTEST_DISCOVER, UNITTEST_RUN } from '../../../telemetry/constants'; import { sendTelemetryEvent } from '../../../telemetry/index'; @@ -171,11 +172,13 @@ export abstract class BaseTestManager implements ITestManager { Run_Specific_Class: 'false', Run_Specific_Function: 'false' }; + //Ensure valid values are sent. + const validCmdSourceValues = getNamesAndValues(CommandSource).map(item => item.value); const telementryProperties: TestRunTelemetry = { tool: this.testProvider, scope: 'all', debugging: debug === true, - triggeredBy: cmdSource, + triggerSource: validCmdSourceValues.indexOf(cmdSource) === -1 ? 'commandpalette' : cmdSource, failed: false }; if (runFailedTests === true) { diff --git a/src/test/common/platform/pathUtils.test.ts b/src/test/common/platform/pathUtils.test.ts new file mode 100644 index 000000000000..f8f9d2d32597 --- /dev/null +++ b/src/test/common/platform/pathUtils.test.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; +import { expect } from 'chai'; +import * as path from 'path'; +import { PathUtils } from '../../../client/common/platform/pathUtils'; +import { getOSType, OSType } from '../../common'; + +suite('PathUtils', () => { + let utils: PathUtils; + suiteSetup(() => { + utils = new PathUtils(getOSType() === OSType.Windows); + }); + test('Path Separator', () => { + expect(utils.separator).to.be.equal(path.sep); + }); +}); diff --git a/src/test/debugger/attach.ptvsd.test.ts b/src/test/debugger/attach.ptvsd.test.ts index 372bc39595c9..576a90ac7329 100644 --- a/src/test/debugger/attach.ptvsd.test.ts +++ b/src/test/debugger/attach.ptvsd.test.ts @@ -14,10 +14,11 @@ import { EXTENSION_ROOT_DIR } from '../../client/common/constants'; import { IS_WINDOWS } from '../../client/common/platform/constants'; import { IPlatformService } from '../../client/common/platform/types'; import { IConfigurationService } from '../../client/common/types'; +import { IMultiStepInputFactory } from '../../client/common/utils/multiStepInput'; import { DebuggerTypeName, PTVSD_PATH } from '../../client/debugger/constants'; -import { PythonDebugConfigurationProvider } from '../../client/debugger/extension/configuration/debugConfigurationProvider'; +import { PythonDebugConfigurationService } from '../../client/debugger/extension/configuration/debugConfigurationService'; import { AttachConfigurationResolver } from '../../client/debugger/extension/configuration/resolvers/attach'; -import { IDebugConfigurationResolver } from '../../client/debugger/extension/configuration/types'; +import { IDebugConfigurationProviderFactory, IDebugConfigurationResolver } from '../../client/debugger/extension/configuration/types'; import { AttachRequestArguments, DebugOptions, LaunchRequestArguments } from '../../client/debugger/types'; import { IServiceContainer } from '../../client/ioc/types'; import { PYTHON_PATH, sleep } from '../common'; @@ -27,7 +28,7 @@ import { continueDebugging, createDebugAdapter } from './utils'; // tslint:disable:no-invalid-this max-func-body-length no-empty no-increment-decrement no-unused-variable no-console const fileToDebug = path.join(EXTENSION_ROOT_DIR, 'src', 'testMultiRootWkspc', 'workspace5', 'remoteDebugger-start-with-ptvsd.py'); -suite('Attach Debugger', () => { +suite('Debugging - Attach Debugger', () => { let debugClient: DebugClient; let proc: ChildProcess; @@ -96,7 +97,9 @@ suite('Attach Debugger', () => { const launchResolver = TypeMoq.Mock.ofType>(); const attachResolver = new AttachConfigurationResolver(workspaceService.object, documentManager.object, platformService.object, configurationService.object); - const configProvider = new PythonDebugConfigurationProvider(attachResolver, launchResolver.object); + const providerFactory = TypeMoq.Mock.ofType().object; + const multiStepIput = TypeMoq.Mock.ofType().object; + const configProvider = new PythonDebugConfigurationService(attachResolver, launchResolver.object, providerFactory, multiStepIput); await configProvider.resolveDebugConfiguration({ index: 0, name: 'root', uri: Uri.file(localRoot) }, options); const attachPromise = debugClient.attachRequest(options); diff --git a/src/test/debugger/extension/configuration/debugConfigurationProvider.unit.test.ts b/src/test/debugger/extension/configuration/debugConfigurationProvider.unit.test.ts index ebfd95f0162b..c0dd996fbf8e 100644 --- a/src/test/debugger/extension/configuration/debugConfigurationProvider.unit.test.ts +++ b/src/test/debugger/extension/configuration/debugConfigurationProvider.unit.test.ts @@ -12,7 +12,7 @@ import { PythonDebugConfigurationProvider } from '../../../../client/debugger/ex import { IDebugConfigurationResolver } from '../../../../client/debugger/extension/configuration/types'; import { AttachRequestArguments, LaunchRequestArguments } from '../../../../client/debugger/types'; -suite('xDebugging - Configuration Provider', () => { +suite('Debugging - Configuration Provider', () => { let attachResolver: typemoq.IMock>; let launchResolver: typemoq.IMock>; let provider: PythonDebugConfigurationProvider; diff --git a/src/test/debugger/extension/configuration/debugConfigurationService.unit.test.ts b/src/test/debugger/extension/configuration/debugConfigurationService.unit.test.ts new file mode 100644 index 000000000000..2dccfa8619e5 --- /dev/null +++ b/src/test/debugger/extension/configuration/debugConfigurationService.unit.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any + +import { expect } from 'chai'; +import { instance, mock } from 'ts-mockito'; +import * as typemoq from 'typemoq'; +import { Uri } from 'vscode'; +import { IMultiStepInput, IMultiStepInputFactory } from '../../../../client/common/utils/multiStepInput'; +import { PythonDebugConfigurationService } from '../../../../client/debugger/extension/configuration/debugConfigurationService'; +import { DebugConfigurationProviderFactory } from '../../../../client/debugger/extension/configuration/providers/providerFactory'; +import { IDebugConfigurationResolver } from '../../../../client/debugger/extension/configuration/types'; +import { DebugConfigurationState } from '../../../../client/debugger/extension/types'; +import { AttachRequestArguments, LaunchRequestArguments } from '../../../../client/debugger/types'; + +// tslint:disable-next-line:max-func-body-length +suite('Debugging - Configuration Provider', () => { + let attachResolver: typemoq.IMock>; + let launchResolver: typemoq.IMock>; + let configService: TestPythonDebugConfigurationService; + let multiStepFactory: typemoq.IMock; + let providerFactory: DebugConfigurationProviderFactory; + + class TestPythonDebugConfigurationService extends PythonDebugConfigurationService { + // tslint:disable-next-line:no-unnecessary-override + public async pickDebugConfiguration(input: IMultiStepInput, state: DebugConfigurationState) { + return super.pickDebugConfiguration(input, state); + } + } + setup(() => { + attachResolver = typemoq.Mock.ofType>(); + launchResolver = typemoq.Mock.ofType>(); + multiStepFactory = typemoq.Mock.ofType(); + providerFactory = mock(DebugConfigurationProviderFactory); + configService = new TestPythonDebugConfigurationService(attachResolver.object, launchResolver.object, instance(providerFactory), multiStepFactory.object); + }); + test('Should use attach resolver when passing attach config', async () => { + const config = { + request: 'attach' + } as any as AttachRequestArguments; + const folder = { name: '1', index: 0, uri: Uri.parse('1234') }; + const expectedConfig = { yay: 1 }; + + attachResolver + .setup(a => a.resolveDebugConfiguration(typemoq.It.isValue(folder), typemoq.It.isValue(config), typemoq.It.isAny())) + .returns(() => Promise.resolve(expectedConfig as any)) + .verifiable(typemoq.Times.once()); + launchResolver + .setup(a => a.resolveDebugConfiguration(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .verifiable(typemoq.Times.never()); + + const resolvedConfig = await configService.resolveDebugConfiguration(folder, config as any); + + expect(resolvedConfig).to.deep.equal(expectedConfig); + attachResolver.verifyAll(); + launchResolver.verifyAll(); + }); + [ + { request: 'launch' }, { request: undefined } + ].forEach(config => { + test(`Should use launch resolver when passing launch config with request=${config.request}`, async () => { + const folder = { name: '1', index: 0, uri: Uri.parse('1234') }; + const expectedConfig = { yay: 1 }; + + launchResolver + .setup(a => a.resolveDebugConfiguration(typemoq.It.isValue(folder), typemoq.It.isValue(config as any as LaunchRequestArguments), typemoq.It.isAny())) + .returns(() => Promise.resolve(expectedConfig as any)) + .verifiable(typemoq.Times.once()); + attachResolver + .setup(a => a.resolveDebugConfiguration(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .verifiable(typemoq.Times.never()); + + const resolvedConfig = await configService.resolveDebugConfiguration(folder, config as any); + + expect(resolvedConfig).to.deep.equal(expectedConfig); + attachResolver.verifyAll(); + launchResolver.verifyAll(); + }); + }); + test('Picker should be displayed', async () => { + // tslint:disable-next-line:no-object-literal-type-assertion + const state = { configs: [], folder: {}, token: undefined } as any as DebugConfigurationState; + const multStepInput = typemoq.Mock.ofType>(); + multStepInput + .setup(i => i.showQuickPick(typemoq.It.isAny())) + .returns(() => Promise.resolve(undefined as any)) + .verifiable(typemoq.Times.once()); + + await configService.pickDebugConfiguration(multStepInput.object, state); + + multStepInput.verifyAll(); + }); + test('Existing Configuration items must be removed before displaying picker', async () => { + // tslint:disable-next-line:no-object-literal-type-assertion + const state = { configs: [1, 2, 3], folder: {}, token: undefined } as any as DebugConfigurationState; + const multStepInput = typemoq.Mock.ofType>(); + multStepInput + .setup(i => i.showQuickPick(typemoq.It.isAny())) + .returns(() => Promise.resolve(undefined as any)) + .verifiable(typemoq.Times.once()); + + await configService.pickDebugConfiguration(multStepInput.object, state); + + multStepInput.verifyAll(); + expect(Object.keys(state.config)).to.be.lengthOf(0); + }); +}); diff --git a/src/test/debugger/extension/configuration/providers/djangoLaunch.unit.test.ts b/src/test/debugger/extension/configuration/providers/djangoLaunch.unit.test.ts new file mode 100644 index 000000000000..18098627d4f8 --- /dev/null +++ b/src/test/debugger/extension/configuration/providers/djangoLaunch.unit.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any no-invalid-template-strings max-func-body-length + +import { expect } from 'chai'; +import * as path from 'path'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { IWorkspaceService } from '../../../../../client/common/application/types'; +import { WorkspaceService } from '../../../../../client/common/application/workspace'; +import { FileSystem } from '../../../../../client/common/platform/fileSystem'; +import { PathUtils } from '../../../../../client/common/platform/pathUtils'; +import { IFileSystem } from '../../../../../client/common/platform/types'; +import { IPathUtils } from '../../../../../client/common/types'; +import { localize } from '../../../../../client/common/utils/localize'; +import { MultiStepInput } from '../../../../../client/common/utils/multiStepInput'; +import { DebuggerTypeName } from '../../../../../client/debugger/constants'; +import { DjangoLaunchDebugConfigurationProvider } from '../../../../../client/debugger/extension/configuration/providers/djangoLaunch'; +import { DebugConfigurationState } from '../../../../../client/debugger/extension/types'; + +suite('Debugging - Configuration Provider Django', () => { + let fs: IFileSystem; + let workspaceService: IWorkspaceService; + let pathUtils: IPathUtils; + let provider: TestDjangoLaunchDebugConfigurationProvider; + let input: MultiStepInput; + class TestDjangoLaunchDebugConfigurationProvider extends DjangoLaunchDebugConfigurationProvider { + // tslint:disable-next-line:no-unnecessary-override + public resolveVariables(pythonPath: string, resource: Uri | undefined): string { + return super.resolveVariables(pythonPath, resource); + } + // tslint:disable-next-line:no-unnecessary-override + public async getManagePyPath(folder: WorkspaceFolder): Promise { + return super.getManagePyPath(folder); + } + } + setup(() => { + fs = mock(FileSystem); + workspaceService = mock(WorkspaceService); + pathUtils = mock(PathUtils); + input = mock>(MultiStepInput); + provider = new TestDjangoLaunchDebugConfigurationProvider(instance(fs), instance(workspaceService), instance(pathUtils)); + }); + test('getManagePyPath should return undefined if file doesn\'t exist', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const managePyPath = path.join(folder.uri.fsPath, 'manage.py'); + when(fs.fileExists(managePyPath)).thenResolve(false); + + const file = await provider.getManagePyPath(folder); + + expect(file).to.be.equal(undefined, 'Should return undefined'); + }); + test('getManagePyPath should file path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const managePyPath = path.join(folder.uri.fsPath, 'manage.py'); + + when(pathUtils.separator).thenReturn('-'); + when(fs.fileExists(managePyPath)).thenResolve(true); + + const file = await provider.getManagePyPath(folder); + + // tslint:disable-next-line:no-invalid-template-strings + expect(file).to.be.equal('${workspaceFolder}-manage.py'); + }); + test('Resolve variables (with resource)', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + when(workspaceService.getWorkspaceFolder(anything())).thenReturn(folder); + + const resolvedPath = provider.resolveVariables('${workspaceFolder}/one.py', Uri.file('')); + + expect(resolvedPath).to.be.equal(`${folder.uri.fsPath}/one.py`); + }); + test('Validation of path should return errors if path is undefined', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + + const error = await provider.validateManagePy(folder, ''); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if path is empty', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + + const error = await provider.validateManagePy(folder, '', ''); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if resolved path is empty', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + provider.resolveVariables = () => ''; + + const error = await provider.validateManagePy(folder, '', 'x'); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if resolved path doesn\'t exist', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + provider.resolveVariables = () => 'xyz'; + + when(fs.fileExists('xyz')).thenResolve(false); + const error = await provider.validateManagePy(folder, '', 'x'); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if resolved path is non-python', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + provider.resolveVariables = () => 'xyz.txt'; + + when(fs.fileExists('xyz.txt')).thenResolve(true); + const error = await provider.validateManagePy(folder, '', 'x'); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if resolved path is python', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + provider.resolveVariables = () => 'xyz.py'; + + when(fs.fileExists('xyz.py')).thenResolve(true); + const error = await provider.validateManagePy(folder, '', 'x'); + + expect(error).to.be.equal(undefined, 'should not have errors'); + }); + test('Launch JSON with valid python path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getManagePyPath = () => Promise.resolve('xyz.py'); + when(pathUtils.separator).thenReturn('-'); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.django.label', 'Python: Django')(), + type: DebuggerTypeName, + request: 'launch', + program: 'xyz.py', + args: [ + 'runserver', + '--noreload', + '--nothreading' + ], + django: true + }; + + expect(state.config).to.be.deep.equal(config); + }); + test('Launch JSON with selected managepy path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getManagePyPath = () => Promise.resolve(undefined); + when(pathUtils.separator).thenReturn('-'); + when(input.showInputBox(anything())).thenResolve('hello'); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.django.label', 'Python: Django')(), + type: DebuggerTypeName, + request: 'launch', + program: 'hello', + args: [ + 'runserver', + '--noreload', + '--nothreading' + ], + django: true + }; + + expect(state.config).to.be.deep.equal(config); + }); + test('Launch JSON with default managepy path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getManagePyPath = () => Promise.resolve(undefined); + const workspaceFolderToken = '${workspaceFolder}'; + const defaultProgram = `${workspaceFolderToken}-manage.py`; + + when(pathUtils.separator).thenReturn('-'); + when(input.showInputBox(anything())).thenResolve(); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.django.label', 'Python: Django')(), + type: DebuggerTypeName, + request: 'launch', + program: defaultProgram, + args: [ + 'runserver', + '--noreload', + '--nothreading' + ], + django: true + }; + + expect(state.config).to.be.deep.equal(config); + }); +}); diff --git a/src/test/debugger/extension/configuration/providers/fileLaunch.unit.test.ts b/src/test/debugger/extension/configuration/providers/fileLaunch.unit.test.ts new file mode 100644 index 000000000000..1e06afe48dc7 --- /dev/null +++ b/src/test/debugger/extension/configuration/providers/fileLaunch.unit.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any no-invalid-template-strings max-func-body-length + +import { expect } from 'chai'; +import * as path from 'path'; +import { Uri } from 'vscode'; +import { localize } from '../../../../../client/common/utils/localize'; +import { DebuggerTypeName } from '../../../../../client/debugger/constants'; +import { FileLaunchDebugConfigurationProvider } from '../../../../../client/debugger/extension/configuration/providers/fileLaunch'; + +suite('Debugging - Configuration Provider File', () => { + let provider: FileLaunchDebugConfigurationProvider; + setup(() => { + provider = new FileLaunchDebugConfigurationProvider(); + }); + test('Launch JSON with default managepy path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + + await provider.buildConfiguration(undefined as any, state); + + const config = { + name: localize('python.snippet.launch.standard.label', 'Python: Current File')(), + type: DebuggerTypeName, + request: 'launch', + // tslint:disable-next-line:no-invalid-template-strings + program: '${file}', + console: 'integratedTerminal' + }; + + expect(state.config).to.be.deep.equal(config); + }); +}); diff --git a/src/test/debugger/extension/configuration/providers/flaskLaunch.unit.test.ts b/src/test/debugger/extension/configuration/providers/flaskLaunch.unit.test.ts new file mode 100644 index 000000000000..ee4e1e665fc0 --- /dev/null +++ b/src/test/debugger/extension/configuration/providers/flaskLaunch.unit.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any no-invalid-template-strings max-func-body-length + +import { expect } from 'chai'; +import * as path from 'path'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { FileSystem } from '../../../../../client/common/platform/fileSystem'; +import { IFileSystem } from '../../../../../client/common/platform/types'; +import { localize } from '../../../../../client/common/utils/localize'; +import { MultiStepInput } from '../../../../../client/common/utils/multiStepInput'; +import { DebuggerTypeName } from '../../../../../client/debugger/constants'; +import { FlaskLaunchDebugConfigurationProvider } from '../../../../../client/debugger/extension/configuration/providers/flaskLaunch'; +import { DebugConfigurationState } from '../../../../../client/debugger/extension/types'; + +suite('Debugging - Configuration Provider Flask', () => { + let fs: IFileSystem; + let provider: TestFlaskLaunchDebugConfigurationProvider; + let input: MultiStepInput; + class TestFlaskLaunchDebugConfigurationProvider extends FlaskLaunchDebugConfigurationProvider { + // tslint:disable-next-line:no-unnecessary-override + public async getApplicationPath(folder: WorkspaceFolder): Promise { + return super.getApplicationPath(folder); + } + } + setup(() => { + fs = mock(FileSystem); + input = mock>(MultiStepInput); + provider = new TestFlaskLaunchDebugConfigurationProvider(instance(fs)); + }); + test('getApplicationPath should return undefined if file doesn\'t exist', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const appPyPath = path.join(folder.uri.fsPath, 'app.py'); + when(fs.fileExists(appPyPath)).thenResolve(false); + + const file = await provider.getApplicationPath(folder); + + expect(file).to.be.equal(undefined, 'Should return undefined'); + }); + test('getApplicationPath should file path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const appPyPath = path.join(folder.uri.fsPath, 'app.py'); + + when(fs.fileExists(appPyPath)).thenResolve(true); + + const file = await provider.getApplicationPath(folder); + + // tslint:disable-next-line:no-invalid-template-strings + expect(file).to.be.equal('app.py'); + }); + test('Launch JSON with valid python path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getApplicationPath = () => Promise.resolve('xyz.py'); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.flask.label', 'Python: Flask')(), + type: DebuggerTypeName, + request: 'launch', + module: 'flask', + env: { + FLASK_APP: 'xyz.py', + FLASK_ENV: 'development', + FLASK_DEBUG: '0' + }, + args: [ + 'run', + '--no-debugger', + '--no-reload' + ], + jinja: true + }; + + expect(state.config).to.be.deep.equal(config); + }); + test('Launch JSON with selected app path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getApplicationPath = () => Promise.resolve(undefined); + + when(input.showInputBox(anything())).thenResolve('hello'); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.flask.label', 'Python: Flask')(), + type: DebuggerTypeName, + request: 'launch', + module: 'flask', + env: { + FLASK_APP: 'hello', + FLASK_ENV: 'development', + FLASK_DEBUG: '0' + }, + args: [ + 'run', + '--no-debugger', + '--no-reload' + ], + jinja: true + }; + + expect(state.config).to.be.deep.equal(config); + }); + test('Launch JSON with default managepy path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getApplicationPath = () => Promise.resolve(undefined); + + when(input.showInputBox(anything())).thenResolve(); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.flask.label', 'Python: Flask')(), + type: DebuggerTypeName, + request: 'launch', + module: 'flask', + env: { + FLASK_APP: 'app.py', + FLASK_ENV: 'development', + FLASK_DEBUG: '0' + }, + args: [ + 'run', + '--no-debugger', + '--no-reload' + ], + jinja: true + }; + + expect(state.config).to.be.deep.equal(config); + }); +}); diff --git a/src/test/debugger/extension/configuration/providers/moduleLaunch.unit.test.ts b/src/test/debugger/extension/configuration/providers/moduleLaunch.unit.test.ts new file mode 100644 index 000000000000..7f8be8d55128 --- /dev/null +++ b/src/test/debugger/extension/configuration/providers/moduleLaunch.unit.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any no-invalid-template-strings max-func-body-length + +import { expect } from 'chai'; +import * as path from 'path'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri } from 'vscode'; +import { localize } from '../../../../../client/common/utils/localize'; +import { MultiStepInput } from '../../../../../client/common/utils/multiStepInput'; +import { DebuggerTypeName } from '../../../../../client/debugger/constants'; +import { ModuleLaunchDebugConfigurationProvider } from '../../../../../client/debugger/extension/configuration/providers/moduleLaunch'; +import { DebugConfigurationState } from '../../../../../client/debugger/extension/types'; + +suite('Debugging - Configuration Provider Module', () => { + let provider: ModuleLaunchDebugConfigurationProvider; + setup(() => { + provider = new ModuleLaunchDebugConfigurationProvider(); + }); + test('Launch JSON with default module name', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + const input = mock>(MultiStepInput); + + when(input.showInputBox(anything())).thenResolve(); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.module.label', 'Python: Module')(), + type: DebuggerTypeName, + request: 'launch', + module: 'enter-your-module-name-here' + }; + + expect(state.config).to.be.deep.equal(config); + }); + test('Launch JSON with selected module name', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + const input = mock>(MultiStepInput); + + when(input.showInputBox(anything())).thenResolve('hello'); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.module.label', 'Python: Module')(), + type: DebuggerTypeName, + request: 'launch', + module: 'hello' + }; + + expect(state.config).to.be.deep.equal(config); + }); +}); diff --git a/src/test/debugger/extension/configuration/providers/providerFactory.unit.test.ts b/src/test/debugger/extension/configuration/providers/providerFactory.unit.test.ts new file mode 100644 index 000000000000..d1433326dc2f --- /dev/null +++ b/src/test/debugger/extension/configuration/providers/providerFactory.unit.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any + +import { expect } from 'chai'; +import { getNamesAndValues } from '../../../../../client/common/utils/enum'; +import { DebugConfigurationProviderFactory } from '../../../../../client/debugger/extension/configuration/providers/providerFactory'; +import { IDebugConfigurationProviderFactory } from '../../../../../client/debugger/extension/configuration/types'; +import { DebugConfigurationType, IDebugConfigurationProvider } from '../../../../../client/debugger/extension/types'; + +suite('Debugging - Configuration Provider Factory', () => { + let mappedProviders: Map; + let factory: IDebugConfigurationProviderFactory; + setup(() => { + mappedProviders = new Map(); + getNamesAndValues(DebugConfigurationType).forEach(item => { + mappedProviders.set(item.value, item.value as any as IDebugConfigurationProvider); + }); + factory = new DebugConfigurationProviderFactory( + mappedProviders.get(DebugConfigurationType.launchFlask)!, + mappedProviders.get(DebugConfigurationType.launchDjango)!, + mappedProviders.get(DebugConfigurationType.launchModule)!, + mappedProviders.get(DebugConfigurationType.launchFile)!, + mappedProviders.get(DebugConfigurationType.launchPyramid)!, + mappedProviders.get(DebugConfigurationType.remoteAttach)! + ); + }); + getNamesAndValues(DebugConfigurationType).forEach(item => { + test(`Configuration Provider for ${item.name}`, () => { + const provider = factory.create(item.value); + expect(provider).to.equal(mappedProviders.get(item.value)); + }); + }); +}); diff --git a/src/test/debugger/extension/configuration/providers/pyramidLaunch.unit.test.ts b/src/test/debugger/extension/configuration/providers/pyramidLaunch.unit.test.ts new file mode 100644 index 000000000000..b3d93ff4b39b --- /dev/null +++ b/src/test/debugger/extension/configuration/providers/pyramidLaunch.unit.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any no-invalid-template-strings max-func-body-length + +import { expect } from 'chai'; +import * as path from 'path'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { IWorkspaceService } from '../../../../../client/common/application/types'; +import { WorkspaceService } from '../../../../../client/common/application/workspace'; +import { FileSystem } from '../../../../../client/common/platform/fileSystem'; +import { PathUtils } from '../../../../../client/common/platform/pathUtils'; +import { IFileSystem } from '../../../../../client/common/platform/types'; +import { IPathUtils } from '../../../../../client/common/types'; +import { localize } from '../../../../../client/common/utils/localize'; +import { MultiStepInput } from '../../../../../client/common/utils/multiStepInput'; +import { DebuggerTypeName } from '../../../../../client/debugger/constants'; +import { PyramidLaunchDebugConfigurationProvider } from '../../../../../client/debugger/extension/configuration/providers/pyramidLaunch'; +import { DebugConfigurationState } from '../../../../../client/debugger/extension/types'; + +suite('Debugging - Configuration Provider Pyramid', () => { + let fs: IFileSystem; + let workspaceService: IWorkspaceService; + let pathUtils: IPathUtils; + let provider: TestPyramidLaunchDebugConfigurationProvider; + let input: MultiStepInput; + class TestPyramidLaunchDebugConfigurationProvider extends PyramidLaunchDebugConfigurationProvider { + // tslint:disable-next-line:no-unnecessary-override + public resolveVariables(pythonPath: string, resource: Uri | undefined): string { + return super.resolveVariables(pythonPath, resource); + } + // tslint:disable-next-line:no-unnecessary-override + public async getDevelopmentIniPath(folder: WorkspaceFolder): Promise { + return super.getDevelopmentIniPath(folder); + } + } + setup(() => { + fs = mock(FileSystem); + workspaceService = mock(WorkspaceService); + pathUtils = mock(PathUtils); + input = mock>(MultiStepInput); + provider = new TestPyramidLaunchDebugConfigurationProvider(instance(fs), instance(workspaceService), instance(pathUtils)); + }); + test('getDevelopmentIniPath should return undefined if file doesn\'t exist', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const managePyPath = path.join(folder.uri.fsPath, 'development.ini'); + when(fs.fileExists(managePyPath)).thenResolve(false); + + const file = await provider.getDevelopmentIniPath(folder); + + expect(file).to.be.equal(undefined, 'Should return undefined'); + }); + test('getDevelopmentIniPath should file path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const managePyPath = path.join(folder.uri.fsPath, 'development.ini'); + + when(pathUtils.separator).thenReturn('-'); + when(fs.fileExists(managePyPath)).thenResolve(true); + + const file = await provider.getDevelopmentIniPath(folder); + + // tslint:disable-next-line:no-invalid-template-strings + expect(file).to.be.equal('${workspaceFolder}-development.ini'); + }); + test('Resolve variables (with resource)', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + when(workspaceService.getWorkspaceFolder(anything())).thenReturn(folder); + + const resolvedPath = provider.resolveVariables('${workspaceFolder}/one.py', Uri.file('')); + + expect(resolvedPath).to.be.equal(`${folder.uri.fsPath}/one.py`); + }); + test('Validation of path should return errors if path is undefined', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + + const error = await provider.validateIniPath(folder, ''); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if path is empty', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + + const error = await provider.validateIniPath(folder, '', ''); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if resolved path is empty', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + provider.resolveVariables = () => ''; + + const error = await provider.validateIniPath(folder, '', 'x'); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if resolved path doesn\'t exist', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + provider.resolveVariables = () => 'xyz'; + + when(fs.fileExists('xyz')).thenResolve(false); + const error = await provider.validateIniPath(folder, '', 'x'); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if resolved path is non-ini', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + provider.resolveVariables = () => 'xyz.txt'; + + when(fs.fileExists('xyz.txt')).thenResolve(true); + const error = await provider.validateIniPath(folder, '', 'x'); + + expect(error).to.be.length.greaterThan(1); + }); + test('Validation of path should return errors if resolved path is ini', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + provider.resolveVariables = () => 'xyz.ini'; + + when(fs.fileExists('xyz.ini')).thenResolve(true); + const error = await provider.validateIniPath(folder, '', 'x'); + + expect(error).to.be.equal(undefined, 'should not have errors'); + }); + test('Launch JSON with valid ini path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getDevelopmentIniPath = () => Promise.resolve('xyz.ini'); + when(pathUtils.separator).thenReturn('-'); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.pyramid.label', 'Python: Pyramid Application')(), + type: DebuggerTypeName, + request: 'launch', + args: [ + 'xyz.ini' + ], + pyramid: true, + jinja: true + }; + + expect(state.config).to.be.deep.equal(config); + }); + test('Launch JSON with selected ini path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getDevelopmentIniPath = () => Promise.resolve(undefined); + when(pathUtils.separator).thenReturn('-'); + when(input.showInputBox(anything())).thenResolve('hello'); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.pyramid.label', 'Python: Pyramid Application')(), + type: DebuggerTypeName, + request: 'launch', + args: [ + 'hello' + ], + pyramid: true, + jinja: true + }; + + expect(state.config).to.be.deep.equal(config); + }); + test('Launch JSON with default ini path', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + provider.getDevelopmentIniPath = () => Promise.resolve(undefined); + const workspaceFolderToken = '${workspaceFolder}'; + const defaultIni = `${workspaceFolderToken}-development.ini`; + + when(pathUtils.separator).thenReturn('-'); + when(input.showInputBox(anything())).thenResolve(); + + await provider.buildConfiguration(instance(input), state); + + const config = { + name: localize('python.snippet.launch.pyramid.label', 'Python: Pyramid Application')(), + type: DebuggerTypeName, + request: 'launch', + args: [ + defaultIni + ], + pyramid: true, + jinja: true + }; + + expect(state.config).to.be.deep.equal(config); + }); +}); diff --git a/src/test/debugger/extension/configuration/providers/remoteAttach.unit.test.ts b/src/test/debugger/extension/configuration/providers/remoteAttach.unit.test.ts new file mode 100644 index 000000000000..b91f815b5e57 --- /dev/null +++ b/src/test/debugger/extension/configuration/providers/remoteAttach.unit.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any no-invalid-template-strings max-func-body-length + +import { expect } from 'chai'; +import * as path from 'path'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { Uri } from 'vscode'; +import { localize } from '../../../../../client/common/utils/localize'; +import { MultiStepInput } from '../../../../../client/common/utils/multiStepInput'; +import { DebuggerTypeName } from '../../../../../client/debugger/constants'; +import { RemoteAttachDebugConfigurationProvider } from '../../../../../client/debugger/extension/configuration/providers/remoteAttach'; +import { DebugConfigurationState } from '../../../../../client/debugger/extension/types'; +import { AttachRequestArguments } from '../../../../../client/debugger/types'; + +suite('Debugging - Configuration Provider Remote Attach', () => { + let provider: TestRemoteAttachDebugConfigurationProvider; + let input: MultiStepInput; + class TestRemoteAttachDebugConfigurationProvider extends RemoteAttachDebugConfigurationProvider { + // tslint:disable-next-line:no-unnecessary-override + public async configurePort(i: MultiStepInput, config: Partial) { + return super.configurePort(i, config); + } + } + setup(() => { + input = mock>(MultiStepInput); + provider = new TestRemoteAttachDebugConfigurationProvider(); + }); + test('Configure port will display prompt', async () => { + when(input.showInputBox(anything())).thenResolve(); + + await provider.configurePort(instance(input), {}); + + verify(input.showInputBox(anything())).once(); + }); + test('Configure port will default to 5678 if entered value is not a number', async () => { + const config: { port?: number } = {}; + when(input.showInputBox(anything())).thenResolve('xyz'); + + await provider.configurePort(instance(input), config); + + verify(input.showInputBox(anything())).once(); + expect(config.port).to.equal(5678); + }); + test('Configure port will default to 5678', async () => { + const config: { port?: number } = {}; + when(input.showInputBox(anything())).thenResolve(); + + await provider.configurePort(instance(input), config); + + verify(input.showInputBox(anything())).once(); + expect(config.port).to.equal(5678); + }); + test('Configure port will use user selected value', async () => { + const config: { port?: number } = {}; + when(input.showInputBox(anything())).thenResolve('1234'); + + await provider.configurePort(instance(input), config); + + verify(input.showInputBox(anything())).once(); + expect(config.port).to.equal(1234); + }); + test('Launch JSON with default host name', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + let portConfigured = false; + when(input.showInputBox(anything())).thenResolve(); + provider.configurePort = () => { + portConfigured = true; + return Promise.resolve(); + }; + + const configurePort = await provider.buildConfiguration(instance(input), state); + if (configurePort) { + await configurePort!(input, state); + } + + const config = { + name: localize('python.snippet.launch.attach.label', 'Python: Attach')(), + type: DebuggerTypeName, + request: 'attach', + port: 5678, + host: 'localhost' + }; + + expect(state.config).to.be.deep.equal(config); + expect(portConfigured).to.be.equal(true, 'Port not configured'); + }); + test('Launch JSON with user defined host name', async () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const state = { config: {}, folder }; + let portConfigured = false; + when(input.showInputBox(anything())).thenResolve('Hello'); + provider.configurePort = (_, cfg) => { + portConfigured = true; + cfg.port = 9999; + return Promise.resolve(); + }; + + const configurePort = await provider.buildConfiguration(instance(input), state); + if (configurePort) { + await configurePort(input, state); + } + + const config = { + name: localize('python.snippet.launch.attach.label', 'Python: Attach')(), + type: DebuggerTypeName, + request: 'attach', + port: 9999, + host: 'Hello' + }; + + expect(state.config).to.be.deep.equal(config); + expect(portConfigured).to.be.equal(true, 'Port not configured'); + }); +}); diff --git a/src/test/debugger/extension/serviceRegistry.unit.test.ts b/src/test/debugger/extension/serviceRegistry.unit.test.ts index 32d2965fe775..b2e6f0e9d8ce 100644 --- a/src/test/debugger/extension/serviceRegistry.unit.test.ts +++ b/src/test/debugger/extension/serviceRegistry.unit.test.ts @@ -9,15 +9,22 @@ import { expect } from 'chai'; import * as typemoq from 'typemoq'; import { DebuggerBanner } from '../../../client/debugger/extension/banner'; import { ConfigurationProviderUtils } from '../../../client/debugger/extension/configuration/configurationProviderUtils'; -import { PythonDebugConfigurationProvider } from '../../../client/debugger/extension/configuration/debugConfigurationProvider'; +import { PythonDebugConfigurationService } from '../../../client/debugger/extension/configuration/debugConfigurationService'; +import { DjangoLaunchDebugConfigurationProvider } from '../../../client/debugger/extension/configuration/providers/djangoLaunch'; +import { FileLaunchDebugConfigurationProvider } from '../../../client/debugger/extension/configuration/providers/fileLaunch'; +import { FlaskLaunchDebugConfigurationProvider } from '../../../client/debugger/extension/configuration/providers/flaskLaunch'; +import { ModuleLaunchDebugConfigurationProvider } from '../../../client/debugger/extension/configuration/providers/moduleLaunch'; +import { DebugConfigurationProviderFactory } from '../../../client/debugger/extension/configuration/providers/providerFactory'; +import { PyramidLaunchDebugConfigurationProvider } from '../../../client/debugger/extension/configuration/providers/pyramidLaunch'; +import { RemoteAttachDebugConfigurationProvider } from '../../../client/debugger/extension/configuration/providers/remoteAttach'; import { AttachConfigurationResolver } from '../../../client/debugger/extension/configuration/resolvers/attach'; import { LaunchConfigurationResolver } from '../../../client/debugger/extension/configuration/resolvers/launch'; -import { IConfigurationProviderUtils, IDebugConfigurationResolver } from '../../../client/debugger/extension/configuration/types'; +import { IConfigurationProviderUtils, IDebugConfigurationProviderFactory, IDebugConfigurationResolver } from '../../../client/debugger/extension/configuration/types'; import { ChildProcessAttachEventHandler } from '../../../client/debugger/extension/hooks/childProcessAttachHandler'; import { ChildProcessAttachService } from '../../../client/debugger/extension/hooks/childProcessAttachService'; import { IChildProcessAttachService, IDebugSessionEventHandlers } from '../../../client/debugger/extension/hooks/types'; import { registerTypes } from '../../../client/debugger/extension/serviceRegistry'; -import { IDebugConfigurationProvider, IDebuggerBanner } from '../../../client/debugger/extension/types'; +import { DebugConfigurationType, IDebugConfigurationProvider, IDebugConfigurationService, IDebuggerBanner } from '../../../client/debugger/extension/types'; import { IServiceManager } from '../../../client/ioc/types'; suite('Debugging - Service Registry', () => { @@ -25,13 +32,20 @@ suite('Debugging - Service Registry', () => { const serviceManager = typemoq.Mock.ofType(); [ - [IDebugConfigurationProvider, PythonDebugConfigurationProvider], + [IDebugConfigurationService, PythonDebugConfigurationService], [IConfigurationProviderUtils, ConfigurationProviderUtils], [IDebuggerBanner, DebuggerBanner], [IChildProcessAttachService, ChildProcessAttachService], [IDebugSessionEventHandlers, ChildProcessAttachEventHandler], [IDebugConfigurationResolver, LaunchConfigurationResolver, 'launch'], - [IDebugConfigurationResolver, AttachConfigurationResolver, 'attach'] + [IDebugConfigurationResolver, AttachConfigurationResolver, 'attach'], + [IDebugConfigurationProviderFactory, DebugConfigurationProviderFactory], + [IDebugConfigurationProvider, FileLaunchDebugConfigurationProvider, DebugConfigurationType.launchFile], + [IDebugConfigurationProvider, DjangoLaunchDebugConfigurationProvider, DebugConfigurationType.launchDjango], + [IDebugConfigurationProvider, FlaskLaunchDebugConfigurationProvider, DebugConfigurationType.launchFlask], + [IDebugConfigurationProvider, RemoteAttachDebugConfigurationProvider, DebugConfigurationType.remoteAttach], + [IDebugConfigurationProvider, ModuleLaunchDebugConfigurationProvider, DebugConfigurationType.launchModule], + [IDebugConfigurationProvider, PyramidLaunchDebugConfigurationProvider, DebugConfigurationType.launchPyramid] ].forEach(mapping => { if (mapping.length === 2) { serviceManager