diff --git a/.vscode/settings.json b/.vscode/settings.json index 54e8de97f27a..2143c6a83864 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -44,5 +44,7 @@ "python.linting.pylintEnabled": false, "python.linting.flake8Enabled": true, "cucumberautocomplete.skipDocStringsFormat": true, - "python.linting.flake8Args": ["--max-line-length=120"] + "python.linting.flake8Args": [ + "--max-line-length=120" + ] } diff --git a/news/1 Enhancements/2772.md b/news/1 Enhancements/2772.md new file mode 100644 index 000000000000..d14310969845 --- /dev/null +++ b/news/1 Enhancements/2772.md @@ -0,0 +1 @@ +Added ability to auto update Insiders build of extension diff --git a/package.json b/package.json index 52326e46e60a..2e9ab2adbf5b 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,21 @@ "title": "%python.command.python.setInterpreter.title%", "category": "Python" }, + { + "command": "python.switchToStable", + "title": "%python.command.python.switchToStable.title%", + "category": "Python" + }, + { + "command": "python.switchToInsidersDaily", + "title": "%python.command.python.switchToInsidersDaily.title%", + "category": "Python" + }, + { + "command": "python.switchToInsidersWeekly", + "title": "%python.command.python.switchToInsidersWeekly.title%", + "category": "Python" + }, { "command": "python.updateSparkLibrary", "title": "%python.command.python.updateSparkLibrary.title%", @@ -569,6 +584,24 @@ } ], "commandPalette": [ + { + "command": "python.switchToStable", + "title": "%python.command.python.switchToStable.title%", + "category": "Python", + "when": "config.python.insidersChannel != 'Stable'" + }, + { + "command": "python.switchToInsidersDaily", + "title": "%python.command.python.switchToInsidersDaily.title%", + "category": "Python", + "when": "config.python.insidersChannel != 'InsidersDaily'" + }, + { + "command": "python.switchToInsidersWeekly", + "title": "%python.command.python.switchToInsidersWeekly.title%", + "category": "Python", + "when": "config.python.insidersChannel != 'InsidersWeekly'" + }, { "command": "python.viewOutput", "title": "%python.command.python.viewOutput.title%", @@ -2115,6 +2148,17 @@ "default": false, "description": "Uncomment shell assignments (#!), line magic (#!%) and cell magic (#!%%) when parsing code cells.", "scope": "resource" + }, + "python.insidersChannel": { + "type": "string", + "default": "Stable", + "description": "Set to \"Insiders\" to automatically download and install the latest Insiders builds of the extension, which include upcoming features and bug fixes.", + "enum": [ + "Stable", + "InsidersWeekly", + "InsidersDaily" + ], + "scope": "application" } } }, diff --git a/package.nls.json b/package.nls.json index 969601a392b9..886e75b951a6 100644 --- a/package.nls.json +++ b/package.nls.json @@ -7,6 +7,9 @@ "python.command.python.debugtests.title": "Debug All Tests", "python.command.python.execInTerminal.title": "Run Python File in Terminal", "python.command.python.setInterpreter.title": "Select Interpreter", + "python.command.python.switchToStable.title": "Switch to Stable build", + "python.command.python.switchToInsidersDaily.title": "Switch to Insiders build (daily)", + "python.command.python.switchToInsidersWeekly.title": "Switch to Insiders build (weekly)", "python.command.python.updateSparkLibrary.title": "Update Workspace PySpark Libraries", "python.command.python.refactorExtractVariable.title": "Extract Variable", "python.command.python.refactorExtractMethod.title": "Extract Method", @@ -109,6 +112,16 @@ "Interpreters.LoadingInterpreters": "Loading Python Interpreters", "Logging.CurrentWorkingDirectory": "cwd:", "Common.doNotShowAgain": "Do not show again", + "Common.reload": "Reload", + "ExtensionChannels.installingInsidersMessage": "Installing Insiders... ", + "ExtensionChannels.installingStableMessage": "Installing Stable... ", + "ExtensionChannels.installationCompleteMessage": "complete.", + "ExtensionChannels.downloadingInsidersMessage": "Downloading Insiders Extension... ", + "ExtensionChannels.useStable": "Use Stable", + "ExtensionChannels.promptMessage": "We noticed you are using Visual Studio Code Insiders. Reload to use the Insiders build of the extension.", + "ExtensionChannels.reloadMessage": "Please reload the window switching between insiders channels", + "ExtensionChannels.downloadCompletedOutputMessage": "Insiders build download complete.", + "ExtensionChannels.startingDownloadOutputMessage": "Starting download for Insiders build.", "Interpreters.environmentPromptMessage": "We noticed a new virtual environment has been created. Do you want to select it for the workspace folder?", "DataScience.restartKernelMessage": "Do you want to restart the IPython kernel? All variables will be lost.", "DataScience.restartKernelMessageYes": "Yes", @@ -231,9 +244,9 @@ "Testing.configureTests": "Configure Test Framework", "Testing.disableTests": "Disable Tests", "Common.openOutputPanel": "Show output", - "LanguageService.downloadFailedOutputMessage": "download failed", - "LanguageService.extractionFailedOutputMessage": "extraction failed", - "LanguageService.extractionCompletedOutputMessage": "complete", + "LanguageService.downloadFailedOutputMessage": "Language server download failed", + "LanguageService.extractionFailedOutputMessage": "Language server extraction failed", + "LanguageService.extractionCompletedOutputMessage": "Language server dowload complete", "LanguageService.extractionDoneOutputMessage": "done", "LanguageService.reloadVSCodeIfSeachPathHasChanged": "Search paths have changed for this Python interpreter. Please reload the extension to ensure that the IntelliSense works correctly", "DataScience.variableExplorerNameColumn": "Name", @@ -285,9 +298,9 @@ "DataScience.imageListLabel": "Image", "DataScience.exportImageFailed": "Error exporting image: {0}", "downloading.file": "Downloading {0}...", - "downloading.file.progress": "{0}{1} of {2} KB ({3})", + "downloading.file.progress": "{0}{1} of {2} KB ({3}%)", "DataScience.jupyterDataRateExceeded": "Cannot view variable because data rate exceeded. Please restart your server with a higher data rate limit. For example, --NotebookApp.iopub_data_rate_limit=10000000000.0", - "DataScience.addCellBelowCommandTitle" : "Add cell", - "DataScience.debugCellCommandTitle" : "Debug cell", - "DataScience.variableExplorerDisabledDuringDebugging" : "Variables are not available while debugging." + "DataScience.addCellBelowCommandTitle": "Add cell", + "DataScience.debugCellCommandTitle": "Debug cell", + "DataScience.variableExplorerDisabledDuringDebugging": "Variables are not available while debugging." } diff --git a/src/client/activation/languageServer/downloader.ts b/src/client/activation/languageServer/downloader.ts index 7a1c03de5847..f4f478823a5a 100644 --- a/src/client/activation/languageServer/downloader.ts +++ b/src/client/activation/languageServer/downloader.ts @@ -10,7 +10,7 @@ import { IApplicationShell, IWorkspaceService } from '../../common/application/t import { STANDARD_OUTPUT_CHANNEL } from '../../common/constants'; import '../../common/extensions'; import { IFileSystem } from '../../common/platform/types'; -import { IOutputChannel, Resource, IFileDownloader } from '../../common/types'; +import { IFileDownloader, IOutputChannel, Resource } from '../../common/types'; import { createDeferred } from '../../common/utils/async'; import { Common, LanguageService } from '../../common/utils/localize'; import { StopWatch } from '../../common/utils/stopWatch'; diff --git a/src/client/common/application/applicationEnvironment.ts b/src/client/common/application/applicationEnvironment.ts index 58ac27e28fff..1356a6f1e702 100644 --- a/src/client/common/application/applicationEnvironment.ts +++ b/src/client/common/application/applicationEnvironment.ts @@ -5,11 +5,12 @@ import { inject, injectable } from 'inversify'; import * as path from 'path'; +import { parse } from 'semver'; import * as vscode from 'vscode'; import { IPlatformService } from '../platform/types'; import { ICurrentProcess, IPathUtils } from '../types'; import { OSType } from '../utils/platform'; -import { IApplicationEnvironment } from './types'; +import { Channel, IApplicationEnvironment } from './types'; @injectable() export class ApplicationEnvironment implements IApplicationEnvironment { @@ -18,7 +19,7 @@ export class ApplicationEnvironment implements IApplicationEnvironment { @inject(ICurrentProcess) private readonly process: ICurrentProcess) { } public get userSettingsFile(): string | undefined { - const vscodeFolderName = vscode.env.appName.indexOf('Insider') > 0 ? 'Code - Insiders' : 'Code'; + const vscodeFolderName = this.channel === 'insiders' ? 'Code - Insiders' : 'Code'; switch (this.platform.osType) { case OSType.OSX: return path.join(this.pathUtils.home, 'Library', 'Application Support', vscodeFolderName, 'User', 'settings.json'); @@ -58,4 +59,11 @@ export class ApplicationEnvironment implements IApplicationEnvironment { // tslint:disable-next-line:non-literal-require no-require-imports return require('../../../../package.json'); } + public get channel(): Channel { + return this.appName.indexOf('Insider') > 0 ? 'insiders' : 'stable'; + } + public get extensionChannel(): Channel { + const version = parse(this.packageJson.version); + return !version || version.prerelease.length > 0 ? 'insiders' : 'stable'; + } } diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 974cd8db6608..83ac9a7e6bf5 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -18,6 +18,9 @@ export type CommandsWithoutArgs = keyof ICommandNameWithoutArgumentTypeMapping; * @interface ICommandNameWithoutArgumentTypeMapping */ interface ICommandNameWithoutArgumentTypeMapping { + [Commands.SwitchToInsidersDaily]: []; + [Commands.SwitchToInsidersWeekly]: []; + [Commands.SwitchToStable]: []; [Commands.Set_Interpreter]: []; [Commands.Set_ShebangInterpreter]: []; [Commands.Run_Linter]: []; @@ -61,6 +64,7 @@ interface ICommandNameWithoutArgumentTypeMapping { * @extends {ICommandNameWithoutArgumentTypeMapping} */ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgumentTypeMapping { + ['workbench.extensions.installExtension']: [Uri | 'ms-python.python']; ['setContext']: [string, boolean]; ['revealLine']: [{ lineNumber: number; at: 'top' | 'center' | 'bottom' }]; ['python._loadLanguageServerExtension']: {}[]; diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 98e014df40cf..e420b354e8c4 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -855,6 +855,17 @@ export interface IApplicationEnvironment { * @memberof IApplicationShell */ readonly shell: string | undefined; + /** + * Gets the vscode channel (whether 'insiders' or 'stable'). + */ + readonly channel: Channel; + /** + * Gets the extension channel (whether 'insiders' or 'stable'). + * + * @type {string} + * @memberof IApplicationShell + */ + readonly extensionChannel: Channel; } export const IWebPanelMessageListener = Symbol('IWebPanelMessageListener'); @@ -961,3 +972,5 @@ export interface ILanguageService { */ registerCompletionItemProvider(selector: DocumentSelector, provider: CompletionItemProvider, ...triggerCharacters: string[]): Disposable; } + +export type Channel = 'stable' | 'insiders'; diff --git a/src/client/common/configSettings.ts b/src/client/common/configSettings.ts index 0f4bfe150405..0c461c3e6a02 100644 --- a/src/client/common/configSettings.ts +++ b/src/client/common/configSettings.ts @@ -10,6 +10,7 @@ import { EventName } from '../telemetry/constants'; import { IWorkspaceService } from './application/types'; import { WorkspaceService } from './application/workspace'; import { isTestExecution } from './constants'; +import { ExtensionChannels } from './insidersBuild/types'; import { IS_WINDOWS } from './platform/constants'; import { IAnalysisSettings, @@ -21,7 +22,8 @@ import { ISortImportSettings, ITerminalSettings, ITestingSettings, - IWorkspaceSymbolSettings + IWorkspaceSymbolSettings, + Resource } from './types'; import { debounceSync } from './utils/decorators'; import { SystemVariables } from './variables/systemVariables'; @@ -55,6 +57,7 @@ export class PythonSettings implements IPythonSettings { public analysis!: IAnalysisSettings; public autoUpdateLanguageServer: boolean = true; public datascience!: IDataScienceSettings; + public insidersChannel!: ExtensionChannels; protected readonly changed = new EventEmitter(); private workspaceRoot: Uri; @@ -66,7 +69,9 @@ export class PythonSettings implements IPythonSettings { return this.changed.event; } - constructor(workspaceFolder: Uri | undefined, private readonly interpreterAutoSelectionService: IInterpreterAutoSeletionProxyService, + constructor( + workspaceFolder: Resource, + private readonly interpreterAutoSelectionService: IInterpreterAutoSeletionProxyService, workspace?: IWorkspaceService) { this.workspace = workspace || new WorkspaceService(); this.workspaceRoot = workspaceFolder ? workspaceFolder : Uri.file(__dirname); @@ -356,6 +361,8 @@ export class PythonSettings implements IPythonSettings { } else { this.datascience = dataScienceSettings; } + + this.insidersChannel = pythonSettings.get('insidersChannel')!; } public get pythonPath(): string { diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index 79a9983f61e0..7d3ae01c387a 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -1,103 +1,106 @@ -import { DocumentFilter } from 'vscode'; - -export const PYTHON_LANGUAGE = 'python'; -export const PYTHON: DocumentFilter[] = [ - { scheme: 'file', language: PYTHON_LANGUAGE }, - { scheme: 'untitled', language: PYTHON_LANGUAGE } -]; -export const PYTHON_ALLFILES = [ - { language: PYTHON_LANGUAGE } -]; - -export const PVSC_EXTENSION_ID = 'ms-python.python'; - -export namespace Commands { - export const Set_Interpreter = 'python.setInterpreter'; - export const Set_ShebangInterpreter = 'python.setShebangInterpreter'; - export const Exec_In_Terminal = 'python.execInTerminal'; - export const Exec_Selection_In_Terminal = 'python.execSelectionInTerminal'; - export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell'; - export const Tests_View_UI = 'python.viewTestUI'; - export const Tests_Picker_UI = 'python.selectTestToRun'; - export const Tests_Picker_UI_Debug = 'python.selectTestToDebug'; - export const Tests_Configure = 'python.configureTests'; - export const Tests_Discover = 'python.discoverTests'; - export const Tests_Discovering = 'python.discoveringTests'; - export const Tests_Run_Failed = 'python.runFailedTests'; - export const Sort_Imports = 'python.sortImports'; - export const Tests_Run = 'python.runtests'; - export const Tests_Debug = 'python.debugtests'; - export const Tests_Ask_To_Stop_Test = 'python.askToStopTests'; - export const Tests_Ask_To_Stop_Discovery = 'python.askToStopTestDiscovery'; - export const Tests_Stop = 'python.stopTests'; - export const Test_Reveal_Test_Item = 'python.revealTestItem'; - export const ViewOutput = 'python.viewOutput'; - export const Tests_ViewOutput = 'python.viewTestOutput'; - export const Tests_Select_And_Run_Method = 'python.selectAndRunTestMethod'; - export const Tests_Select_And_Debug_Method = 'python.selectAndDebugTestMethod'; - export const Tests_Select_And_Run_File = 'python.selectAndRunTestFile'; - export const Tests_Run_Current_File = 'python.runCurrentTestFile'; - export const Refactor_Extract_Variable = 'python.refactorExtractVariable'; - export const Refactor_Extract_Method = 'python.refactorExtractMethod'; - export const Update_SparkLibrary = 'python.updateSparkLibrary'; - export const Build_Workspace_Symbols = 'python.buildWorkspaceSymbols'; - export const Start_REPL = 'python.startREPL'; - export const Create_Terminal = 'python.createTerminal'; - export const Set_Linter = 'python.setLinter'; - export const Enable_Linter = 'python.enableLinting'; - export const Run_Linter = 'python.runLinting'; - export const Enable_SourceMap_Support = 'python.enableSourceMapSupport'; - export const navigateToTestFunction = 'navigateToTestFunction'; - export const navigateToTestSuite = 'navigateToTestSuite'; - export const navigateToTestFile = 'navigateToTestFile'; - export const openTestNodeInEditor = 'python.openTestNodeInEditor'; - export const runTestNode = 'python.runTestNode'; - export const debugTestNode = 'python.debugTestNode'; -} -export namespace Octicons { - export const Test_Pass = '$(check)'; - export const Test_Fail = '$(alert)'; - export const Test_Error = '$(x)'; - export const Test_Skip = '$(circle-slash)'; -} - -export const Button_Text_Tests_View_Output = 'View Output'; - -export namespace Text { - export const CodeLensRunUnitTest = 'Run Test'; - export const CodeLensDebugUnitTest = 'Debug Test'; -} -export namespace Delays { - // Max time to wait before aborting the generation of code lenses for unit tests - export const MaxUnitTestCodeLensDelay = 5000; -} - -export namespace LinterErrors { - export namespace pylint { - export const InvalidSyntax = 'E0001'; - } - export namespace prospector { - export const InvalidSyntax = 'F999'; - } - export namespace flake8 { - export const InvalidSyntax = 'E999'; - } -} - -export const STANDARD_OUTPUT_CHANNEL = 'STANDARD_OUTPUT_CHANNEL'; - -export function isTestExecution(): boolean { - return process.env.VSC_PYTHON_CI_TEST === '1' || isUnitTestExecution(); -} - -/** - * Whether we're running unit tests (*.unit.test.ts). - * These tests have a speacial meaning, they run fast. - * @export - * @returns {boolean} - */ -export function isUnitTestExecution(): boolean { - return process.env.VSC_PYTHON_UNIT_TEST === '1'; -} - -export * from '../constants'; +import { DocumentFilter } from 'vscode'; + +export const PYTHON_LANGUAGE = 'python'; +export const PYTHON: DocumentFilter[] = [ + { scheme: 'file', language: PYTHON_LANGUAGE }, + { scheme: 'untitled', language: PYTHON_LANGUAGE } +]; +export const PYTHON_ALLFILES = [ + { language: PYTHON_LANGUAGE } +]; + +export const PVSC_EXTENSION_ID = 'ms-python.python'; + +export namespace Commands { + export const Set_Interpreter = 'python.setInterpreter'; + export const Set_ShebangInterpreter = 'python.setShebangInterpreter'; + export const Exec_In_Terminal = 'python.execInTerminal'; + export const Exec_Selection_In_Terminal = 'python.execSelectionInTerminal'; + export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell'; + export const Tests_View_UI = 'python.viewTestUI'; + export const Tests_Picker_UI = 'python.selectTestToRun'; + export const Tests_Picker_UI_Debug = 'python.selectTestToDebug'; + export const Tests_Configure = 'python.configureTests'; + export const Tests_Discover = 'python.discoverTests'; + export const Tests_Discovering = 'python.discoveringTests'; + export const Tests_Run_Failed = 'python.runFailedTests'; + export const Sort_Imports = 'python.sortImports'; + export const Tests_Run = 'python.runtests'; + export const Tests_Debug = 'python.debugtests'; + export const Tests_Ask_To_Stop_Test = 'python.askToStopTests'; + export const Tests_Ask_To_Stop_Discovery = 'python.askToStopTestDiscovery'; + export const Tests_Stop = 'python.stopTests'; + export const Test_Reveal_Test_Item = 'python.revealTestItem'; + export const ViewOutput = 'python.viewOutput'; + export const Tests_ViewOutput = 'python.viewTestOutput'; + export const Tests_Select_And_Run_Method = 'python.selectAndRunTestMethod'; + export const Tests_Select_And_Debug_Method = 'python.selectAndDebugTestMethod'; + export const Tests_Select_And_Run_File = 'python.selectAndRunTestFile'; + export const Tests_Run_Current_File = 'python.runCurrentTestFile'; + export const Refactor_Extract_Variable = 'python.refactorExtractVariable'; + export const Refactor_Extract_Method = 'python.refactorExtractMethod'; + export const Update_SparkLibrary = 'python.updateSparkLibrary'; + export const Build_Workspace_Symbols = 'python.buildWorkspaceSymbols'; + export const Start_REPL = 'python.startREPL'; + export const Create_Terminal = 'python.createTerminal'; + export const Set_Linter = 'python.setLinter'; + export const Enable_Linter = 'python.enableLinting'; + export const Run_Linter = 'python.runLinting'; + export const Enable_SourceMap_Support = 'python.enableSourceMapSupport'; + export const navigateToTestFunction = 'navigateToTestFunction'; + export const navigateToTestSuite = 'navigateToTestSuite'; + export const navigateToTestFile = 'navigateToTestFile'; + export const openTestNodeInEditor = 'python.openTestNodeInEditor'; + export const runTestNode = 'python.runTestNode'; + export const debugTestNode = 'python.debugTestNode'; + export const SwitchToStable = 'python.switchToStable'; + export const SwitchToInsidersDaily = 'python.switchToInsidersDaily'; + export const SwitchToInsidersWeekly = 'python.switchToInsidersWeekly'; +} +export namespace Octicons { + export const Test_Pass = '$(check)'; + export const Test_Fail = '$(alert)'; + export const Test_Error = '$(x)'; + export const Test_Skip = '$(circle-slash)'; +} + +export const Button_Text_Tests_View_Output = 'View Output'; + +export namespace Text { + export const CodeLensRunUnitTest = 'Run Test'; + export const CodeLensDebugUnitTest = 'Debug Test'; +} +export namespace Delays { + // Max time to wait before aborting the generation of code lenses for unit tests + export const MaxUnitTestCodeLensDelay = 5000; +} + +export namespace LinterErrors { + export namespace pylint { + export const InvalidSyntax = 'E0001'; + } + export namespace prospector { + export const InvalidSyntax = 'F999'; + } + export namespace flake8 { + export const InvalidSyntax = 'E999'; + } +} + +export const STANDARD_OUTPUT_CHANNEL = 'STANDARD_OUTPUT_CHANNEL'; + +export function isTestExecution(): boolean { + return process.env.VSC_PYTHON_CI_TEST === '1' || isUnitTestExecution(); +} + +/** + * Whether we're running unit tests (*.unit.test.ts). + * These tests have a speacial meaning, they run fast. + * @export + * @returns {boolean} + */ +export function isUnitTestExecution(): boolean { + return process.env.VSC_PYTHON_UNIT_TEST === '1'; +} + +export * from '../constants'; diff --git a/src/client/common/insidersBuild/downloadChannelRules.ts b/src/client/common/insidersBuild/downloadChannelRules.ts new file mode 100644 index 000000000000..1c588f6fcc32 --- /dev/null +++ b/src/client/common/insidersBuild/downloadChannelRules.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable, named } from 'inversify'; +import { IExtensionBuildInstaller, INSIDERS_INSTALLER, STABLE_INSTALLER } from '../installer/types'; +import { traceDecorators } from '../logger'; +import { IPersistentStateFactory } from '../types'; +import { IExtensionChannelRule } from './types'; + +export const frequencyForDailyInsidersCheck = 1000 * 60 * 60 * 24; // One day. +export const frequencyForWeeklyInsidersCheck = 1000 * 60 * 60 * 24 * 7; // One week. +export const lastLookUpTimeKey = 'INSIDERS_LAST_LOOK_UP_TIME_KEY'; + +@injectable() +export class ExtensionStableChannelRule implements IExtensionChannelRule { + constructor(@inject(IExtensionBuildInstaller) @named(STABLE_INSTALLER) private readonly stableInstaller: IExtensionBuildInstaller) { } + public async getInstaller(isChannelRuleNew: boolean = false): Promise { + if (isChannelRuleNew) { + // Channel rule has changed to stable, return stable installer + return this.stableInstaller; + } + } +} +@injectable() +export class ExtensionInsidersDailyChannelRule implements IExtensionChannelRule { + constructor( + @inject(IExtensionBuildInstaller) @named(INSIDERS_INSTALLER) private readonly insidersInstaller: IExtensionBuildInstaller, + @inject(IPersistentStateFactory) private readonly persistentStateFactory: IPersistentStateFactory + ) { } + @traceDecorators.error('Error in getting installer for daily channel rule') + public async getInstaller(isChannelRuleNew: boolean): Promise { + if (await this.shouldLookForInsidersBuild(isChannelRuleNew)) { + return this.insidersInstaller; + } + } + private async shouldLookForInsidersBuild(isChannelRuleNew: boolean): Promise { + const lastLookUpTime = this.persistentStateFactory.createGlobalPersistentState(lastLookUpTimeKey, -1); + if (isChannelRuleNew) { + // Channel rule has changed to insiders, look for insiders build + await lastLookUpTime.updateValue(Date.now()); + return true; + } + // If we have not looked for it in the last 24 hours, then look. + if (lastLookUpTime.value === -1 || lastLookUpTime.value + frequencyForDailyInsidersCheck < Date.now()) { + await lastLookUpTime.updateValue(Date.now()); + return true; + } + return false; + } +} +@injectable() +export class ExtensionInsidersWeeklyChannelRule implements IExtensionChannelRule { + constructor( + @inject(IExtensionBuildInstaller) @named(INSIDERS_INSTALLER) private readonly insidersInstaller: IExtensionBuildInstaller, + @inject(IPersistentStateFactory) private readonly persistentStateFactory: IPersistentStateFactory + ) { } + @traceDecorators.error('Error in getting installer for weekly channel rule') + public async getInstaller(isChannelRuleNew: boolean): Promise { + if (await this.shouldLookForInsidersBuild(isChannelRuleNew)) { + return this.insidersInstaller; + } + } + private async shouldLookForInsidersBuild(isChannelRuleNew: boolean): Promise { + const lastLookUpTime = this.persistentStateFactory.createGlobalPersistentState(lastLookUpTimeKey, -1); + if (isChannelRuleNew) { + // Channel rule has changed to insiders, look for insiders build + await lastLookUpTime.updateValue(Date.now()); + return true; + } + // If we have not looked for it in the last week, then look. + if (lastLookUpTime.value === -1 || lastLookUpTime.value + frequencyForWeeklyInsidersCheck < Date.now()) { + await lastLookUpTime.updateValue(Date.now()); + return true; + } + return false; + } +} diff --git a/src/client/common/insidersBuild/downloadChannelService.ts b/src/client/common/insidersBuild/downloadChannelService.ts new file mode 100644 index 000000000000..5ec296a596e1 --- /dev/null +++ b/src/client/common/insidersBuild/downloadChannelService.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { ConfigurationChangeEvent, ConfigurationTarget, Event, EventEmitter } from 'vscode'; +import { IApplicationEnvironment, IWorkspaceService } from '../application/types'; +import { traceDecorators } from '../logger'; +import { IConfigurationService, IDisposable, IDisposableRegistry, IPersistentState, IPersistentStateFactory, IPythonSettings } from '../types'; +import { ExtensionChannel, ExtensionChannels, IExtensionChannelService } from './types'; + +export const insidersChannelSetting: keyof IPythonSettings = 'insidersChannel'; +export const isThisFirstSessionStateKey = 'IS_THIS_FIRST_SESSION_KEY'; + +@injectable() +export class ExtensionChannelService implements IExtensionChannelService { + public readonly isThisFirstSessionState: IPersistentState; + public _onDidChannelChange: EventEmitter = new EventEmitter(); + constructor( + @inject(IApplicationEnvironment) private readonly appEnvironment: IApplicationEnvironment, + @inject(IConfigurationService) private readonly configService: IConfigurationService, + @inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService, + @inject(IPersistentStateFactory) private readonly persistentStateFactory: IPersistentStateFactory, + @inject(IDisposableRegistry) disposables: IDisposable[] + ) { + this.isThisFirstSessionState = this.persistentStateFactory.createGlobalPersistentState(isThisFirstSessionStateKey, true); + disposables.push(this.workspaceService.onDidChangeConfiguration(this.onDidChangeConfiguration.bind(this))); + } + public async getChannel(): Promise { + const settings = this.workspaceService.getConfiguration('python').inspect(insidersChannelSetting); + if (!settings) { + throw new Error(`WorkspaceConfiguration.inspect returns 'undefined' for setting 'python.${insidersChannelSetting}'`); + } + if (settings.globalValue === undefined) { + const isThisFirstSession = this.isThisFirstSessionState.value; + await this.isThisFirstSessionState.updateValue(false); + // "Official" VSC default setting value is stable. To keep the official value to be in sync with what is being used, + // Use Insiders default as 'InsidersWeekly' only for the first session (insiders gets installed for the first session). + return this.appEnvironment.channel === 'insiders' && isThisFirstSession ? ExtensionChannel.insidersDefaultForTheFirstSession : 'Stable'; + } + return settings.globalValue; + } + + @traceDecorators.error('Updating channel failed') + public async updateChannel(value: ExtensionChannels): Promise { + await this.configService.updateSetting(insidersChannelSetting, value, undefined, ConfigurationTarget.Global); + } + + public get onDidChannelChange(): Event { + return this._onDidChannelChange.event; + } + + public async onDidChangeConfiguration(event: ConfigurationChangeEvent) { + if (event.affectsConfiguration(`python.${insidersChannelSetting}`)) { + const settings = this.configService.getSettings(); + this._onDidChannelChange.fire(settings.insidersChannel); + } + } +} diff --git a/src/client/common/insidersBuild/insidersExtensionPrompt.ts b/src/client/common/insidersBuild/insidersExtensionPrompt.ts new file mode 100644 index 000000000000..252687dabf3c --- /dev/null +++ b/src/client/common/insidersBuild/insidersExtensionPrompt.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { sendTelemetryEvent } from '../../telemetry'; +import { EventName } from '../../telemetry/constants'; +import { IApplicationShell, ICommandManager } from '../application/types'; +import { traceDecorators } from '../logger'; +import { IPersistentState, IPersistentStateFactory } from '../types'; +import { Common, ExtensionChannels } from '../utils/localize'; +import { noop } from '../utils/misc'; +import { ExtensionChannel, IExtensionChannelService, IInsiderExtensionPrompt } from './types'; + +export const insidersPromptStateKey = 'INSIDERS_PROMPT_STATE_KEY'; +@injectable() +export class InsidersExtensionPrompt implements IInsiderExtensionPrompt { + public readonly hasUserBeenNotified: IPersistentState; + public reloadPromptDisabled: boolean = false; + constructor( + @inject(IApplicationShell) private readonly appShell: IApplicationShell, + @inject(IExtensionChannelService) private readonly insidersDownloadChannelService: IExtensionChannelService, + @inject(ICommandManager) private readonly cmdManager: ICommandManager, + @inject(IPersistentStateFactory) private readonly persistentStateFactory: IPersistentStateFactory + ) { + this.hasUserBeenNotified = this.persistentStateFactory.createGlobalPersistentState(insidersPromptStateKey, false); + } + + @traceDecorators.error('Error in prompting to install insiders') + public async notifyToInstallInsiders(): Promise { + const prompts = [ExtensionChannels.useStable(), Common.reload()]; + const telemetrySelections: ['Use Stable', 'Reload'] = ['Use Stable', 'Reload']; + const selection = await this.appShell.showInformationMessage(ExtensionChannels.promptMessage(), ...prompts); + sendTelemetryEvent(EventName.INSIDERS_PROMPT, undefined, { selection: selection ? telemetrySelections[prompts.indexOf(selection)] : undefined }); + await this.hasUserBeenNotified.updateValue(true); + this.reloadPromptDisabled = true; + if (!selection) { + // Insiders is already installed, but the official default setting is still Stable. Update the setting to be in sync with what is installed. + return this.insidersDownloadChannelService.updateChannel(ExtensionChannel.insidersDefaultForTheFirstSession); + } + if (selection === ExtensionChannels.useStable()) { + await this.insidersDownloadChannelService.updateChannel(ExtensionChannel.stable); + } else if (selection === Common.reload()) { + await this.insidersDownloadChannelService.updateChannel(ExtensionChannel.insidersDefaultForTheFirstSession); + this.cmdManager.executeCommand('workbench.action.reloadWindow').then(noop); + } + } + + @traceDecorators.error('Error in prompting to reload') + public async promptToReload(): Promise { + if (this.reloadPromptDisabled) { + this.reloadPromptDisabled = false; + return; + } + const selection = await this.appShell.showInformationMessage(ExtensionChannels.reloadMessage(), Common.reload()); + sendTelemetryEvent(EventName.INSIDERS_RELOAD_PROMPT, undefined, { selection: selection ? 'Reload' : undefined }); + if (!selection) { + return; + } + if (selection === Common.reload()) { + this.cmdManager.executeCommand('workbench.action.reloadWindow').then(noop); + } + } +} diff --git a/src/client/common/insidersBuild/insidersExtensionService.ts b/src/client/common/insidersBuild/insidersExtensionService.ts new file mode 100644 index 000000000000..092187d8cd5a --- /dev/null +++ b/src/client/common/insidersBuild/insidersExtensionService.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { IExtensionActivationService } from '../../../client/activation/types'; +import { IServiceContainer } from '../../ioc/types'; +import { Channel, IApplicationEnvironment, ICommandManager } from '../application/types'; +import { Commands } from '../constants'; +import { traceDecorators } from '../logger'; +import { IDisposable, IDisposableRegistry, Resource } from '../types'; +import { ExtensionChannels, IExtensionChannelRule, IExtensionChannelService, IInsiderExtensionPrompt } from './types'; + +@injectable() +export class InsidersExtensionService implements IExtensionActivationService { + public activatedOnce: boolean = false; + constructor( + @inject(IExtensionChannelService) private readonly extensionChannelService: IExtensionChannelService, + @inject(IInsiderExtensionPrompt) private readonly insidersPrompt: IInsiderExtensionPrompt, + @inject(IApplicationEnvironment) private readonly appEnvironment: IApplicationEnvironment, + @inject(ICommandManager) private readonly cmdManager: ICommandManager, + @inject(IServiceContainer) private readonly serviceContainer: IServiceContainer, + @inject(IDisposableRegistry) public readonly disposables: IDisposable[] + ) { } + + public async activate(_resource: Resource) { + if (this.activatedOnce) { + return; + } + this.registerCommandsAndHandlers(); + this.activatedOnce = true; + const installChannel = await this.extensionChannelService.getChannel(); + const newExtensionChannel: Channel = installChannel === 'Stable' ? 'stable' : 'insiders'; + this.handleChannel(installChannel, newExtensionChannel !== this.appEnvironment.extensionChannel).ignoreErrors(); + } + + @traceDecorators.error('Handling channel failed') + public async handleChannel(installChannel: ExtensionChannels, didChannelChange: boolean = false): Promise { + const channelRule = this.serviceContainer.get(IExtensionChannelRule, installChannel); + const buildInstaller = await channelRule.getInstaller(didChannelChange); + if (!buildInstaller) { + return; + } + await buildInstaller.install(); + await this.choosePromptAndDisplay(installChannel, didChannelChange); + } + + /** + * Choose between the following prompts and display the right one + * * 'Reload prompt' - Ask users to reload on channel change + * * 'Notify to install insiders prompt' - Only when using VSC insiders and if they have not been notified before (usually the first session) + */ + public async choosePromptAndDisplay(installChannel: ExtensionChannels, didChannelChange: boolean): Promise { + if (this.appEnvironment.channel === 'insiders' && installChannel !== 'Stable' && !this.insidersPrompt.hasUserBeenNotified.value) { + // If user is using VS Code Insiders, channel is `Insiders*` and user has not been notified, then notify user + await this.insidersPrompt.notifyToInstallInsiders(); + } else if (didChannelChange) { + await this.insidersPrompt.promptToReload(); + } + } + + public registerCommandsAndHandlers(): void { + this.disposables.push(this.extensionChannelService.onDidChannelChange(channel => this.handleChannel(channel, true))); + this.disposables.push(this.cmdManager.registerCommand(Commands.SwitchToStable, () => this.extensionChannelService.updateChannel('Stable'))); + this.disposables.push(this.cmdManager.registerCommand(Commands.SwitchToInsidersDaily, () => this.extensionChannelService.updateChannel('InsidersDaily'))); + this.disposables.push(this.cmdManager.registerCommand(Commands.SwitchToInsidersWeekly, () => this.extensionChannelService.updateChannel('InsidersWeekly'))); + } +} diff --git a/src/client/common/insidersBuild/types.ts b/src/client/common/insidersBuild/types.ts new file mode 100644 index 000000000000..582776175f45 --- /dev/null +++ b/src/client/common/insidersBuild/types.ts @@ -0,0 +1,45 @@ +import { Event } from 'vscode'; +import { IExtensionBuildInstaller } from '../installer/types'; +import { IPersistentState } from '../types'; + +export const IExtensionChannelRule = Symbol('IExtensionChannelRule'); +export interface IExtensionChannelRule { + /** + * Returns the installer corresponding to an extension channel (`Stable`, `InsidersWeekly`, etc...). + * Return value is `undefined` when no extension build is required to be installed for the channel. + * @param isChannelRuleNew Carries boolean `true` if insiders channel just changed to this channel rule + */ + getInstaller(isChannelRuleNew?: boolean): Promise; +} + +export const IExtensionChannelService = Symbol('IExtensionChannelService'); +export interface IExtensionChannelService { + readonly onDidChannelChange: Event; + getChannel(): Promise; + updateChannel(value: ExtensionChannels): Promise; +} + +export const IInsiderExtensionPrompt = Symbol('IInsiderExtensionPrompt'); +export interface IInsiderExtensionPrompt { + /** + * Carries boolean `false` for the first session when user has not been notified. + * Gets updated to `true` once user has been prompted to install insiders. + */ + readonly hasUserBeenNotified: IPersistentState; + notifyToInstallInsiders(): Promise; + promptToReload(): Promise; +} + +/** + * Note the values in this enum must belong to `ExtensionChannels` type + */ +export enum ExtensionChannel { + stable = 'Stable', + weekly = 'InsidersWeekly', + daily = 'InsidersDaily', + /** + * The default value for insiders for the first session. The default value is `Stable` from the second session onwards + */ + insidersDefaultForTheFirstSession = 'InsidersWeekly' +} +export type ExtensionChannels = 'Stable' | 'InsidersWeekly' | 'InsidersDaily'; diff --git a/src/client/common/installer/extensionBuildInstaller.ts b/src/client/common/installer/extensionBuildInstaller.ts new file mode 100644 index 000000000000..4b909c05aac0 --- /dev/null +++ b/src/client/common/installer/extensionBuildInstaller.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable, named } from 'inversify'; +import { Uri } from 'vscode'; +import { ICommandManager } from '../application/types'; +import { PVSC_EXTENSION_ID, STANDARD_OUTPUT_CHANNEL } from '../constants'; +import { traceDecorators } from '../logger'; +import { IFileSystem } from '../platform/types'; +import { IFileDownloader, IOutputChannel } from '../types'; +import { ExtensionChannels } from '../utils/localize'; +import { IExtensionBuildInstaller } from './types'; + +export const developmentBuildUri = 'https://pvsc.blob.core.windows.net/extension-builds/ms-python-insiders.vsix'; +export const vsixFileExtension = '.vsix'; + +@injectable() +export class StableBuildInstaller implements IExtensionBuildInstaller { + constructor( + @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly output: IOutputChannel, + @inject(ICommandManager) private readonly cmdManager: ICommandManager + ) { } + + @traceDecorators.error('Installing stable build of extension failed') + public async install(): Promise { + this.output.append(ExtensionChannels.installingStableMessage()); + await this.cmdManager.executeCommand('workbench.extensions.installExtension', PVSC_EXTENSION_ID); + this.output.appendLine(ExtensionChannels.installationCompleteMessage()); + } +} + +@injectable() +export class InsidersBuildInstaller implements IExtensionBuildInstaller { + constructor( + @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly output: IOutputChannel, + @inject(IFileDownloader) private readonly fileDownloader: IFileDownloader, + @inject(IFileSystem) private readonly fs: IFileSystem, + @inject(ICommandManager) private readonly cmdManager: ICommandManager) { } + + @traceDecorators.error('Installing insiders build of extension failed') + public async install(): Promise { + const vsixFilePath = await this.downloadInsiders(); + this.output.append(ExtensionChannels.installingInsidersMessage()); + await this.cmdManager.executeCommand('workbench.extensions.installExtension', Uri.file(vsixFilePath)); + this.output.appendLine(ExtensionChannels.installationCompleteMessage()); + await this.fs.deleteFile(vsixFilePath); + } + + @traceDecorators.error('Downloading insiders build of extension failed') + public async downloadInsiders(): Promise { + this.output.appendLine(ExtensionChannels.startingDownloadOutputMessage()); + const downloadOptions = { + extension: vsixFileExtension, + outputChannel: this.output, + progressMessagePrefix: ExtensionChannels.downloadingInsidersMessage() + }; + return this.fileDownloader.downloadFile(developmentBuildUri, downloadOptions).then(file => { + this.output.appendLine(ExtensionChannels.downloadCompletedOutputMessage()); + return file; + }); + } +} diff --git a/src/client/common/installer/serviceRegistry.ts b/src/client/common/installer/serviceRegistry.ts index 0083fada0fd1..0d0ca786e3c6 100644 --- a/src/client/common/installer/serviceRegistry.ts +++ b/src/client/common/installer/serviceRegistry.ts @@ -8,12 +8,13 @@ import { WebPanelProvider } from '../application/webPanelProvider'; import { ProductType } from '../types'; import { InstallationChannelManager } from './channelManager'; import { CondaInstaller } from './condaInstaller'; +import { InsidersBuildInstaller, StableBuildInstaller } from './extensionBuildInstaller'; import { PipEnvInstaller } from './pipEnvInstaller'; import { PipInstaller } from './pipInstaller'; import { PoetryInstaller } from './poetryInstaller'; -import { CTagsProductPathService, DataScienceProductPathService, FormatterProductPathService, LinterProductPathService, RefactoringLibraryProductPathService, TestFrameworkProductPathService } from './productPath'; +import { CTagsProductPathService, FormatterProductPathService, LinterProductPathService, RefactoringLibraryProductPathService, TestFrameworkProductPathService, DataScienceProductPathService } from './productPath'; import { ProductService } from './productService'; -import { IInstallationChannelManager, IModuleInstaller, IProductPathService, IProductService } from './types'; +import { IExtensionBuildInstaller, IInstallationChannelManager, IModuleInstaller, INSIDERS_INSTALLER, IProductPathService, IProductService, STABLE_INSTALLER } from './types'; export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IModuleInstaller, CondaInstaller); @@ -21,6 +22,8 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IModuleInstaller, PipEnvInstaller); serviceManager.addSingleton(IModuleInstaller, PoetryInstaller); serviceManager.addSingleton(IInstallationChannelManager, InstallationChannelManager); + serviceManager.addSingleton(IExtensionBuildInstaller, StableBuildInstaller, STABLE_INSTALLER); + serviceManager.addSingleton(IExtensionBuildInstaller, InsidersBuildInstaller, INSIDERS_INSTALLER); serviceManager.addSingleton(IProductService, ProductService); serviceManager.addSingleton(IProductPathService, CTagsProductPathService, ProductType.WorkspaceSymbols); diff --git a/src/client/common/installer/types.ts b/src/client/common/installer/types.ts index c0521ee9386e..5fbc22e1c75a 100644 --- a/src/client/common/installer/types.ts +++ b/src/client/common/installer/types.ts @@ -1,34 +1,41 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { Uri } from 'vscode'; -import { Product, ProductType } from '../types'; - -export const IModuleInstaller = Symbol('IModuleInstaller'); -export interface IModuleInstaller { - readonly displayName: string; - readonly priority: number; - installModule(name: string, resource?: Uri): Promise; - isSupported(resource?: Uri): Promise; -} - -export const IPythonInstallation = Symbol('IPythonInstallation'); -export interface IPythonInstallation { - checkInstallation(): Promise; -} - -export const IInstallationChannelManager = Symbol('IInstallationChannelManager'); -export interface IInstallationChannelManager { - getInstallationChannel(product: Product, resource?: Uri): Promise; - getInstallationChannels(resource?: Uri): Promise; - showNoInstallersMessage(): void; -} -export const IProductService = Symbol('IProductService'); -export interface IProductService { - getProductType(product: Product): ProductType; -} -export const IProductPathService = Symbol('IProductPathService'); -export interface IProductPathService { - getExecutableNameFromSettings(product: Product, resource?: Uri): string; - isExecutableAModule(product: Product, resource?: Uri): Boolean; -} +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Uri } from 'vscode'; +import { Product, ProductType } from '../types'; + +export const IModuleInstaller = Symbol('IModuleInstaller'); +export interface IModuleInstaller { + readonly displayName: string; + readonly priority: number; + installModule(name: string, resource?: Uri): Promise; + isSupported(resource?: Uri): Promise; +} + +export const IPythonInstallation = Symbol('IPythonInstallation'); +export interface IPythonInstallation { + checkInstallation(): Promise; +} + +export const IInstallationChannelManager = Symbol('IInstallationChannelManager'); +export interface IInstallationChannelManager { + getInstallationChannel(product: Product, resource?: Uri): Promise; + getInstallationChannels(resource?: Uri): Promise; + showNoInstallersMessage(): void; +} +export const IProductService = Symbol('IProductService'); +export interface IProductService { + getProductType(product: Product): ProductType; +} +export const IProductPathService = Symbol('IProductPathService'); +export interface IProductPathService { + getExecutableNameFromSettings(product: Product, resource?: Uri): string; + isExecutableAModule(product: Product, resource?: Uri): Boolean; +} + +export const INSIDERS_INSTALLER = 'INSIDERS_INSTALLER'; +export const STABLE_INSTALLER = 'STABLE_INSTALLER'; +export const IExtensionBuildInstaller = Symbol('IExtensionBuildInstaller'); +export interface IExtensionBuildInstaller { + install(): Promise; +} diff --git a/src/client/common/logger.ts b/src/client/common/logger.ts index f333642e8f8e..57f436a13cd3 100644 --- a/src/client/common/logger.ts +++ b/src/client/common/logger.ts @@ -131,7 +131,7 @@ function initializeConsoleLogger() { constructor(options?: any) { super(options); } - public log?(info: { level: string; message: string;[formattedMessage]: string }, next: () => void): any { + public log?(info: { level: string; message: string; [formattedMessage]: string }, next: () => void): any { setImmediate(() => this.emit('logged', info)); logToConsole(info.level as any, info[formattedMessage] || info.message); if (next) { diff --git a/src/client/common/net/fileDownloader.ts b/src/client/common/net/fileDownloader.ts index c6fb08a587f2..53162c965304 100644 --- a/src/client/common/net/fileDownloader.ts +++ b/src/client/common/net/fileDownloader.ts @@ -21,7 +21,7 @@ export class FileDownloader implements IFileDownloader { } public async downloadFile(uri: string, options: DownloadOptions): Promise { if (options.outputChannel) { - options.outputChannel.append(Http.downloadingFile().format(uri)); + options.outputChannel.appendLine(Http.downloadingFile().format(uri)); } const tempFile = await this.fs.createTemporaryFile(options.extension); diff --git a/src/client/common/serviceRegistry.ts b/src/client/common/serviceRegistry.ts index 5b6f9aa19c56..aafd221e89b6 100644 --- a/src/client/common/serviceRegistry.ts +++ b/src/client/common/serviceRegistry.ts @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { IExtensionActivationService } from '../activation/types'; import { IFileDownloader, IHttpClient } from '../common/types'; import { IServiceManager } from '../ioc/types'; import { ImportTracker } from '../telemetry/importTracker'; @@ -30,6 +31,11 @@ import { CryptoUtils } from './crypto'; import { EditorUtils } from './editor'; import { ExperimentsManager } from './experiments'; import { FeatureDeprecationManager } from './featureDeprecationManager'; +import { ExtensionInsidersDailyChannelRule, ExtensionInsidersWeeklyChannelRule, ExtensionStableChannelRule } from './insidersBuild/downloadChannelRules'; +import { ExtensionChannelService } from './insidersBuild/downloadChannelService'; +import { InsidersExtensionPrompt } from './insidersBuild/insidersExtensionPrompt'; +import { InsidersExtensionService } from './insidersBuild/insidersExtensionService'; +import { ExtensionChannel, IExtensionChannelRule, IExtensionChannelService, IInsiderExtensionPrompt } from './insidersBuild/types'; import { ProductInstaller } from './installer/productInstaller'; import { LiveShareApi } from './liveshare/liveshare'; import { Logger } from './logger'; @@ -138,4 +144,10 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IShellDetector, SettingsShellDetector); serviceManager.addSingleton(IShellDetector, UserEnvironmentShellDetector); serviceManager.addSingleton(IShellDetector, VSCEnvironmentShellDetector); + serviceManager.addSingleton(IInsiderExtensionPrompt, InsidersExtensionPrompt); + serviceManager.addSingleton(IExtensionActivationService, InsidersExtensionService); + serviceManager.addSingleton(IExtensionChannelService, ExtensionChannelService); + serviceManager.addSingleton(IExtensionChannelRule, ExtensionStableChannelRule, ExtensionChannel.stable); + serviceManager.addSingleton(IExtensionChannelRule, ExtensionInsidersDailyChannelRule, ExtensionChannel.daily); + serviceManager.addSingleton(IExtensionChannelRule, ExtensionInsidersWeeklyChannelRule, ExtensionChannel.weekly); } diff --git a/src/client/common/types.ts b/src/client/common/types.ts index 0ed6a7d89842..508f438b3fea 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -6,6 +6,7 @@ import { Socket } from 'net'; import { Request as RequestResult } from 'request'; import { ConfigurationTarget, DiagnosticSeverity, Disposable, DocumentSymbolProvider, Event, Extension, ExtensionContext, OutputChannel, Uri, WorkspaceEdit } from 'vscode'; import { CommandsWithoutArgs } from './application/commands'; +import { ExtensionChannels } from './insidersBuild/types'; import { EnvironmentVariables } from './variables/types'; export const IOutputChannel = Symbol('IOutputChannel'); export interface IOutputChannel extends OutputChannel { } @@ -151,6 +152,7 @@ export interface IPythonSettings { readonly condaPath: string; readonly pipenvPath: string; readonly poetryPath: string; + readonly insidersChannel: ExtensionChannels; readonly downloadLanguageServer: boolean; readonly jediEnabled: boolean; readonly jediPath: string; diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index a8cdc34cda1b..5647d54f3fa5 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -32,6 +32,7 @@ export namespace Common { export const noIWillDoItLater = localize('Common.noIWillDoItLater', 'No, I will do it later'); export const notNow = localize('Common.notNow', 'Not now'); export const doNotShowAgain = localize('Common.doNotShowAgain', 'Do not show again'); + export const reload = localize('Common.reload', 'Reload'); } export namespace LanguageService { @@ -41,9 +42,9 @@ export namespace LanguageService { export const lsFailedToStart = localize('LanguageService.lsFailedToStart', 'We encountered an issue starting the Language Server. Reverting to the alternative, Jedi. Check the Python output panel for details.'); export const lsFailedToDownload = localize('LanguageService.lsFailedToDownload', 'We encountered an issue downloading the Language Server. Reverting to the alternative, Jedi. Check the Python output panel for details.'); export const lsFailedToExtract = localize('LanguageService.lsFailedToExtract', 'We encountered an issue extracting the Language Server. Reverting to the alternative, Jedi. Check the Python output panel for details.'); - export const downloadFailedOutputMessage = localize('LanguageService.downloadFailedOutputMessage', 'download failed.'); - export const extractionFailedOutputMessage = localize('LanguageService.extractionFailedOutputMessage', 'extraction failed.'); - export const extractionCompletedOutputMessage = localize('LanguageService.extractionCompletedOutputMessage', 'complete.'); + export const downloadFailedOutputMessage = localize('LanguageService.downloadFailedOutputMessage', 'Language server download failed.'); + export const extractionFailedOutputMessage = localize('LanguageService.extractionFailedOutputMessage', 'Language server extraction failed.'); + export const extractionCompletedOutputMessage = localize('LanguageService.extractionCompletedOutputMessage', 'Language server dowload complete.'); export const extractionDoneOutputMessage = localize('LanguageService.extractionDoneOutputMessage', 'done.'); export const reloadVSCodeIfSeachPathHasChanged = localize('LanguageService.reloadVSCodeIfSeachPathHasChanged', 'Search paths have changed for this Python interpreter. Please reload the extension to ensure that the IntelliSense works correctly.'); @@ -51,7 +52,7 @@ export namespace LanguageService { export namespace Http { export const downloadingFile = localize('downloading.file', 'Downloading {0}...'); - export const downloadingFileProgress = localize('downloading.file.progress', '{0}{1} of {2} KB ({3})'); + export const downloadingFileProgress = localize('downloading.file.progress', '{0}{1} of {2} KB ({3}%)'); } export namespace Experiments { export const inGroup = localize('Experiments.inGroup', 'User belongs to experiment group \'{0}\''); @@ -62,6 +63,17 @@ export namespace Interpreters { export const environmentPromptMessage = localize('Interpreters.environmentPromptMessage', 'We noticed a new virtual environment has been created. Do you want to select it for the workspace folder?'); export const selectInterpreterTip = localize('Interpreters.selectInterpreterTip', 'Tip: you can change the Python interpreter used by the Python extension by clicking on the Python version in the status bar'); } +export namespace ExtensionChannels { + export const useStable = localize('ExtensionChannels.useStable', 'Use Stable'); + export const promptMessage = localize('ExtensionChannels.promptMessage', 'We noticed you are using Visual Studio Code ExtensionChannels. Reload to use the Insiders build of the extension.'); + export const reloadMessage = localize('ExtensionChannels.reloadMessage', 'Please reload the window switching between insiders channels'); + export const downloadCompletedOutputMessage = localize('ExtensionChannels.downloadCompletedOutputMessage', 'Insiders build download complete.'); + export const startingDownloadOutputMessage = localize('ExtensionChannels.startingDownloadOutputMessage', 'Starting download for Insiders build.'); + export const downloadingInsidersMessage = localize('ExtensionChannels.downloadingInsidersMessage', 'Downloading Insiders Extension... '); + export const installingInsidersMessage = localize('ExtensionChannels.installingInsidersMessage', 'Installing Insiders build of extension... '); + export const installingStableMessage = localize('ExtensionChannels.installingStableMessage', 'Installing Stable build of extension... '); + export const installationCompleteMessage = localize('ExtensionChannels.installationCompleteMessage', 'complete.'); +} export namespace Logging { export const currentWorkingDirectory = localize('Logging.CurrentWorkingDirectory', 'cwd:'); diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index e86486fd0f5f..32c5048e16c0 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -31,6 +31,8 @@ export enum EventName { PYTHON_INTERPRETER_ACTIVATION_FOR_TERMINAL = 'PYTHON_INTERPRETER_ACTIVATION_FOR_TERMINAL', TERMINAL_SHELL_IDENTIFICATION = 'TERMINAL_SHELL_IDENTIFICATION', PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT = 'PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT', + INSIDERS_RELOAD_PROMPT = 'INSIDERS_RELOAD_PROMPT', + INSIDERS_PROMPT = 'INSIDERS_PROMPT', ENVFILE_VARIABLE_SUBSTITUTION = 'ENVFILE_VARIABLE_SUBSTITUTION', WORKSPACE_SYMBOLS_BUILD = 'WORKSPACE_SYMBOLS.BUILD', WORKSPACE_SYMBOLS_GO_TO = 'WORKSPACE_SYMBOLS.GO_TO', diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 7c4b4404caf7..6d7238d4e8bb 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -298,6 +298,8 @@ export interface IEventNamePropertyMapping { [EventName.PYTHON_INTERPRETER_AUTO_SELECTION]: InterpreterAutoSelection; [EventName.PYTHON_INTERPRETER_DISCOVERY]: InterpreterDiscovery; [EventName.PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT]: { selection: 'Yes' | 'No' | 'Ignore' | undefined }; + [EventName.INSIDERS_PROMPT]: { selection: 'Use Stable' | 'Reload' | undefined }; + [EventName.INSIDERS_RELOAD_PROMPT]: { selection: 'Reload' | undefined }; [EventName.PYTHON_LANGUAGE_SERVER_SWITCHED]: { change: 'Switch to Jedi from LS' | 'Switch to LS from Jedi' }; [EventName.PYTHON_LANGUAGE_SERVER_DOWNLOADED]: LanguageServerVersionTelemetry; [EventName.PYTHON_LANGUAGE_SERVER_ENABLED]: never | undefined; diff --git a/src/test/common/configSettings/configSettings.unit.test.ts b/src/test/common/configSettings/configSettings.unit.test.ts index 327e6ef91361..fd96b4ce5e3b 100644 --- a/src/test/common/configSettings/configSettings.unit.test.ts +++ b/src/test/common/configSettings/configSettings.unit.test.ts @@ -3,6 +3,8 @@ 'use strict'; +// tslint:disable:no-any + import { expect } from 'chai'; import * as path from 'path'; import * as TypeMoq from 'typemoq'; @@ -27,7 +29,7 @@ import { noop } from '../../../client/common/utils/misc'; import { MockAutoSelectionService } from '../../mocks/autoSelector'; // tslint:disable-next-line:max-func-body-length -suite('Python Settings', () => { +suite('Python Settings', async () => { class CustomPythonSettings extends PythonSettings { // tslint:disable-next-line:no-unnecessary-override public update(pythonSettings: WorkspaceConfiguration) { @@ -46,7 +48,7 @@ suite('Python Settings', () => { function initializeConfig(sourceSettings: PythonSettings) { // string settings - for (const name of ['pythonPath', 'venvPath', 'condaPath', 'pipenvPath', 'envFile', 'poetryPath']) { + for (const name of ['pythonPath', 'venvPath', 'condaPath', 'pipenvPath', 'envFile', 'poetryPath', 'insidersChannel']) { config.setup(c => c.get(name)) // tslint:disable-next-line:no-any .returns(() => (sourceSettings as any)[name]); @@ -105,6 +107,37 @@ suite('Python Settings', () => { .returns(() => sourceSettings.datascience); } + function testIfValueIsUpdated(settingName: string, value: any) { + test(`${settingName} updated`, async () => { + expected.pythonPath = 'python3'; + (expected as any)[settingName] = value; + initializeConfig(expected); + + settings.update(config.object); + + expect((settings as any)[settingName]).to.be.equal((expected as any)[settingName]); + config.verifyAll(); + }); + } + + suite('String settings', async () => { + ['pythonPath', 'venvPath', 'condaPath', 'pipenvPath', 'envFile', 'poetryPath', 'insidersChannel'].forEach(async settingName => { + testIfValueIsUpdated(settingName, 'stringValue'); + }); + }); + + suite('Boolean settings', async () => { + ['downloadLanguageServer', 'jediEnabled', 'autoUpdateLanguageServer', 'globalModuleInstallation'].forEach(async settingName => { + testIfValueIsUpdated(settingName, true); + }); + }); + + suite('Number settings', async () => { + ['jediMemoryLimit'].forEach(async settingName => { + testIfValueIsUpdated(settingName, 1001); + }); + }); + test('condaPath updated', () => { expected.pythonPath = 'python3'; expected.condaPath = 'spam'; @@ -119,7 +152,7 @@ suite('Python Settings', () => { config.verifyAll(); }); - test('condaPath (relative to home) updated', () => { + test('condaPath (relative to home) updated', async () => { expected.pythonPath = 'python3'; expected.condaPath = path.join('~', 'anaconda3', 'bin', 'conda'); initializeConfig(expected); diff --git a/src/test/common/insidersBuild/downloadChannelRules.unit.test.ts b/src/test/common/insidersBuild/downloadChannelRules.unit.test.ts new file mode 100644 index 000000000000..aeb9417b3c9e --- /dev/null +++ b/src/test/common/insidersBuild/downloadChannelRules.unit.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any + +import { assert, expect } from 'chai'; +import { instance, mock, when } from 'ts-mockito'; +import * as TypeMoq from 'typemoq'; +import { ExtensionInsidersDailyChannelRule, ExtensionInsidersWeeklyChannelRule, ExtensionStableChannelRule, frequencyForDailyInsidersCheck, frequencyForWeeklyInsidersCheck, lastLookUpTimeKey } from '../../../client/common/insidersBuild/downloadChannelRules'; +import { InsidersBuildInstaller, StableBuildInstaller } from '../../../client/common/installer/extensionBuildInstaller'; +import { PersistentStateFactory } from '../../../client/common/persistentState'; +import { IPersistentState, IPersistentStateFactory } from '../../../client/common/types'; + +suite('Download channel rules - ExtensionStableChannelRule', () => { + let stableInstaller: StableBuildInstaller; + let stableChannelRule: ExtensionStableChannelRule; + setup(() => { + stableInstaller = new StableBuildInstaller(undefined as any, undefined as any); + stableChannelRule = new ExtensionStableChannelRule(stableInstaller); + }); + + test('If insiders channel rule is new, return installer for stable build', async () => { + const result = await stableChannelRule.getInstaller(true); + assert.instanceOf(result, StableBuildInstaller, 'Not looking for the correct build'); + }); + test('If insiders channel rule is not new, do not return any installer', async () => { + const result = await stableChannelRule.getInstaller(); + expect(result).to.equal(undefined, 'Should not look for any installer'); + }); +}); + +suite('Download channel rules - ExtensionInsidersDailyChannelRule', () => { + let insidersInstaller: InsidersBuildInstaller; + let persistentStateFactory: IPersistentStateFactory; + let lastLookUpTime: TypeMoq.IMock>; + let insidersDailyChannelRule: ExtensionInsidersDailyChannelRule; + setup(() => { + // tslint:disable-next-line:no-any + insidersInstaller = new InsidersBuildInstaller(undefined as any, undefined as any, undefined as any, undefined as any); + persistentStateFactory = mock(PersistentStateFactory); + lastLookUpTime = TypeMoq.Mock.ofType>(); + when(persistentStateFactory.createGlobalPersistentState(lastLookUpTimeKey, -1)).thenReturn(lastLookUpTime.object); + insidersDailyChannelRule = new ExtensionInsidersDailyChannelRule(insidersInstaller, instance(persistentStateFactory)); + }); + + test('If insiders channel rule is new, update look up time and return installer for insiders build', async () => { + lastLookUpTime + .setup(l => l.updateValue(TypeMoq.It.isAnyNumber())) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + const result = await insidersDailyChannelRule.getInstaller(true); + lastLookUpTime.verifyAll(); + assert.instanceOf(result, InsidersBuildInstaller, 'Not looking for the correct build'); + }); + suite('If insiders channel rule is not new', async () => { + test('Update look up time and return installer for insiders build if looking for insiders the first time', async () => { + lastLookUpTime + .setup(l => l.updateValue(TypeMoq.It.isAnyNumber())) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + lastLookUpTime + .setup(l => l.value) + .returns(() => -1) + .verifiable(TypeMoq.Times.atLeastOnce()); + const result = await insidersDailyChannelRule.getInstaller(false); + lastLookUpTime.verifyAll(); + assert.instanceOf(result, InsidersBuildInstaller, 'Not looking for the correct build'); + }); + test('Update look up time and return installer for insiders build if looking for insiders after 24 hrs of last lookup time', async () => { + lastLookUpTime + .setup(l => l.updateValue(TypeMoq.It.isAnyNumber())) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + lastLookUpTime + .setup(l => l.value) + .returns(() => Date.now() - 2 * frequencyForDailyInsidersCheck) // Looking after 2 days + .verifiable(TypeMoq.Times.atLeastOnce()); + const result = await insidersDailyChannelRule.getInstaller(false); + lastLookUpTime.verifyAll(); + assert.instanceOf(result, InsidersBuildInstaller, 'Not looking for the correct build'); + }); + test('Do not update look up time or return any installer if looking for insiders within 24 hrs of last lookup time', async () => { + lastLookUpTime + .setup(l => l.updateValue(TypeMoq.It.isAnyNumber())) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.never()); + lastLookUpTime + .setup(l => l.value) + .returns(() => Date.now() - frequencyForDailyInsidersCheck / 2) // Looking after half a day + .verifiable(TypeMoq.Times.atLeastOnce()); + const result = await insidersDailyChannelRule.getInstaller(false); + lastLookUpTime.verifyAll(); + expect(result).to.equal(undefined, 'Should not look for any installer'); + }); + }); +}); + +suite('Download channel rules - ExtensionInsidersWeeklyChannelRule', () => { + let insidersInstaller: InsidersBuildInstaller; + let persistentStateFactory: IPersistentStateFactory; + let lastLookUpTime: TypeMoq.IMock>; + let insidersDailyChannelRule: ExtensionInsidersWeeklyChannelRule; + setup(() => { + insidersInstaller = new InsidersBuildInstaller(undefined as any, undefined as any, undefined as any, undefined as any); + persistentStateFactory = mock(PersistentStateFactory); + lastLookUpTime = TypeMoq.Mock.ofType>(); + when(persistentStateFactory.createGlobalPersistentState(lastLookUpTimeKey, -1)).thenReturn(lastLookUpTime.object); + insidersDailyChannelRule = new ExtensionInsidersWeeklyChannelRule(insidersInstaller, instance(persistentStateFactory)); + }); + + test('If insiders channel rule is new, update look up time and return installer for insiders build', async () => { + lastLookUpTime + .setup(l => l.updateValue(TypeMoq.It.isAnyNumber())) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + const result = await insidersDailyChannelRule.getInstaller(true); + lastLookUpTime.verifyAll(); + assert.instanceOf(result, InsidersBuildInstaller, 'Not looking for the correct build'); + }); + suite('If insiders channel rule is not new', async () => { + test('Update look up time and return installer for insiders build if looking for insiders the first time', async () => { + lastLookUpTime + .setup(l => l.updateValue(TypeMoq.It.isAnyNumber())) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + lastLookUpTime + .setup(l => l.value) + .returns(() => -1) + .verifiable(TypeMoq.Times.atLeastOnce()); + const result = await insidersDailyChannelRule.getInstaller(false); + lastLookUpTime.verifyAll(); + assert.instanceOf(result, InsidersBuildInstaller, 'Not looking for the correct build'); + }); + test('Update look up time and return installer for insiders build if looking for insiders after one week of last lookup time', async () => { + lastLookUpTime + .setup(l => l.updateValue(TypeMoq.It.isAnyNumber())) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + lastLookUpTime + .setup(l => l.value) + .returns(() => Date.now() - 2 * frequencyForWeeklyInsidersCheck) // Looking after 2 weeks + .verifiable(TypeMoq.Times.atLeastOnce()); + const result = await insidersDailyChannelRule.getInstaller(false); + lastLookUpTime.verifyAll(); + assert.instanceOf(result, InsidersBuildInstaller, 'Not looking for the correct build'); + }); + test('Do not update look up time or return any installer if looking for insiders within a week of last lookup time', async () => { + lastLookUpTime + .setup(l => l.updateValue(TypeMoq.It.isAnyNumber())) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.never()); + lastLookUpTime + .setup(l => l.value) + .returns(() => Date.now() - frequencyForWeeklyInsidersCheck / 2) // Looking after half a week + .verifiable(TypeMoq.Times.atLeastOnce()); + const result = await insidersDailyChannelRule.getInstaller(false); + lastLookUpTime.verifyAll(); + expect(result).to.equal(undefined, 'Should not look for any installer'); + }); + }); +}); diff --git a/src/test/common/insidersBuild/downloadChannelService.unit.test.ts b/src/test/common/insidersBuild/downloadChannelService.unit.test.ts new file mode 100644 index 000000000000..7d7568ca9555 --- /dev/null +++ b/src/test/common/insidersBuild/downloadChannelService.unit.test.ts @@ -0,0 +1,204 @@ +// 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, verify, when } from 'ts-mockito'; +import * as TypeMoq from 'typemoq'; +import { ConfigurationChangeEvent, ConfigurationTarget, EventEmitter, WorkspaceConfiguration } from 'vscode'; +import { ApplicationEnvironment } from '../../../client/common/application/applicationEnvironment'; +import { IApplicationEnvironment, IWorkspaceService } from '../../../client/common/application/types'; +import { WorkspaceService } from '../../../client/common/application/workspace'; +import { ConfigurationService } from '../../../client/common/configuration/service'; +import { ExtensionChannelService, insidersChannelSetting, isThisFirstSessionStateKey } from '../../../client/common/insidersBuild/downloadChannelService'; +import { ExtensionChannels } from '../../../client/common/insidersBuild/types'; +import { PersistentStateFactory } from '../../../client/common/persistentState'; +import { IConfigurationService, IPersistentState, IPersistentStateFactory } from '../../../client/common/types'; + +// tslint:disable-next-line:max-func-body-length +suite('Download channel service', () => { + let configService: IConfigurationService; + let appEnvironment: IApplicationEnvironment; + let workspaceService: IWorkspaceService; + let channelService: ExtensionChannelService; + let persistentState: IPersistentStateFactory; + let isThisFirstSessionState: TypeMoq.IMock>; + let configChangeEvent: EventEmitter; + setup(() => { + configService = mock(ConfigurationService); + appEnvironment = mock(ApplicationEnvironment); + workspaceService = mock(WorkspaceService); + configChangeEvent = new EventEmitter(); + when(workspaceService.onDidChangeConfiguration).thenReturn(configChangeEvent.event); + persistentState = mock(PersistentStateFactory); + isThisFirstSessionState = TypeMoq.Mock.ofType>(); + when(persistentState.createGlobalPersistentState(isThisFirstSessionStateKey, true)).thenReturn(isThisFirstSessionState.object); + channelService = new ExtensionChannelService(instance(appEnvironment), instance(configService), instance(workspaceService), instance(persistentState), []); + }); + + teardown(() => { + configChangeEvent.dispose(); + }); + + [ + { + testName: 'Get channel returns \'InsidersWeekly\' if user is using default setting in the first session and is using VS Code Insiders', + settings: {}, + vscodeChannel: 'insiders', + expectedResult: 'InsidersWeekly' + }, + { + testName: 'Get channel returns \'Stable\' if user is using default setting and is using VS Code Stable', + settings: {}, + vscodeChannel: 'stable', + expectedResult: 'Stable' + }, + { + testName: 'Get channel returns \'Stable\' if settings value is set to \'Stable\'', + settings: { globalValue: 'Stable' }, + vscodeChannel: 'insiders', + expectedResult: 'Stable' + }, + { + testName: 'Get channel returns \'InsidersWeekly\' if settings value is set to \'InsidersWeekly\'', + settings: { globalValue: 'InsidersWeekly' }, + vscodeChannel: 'insiders', + expectedResult: 'InsidersWeekly' + }, + { + testName: 'Get channel returns \'InsidersDaily\' if settings value is set to \'InsidersDaily\'', + settings: { globalValue: 'InsidersDaily' }, + vscodeChannel: 'insiders', + expectedResult: 'InsidersDaily' + } + ].forEach(testParams => { + test(testParams.testName, async () => { + const workspaceConfig = TypeMoq.Mock.ofType(); + const settings = testParams.settings; + + when( + workspaceService.getConfiguration('python') + ).thenReturn(workspaceConfig.object); + workspaceConfig.setup(c => c.inspect(insidersChannelSetting)) + .returns(() => settings as any) + .verifiable(TypeMoq.Times.once()); + isThisFirstSessionState + .setup(u => u.value) + .returns(() => true); + isThisFirstSessionState + .setup(u => u.updateValue(false)) + .returns(() => Promise.resolve()); + when(appEnvironment.channel).thenReturn(testParams.vscodeChannel as any); + const channel = await channelService.getChannel(); + expect(channel).to.equal(testParams.expectedResult); + workspaceConfig.verifyAll(); + }); + }); + + test('Get channel returns \'Stable\' if user is using default setting and is using VS Code Insiders, but this is not the first session', async () => { + const workspaceConfig = TypeMoq.Mock.ofType(); + const settings = {}; + + when( + workspaceService.getConfiguration('python') + ).thenReturn(workspaceConfig.object); + workspaceConfig.setup(c => c.inspect(insidersChannelSetting)) + .returns(() => settings as any) + .verifiable(TypeMoq.Times.once()); + isThisFirstSessionState + .setup(u => u.value) + .returns(() => false); + isThisFirstSessionState + .setup(u => u.updateValue(false)) + .returns(() => Promise.resolve()); + when(appEnvironment.channel).thenReturn('insiders'); + const channel = await channelService.getChannel(); + expect(channel).to.equal('Stable'); + workspaceConfig.verifyAll(); + }); + + test('Get channel throws error if not setting is found', async () => { + const workspaceConfig = TypeMoq.Mock.ofType(); + const settings = undefined; + + when( + workspaceService.getConfiguration('python') + ).thenReturn(workspaceConfig.object); + workspaceConfig.setup(c => c.inspect(insidersChannelSetting)) + .returns(() => settings as any) + .verifiable(TypeMoq.Times.once()); + await expect(channelService.getChannel()).to.eventually.be.rejected; + workspaceConfig.verifyAll(); + }); + + test('Update channel updates configuration settings', async () => { + const value = 'Random'; + when( + configService.updateSetting(insidersChannelSetting, value, undefined, ConfigurationTarget.Global) + ).thenResolve(undefined); + await channelService.updateChannel(value as any); + verify( + configService.updateSetting(insidersChannelSetting, value, undefined, ConfigurationTarget.Global) + ).once(); + }); + + test('Update channel throws error when updates configuration settings fails', async () => { + const value = 'Random'; + when( + configService.updateSetting(insidersChannelSetting, value, undefined, ConfigurationTarget.Global) + ).thenThrow(new Error('Kaboom')); + const promise = channelService.updateChannel(value as any); + await expect(promise).to.eventually.be.rejectedWith('Kaboom'); + }); + + test('If insidersChannelSetting is changed, an event is fired', async () => { + const _onDidChannelChange = TypeMoq.Mock.ofType>(); + const event = TypeMoq.Mock.ofType(); + const settings = { insidersChannel: 'Stable' }; + event + .setup(e => e.affectsConfiguration(`python.${insidersChannelSetting}`)) + .returns(() => true) + .verifiable(TypeMoq.Times.once()); + when( + configService.getSettings() + ).thenReturn(settings as any); + channelService._onDidChannelChange = _onDidChannelChange.object; + _onDidChannelChange + .setup(emitter => emitter.fire(TypeMoq.It.isValue(settings.insidersChannel as any))) + .returns(() => undefined) + .verifiable(TypeMoq.Times.once()); + await channelService.onDidChangeConfiguration(event.object); + _onDidChannelChange.verifyAll(); + event.verifyAll(); + verify( + configService.getSettings() + ).once(); + }); + + test('If some other setting changed, no event is fired', async () => { + const _onDidChannelChange = TypeMoq.Mock.ofType>(); + const event = TypeMoq.Mock.ofType(); + const settings = { insidersChannel: 'Stable' }; + event + .setup(e => e.affectsConfiguration(`python.${insidersChannelSetting}`)) + .returns(() => false) + .verifiable(TypeMoq.Times.once()); + when( + configService.getSettings() + ).thenReturn(settings as any); + channelService._onDidChannelChange = _onDidChannelChange.object; + _onDidChannelChange + .setup(emitter => emitter.fire(TypeMoq.It.isValue(settings.insidersChannel as any))) + .returns(() => undefined) + .verifiable(TypeMoq.Times.never()); + await channelService.onDidChangeConfiguration(event.object); + _onDidChannelChange.verifyAll(); + event.verifyAll(); + verify( + configService.getSettings() + ).never(); + }); +}); diff --git a/src/test/common/insidersBuild/insidersExtensionPrompt.unit.test.ts b/src/test/common/insidersBuild/insidersExtensionPrompt.unit.test.ts new file mode 100644 index 000000000000..94ccc9195f2a --- /dev/null +++ b/src/test/common/insidersBuild/insidersExtensionPrompt.unit.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any + +import { expect } from 'chai'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import * as TypeMoq from 'typemoq'; +import { ApplicationShell } from '../../../client/common/application/applicationShell'; +import { CommandManager } from '../../../client/common/application/commandManager'; +import { IApplicationShell, ICommandManager } from '../../../client/common/application/types'; +import { ExtensionChannelService } from '../../../client/common/insidersBuild/downloadChannelService'; +import { InsidersExtensionPrompt, insidersPromptStateKey } from '../../../client/common/insidersBuild/insidersExtensionPrompt'; +import { ExtensionChannel, IExtensionChannelService } from '../../../client/common/insidersBuild/types'; +import { PersistentStateFactory } from '../../../client/common/persistentState'; +import { IPersistentState, IPersistentStateFactory } from '../../../client/common/types'; +import { Common, ExtensionChannels } from '../../../client/common/utils/localize'; + +// tslint:disable-next-line: max-func-body-length +suite('Insiders Extension prompt', () => { + let appShell: IApplicationShell; + let extensionChannelService: IExtensionChannelService; + let cmdManager: ICommandManager; + let persistentState: IPersistentStateFactory; + let hasUserBeenNotifiedState: TypeMoq.IMock>; + let insidersPrompt: InsidersExtensionPrompt; + setup(() => { + extensionChannelService = mock(ExtensionChannelService); + appShell = mock(ApplicationShell); + persistentState = mock(PersistentStateFactory); + cmdManager = mock(CommandManager); + hasUserBeenNotifiedState = TypeMoq.Mock.ofType>(); + when(persistentState.createGlobalPersistentState(insidersPromptStateKey, false)).thenReturn(hasUserBeenNotifiedState.object); + insidersPrompt = new InsidersExtensionPrompt(instance(appShell), instance(extensionChannelService), instance(cmdManager), instance(persistentState)); + }); + + test('Channel is set to stable and reload prompt is disabled if \'Use Stable\' option is selected', async () => { + const prompts = [ExtensionChannels.useStable(), Common.reload()]; + when( + appShell.showInformationMessage(ExtensionChannels.promptMessage(), ...prompts) + ).thenResolve(ExtensionChannels.useStable() as any); + when( + cmdManager.executeCommand('workbench.action.reloadWindow') + ).thenResolve(); + when( + extensionChannelService.updateChannel(ExtensionChannel.stable) + ).thenResolve(); + hasUserBeenNotifiedState + .setup(u => u.updateValue(true)) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + await insidersPrompt.notifyToInstallInsiders(); + verify(appShell.showInformationMessage(ExtensionChannels.promptMessage(), ...prompts)).once(); + verify(extensionChannelService.updateChannel(ExtensionChannel.stable)).once(); + hasUserBeenNotifiedState.verifyAll(); + expect(insidersPrompt.reloadPromptDisabled).to.equal(true, 'Reload prompt should be disabled'); + verify(cmdManager.executeCommand('workbench.action.reloadWindow')).never(); + }); + + test('Channel is set to \'InsidersWeekly\', reload prompt is disabled and reload command is invoked if \'Reload\' option is selected', async () => { + const prompts = [ExtensionChannels.useStable(), Common.reload()]; + when( + appShell.showInformationMessage(ExtensionChannels.promptMessage(), ...prompts) + ).thenResolve(Common.reload() as any); + when( + extensionChannelService.updateChannel(ExtensionChannel.insidersDefaultForTheFirstSession) + ).thenResolve(); + when( + cmdManager.executeCommand('workbench.action.reloadWindow') + ).thenResolve(); + hasUserBeenNotifiedState + .setup(u => u.updateValue(true)) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + await insidersPrompt.notifyToInstallInsiders(); + verify(appShell.showInformationMessage(ExtensionChannels.promptMessage(), ...prompts)).once(); + verify(extensionChannelService.updateChannel(ExtensionChannel.insidersDefaultForTheFirstSession)).once(); + verify(cmdManager.executeCommand('workbench.action.reloadWindow')).once(); + hasUserBeenNotifiedState.verifyAll(); + expect(insidersPrompt.reloadPromptDisabled).to.equal(true, 'Reload prompt should be disabled'); + }); + + test('Channel is set to \'InsidersWeekly\', if no option is selected', async () => { + const prompts = [ExtensionChannels.useStable(), Common.reload()]; + when( + appShell.showInformationMessage(ExtensionChannels.promptMessage(), ...prompts) + ).thenResolve(undefined); + when( + extensionChannelService.updateChannel(ExtensionChannel.insidersDefaultForTheFirstSession) + ).thenResolve(); + when( + cmdManager.executeCommand('workbench.action.reloadWindow') + ).thenResolve(); + hasUserBeenNotifiedState + .setup(u => u.updateValue(true)) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + await insidersPrompt.notifyToInstallInsiders(); + verify(appShell.showInformationMessage(ExtensionChannels.promptMessage(), ...prompts)).once(); + verify(extensionChannelService.updateChannel(ExtensionChannel.insidersDefaultForTheFirstSession)).once(); + verify(cmdManager.executeCommand('workbench.action.reloadWindow')).never(); + hasUserBeenNotifiedState.verifyAll(); + expect(insidersPrompt.reloadPromptDisabled).to.equal(true, 'Reload prompt should be disabled'); + }); + + test('Do not do anything if no option is selected in the reload prompt', async () => { + when( + appShell.showInformationMessage(ExtensionChannels.reloadMessage(), Common.reload()) + ).thenResolve(undefined); + when( + cmdManager.executeCommand('workbench.action.reloadWindow') + ).thenResolve(); + await insidersPrompt.promptToReload(); + verify(appShell.showInformationMessage(ExtensionChannels.reloadMessage(), Common.reload())).once(); + verify(cmdManager.executeCommand('workbench.action.reloadWindow')).never(); + expect(insidersPrompt.reloadPromptDisabled).to.equal(false, 'Reload prompt should not be disabled'); + }); + + test('Reload windows if \'Reload\' option is selected in the reload prompt', async () => { + when( + appShell.showInformationMessage(ExtensionChannels.reloadMessage(), Common.reload()) + ).thenResolve(Common.reload() as any); + when( + cmdManager.executeCommand('workbench.action.reloadWindow') + ).thenResolve(); + await insidersPrompt.promptToReload(); + verify(appShell.showInformationMessage(ExtensionChannels.reloadMessage(), Common.reload())).once(); + verify(cmdManager.executeCommand('workbench.action.reloadWindow')).once(); + expect(insidersPrompt.reloadPromptDisabled).to.equal(false, 'Reload prompt should not be disabled'); + }); + + test('Do not show prompt if prompt is disabled', async () => { + when( + appShell.showInformationMessage(anything(), anything()) + ).thenResolve(Common.reload() as any); + when( + cmdManager.executeCommand('workbench.action.reloadWindow') + ).thenResolve(); + insidersPrompt.reloadPromptDisabled = true; + await insidersPrompt.promptToReload(); + verify(appShell.showInformationMessage(anything(), anything())).never(); + verify(cmdManager.executeCommand('workbench.action.reloadWindow')).never(); + expect(insidersPrompt.reloadPromptDisabled).to.equal(false, 'Reload prompt should not be disabled'); + }); +}); diff --git a/src/test/common/insidersBuild/insidersExtensionService.unit.test.ts b/src/test/common/insidersBuild/insidersExtensionService.unit.test.ts new file mode 100644 index 000000000000..0611e09a5460 --- /dev/null +++ b/src/test/common/insidersBuild/insidersExtensionService.unit.test.ts @@ -0,0 +1,372 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any + +import * as assert from 'assert'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import * as TypeMoq from 'typemoq'; +import { EventEmitter, Uri } from 'vscode'; +import { ApplicationEnvironment } from '../../../client/common/application/applicationEnvironment'; +import { CommandManager } from '../../../client/common/application/commandManager'; +import { Channel, IApplicationEnvironment, ICommandManager } from '../../../client/common/application/types'; +import { Commands } from '../../../client/common/constants'; +import { ExtensionChannelService } from '../../../client/common/insidersBuild/downloadChannelService'; +import { InsidersExtensionPrompt } from '../../../client/common/insidersBuild/insidersExtensionPrompt'; +import { InsidersExtensionService } from '../../../client/common/insidersBuild/insidersExtensionService'; +import { ExtensionChannels, IExtensionChannelRule, IExtensionChannelService, IInsiderExtensionPrompt } from '../../../client/common/insidersBuild/types'; +import { IExtensionBuildInstaller } from '../../../client/common/installer/types'; +import { IDisposable, IPersistentState } from '../../../client/common/types'; +import { createDeferred, createDeferredFromPromise } from '../../../client/common/utils/async'; +import { ServiceContainer } from '../../../client/ioc/container'; +import { IServiceContainer } from '../../../client/ioc/types'; +import { sleep } from '../../../test/core'; + +suite('Insiders Extension Service - Handle channel', () => { + let appEnvironment: IApplicationEnvironment; + let serviceContainer: IServiceContainer; + let extensionChannelService: IExtensionChannelService; + let cmdManager: ICommandManager; + let insidersPrompt: IInsiderExtensionPrompt; + let choosePromptAndDisplay: sinon.SinonStub; + let insidersExtensionService: InsidersExtensionService; + setup(() => { + extensionChannelService = mock(ExtensionChannelService); + appEnvironment = mock(ApplicationEnvironment); + cmdManager = mock(CommandManager); + serviceContainer = mock(ServiceContainer); + insidersPrompt = mock(InsidersExtensionPrompt); + choosePromptAndDisplay = sinon.stub(InsidersExtensionService.prototype, 'choosePromptAndDisplay'); + choosePromptAndDisplay.callsFake(() => Promise.resolve()); + insidersExtensionService = new InsidersExtensionService(instance(extensionChannelService), instance(insidersPrompt), instance(appEnvironment), instance(cmdManager), instance(serviceContainer), []); + }); + + teardown(() => { + sinon.restore(); + }); + + test('If no build installer is returned, handling channel does not do anything and simply returns', async () => { + const channelRule = TypeMoq.Mock.ofType(); + when(serviceContainer.get(IExtensionChannelRule, 'Stable')).thenReturn(channelRule.object); + channelRule + .setup(c => c.getInstaller(false)) + .returns(() => Promise.resolve(undefined)) + .verifiable(TypeMoq.Times.once()); + await insidersExtensionService.handleChannel('Stable'); + channelRule.verifyAll(); + assert.ok(choosePromptAndDisplay.notCalled); + }); + + test('If build installer is returned, handling channel installs the build and prompts user', async () => { + const channelRule = TypeMoq.Mock.ofType(); + const buildInstaller = TypeMoq.Mock.ofType(); + buildInstaller.setup(b => (b as any).then).returns(() => undefined); + when(serviceContainer.get(IExtensionChannelRule, 'Stable')).thenReturn(channelRule.object); + channelRule + .setup(c => c.getInstaller(false)) + .returns(() => Promise.resolve(buildInstaller.object)) + .verifiable(TypeMoq.Times.once()); + buildInstaller + .setup(b => b.install()) + .returns(() => Promise.resolve()) + .verifiable(TypeMoq.Times.once()); + await insidersExtensionService.handleChannel('Stable'); + channelRule.verifyAll(); + buildInstaller.verifyAll(); + expect(choosePromptAndDisplay.args[0][0]).to.equal('Stable'); + expect(choosePromptAndDisplay.args[0][1]).to.equal(false, 'Should be false'); + assert.ok(choosePromptAndDisplay.calledOnce); + }); +}); + +// tslint:disable-next-line: max-func-body-length +suite('Insiders Extension Service - Activation', () => { + let appEnvironment: IApplicationEnvironment; + let serviceContainer: IServiceContainer; + let extensionChannelService: IExtensionChannelService; + let cmdManager: ICommandManager; + let insidersPrompt: IInsiderExtensionPrompt; + let registerCommandsAndHandlers: sinon.SinonStub; + let handleChannel: sinon.SinonStub; + let insidersExtensionService: InsidersExtensionService; + setup(() => { + extensionChannelService = mock(ExtensionChannelService); + appEnvironment = mock(ApplicationEnvironment); + cmdManager = mock(CommandManager); + serviceContainer = mock(ServiceContainer); + insidersPrompt = mock(InsidersExtensionPrompt); + registerCommandsAndHandlers = sinon.stub(InsidersExtensionService.prototype, 'registerCommandsAndHandlers'); + registerCommandsAndHandlers.callsFake(() => Promise.resolve()); + }); + + teardown(() => { + sinon.restore(); + }); + + test('If service has been activated once, simply return', async () => { + handleChannel = sinon.stub(InsidersExtensionService.prototype, 'handleChannel'); + handleChannel.callsFake(() => Promise.resolve()); + insidersExtensionService = new InsidersExtensionService(instance(extensionChannelService), instance(insidersPrompt), instance(appEnvironment), instance(cmdManager), instance(serviceContainer), []); + insidersExtensionService.activatedOnce = true; + await insidersExtensionService.activate(Uri.parse('r')); + assert.ok(registerCommandsAndHandlers.notCalled); + }); + + const testsForActivation: { + installChannel: ExtensionChannels; + extensionChannel: Channel; + expectedResult: boolean; + }[] = + [ + { + installChannel: 'Stable', + extensionChannel: 'stable', + expectedResult: false + }, + { + installChannel: 'Stable', + extensionChannel: 'insiders', + expectedResult: true + }, + { + installChannel: 'InsidersDaily', + extensionChannel: 'stable', + expectedResult: true + }, { + installChannel: 'InsidersDaily', + extensionChannel: 'insiders', + expectedResult: false + }, { + installChannel: 'InsidersWeekly', + extensionChannel: 'stable', + expectedResult: true + }, { + installChannel: 'InsidersWeekly', + extensionChannel: 'insiders', + expectedResult: false + } + ]; + + testsForActivation.forEach(testParams => { + const testName = `Handle channel is passed with didChannelChange argument = '${testParams.expectedResult}' when installChannel = '${testParams.installChannel}' and extensionChannel = '${testParams.extensionChannel}'`; + test(testName, async () => { + handleChannel = sinon.stub(InsidersExtensionService.prototype, 'handleChannel'); + handleChannel.callsFake(() => Promise.resolve()); + insidersExtensionService = new InsidersExtensionService(instance(extensionChannelService), instance(insidersPrompt), instance(appEnvironment), instance(cmdManager), instance(serviceContainer), []); + when(extensionChannelService.getChannel()).thenResolve(testParams.installChannel); + when(appEnvironment.extensionChannel).thenReturn(testParams.extensionChannel); + await insidersExtensionService.activate(Uri.parse('r')); + expect(handleChannel.args[0][1]).to.equal(testParams.expectedResult); + verify(extensionChannelService.getChannel()).once(); + verify(appEnvironment.extensionChannel).once(); + expect(insidersExtensionService.activatedOnce).to.equal(true, 'Variable should be set to true'); + }); + }); + + test('Ensure channels are reliably handled in the background', async () => { + const handleChannelsDeferred = createDeferred(); + handleChannel = sinon.stub(InsidersExtensionService.prototype, 'handleChannel'); + handleChannel.callsFake(() => handleChannelsDeferred.promise); + insidersExtensionService = new InsidersExtensionService(instance(extensionChannelService), instance(insidersPrompt), instance(appEnvironment), instance(cmdManager), instance(serviceContainer), []); + when(extensionChannelService.getChannel()).thenResolve('InsidersDaily'); + when(appEnvironment.extensionChannel).thenReturn('insiders'); + + const promise = insidersExtensionService.activate(Uri.parse('r')); + const deferred = createDeferredFromPromise(promise); + await sleep(1); + + // Ensure activate() function has completed while handleChannel is still running + assert.equal(deferred.completed, true); + + handleChannelsDeferred.resolve(); + await sleep(1); + + verify(extensionChannelService.getChannel()).once(); + verify(appEnvironment.extensionChannel).once(); + expect(insidersExtensionService.activatedOnce).to.equal(true, 'Variable should be set to true'); + }); +}); + +// tslint:disable-next-line: max-func-body-length +suite('Insiders Extension Service - Function choosePromptAndDisplay()', () => { + let appEnvironment: IApplicationEnvironment; + let serviceContainer: IServiceContainer; + let extensionChannelService: IExtensionChannelService; + let cmdManager: ICommandManager; + let insidersPrompt: IInsiderExtensionPrompt; + let hasUserBeenNotifiedState: TypeMoq.IMock>; + let insidersExtensionService: InsidersExtensionService; + setup(() => { + extensionChannelService = mock(ExtensionChannelService); + appEnvironment = mock(ApplicationEnvironment); + cmdManager = mock(CommandManager); + serviceContainer = mock(ServiceContainer); + insidersPrompt = mock(InsidersExtensionPrompt); + hasUserBeenNotifiedState = TypeMoq.Mock.ofType>(); + when(insidersPrompt.hasUserBeenNotified).thenReturn(hasUserBeenNotifiedState.object); + insidersExtensionService = new InsidersExtensionService(instance(extensionChannelService), instance(insidersPrompt), instance(appEnvironment), instance(cmdManager), instance(serviceContainer), []); + }); + + teardown(() => { + sinon.restore(); + }); + + const testsForChoosePromptAndDisplay: { + vscodeChannel: Channel; + promptToDisplay: 'Reload Prompt' | 'Insiders Install Prompt' | undefined; + didChannelChange?: boolean; + hasUserBeenNotified?: boolean; + installChannel?: ExtensionChannels; + }[] = + [ + { + vscodeChannel: 'stable', + didChannelChange: true, + promptToDisplay: 'Reload Prompt' + }, + { + vscodeChannel: 'stable', + didChannelChange: false, + promptToDisplay: undefined + }, + { + vscodeChannel: 'insiders', + installChannel: 'Stable', + didChannelChange: true, + promptToDisplay: 'Reload Prompt' + }, + { + vscodeChannel: 'insiders', + installChannel: 'Stable', + didChannelChange: false, + promptToDisplay: undefined + }, + { + vscodeChannel: 'insiders', + installChannel: 'InsidersWeekly', + hasUserBeenNotified: false, + promptToDisplay: 'Insiders Install Prompt' + }, + { + vscodeChannel: 'insiders', + installChannel: 'InsidersWeekly', + hasUserBeenNotified: true, + didChannelChange: true, + promptToDisplay: 'Reload Prompt' + }, + { + vscodeChannel: 'insiders', + installChannel: 'InsidersWeekly', + hasUserBeenNotified: true, + didChannelChange: false, + promptToDisplay: undefined + } + ]; + + testsForChoosePromptAndDisplay.forEach(testParams => { + const testName = `${testParams.promptToDisplay ? testParams.promptToDisplay : 'No prompt'} is displayed when vscode channel = '${testParams.vscodeChannel}', extension channel = '${testParams.installChannel}', ${!testParams.hasUserBeenNotified ? 'user has not been notified to install insiders' : 'user has already been notified to install insiders'}, didChannelChange = ${testParams.didChannelChange === undefined ? false : testParams.didChannelChange}`; + test(testName, async () => { + hasUserBeenNotifiedState + .setup(c => c.value) + .returns(() => testParams.hasUserBeenNotified !== undefined ? testParams.hasUserBeenNotified : true); + when(appEnvironment.channel).thenReturn(testParams.vscodeChannel); + when(insidersPrompt.notifyToInstallInsiders()).thenResolve(); + when(insidersPrompt.promptToReload()).thenResolve(); + await insidersExtensionService.choosePromptAndDisplay(testParams.installChannel !== undefined ? testParams.installChannel : 'Stable', testParams.didChannelChange !== undefined ? testParams.didChannelChange : false); + if (testParams.promptToDisplay === 'Reload Prompt') { + verify(insidersPrompt.promptToReload()).once(); + verify(insidersPrompt.notifyToInstallInsiders()).never(); + } else if (testParams.promptToDisplay === 'Insiders Install Prompt') { + verify(insidersPrompt.promptToReload()).never(); + verify(insidersPrompt.notifyToInstallInsiders()).once(); + } else { + verify(insidersPrompt.promptToReload()).never(); + verify(insidersPrompt.notifyToInstallInsiders()).never(); + } + verify(appEnvironment.channel).once(); + }); + }); +}); + +// tslint:disable-next-line: max-func-body-length +suite('Insiders Extension Service - Function registerCommandsAndHandlers()', () => { + let appEnvironment: IApplicationEnvironment; + let serviceContainer: IServiceContainer; + let extensionChannelService: IExtensionChannelService; + let cmdManager: ICommandManager; + let insidersPrompt: IInsiderExtensionPrompt; + let channelChangeEvent: EventEmitter; + let handleChannel: sinon.SinonStub; + let insidersExtensionService: InsidersExtensionService; + setup(() => { + extensionChannelService = mock(ExtensionChannelService); + appEnvironment = mock(ApplicationEnvironment); + cmdManager = mock(CommandManager); + serviceContainer = mock(ServiceContainer); + insidersPrompt = mock(InsidersExtensionPrompt); + channelChangeEvent = new EventEmitter(); + handleChannel = sinon.stub(InsidersExtensionService.prototype, 'handleChannel'); + handleChannel.callsFake(() => Promise.resolve()); + insidersExtensionService = new InsidersExtensionService(instance(extensionChannelService), instance(insidersPrompt), instance(appEnvironment), instance(cmdManager), instance(serviceContainer), []); + }); + + teardown(() => { + sinon.restore(); + channelChangeEvent.dispose(); + }); + + test('Ensure commands and handlers get registered, and disposables returned are in the disposable list', async () => { + const disposable1 = TypeMoq.Mock.ofType(); + const disposable2 = TypeMoq.Mock.ofType(); + const disposable3 = TypeMoq.Mock.ofType(); + const disposable4 = TypeMoq.Mock.ofType(); + when(extensionChannelService.onDidChannelChange).thenReturn(() => disposable1.object); + when(cmdManager.registerCommand(Commands.SwitchToStable, anything())).thenReturn(disposable2.object); + when(cmdManager.registerCommand(Commands.SwitchToInsidersDaily, anything())).thenReturn(disposable3.object); + when(cmdManager.registerCommand(Commands.SwitchToInsidersWeekly, anything())).thenReturn(disposable4.object); + + insidersExtensionService.registerCommandsAndHandlers(); + + expect(insidersExtensionService.disposables.length).to.equal(4); + verify(extensionChannelService.onDidChannelChange).once(); + verify(cmdManager.registerCommand(Commands.SwitchToStable, anything())).once(); + verify(cmdManager.registerCommand(Commands.SwitchToInsidersDaily, anything())).once(); + verify(cmdManager.registerCommand(Commands.SwitchToInsidersWeekly, anything())).once(); + }); + + test('Ensure commands and handlers get registered with the correct callback handlers', async () => { + const disposable1 = TypeMoq.Mock.ofType(); + const disposable2 = TypeMoq.Mock.ofType(); + const disposable3 = TypeMoq.Mock.ofType(); + const disposable4 = TypeMoq.Mock.ofType(); + let channelChangedHandler!: Function; + let switchToStableHandler!: Function; + let switchToInsidersDailyHandler!: Function; + let switchToInsidersWeeklyHandler!: Function; + when(extensionChannelService.onDidChannelChange).thenReturn(cb => { channelChangedHandler = cb; return disposable1.object; }); + when(cmdManager.registerCommand(Commands.SwitchToStable, anything())).thenCall((_, cb) => { switchToStableHandler = cb; return disposable2.object; }); + when(cmdManager.registerCommand(Commands.SwitchToInsidersDaily, anything())).thenCall((_, cb) => { switchToInsidersDailyHandler = cb; return disposable3.object; }); + when(cmdManager.registerCommand(Commands.SwitchToInsidersWeekly, anything())).thenCall((_, cb) => { switchToInsidersWeeklyHandler = cb; return disposable4.object; }); + + insidersExtensionService.registerCommandsAndHandlers(); + + channelChangedHandler('Some channel'); + assert.ok(handleChannel.calledOnce); + + when(extensionChannelService.updateChannel('Stable')).thenResolve(); + await switchToStableHandler(); + verify(extensionChannelService.updateChannel('Stable')).once(); + + when(extensionChannelService.updateChannel('InsidersDaily')).thenResolve(); + await switchToInsidersDailyHandler(); + verify(extensionChannelService.updateChannel('InsidersDaily')).once(); + + when(extensionChannelService.updateChannel('InsidersWeekly')).thenResolve(); + await switchToInsidersWeeklyHandler(); + verify(extensionChannelService.updateChannel('InsidersWeekly')).once(); + }); +}); diff --git a/src/test/common/installer/extensionBuildInstaller.unit.test.ts b/src/test/common/installer/extensionBuildInstaller.unit.test.ts new file mode 100644 index 000000000000..154a94e97dc5 --- /dev/null +++ b/src/test/common/installer/extensionBuildInstaller.unit.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any max-func-body-length no-invalid-this + +import * as assert from 'assert'; +import { expect } from 'chai'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { Uri } from 'vscode'; +import { CommandManager } from '../../../client/common/application/commandManager'; +import { ICommandManager } from '../../../client/common/application/types'; +import { PVSC_EXTENSION_ID } from '../../../client/common/constants'; +import { developmentBuildUri, InsidersBuildInstaller, StableBuildInstaller, vsixFileExtension } from '../../../client/common/installer/extensionBuildInstaller'; +import { FileDownloader } from '../../../client/common/net/fileDownloader'; +import { FileSystem } from '../../../client/common/platform/fileSystem'; +import { IFileSystem } from '../../../client/common/platform/types'; +import { DownloadOptions, IFileDownloader, IOutputChannel } from '../../../client/common/types'; +import { ExtensionChannels } from '../../../client/common/utils/localize'; +import { MockOutputChannel } from '../../../test/mockClasses'; + +suite('Extension build installer - Stable build installer', async () => { + let output: IOutputChannel; + let cmdManager: ICommandManager; + let stableBuildInstaller: StableBuildInstaller; + setup(() => { + output = mock(MockOutputChannel); + cmdManager = mock(CommandManager); + stableBuildInstaller = new StableBuildInstaller(instance(output), instance(cmdManager)); + }); + test('Installing stable build logs progress and installs stable', async () => { + when(output.append(ExtensionChannels.installingStableMessage())).thenReturn(); + when(output.appendLine(ExtensionChannels.installationCompleteMessage())).thenReturn(); + when(cmdManager.executeCommand('workbench.extensions.installExtension', PVSC_EXTENSION_ID)).thenResolve(undefined); + await stableBuildInstaller.install(); + verify(output.append(ExtensionChannels.installingStableMessage())).once(); + verify(output.appendLine(ExtensionChannels.installationCompleteMessage())).once(); + verify(cmdManager.executeCommand('workbench.extensions.installExtension', PVSC_EXTENSION_ID)).once(); + }); +}); + +suite('Extension build installer - Insiders build installer', async () => { + let output: IOutputChannel; + let cmdManager: ICommandManager; + let fileDownloader: IFileDownloader; + let fs: IFileSystem; + let insidersBuildInstaller: InsidersBuildInstaller; + setup(() => { + output = mock(MockOutputChannel); + fileDownloader = mock(FileDownloader); + fs = mock(FileSystem); + cmdManager = mock(CommandManager); + insidersBuildInstaller = new InsidersBuildInstaller(instance(output), instance(fileDownloader), instance(fs), instance(cmdManager)); + }); + test('Installing Insiders build downloads and installs Insiders', async () => { + const vsixFilePath = 'path/to/vsix'; + const options = { + extension: vsixFileExtension, + outputChannel: output, + progressMessagePrefix: ExtensionChannels.downloadingInsidersMessage() + }; + when(output.append(ExtensionChannels.installingInsidersMessage())).thenReturn(); + when(output.appendLine(ExtensionChannels.startingDownloadOutputMessage())).thenReturn(); + when(output.appendLine(ExtensionChannels.downloadCompletedOutputMessage())).thenReturn(); + when(output.appendLine(ExtensionChannels.installationCompleteMessage())).thenReturn(); + when( + fileDownloader.downloadFile(developmentBuildUri, anything()) + ).thenCall((_, downloadOptions: DownloadOptions) => { + expect(downloadOptions.extension).to.equal(options.extension, 'Incorrect file extension'); + expect(downloadOptions.progressMessagePrefix).to.equal(options.progressMessagePrefix); + return Promise.resolve(vsixFilePath); + }); + when( + cmdManager.executeCommand('workbench.extensions.installExtension', anything()) + ).thenCall((_, cb) => { + assert.deepEqual(cb, Uri.file(vsixFilePath), 'Wrong VSIX installed'); + }); + when(fs.deleteFile(vsixFilePath)).thenResolve(); + + await insidersBuildInstaller.install(); + + verify(output.append(ExtensionChannels.installingInsidersMessage())).once(); + verify(output.appendLine(ExtensionChannels.startingDownloadOutputMessage())).once(); + verify(output.appendLine(ExtensionChannels.downloadCompletedOutputMessage())).once(); + verify(output.appendLine(ExtensionChannels.installationCompleteMessage())).once(); + verify(cmdManager.executeCommand('workbench.extensions.installExtension', anything())).once(); + verify(fs.deleteFile(vsixFilePath)).once(); + }); +}); diff --git a/src/test/common/net/fileDownloader.unit.test.ts b/src/test/common/net/fileDownloader.unit.test.ts index 08f7fd354645..d3b31e1ffc86 100644 --- a/src/test/common/net/fileDownloader.unit.test.ts +++ b/src/test/common/net/fileDownloader.unit.test.ts @@ -238,7 +238,7 @@ suite('File Downloader', () => { await fileDownloader.downloadFile('file to download', { progressMessagePrefix: '', extension: '.pdf', outputChannel: outputChannel }); - verify(outputChannel.append(Http.downloadingFile().format('file to download'))); + verify(outputChannel.appendLine(Http.downloadingFile().format('file to download'))); }); test('Display progress when downloading', async () => { const tmpFile = { filePath: 'my temp file', dispose: noop }; diff --git a/src/test/common/serviceRegistry.unit.test.ts b/src/test/common/serviceRegistry.unit.test.ts index 1b15827129a6..18df2cb9d933 100644 --- a/src/test/common/serviceRegistry.unit.test.ts +++ b/src/test/common/serviceRegistry.unit.test.ts @@ -7,6 +7,7 @@ import { expect } from 'chai'; import * as typemoq from 'typemoq'; +import { IExtensionActivationService } from '../../client/activation/types'; import { ApplicationEnvironment } from '../../client/common/application/applicationEnvironment'; import { ApplicationShell } from '../../client/common/application/applicationShell'; import { CommandManager } from '../../client/common/application/commandManager'; @@ -23,6 +24,11 @@ import { CryptoUtils } from '../../client/common/crypto'; import { EditorUtils } from '../../client/common/editor'; import { ExperimentsManager } from '../../client/common/experiments'; import { FeatureDeprecationManager } from '../../client/common/featureDeprecationManager'; +import { ExtensionInsidersDailyChannelRule, ExtensionInsidersWeeklyChannelRule, ExtensionStableChannelRule } from '../../client/common/insidersBuild/downloadChannelRules'; +import { ExtensionChannelService } from '../../client/common/insidersBuild/downloadChannelService'; +import { InsidersExtensionPrompt } from '../../client/common/insidersBuild/insidersExtensionPrompt'; +import { InsidersExtensionService } from '../../client/common/insidersBuild/insidersExtensionService'; +import { ExtensionChannel, IExtensionChannelRule, IExtensionChannelService, IInsiderExtensionPrompt } from '../../client/common/insidersBuild/types'; import { ProductInstaller } from '../../client/common/installer/productInstaller'; import { LiveShareApi } from '../../client/common/liveshare/liveshare'; import { Logger } from '../../client/common/logger'; @@ -99,7 +105,13 @@ suite('Common - Service Registry', () => { [IShellDetector, TerminalNameShellDetector], [IShellDetector, SettingsShellDetector], [IShellDetector, UserEnvironmentShellDetector], - [IShellDetector, VSCEnvironmentShellDetector] + [IShellDetector, VSCEnvironmentShellDetector], + [IInsiderExtensionPrompt, InsidersExtensionPrompt], + [IExtensionActivationService, InsidersExtensionService], + [IExtensionChannelService, ExtensionChannelService], + [IExtensionChannelRule, ExtensionStableChannelRule, ExtensionChannel.stable], + [IExtensionChannelRule, ExtensionInsidersDailyChannelRule, ExtensionChannel.daily], + [IExtensionChannelRule, ExtensionInsidersWeeklyChannelRule, ExtensionChannel.weekly] ].forEach(mapping => { if (mapping.length === 2) { serviceManager