diff --git a/package.nls.json b/package.nls.json index 01c38a8b7111..502a6e757c95 100644 --- a/package.nls.json +++ b/package.nls.json @@ -97,6 +97,7 @@ "ExtensionSurveyBanner.bannerLabelYes": "Yes, take survey now", "ExtensionSurveyBanner.bannerLabelNo": "No, thanks", "ExtensionSurveyBanner.maybeLater": "Maybe later", + "ExtensionSurveyBanner.mplsMessage": "Can you please take 2 minutes to tell us about your experience using the Microsoft Python Language Server?", "ExtensionChannels.installingInsidersMessage": "Installing Insiders... ", "ExtensionChannels.installingStableMessage": "Installing Stable... ", "ExtensionChannels.installationCompleteMessage": "complete.", diff --git a/src/client/activation/serviceRegistry.ts b/src/client/activation/serviceRegistry.ts index 00c50b761af2..91d72c078c52 100644 --- a/src/client/activation/serviceRegistry.ts +++ b/src/client/activation/serviceRegistry.ts @@ -61,6 +61,11 @@ import { LanguageServerType, } from './types'; import { JediLanguageServerActivator } from './jedi/activator'; +import { IDiagnosticsService } from '../application/diagnostics/types'; +import { + MPLSSurveyDiagnosticService, + MPLSSurveyDiagnosticServiceId, +} from '../application/diagnostics/checks/mplsSurvey'; export function registerTypes(serviceManager: IServiceManager, languageServerType: LanguageServerType): void { serviceManager.addSingleton(ILanguageServerCache, LanguageServerExtensionActivationService); @@ -117,6 +122,11 @@ export function registerTypes(serviceManager: IServiceManager, languageServerTyp DotNetLanguageServerPackageService, ); registerDotNetTypes(serviceManager); + serviceManager.addSingleton( + IDiagnosticsService, + MPLSSurveyDiagnosticService, + MPLSSurveyDiagnosticServiceId, + ); } else if (languageServerType === LanguageServerType.Node) { serviceManager.add( ILanguageServerAnalysisOptions, diff --git a/src/client/application/diagnostics/checks/mplsSurvey.ts b/src/client/application/diagnostics/checks/mplsSurvey.ts new file mode 100644 index 000000000000..e6e95edf52bb --- /dev/null +++ b/src/client/application/diagnostics/checks/mplsSurvey.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// eslint-disable-next-line max-classes-per-file +import { inject, named } from 'inversify'; +import { DiagnosticSeverity, UIKind } from 'vscode'; +import * as querystring from 'querystring'; +import { IDisposableRegistry, Resource } from '../../../common/types'; +import { ExtensionSurveyBanner } from '../../../common/utils/localize'; +import { IServiceContainer } from '../../../ioc/types'; +import { BaseDiagnostic, BaseDiagnosticsService } from '../base'; +import { DiagnosticCodes } from '../constants'; +import { DiagnosticCommandPromptHandlerServiceId, MessageCommandPrompt } from '../promptHandler'; +import { DiagnosticScope, IDiagnostic, IDiagnosticHandlerService } from '../types'; +import { IApplicationEnvironment } from '../../../common/application/types'; +import { IPlatformService } from '../../../common/platform/types'; +import { IDiagnosticsCommandFactory } from '../commands/types'; + +export class MPLSSurveyDiagnostic extends BaseDiagnostic { + constructor(message: string, resource: Resource) { + super( + DiagnosticCodes.MPLSSurveyDiagnostic, + message, + DiagnosticSeverity.Information, + DiagnosticScope.Global, + resource, + ); + } +} + +export const MPLSSurveyDiagnosticServiceId = 'MPLSSurveyDiagnosticServiceId'; + +export class MPLSSurveyDiagnosticService extends BaseDiagnosticsService { + constructor( + @inject(IServiceContainer) serviceContainer: IServiceContainer, + @inject(IDiagnosticHandlerService) + @named(DiagnosticCommandPromptHandlerServiceId) + protected readonly messageService: IDiagnosticHandlerService, + @inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry, + @inject(IApplicationEnvironment) private appEnvironment: IApplicationEnvironment, + @inject(IPlatformService) private platformService: IPlatformService, + ) { + super([DiagnosticCodes.MPLSSurveyDiagnostic], serviceContainer, disposableRegistry, true); + } + + public async diagnose(resource: Resource): Promise { + if (this.appEnvironment.uiKind === UIKind?.Web) { + return []; + } + + return [new MPLSSurveyDiagnostic(ExtensionSurveyBanner.mplsMessage(), resource)]; + } + + protected async onHandle(diagnostics: IDiagnostic[]): Promise { + if (diagnostics.length === 0 || !this.canHandle(diagnostics[0])) { + return; + } + + const diagnostic = diagnostics[0]; + if (await this.filterService.shouldIgnoreDiagnostic(diagnostic.code)) { + return; + } + + const commandFactory = this.serviceContainer.get(IDiagnosticsCommandFactory); + + await this.messageService.handle(diagnostic, { + commandPrompts: [ + { + prompt: ExtensionSurveyBanner.bannerLabelYes(), + command: { + diagnostic, + invoke: () => this.launchSurvey(diagnostic), + }, + }, + { + prompt: ExtensionSurveyBanner.maybeLater(), + }, + { + prompt: ExtensionSurveyBanner.bannerLabelNo(), + command: commandFactory.createCommand(diagnostic, { + type: 'ignore', + options: DiagnosticScope.Global, + }), + }, + ], + }); + } + + private async launchSurvey(diagnostic: IDiagnostic) { + const query = querystring.stringify({ + o: encodeURIComponent(this.platformService.osType), // platform + v: encodeURIComponent(this.appEnvironment.vscodeVersion), + e: encodeURIComponent(this.appEnvironment.packageJson.version), // extension version + m: encodeURIComponent(this.appEnvironment.sessionId), + }); + const url = `https://aka.ms/mpls-experience-survey?${query}`; + + const commandFactory = this.serviceContainer.get(IDiagnosticsCommandFactory); + await commandFactory.createCommand(diagnostic, { type: 'ignore', options: DiagnosticScope.Global }).invoke(); + await commandFactory.createCommand(diagnostic, { type: 'launch', options: url }).invoke(); + } +} diff --git a/src/client/application/diagnostics/constants.ts b/src/client/application/diagnostics/constants.ts index f03564ff2c42..09e7c16935e4 100644 --- a/src/client/application/diagnostics/constants.ts +++ b/src/client/application/diagnostics/constants.ts @@ -20,4 +20,5 @@ export enum DiagnosticCodes { ConfigPythonPathDiagnostic = 'ConfigPythonPathDiagnostic', UpgradeCodeRunnerDiagnostic = 'UpgradeCodeRunnerDiagnostic', PylanceDefaultDiagnostic = 'PylanceDefaultDiagnostic', + MPLSSurveyDiagnostic = 'MPLSSurveyDiagnostic', } diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index d266f9805eef..6e0319a0b411 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -416,6 +416,10 @@ export namespace ExtensionSurveyBanner { export const bannerLabelYes = localize('ExtensionSurveyBanner.bannerLabelYes', 'Yes, take survey now'); export const bannerLabelNo = localize('ExtensionSurveyBanner.bannerLabelNo', 'No, thanks'); export const maybeLater = localize('ExtensionSurveyBanner.maybeLater', 'Maybe later'); + export const mplsMessage = localize( + 'ExtensionSurveyBanner.mplsMessage', + 'Can you please take 2 minutes to tell us about your experience using the Microsoft Python Language Server?', + ); } export namespace Products { diff --git a/src/test/application/diagnostics/checks/mplsSurvey.unit.test.ts b/src/test/application/diagnostics/checks/mplsSurvey.unit.test.ts new file mode 100644 index 000000000000..9d8f3e53212e --- /dev/null +++ b/src/test/application/diagnostics/checks/mplsSurvey.unit.test.ts @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import * as typemoq from 'typemoq'; +import { UIKind } from 'vscode'; +import { BaseDiagnosticsService } from '../../../../client/application/diagnostics/base'; +import { + MPLSSurveyDiagnostic, + MPLSSurveyDiagnosticService, +} from '../../../../client/application/diagnostics/checks/mplsSurvey'; +import { CommandOption, IDiagnosticsCommandFactory } from '../../../../client/application/diagnostics/commands/types'; +import { DiagnosticCodes } from '../../../../client/application/diagnostics/constants'; +import { MessageCommandPrompt } from '../../../../client/application/diagnostics/promptHandler'; +import { + DiagnosticScope, + IDiagnostic, + IDiagnosticCommand, + IDiagnosticFilterService, + IDiagnosticHandlerService, + IDiagnosticsService, +} from '../../../../client/application/diagnostics/types'; +import { IApplicationEnvironment } from '../../../../client/common/application/types'; +import { IPlatformService } from '../../../../client/common/platform/types'; +import { ExtensionSurveyBanner } from '../../../../client/common/utils/localize'; +import { OSType } from '../../../../client/common/utils/platform'; +import { IServiceContainer } from '../../../../client/ioc/types'; + +suite('Application Diagnostics - MPLS survey', () => { + let serviceContainer: typemoq.IMock; + let diagnosticService: IDiagnosticsService; + let commandFactory: typemoq.IMock; + let filterService: typemoq.IMock; + let messageHandler: typemoq.IMock>; + let appEnvironment: typemoq.IMock; + let platformService: typemoq.IMock; + + setup(() => { + serviceContainer = typemoq.Mock.ofType(); + filterService = typemoq.Mock.ofType(); + messageHandler = typemoq.Mock.ofType>(); + appEnvironment = typemoq.Mock.ofType(); + platformService = typemoq.Mock.ofType(); + + commandFactory = typemoq.Mock.ofType(); + serviceContainer + .setup((s) => s.get(typemoq.It.isValue(IDiagnosticFilterService))) + .returns(() => filterService.object); + serviceContainer + .setup((s) => s.get(typemoq.It.isValue(IDiagnosticsCommandFactory))) + .returns(() => commandFactory.object); + + diagnosticService = new (class extends MPLSSurveyDiagnosticService { + // eslint-disable-next-line class-methods-use-this + public _clear() { + while (BaseDiagnosticsService.handledDiagnosticCodeKeys.length > 0) { + BaseDiagnosticsService.handledDiagnosticCodeKeys.shift(); + } + } + })(serviceContainer.object, messageHandler.object, [], appEnvironment.object, platformService.object); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (diagnosticService as any)._clear(); + }); + + test('Should diagnose survey', async () => { + appEnvironment.setup((a) => a.uiKind).returns(() => UIKind.Desktop); + const diagnostics = await diagnosticService.diagnose(undefined); + + expect(diagnostics).to.be.deep.equal([ + new MPLSSurveyDiagnostic(ExtensionSurveyBanner.mplsMessage(), undefined), + ]); + }); + + test('Should not diagnose if in web UI', async () => { + appEnvironment.setup((a) => a.uiKind).returns(() => UIKind.Web); + + const diagnostics = await diagnosticService.diagnose(undefined); + + expect(diagnostics).to.be.lengthOf(0); + }); + + test('Should display a prompt when handling the diagnostic code', async () => { + const diagnostic = new MPLSSurveyDiagnostic(DiagnosticCodes.MPLSSurveyDiagnostic, undefined); + let messagePrompt: MessageCommandPrompt | undefined; + + messageHandler + .setup((f) => f.handle(typemoq.It.isValue(diagnostic), typemoq.It.isAny())) + .callback((_d, prompt: MessageCommandPrompt) => { + messagePrompt = prompt; + }) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + + const alwaysIgnoreCommand = typemoq.Mock.ofType(); + commandFactory + .setup((f) => + f.createCommand( + typemoq.It.isAny(), + typemoq.It.isObjectWith>({ + type: 'ignore', + options: DiagnosticScope.Global, + }), + ), + ) + .returns(() => alwaysIgnoreCommand.object) + .verifiable(typemoq.Times.once()); + + alwaysIgnoreCommand.setup((c) => c.invoke()).verifiable(typemoq.Times.never()); + + await diagnosticService.handle([diagnostic]); + + filterService.verifyAll(); + messageHandler.verifyAll(); + commandFactory.verifyAll(); + alwaysIgnoreCommand.verifyAll(); + + expect(messagePrompt).to.not.be.equal(undefined); + expect(messagePrompt!.onClose).to.be.equal(undefined, 'onClose was not undefined'); + expect(messagePrompt!.commandPrompts).to.be.lengthOf(3); + + expect(messagePrompt!.commandPrompts[0].prompt).to.be.equal(ExtensionSurveyBanner.bannerLabelYes()); + expect(messagePrompt!.commandPrompts[0].command).to.not.be.equal(undefined, 'Yes command was undefined'); + expect(messagePrompt!.commandPrompts[1].prompt).to.be.equal(ExtensionSurveyBanner.maybeLater()); + expect(messagePrompt!.commandPrompts[1].command).to.be.equal(undefined, 'Later command was not undefined'); + expect(messagePrompt!.commandPrompts[2].prompt).to.be.equal(ExtensionSurveyBanner.bannerLabelNo()); + expect(messagePrompt!.commandPrompts[2].command).to.be.equal(alwaysIgnoreCommand.object); + }); + + test('Should return empty diagnostics if the diagnostic code has been ignored', async () => { + const diagnostic = new MPLSSurveyDiagnostic(DiagnosticCodes.MPLSSurveyDiagnostic, undefined); + + filterService + .setup((f) => f.shouldIgnoreDiagnostic(typemoq.It.isValue(DiagnosticCodes.MPLSSurveyDiagnostic))) + .returns(() => Promise.resolve(true)) + .verifiable(typemoq.Times.once()); + + messageHandler.setup((f) => f.handle(typemoq.It.isAny(), typemoq.It.isAny())).verifiable(typemoq.Times.never()); + + await diagnosticService.handle([diagnostic]); + + filterService.verifyAll(); + messageHandler.verifyAll(); + }); + + test('MPLSSurveyDiagnosticService can handle MPLSSurveyDiagnostic diagnostics', async () => { + const diagnostic = typemoq.Mock.ofType(); + diagnostic + .setup((d) => d.code) + .returns(() => DiagnosticCodes.MPLSSurveyDiagnostic) + .verifiable(typemoq.Times.atLeastOnce()); + + const canHandle = await diagnosticService.canHandle(diagnostic.object); + + expect(canHandle).to.be.equal(true, 'Invalid value'); + diagnostic.verifyAll(); + }); + + test('MPLSSurveyDiagnosticService cannot handle non-MPLSSurveyDiagnostic diagnostics', async () => { + const diagnostic = typemoq.Mock.ofType(); + diagnostic + .setup((d) => d.code) + .returns(() => DiagnosticCodes.EnvironmentActivationInPowerShellWithBatchFilesNotSupportedDiagnostic) + .verifiable(typemoq.Times.atLeastOnce()); + + const canHandle = await diagnosticService.canHandle(diagnostic.object); + + expect(canHandle).to.be.equal(false, 'Invalid value'); + diagnostic.verifyAll(); + }); + + test('Should open brower with info on yes', async () => { + const diagnostic = new MPLSSurveyDiagnostic(DiagnosticCodes.MPLSSurveyDiagnostic, undefined); + let messagePrompt: MessageCommandPrompt | undefined; + + messageHandler + .setup((f) => f.handle(typemoq.It.isValue(diagnostic), typemoq.It.isAny())) + .callback((_d, prompt: MessageCommandPrompt) => { + messagePrompt = prompt; + }) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + + const alwaysIgnoreCommand = typemoq.Mock.ofType(); + commandFactory + .setup((f) => + f.createCommand( + typemoq.It.isAny(), + typemoq.It.isObjectWith>({ + type: 'ignore', + options: DiagnosticScope.Global, + }), + ), + ) + .returns(() => alwaysIgnoreCommand.object) + .verifiable(typemoq.Times.once()); + + alwaysIgnoreCommand + .setup((c) => c.invoke()) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + + platformService + .setup((p) => p.osType) + .returns(() => OSType.Linux) + .verifiable(typemoq.Times.once()); + + appEnvironment + .setup((a) => a.vscodeVersion) + .returns(() => '1.56.2') + .verifiable(typemoq.Times.once()); + + appEnvironment + .setup((a) => a.packageJson) + .returns(() => ({ version: '2021.6.0' })) + .verifiable(typemoq.Times.once()); + + appEnvironment + .setup((a) => a.sessionId) + .returns(() => 'session-id') + .verifiable(typemoq.Times.once()); + + const launchCommand = typemoq.Mock.ofType(); + commandFactory + .setup((f) => + f.createCommand( + typemoq.It.isAny(), + typemoq.It.isObjectWith>({ + type: 'launch', + options: 'https://aka.ms/mpls-experience-survey?o=Linux&v=1.56.2&e=2021.6.0&m=session-id', + }), + ), + ) + .returns(() => launchCommand.object) + .verifiable(typemoq.Times.once()); + + launchCommand + .setup((c) => c.invoke()) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + + await diagnosticService.handle([diagnostic]); + + filterService.verifyAll(); + messageHandler.verifyAll(); + + await messagePrompt!.commandPrompts[0].command!.invoke(); + + platformService.verifyAll(); + appEnvironment.verifyAll(); + alwaysIgnoreCommand.verifyAll(); + launchCommand.verifyAll(); + }); +});