diff --git a/news/1 Enhancements/2127.md b/news/1 Enhancements/2127.md new file mode 100644 index 000000000000..04135259c073 --- /dev/null +++ b/news/1 Enhancements/2127.md @@ -0,0 +1 @@ +Add two popups to the extension: one to ask users to move to the new language server, the other to request feedback from users of that language server. diff --git a/src/client/activation/languageServer.ts b/src/client/activation/languageServer.ts index 14e0d53ff0f1..8c416206a7b1 100644 --- a/src/client/activation/languageServer.ts +++ b/src/client/activation/languageServer.ts @@ -3,15 +3,18 @@ import { inject, injectable } from 'inversify'; import * as path from 'path'; -import { OutputChannel, Uri } from 'vscode'; -import { Disposable, LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient'; +import { CancellationToken, CompletionContext, OutputChannel, Position, + TextDocument, Uri } from 'vscode'; +import { Disposable, LanguageClient, LanguageClientOptions, + ProvideCompletionItemsSignature, ServerOptions } from 'vscode-languageclient'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../common/application/types'; import { PythonSettings } from '../common/configSettings'; import { isTestExecution, STANDARD_OUTPUT_CHANNEL } from '../common/constants'; import { createDeferred, Deferred } from '../common/helpers'; import { IFileSystem, IPlatformService } from '../common/platform/types'; import { StopWatch } from '../common/stopWatch'; -import { IConfigurationService, IExtensionContext, ILogger, IOutputChannel, IPythonSettings } from '../common/types'; +import { BANNER_NAME_LS_SURVEY, IConfigurationService, IExtensionContext, ILogger, + IOutputChannel, IPythonExtensionBanner, IPythonSettings } from '../common/types'; import { IServiceContainer } from '../ioc/types'; import { PYTHON_LANGUAGE_SERVER_DOWNLOADED, @@ -51,6 +54,7 @@ export class LanguageServerExtensionActivator implements IExtensionActivator { private excludedFiles: string[] = []; private typeshedPaths: string[] = []; private loadExtensionArgs: {} | undefined; + private surveyBanner: IPythonExtensionBanner; // tslint:disable-next-line:no-unused-variable private progressReporting: ProgressReporting | undefined; @@ -81,6 +85,8 @@ export class LanguageServerExtensionActivator implements IExtensionActivator { } )); + this.surveyBanner = services.get(IPythonExtensionBanner, BANNER_NAME_LS_SURVEY); + (this.configuration.getSettings() as PythonSettings).addListener('change', this.onSettingsChanged); } @@ -155,6 +161,7 @@ export class LanguageServerExtensionActivator implements IExtensionActivator { if (this.loadExtensionArgs) { this.languageClient!.sendRequest('python/loadExtension', this.loadExtensionArgs); } + this.startupCompleted.resolve(); } @@ -250,6 +257,14 @@ export class LanguageServerExtensionActivator implements IExtensionActivator { testEnvironment: isTestExecution(), analysisUpdates: true, traceLogging + }, + middleware: { + provideCompletionItem: (document: TextDocument, position: Position, context: CompletionContext, token: CancellationToken, next: ProvideCompletionItemsSignature) => { + if (this.surveyBanner) { + this.surveyBanner.showBanner().ignoreErrors(); + } + return next(document, position, context, token); + } } }; } diff --git a/src/client/common/types.ts b/src/client/common/types.ts index 4e5c35215e63..83dee6718c57 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -267,3 +267,23 @@ export const IBrowserService = Symbol('IBrowserService'); export interface IBrowserService { launch(url: string): void; } + +export const IExperimentalDebuggerBanner = Symbol('IExperimentalDebuggerBanner'); +export interface IExperimentalDebuggerBanner { + enabled: boolean; + initialize(): void; + showBanner(): Promise; + shouldShowBanner(): Promise; + disable(): Promise; + launchSurvey(): Promise; +} + +export const IPythonExtensionBanner = Symbol('IPythonExtensionBanner'); +export interface IPythonExtensionBanner { + enabled: boolean; + shownCount: Promise; + optionLabels: string[]; + showBanner(): Promise; +} +export const BANNER_NAME_LS_SURVEY: string = 'LSSurveyBanner'; +export const BANNER_NAME_PROPOSE_LS: string = 'ProposeLS'; diff --git a/src/client/common/utils.ts b/src/client/common/utils.ts index 3e803b388466..4cd5f2c027e0 100644 --- a/src/client/common/utils.ts +++ b/src/client/common/utils.ts @@ -1,6 +1,7 @@ 'use strict'; // tslint:disable: no-any one-line no-suspicious-comment prefer-template prefer-const no-unnecessary-callback-wrapper no-function-expression no-string-literal no-control-regex no-shadowed-variable +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -111,3 +112,18 @@ export function arePathsSame(path1: string, path2: string) { return path1 === path2; } } + +function getRandom(): number { + let num: number = 0; + + const buf: Buffer = crypto.randomBytes(2); + num = (buf.readUInt8(0) << 8) + buf.readUInt8(1); + + const maxValue: number = Math.pow(16, 4) - 1; + return (num / maxValue); +} + +export function getRandomBetween(min: number = 0, max: number = 10): number { + const randomVal: number = getRandom(); + return min + (randomVal * (max - min)); +} diff --git a/src/client/debugger/banner.ts b/src/client/debugger/banner.ts index a5971b7cfe36..480dc604b5e0 100644 --- a/src/client/debugger/banner.ts +++ b/src/client/debugger/banner.ts @@ -8,10 +8,10 @@ import { inject, injectable } from 'inversify'; import { Disposable } from 'vscode'; import { IApplicationEnvironment, IApplicationShell, IDebugService } from '../common/application/types'; import '../common/extensions'; -import { IBrowserService, IDisposableRegistry, ILogger, IPersistentStateFactory } from '../common/types'; +import { IBrowserService, IDisposableRegistry, IExperimentalDebuggerBanner, + ILogger, IPersistentStateFactory } from '../common/types'; import { IServiceContainer } from '../ioc/types'; import { ExperimentalDebuggerType } from './Common/constants'; -import { IExperimentalDebuggerBanner } from './types'; export enum PersistentStateKeys { ShowBanner = 'ShowBanner', diff --git a/src/client/debugger/serviceRegistry.ts b/src/client/debugger/serviceRegistry.ts index e634d828eed7..efb44040b4cf 100644 --- a/src/client/debugger/serviceRegistry.ts +++ b/src/client/debugger/serviceRegistry.ts @@ -9,16 +9,19 @@ import { FileSystem } from '../common/platform/fileSystem'; import { PlatformService } from '../common/platform/platformService'; import { IFileSystem, IPlatformService } from '../common/platform/types'; import { CurrentProcess } from '../common/process/currentProcess'; -import { ICurrentProcess, ISocketServer } from '../common/types'; +import { BANNER_NAME_LS_SURVEY, BANNER_NAME_PROPOSE_LS, ICurrentProcess, + IExperimentalDebuggerBanner, IPythonExtensionBanner, ISocketServer } from '../common/types'; import { ServiceContainer } from '../ioc/container'; import { ServiceManager } from '../ioc/serviceManager'; import { IServiceContainer, IServiceManager } from '../ioc/types'; +import { LanguageServerSurveyBanner } from '../languageServices/languageServerSurveyBanner'; +import { ProposeLanguageServerBanner } from '../languageServices/proposeLanguageServerBanner'; import { ExperimentalDebuggerBanner } from './banner'; import { DebugStreamProvider } from './Common/debugStreamProvider'; import { ProtocolLogger } from './Common/protocolLogger'; import { ProtocolParser } from './Common/protocolParser'; import { ProtocolMessageWriter } from './Common/protocolWriter'; -import { IDebugStreamProvider, IExperimentalDebuggerBanner, IProtocolLogger, IProtocolMessageWriter, IProtocolParser } from './types'; +import { IDebugStreamProvider, IProtocolLogger, IProtocolMessageWriter, IProtocolParser } from './types'; export function initializeIoc(): IServiceContainer { const cont = new Container(); @@ -42,4 +45,6 @@ function registerDebuggerTypes(serviceManager: IServiceManager) { export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IExperimentalDebuggerBanner, ExperimentalDebuggerBanner); + serviceManager.addSingleton(IPythonExtensionBanner, LanguageServerSurveyBanner, BANNER_NAME_LS_SURVEY); + serviceManager.addSingleton(IPythonExtensionBanner, ProposeLanguageServerBanner, BANNER_NAME_PROPOSE_LS); } diff --git a/src/client/debugger/types.ts b/src/client/debugger/types.ts index 6a641a08e8ad..c34031b082af 100644 --- a/src/client/debugger/types.ts +++ b/src/client/debugger/types.ts @@ -38,13 +38,3 @@ export interface IProtocolMessageWriter { } export const IDebugConfigurationProvider = Symbol('DebugConfigurationProvider'); - -export const IExperimentalDebuggerBanner = Symbol('IExperimentalDebuggerBanner'); -export interface IExperimentalDebuggerBanner { - enabled: boolean; - initialize(): void; - showBanner(): Promise; - shouldShowBanner(): Promise; - disable(): Promise; - launchSurvey(): Promise; -} diff --git a/src/client/extension.ts b/src/client/extension.ts index d3528183e022..2761d4b6b863 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -26,13 +26,15 @@ import { registerTypes as platformRegisterTypes } from './common/platform/servic import { registerTypes as processRegisterTypes } from './common/process/serviceRegistry'; import { registerTypes as commonRegisterTypes } from './common/serviceRegistry'; import { ITerminalHelper } from './common/terminal/types'; -import { GLOBAL_MEMENTO, IConfigurationService, IDisposableRegistry, IExtensionContext, ILogger, IMemento, IOutputChannel, IPersistentStateFactory, WORKSPACE_MEMENTO } from './common/types'; +import { GLOBAL_MEMENTO, IConfigurationService, IDisposableRegistry, + IExperimentalDebuggerBanner, IExtensionContext, ILogger, IMemento, IOutputChannel, + IPersistentStateFactory, WORKSPACE_MEMENTO } from './common/types'; import { registerTypes as variableRegisterTypes } from './common/variables/serviceRegistry'; import { AttachRequestArguments, LaunchRequestArguments } from './debugger/Common/Contracts'; import { BaseConfigurationProvider } from './debugger/configProviders/baseProvider'; import { registerTypes as debugConfigurationRegisterTypes } from './debugger/configProviders/serviceRegistry'; import { registerTypes as debuggerRegisterTypes } from './debugger/serviceRegistry'; -import { IDebugConfigurationProvider, IExperimentalDebuggerBanner } from './debugger/types'; +import { IDebugConfigurationProvider } from './debugger/types'; import { registerTypes as formattersRegisterTypes } from './formatters/serviceRegistry'; import { IInterpreterSelector } from './interpreter/configuration/types'; import { ICondaService, IInterpreterService, PythonInterpreter } from './interpreter/contracts'; diff --git a/src/client/languageServices/languageServerSurveyBanner.ts b/src/client/languageServices/languageServerSurveyBanner.ts new file mode 100644 index 000000000000..7a375df9bb49 --- /dev/null +++ b/src/client/languageServices/languageServerSurveyBanner.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { IApplicationShell } from '../common/application/types'; +import '../common/extensions'; +import { IBrowserService, IPersistentStateFactory, + IPythonExtensionBanner } from '../common/types'; +import { getRandomBetween } from '../common/utils'; + +// persistent state names, exported to make use of in testing +export enum LSSurveyStateKeys { + ShowBanner = 'ShowLSSurveyBanner', + ShowAttemptCounter = 'LSSurveyShowAttempt', + ShowAfterCompletionCount = 'LSSurveyShowCount' +} + +enum LSSurveyLabelIndex { + Yes, + No +} + +/* +This class represents a popup that will ask our users for some feedback after +a specific event occurs N times. +*/ +@injectable() +export class LanguageServerSurveyBanner implements IPythonExtensionBanner { + private disabledInCurrentSession: boolean = false; + private minCompletionsBeforeShow: number; + private maxCompletionsBeforeShow: number; + private isInitialized: boolean = false; + private bannerMessage: string = 'Can you please take 2 minutes to tell us how the Experimental Debugger is working for you?'; + private bannerLabels: string [] = [ 'Yes, take survey now', 'No, thanks']; + + constructor( + @inject(IApplicationShell) private appShell: IApplicationShell, + @inject(IPersistentStateFactory) private persistentState: IPersistentStateFactory, + @inject(IBrowserService) private browserService: IBrowserService, + showAfterMinimumEventsCount: number = 100, + showBeforeMaximumEventsCount: number = 500) + { + this.minCompletionsBeforeShow = showAfterMinimumEventsCount; + this.maxCompletionsBeforeShow = showBeforeMaximumEventsCount; + this.initialize(); + } + + public initialize(): void { + if (this.isInitialized) { + return; + } + this.isInitialized = true; + + if (this.minCompletionsBeforeShow >= this.maxCompletionsBeforeShow) { + this.disable().ignoreErrors(); + } + } + + public get optionLabels(): string[] { + return this.bannerLabels; + } + + public get shownCount(): Promise { + return this.getPythonLSLaunchCounter(); + } + + public get enabled(): boolean { + return this.persistentState.createGlobalPersistentState(LSSurveyStateKeys.ShowBanner, true).value; + } + + public async showBanner(): Promise { + if (!this.enabled || this.disabledInCurrentSession) { + return; + } + + const launchCounter: number = await this.incrementPythonLanguageServiceLaunchCounter(); + const show = await this.shouldShowBanner(launchCounter); + if (!show) { + return; + } + + const response = await this.appShell.showInformationMessage(this.bannerMessage, ...this.bannerLabels); + switch (response) { + case this.bannerLabels[LSSurveyLabelIndex.Yes]: + { + await this.launchSurvey(); + await this.disable(); + break; + } + case this.bannerLabels[LSSurveyLabelIndex.No]: { + await this.disable(); + break; + } + default: { + // Disable for the current session. + this.disabledInCurrentSession = true; + } + } + } + + public async shouldShowBanner(launchCounter?: number): Promise { + if (!this.enabled || this.disabledInCurrentSession) { + return false; + } + + if (! launchCounter) { + launchCounter = await this.getPythonLSLaunchCounter(); + } + const threshold: number = await this.getPythonLSLaunchThresholdCounter(); + + return launchCounter >= threshold; + } + + public async disable(): Promise { + await this.persistentState.createGlobalPersistentState(LSSurveyStateKeys.ShowBanner, false).updateValue(false); + } + + public async launchSurvey(): Promise { + const launchCounter = await this.getPythonLSLaunchCounter(); + this.browserService.launch(`https://www.research.net/r/LJZV9BZ?n=${launchCounter}`); + } + + private async incrementPythonLanguageServiceLaunchCounter(): Promise { + const state = this.persistentState.createGlobalPersistentState(LSSurveyStateKeys.ShowAttemptCounter, 0); + await state.updateValue(state.value + 1); + return state.value; + } + + private async getPythonLSLaunchCounter(): Promise { + const state = this.persistentState.createGlobalPersistentState(LSSurveyStateKeys.ShowAttemptCounter, 0); + return state.value; + } + + private async getPythonLSLaunchThresholdCounter(): Promise { + const state = this.persistentState.createGlobalPersistentState(LSSurveyStateKeys.ShowAfterCompletionCount, undefined); + if (state.value === undefined) { + await state.updateValue(getRandomBetween(this.minCompletionsBeforeShow, this.maxCompletionsBeforeShow)); + } + return state.value!; + } +} diff --git a/src/client/languageServices/proposeLanguageServerBanner.ts b/src/client/languageServices/proposeLanguageServerBanner.ts new file mode 100644 index 000000000000..7ae5dc6b5b98 --- /dev/null +++ b/src/client/languageServices/proposeLanguageServerBanner.ts @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { ConfigurationTarget } from 'vscode'; +import { IApplicationShell } from '../common/application/types'; +import '../common/extensions'; +import { IConfigurationService, IPersistentStateFactory, + IPythonExtensionBanner } from '../common/types'; +import { getRandomBetween } from '../common/utils'; + +// persistent state names, exported to make use of in testing +export enum ProposeLSStateKeys { + ShowBanner = 'ProposeLSBanner' +} + +enum ProposeLSLabelIndex { + Yes, + No, + Later +} + +/* +This class represents a popup that propose that the user try out a new +feature of the extension, and optionally enable that new feature if they +choose to do so. It is meant to be shown only to a subset of our users, +and will show as soon as it is instructed to do so, if a random sample +function enables the popup for this user. +*/ +@injectable() +export class ProposeLanguageServerBanner implements IPythonExtensionBanner { + private initialized?: boolean; + private disabledInCurrentSession: boolean = false; + private sampleSizePerHundred: number; + private bannerMessage: string = 'Try out Preview of our new Python Language Server to get richer and faster IntelliSense completions, and syntax errors as you type.'; + private bannerLabels: string[] = [ 'Try it now', 'No thanks', 'Remind me Later' ]; + + constructor( + @inject(IApplicationShell) private appShell: IApplicationShell, + @inject(IPersistentStateFactory) private persistentState: IPersistentStateFactory, + @inject(IConfigurationService) private configuration: IConfigurationService, + sampleSizePerOneHundredUsers: number = 10) + { + this.sampleSizePerHundred = sampleSizePerOneHundredUsers; + this.initialize(); + } + + public initialize() { + if (this.initialized) { + return; + } + this.initialized = true; + + // Don't even bother adding handlers if banner has been turned off. + if (!this.enabled) { + return; + } + + // we only want 10% of folks that use Jedi to see this survey. + const randomSample: number = getRandomBetween(0, 100); + if (randomSample >= this.sampleSizePerHundred) { + this.disable().ignoreErrors(); + return; + } + } + + public get shownCount(): Promise { + return Promise.resolve(-1); // we don't count this popup banner! + } + + public get optionLabels(): string[] { + return this.bannerLabels; + } + + public get enabled(): boolean { + return this.persistentState.createGlobalPersistentState(ProposeLSStateKeys.ShowBanner, true).value; + } + + public async showBanner(): Promise { + if (!this.enabled) { + return; + } + + const show = await this.shouldShowBanner(); + if (!show) { + return; + } + + const response = await this.appShell.showInformationMessage(this.bannerMessage, ...this.bannerLabels); + switch (response) { + case this.bannerLabels[ProposeLSLabelIndex.Yes]: { + await this.enableNewLanguageServer(); + await this.disable(); + break; + } + case this.bannerLabels[ProposeLSLabelIndex.No]: { + await this.disable(); + break; + } + case this.bannerLabels[ProposeLSLabelIndex.Later]: { + this.disabledInCurrentSession = true; + break; + } + default: { + // Disable for the current session. + this.disabledInCurrentSession = true; + } + } + } + + public async shouldShowBanner(): Promise { + return Promise.resolve(this.enabled && !this.disabledInCurrentSession); + } + + public async disable(): Promise { + await this.persistentState.createGlobalPersistentState(ProposeLSStateKeys.ShowBanner, false).updateValue(false); + } + + public async enableNewLanguageServer(): Promise { + await this.configuration.updateSettingAsync('jediEnabled', false, undefined, ConfigurationTarget.Global); + } +} diff --git a/src/client/providers/jediProxy.ts b/src/client/providers/jediProxy.ts index 73e1dbe44992..962b6dc8c0b0 100644 --- a/src/client/providers/jediProxy.ts +++ b/src/client/providers/jediProxy.ts @@ -6,14 +6,16 @@ import { ChildProcess } from 'child_process'; import * as fs from 'fs-extra'; import * as path from 'path'; import * as pidusage from 'pidusage'; -import { CancellationToken, CancellationTokenSource, CompletionItemKind, Disposable, SymbolKind, Uri } from 'vscode'; +import { CancellationToken, CancellationTokenSource, CompletionItemKind, + Disposable, SymbolKind, Uri } from 'vscode'; import { PythonSettings } from '../common/configSettings'; +import { isTestExecution } from '../common/constants'; import { debounce, swallowExceptions } from '../common/decorators'; import '../common/extensions'; import { createDeferred, Deferred } from '../common/helpers'; import { IPythonExecutionFactory } from '../common/process/types'; import { StopWatch } from '../common/stopWatch'; -import { ILogger } from '../common/types'; +import { BANNER_NAME_PROPOSE_LS, ILogger, IPythonExtensionBanner } from '../common/types'; import { IEnvironmentVariablesProvider } from '../common/variables/types'; import { IServiceContainer } from '../ioc/types'; import * as logger from './../common/logger'; @@ -148,6 +150,7 @@ export class JediProxy implements Disposable { private pidUsageFailures = { timer: new StopWatch(), counter: 0 }; private lastCmdIdProcessed?: number; private lastCmdIdProcessedForPidUsage?: number; + private proposeNewLanguageServerPopup: IPythonExtensionBanner; public constructor(private extensionRootDir: string, workspacePath: string, private serviceContainer: IServiceContainer) { this.workspacePath = workspacePath; @@ -158,6 +161,8 @@ export class JediProxy implements Disposable { this.initialized = createDeferred(); this.startLanguageServer().then(() => this.initialized.resolve()).ignoreErrors(); + this.proposeNewLanguageServerPopup = serviceContainer.get(IPythonExtensionBanner, BANNER_NAME_PROPOSE_LS); + this.checkJediMemoryFootprint().ignoreErrors(); } @@ -292,6 +297,9 @@ export class JediProxy implements Disposable { private async startLanguageServer(): Promise { const newAutoComletePaths = await this.buildAutoCompletePaths(); this.additionalAutoCompletePaths = newAutoComletePaths; + if (!isTestExecution()) { + await this.proposeNewLanguageServerPopup.showBanner(); + } return this.restartLanguageServer(); } private restartLanguageServer(): Promise { diff --git a/src/client/unittests/pytest/services/argsService.ts b/src/client/unittests/pytest/services/argsService.ts index c539fdbae265..e8942a840b9f 100644 --- a/src/client/unittests/pytest/services/argsService.ts +++ b/src/client/unittests/pytest/services/argsService.ts @@ -9,6 +9,7 @@ import { IArgumentsHelper, IArgumentsService, TestFilter } from '../../types'; const OptionsWithArguments = ['-c', '-k', '-m', '-o', '-p', '-r', '-W', '--assert', '--basetemp', '--capture', '--color', '--confcutdir', + '--cov', '--cov-config', '--cov-fail-under', '--cov-report', '--deselect', '--dist', '--doctest-glob', '--doctest-report', '--durations', '--ignore', '--import-mode', '--junit-prefix', '--junit-xml', '--last-failed-no-failures', @@ -21,12 +22,14 @@ const OptionsWithArguments = ['-c', '-k', '-m', '-o', '-p', '-r', '-W', '--numprocesses', '--rsyncdir', '--rsyncignore', '--tx']; const OptionsWithoutArguments = ['--cache-clear', '--cache-show', '--collect-in-virtualenv', - '--collect-only', '--continue-on-collection-errors', '--debug', '--disable-pytest-warnings', + '--collect-only', '--continue-on-collection-errors', + '--cov-append', '--cov-branch', '--debug', '--disable-pytest-warnings', '--disable-warnings', '--doctest-continue-on-failure', '--doctest-ignore-import-errors', '--doctest-modules', '--exitfirst', '--failed-first', '--ff', '--fixtures', '--fixtures-per-test', '--force-sugar', '--full-trace', '--funcargs', '--help', '--keep-duplicates', '--last-failed', '--lf', '--markers', '--new-first', '--nf', - '--no-print-logs', '--noconftest', '--old-summary', '--pdb', '--pyargs', + '--no-cov', '--no-cov-on-fail', + '--no-print-logs', '--noconftest', '--old-summary', '--pdb', '--pyargs', '-PyTest, Unittest-pyargs', '--quiet', '--runxfail', '--setup-only', '--setup-plan', '--setup-show', '--showlocals', '--strict', '--trace-config', '--verbose', '--version', '-h', '-l', '-q', '-s', '-v', '-x', '--boxed', '--forked', '--looponfail', '--tx', '-d']; diff --git a/src/test/debugger/banner.unit.test.ts b/src/test/debugger/banner.unit.test.ts index 22706fea11ae..59ac609c5c02 100644 --- a/src/test/debugger/banner.unit.test.ts +++ b/src/test/debugger/banner.unit.test.ts @@ -9,10 +9,10 @@ import { expect } from 'chai'; import * as typemoq from 'typemoq'; import { DebugSession } from 'vscode'; import { IApplicationShell, IDebugService } from '../../client/common/application/types'; -import { IBrowserService, IDisposableRegistry, ILogger, IPersistentState, IPersistentStateFactory } from '../../client/common/types'; +import { IBrowserService, IDisposableRegistry, IExperimentalDebuggerBanner, + ILogger, IPersistentState, IPersistentStateFactory } from '../../client/common/types'; import { ExperimentalDebuggerBanner, PersistentStateKeys } from '../../client/debugger/banner'; import { ExperimentalDebuggerType } from '../../client/debugger/Common/constants'; -import { IExperimentalDebuggerBanner } from '../../client/debugger/types'; import { IServiceContainer } from '../../client/ioc/types'; suite('Debugging - Banner', () => { diff --git a/src/test/unittests/banners/languageServerSurvey.unit.test.ts b/src/test/unittests/banners/languageServerSurvey.unit.test.ts new file mode 100644 index 000000000000..bf3b1660cab8 --- /dev/null +++ b/src/test/unittests/banners/languageServerSurvey.unit.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any max-func-body-length + +import { expect } from 'chai'; +import * as typemoq from 'typemoq'; +import { IApplicationShell } from '../../../client/common/application/types'; +import { IBrowserService, IConfigurationService, IPersistentState, IPersistentStateFactory } from '../../../client/common/types'; +import { LanguageServerSurveyBanner, LSSurveyStateKeys } from '../../../client/languageServices/languageServerSurveyBanner'; + +suite('Language Server Survey Banner', () => { + let config: typemoq.IMock; + let appShell: typemoq.IMock; + let browser: typemoq.IMock; + const message = 'Can you please take 2 minutes to tell us how the Experimental Debugger is working for you?'; + const yes = 'Yes, take survey now'; + const no = 'No, thanks'; + + setup(() => { + config = typemoq.Mock.ofType(); + appShell = typemoq.Mock.ofType(); + browser = typemoq.Mock.ofType(); + }); + test('Is debugger enabled upon creation?', () => { + const enabledValue: boolean = true; + const attemptCounter: number = 0; + const completionsCount: number = 0; + const testBanner: LanguageServerSurveyBanner = preparePopup(attemptCounter, completionsCount, enabledValue, 0, 100, appShell.object, browser.object); + expect(testBanner.enabled).to.be.equal(true, 'Sampling 100/100 should always enable the banner.'); + }); + test('Do not show banner when it is disabled', () => { + appShell.setup(a => a.showInformationMessage(typemoq.It.isValue(message), + typemoq.It.isValue(yes), + typemoq.It.isValue(no))) + .verifiable(typemoq.Times.never()); + const enabledValue: boolean = true; + const attemptCounter: number = 0; + const completionsCount: number = 0; + const testBanner: LanguageServerSurveyBanner = preparePopup(attemptCounter, completionsCount, enabledValue, 0, 0, appShell.object, browser.object); + testBanner.showBanner().ignoreErrors(); + }); + test('shouldShowBanner must return false when Banner is implicitly disabled by sampling', () => { + const enabledValue: boolean = true; + const attemptCounter: number = 0; + const completionsCount: number = 0; + const testBanner: LanguageServerSurveyBanner = preparePopup(attemptCounter, completionsCount, enabledValue, 0, 0, appShell.object, browser.object); + expect(testBanner.enabled).to.be.equal(false, 'We implicitly disabled the banner, it should never show.'); + }); +}); + +function preparePopup(attemptCounter: number, completionsCount: number, enabledValue: boolean, minCompletionCount: number, maxCompletionCount: number, appShell: IApplicationShell, browser: IBrowserService): LanguageServerSurveyBanner { + const myfactory: typemoq.IMock = typemoq.Mock.ofType(); + const enabledValState: typemoq.IMock> = typemoq.Mock.ofType>(); + const attemptCountState: typemoq.IMock> = typemoq.Mock.ofType>(); + const completionCountState: typemoq.IMock> = typemoq.Mock.ofType>(); + + enabledValState.setup(a => a.updateValue(typemoq.It.isValue(true))).returns(() => { + enabledValue = true; + return Promise.resolve(); + }); + enabledValState.setup(a => a.updateValue(typemoq.It.isValue(false))).returns(() => { + enabledValue = false; + return Promise.resolve(); + }); + + attemptCountState.setup(a => a.updateValue(typemoq.It.isAnyNumber())).returns(() => { + attemptCounter += 1; + return Promise.resolve(); + }); + + completionCountState.setup(a => a.updateValue(typemoq.It.isAnyNumber())).returns(() => { + completionsCount += 1; + return Promise.resolve(); + }); + + enabledValState.setup(a => a.value).returns(() => enabledValue); + attemptCountState.setup(a => a.value).returns(() => attemptCounter); + completionCountState.setup(a => a.value).returns(() => completionsCount); + + myfactory.setup(a => a.createGlobalPersistentState(typemoq.It.isValue(LSSurveyStateKeys.ShowBanner), + typemoq.It.isValue(true))).returns(() => { + return enabledValState.object; + }); + myfactory.setup(a => a.createGlobalPersistentState(typemoq.It.isValue(LSSurveyStateKeys.ShowBanner), + typemoq.It.isValue(false))).returns(() => { + return enabledValState.object; + }); + myfactory.setup(a => a.createGlobalPersistentState(typemoq.It.isValue(LSSurveyStateKeys.ShowAttemptCounter), + typemoq.It.isAnyNumber())).returns(() => { + return attemptCountState.object; + }); + myfactory.setup(a => a.createGlobalPersistentState(typemoq.It.isValue(LSSurveyStateKeys.ShowAfterCompletionCount), + typemoq.It.isAnyNumber())).returns(() => { + return completionCountState.object; + }); + return new LanguageServerSurveyBanner( + appShell, + myfactory.object, + browser, + minCompletionCount, + maxCompletionCount); +} diff --git a/src/test/unittests/banners/proposeNewLanguageServerBanner.unit.test.ts b/src/test/unittests/banners/proposeNewLanguageServerBanner.unit.test.ts new file mode 100644 index 000000000000..e11c7e75d637 --- /dev/null +++ b/src/test/unittests/banners/proposeNewLanguageServerBanner.unit.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +// tslint:disable:no-any max-func-body-length + +import { expect } from 'chai'; +import * as typemoq from 'typemoq'; +import { IApplicationShell } from '../../../client/common/application/types'; +import { IConfigurationService, IPersistentState, IPersistentStateFactory } from '../../../client/common/types'; +import { ProposeLanguageServerBanner, ProposeLSStateKeys } from '../../../client/languageServices/proposeLanguageServerBanner'; + +suite('Propose New Language Server Banner', () => { + let config: typemoq.IMock; + let appShell: typemoq.IMock; + const message = 'Try out Preview of our new Python Language Server to get richer and faster IntelliSense completions, and syntax errors as you type.'; + const yes = 'Try it now'; + const no = 'No thanks'; + const later = 'Remind me Later'; + + setup(() => { + config = typemoq.Mock.ofType(); + appShell = typemoq.Mock.ofType(); + }); + test('Is debugger enabled upon creation?', () => { + const enabledValue: boolean = true; + const testBanner: ProposeLanguageServerBanner = preparePopup(enabledValue, 100, appShell.object, config.object); + expect(testBanner.enabled).to.be.equal(true, 'Sampling 100/100 should always enable the banner.'); + }); + test('Do not show banner when it is disabled', () => { + appShell.setup(a => a.showInformationMessage(typemoq.It.isValue(message), + typemoq.It.isValue(yes), + typemoq.It.isValue(no), + typemoq.It.isValue(later))) + .verifiable(typemoq.Times.never()); + const enabled: boolean = true; + const testBanner: ProposeLanguageServerBanner = preparePopup(enabled, 0, appShell.object, config.object); + testBanner.showBanner().ignoreErrors(); + }); + test('shouldShowBanner must return false when Banner is implicitly disabled by sampling', () => { + const enabled: boolean = true; + const testBanner: ProposeLanguageServerBanner = preparePopup(enabled, 0, appShell.object, config.object); + expect(testBanner.enabled).to.be.equal(false, 'We implicitly disabled the banner, it should never show.'); + }); + test('shouldShowBanner must return false when Banner is explicitly disabled', async () => { + const enabled: boolean = true; + const testBanner: ProposeLanguageServerBanner = preparePopup(enabled, 100, appShell.object, config.object); + + expect(await testBanner.shouldShowBanner()).to.be.equal(true, '100% sample size should always make the banner enabled.'); + await testBanner.disable(); + expect(await testBanner.shouldShowBanner()).to.be.equal(false, 'Explicitly disabled banner shouldShowBanner != false.'); + }); +}); + +function preparePopup(enabledValue: boolean, sampleValue: number, appShell: IApplicationShell, config: IConfigurationService): ProposeLanguageServerBanner { + const myfactory: typemoq.IMock = typemoq.Mock.ofType(); + const val: typemoq.IMock> = typemoq.Mock.ofType>(); + val.setup(a => a.updateValue(typemoq.It.isValue(true))).returns(() => { + enabledValue = true; + return Promise.resolve(); + }); + val.setup(a => a.updateValue(typemoq.It.isValue(false))).returns(() => { + enabledValue = false; + return Promise.resolve(); + }); + val.setup(a => a.value).returns(() => { + return enabledValue; + }); + myfactory.setup(a => a.createGlobalPersistentState(typemoq.It.isValue(ProposeLSStateKeys.ShowBanner), + typemoq.It.isValue(true))) + .returns(() => { + return val.object; + }); + myfactory.setup(a => a.createGlobalPersistentState(typemoq.It.isValue(ProposeLSStateKeys.ShowBanner), + typemoq.It.isValue(false))) + .returns(() => { + return val.object; + }); + return new ProposeLanguageServerBanner( + appShell, + myfactory.object, + config, + sampleValue); +}