diff --git a/package.json b/package.json index 2c23f449..676cff60 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "menus": { "commandPalette": [ { - "command": "salesforcedx-vscode-offline-app.configureLintingTools", + "command": "salesforcedx-vscode-offline-app.configure-linting-tools", "when": "sfdx_project_opened" } ] @@ -53,7 +53,7 @@ "category": "%extension.commands.config-wizard.category%" }, { - "command": "salesforcedx-vscode-offline-app.configureLintingTools", + "command": "salesforcedx-vscode-offline-app.configure-linting-tools", "title": "%extension.commands.config-linting-tools.title%", "category": "%extension.commands.salesforce-mobile-offline.category%" } @@ -98,8 +98,8 @@ "lint": "eslint src --ext ts", "test": "node ./out/test/runTest.js", "test-coverage": "node ./out/test/runTest.js --coverage", - "prettier:write": "prettier --write \"src/**/*.{ts, js}\" \"resources/instructions/**/*.html\"", - "prettier:verify": "prettier --list-different \"src/**/*.{ts, js}\" \"resources/instructions/**/*.html\"", + "prettier:write": "prettier --write \"src/**/*.{ts, js}\" \"resources/instructions/**/*.html\" \"src/lsp/server/**/*.{ts, js}\" \"src/lsp/client/**/*.{ts, js}\"", + "prettier:verify": "prettier --list-different \"src/**/*.{ts, js}\" \"resources/instructions/**/*.html\" \"src/lsp/server/**/*.{ts, js}\" \"src/lsp/client/**/*.{ts, js}\"", "bundle:extension": "esbuild ./src/extension.ts --bundle --outdir=out/src --format=cjs --target=es2020 --platform=node --external:vscode --external:@salesforce/core --external:@oclif/core --external:@salesforce/lwc-dev-mobile-core --minify --sourcemap", "vscode:prepublish": "npm run clean && npm run compile && npm run bundle:extension" }, diff --git a/src/commands/lint/configureLintingToolsCommand.ts b/src/commands/lint/configureLintingToolsCommand.ts index 55f4557c..f6f99c8b 100644 --- a/src/commands/lint/configureLintingToolsCommand.ts +++ b/src/commands/lint/configureLintingToolsCommand.ts @@ -10,9 +10,21 @@ import * as fs from 'fs'; import * as path from 'path'; import { WorkspaceUtils } from '../../utils/workspaceUtils'; import { JSON_INDENTATION_SPACES } from '../../utils/constants'; - -const configureLintingToolsCommand = - 'salesforcedx-vscode-offline-app.configureLintingTools'; +import { CoreExtensionService } from '../../services/CoreExtensionService'; + +const commandName = 'salesforcedx-vscode-offline-app.configure-linting-tools'; + +enum MetricEvents { + CONFIGURE_LINTING_TOOLS_COMMAND_STARTED = 'configure-linting-tools-command-started', + UPDATED_PACKAGE_JSON = 'updated-package-json', + UPDATED_ESLINTRC_JSON = 'updated-eslintrc-json', + ALREADY_CONFIGURED = 'already-configured', + LWC_FOLDER_DOES_NOT_EXIST = 'lwc-folder-does-not-exist', + PACKAGE_JSON_DOES_NOT_EXIST = 'package-json-does-not-exist', + ERROR_UPDATING_PACKAGE_JSON = 'error-updating-package-json', + ERROR_UPDATING_ESLINTRC_JSON = 'error-updating-eslintrc-json', + GENERAL_ERROR = 'general-error' +} const config = workspace.getConfiguration(); @@ -66,19 +78,34 @@ enum MessageType { export class ConfigureLintingToolsCommand { static async configure(): Promise { + const telemetryService = CoreExtensionService.getTelemetryService(); + + // Send marker to record that the command got executed. + telemetryService.sendCommandEvent(commandName, process.hrtime(), { + metricEvents: MetricEvents.CONFIGURE_LINTING_TOOLS_COMMAND_STARTED + }); + try { if (!WorkspaceUtils.lwcFolderExists()) { - await this.showMessage( - 'The "force-app/main/default/lwc" folder does not exist in your project. This folder is required to create a configuration file for ESLint.' - ); - return Promise.resolve(false); + const event = `${commandName}.${MetricEvents.LWC_FOLDER_DOES_NOT_EXIST}`; + const message = + 'The "force-app/main/default/lwc" folder does not exist in your project. This folder is required to create a configuration file for ESLint.'; + + await this.showMessage(message); + telemetryService.sendException(event, message); + + return false; } if (!WorkspaceUtils.packageJsonExists()) { - await this.showMessage( - 'Your project does not contain a "package.json" specification. You must have a package specification to configure these ESLint packages and their dependencies in your project.' - ); - return Promise.resolve(false); + const event = `${commandName}.${MetricEvents.PACKAGE_JSON_DOES_NOT_EXIST}`; + const message = + 'Your project does not contain a "package.json" specification. You must have a package specification to configure these ESLint packages and their dependencies in your project.'; + + await this.showMessage(message); + telemetryService.sendException(event, message); + + return false; } // Ask user to add eslint plugin @@ -88,29 +115,40 @@ export class ConfigureLintingToolsCommand { ); if (!result || result.title === l10n.t('No')) { - return Promise.resolve(false); + return false; } else { let modifiedDevDependencies = false; try { modifiedDevDependencies = this.updateDevDependencies(); } catch (error) { - await this.showMessage( - `Error updating package.json: ${error}` - ); - return Promise.resolve(false); + const event = `${commandName}.${MetricEvents.ERROR_UPDATING_PACKAGE_JSON}`; + const message = `Error updating package.json: ${error}`; + + await this.showMessage(message); + telemetryService.sendException(event, message); + + return false; } let modifiedEslintrc = false; try { modifiedEslintrc = this.updateEslintrc(); } catch (error) { - await this.showMessage( - `Error updating .eslintrc.json: ${error}` - ); - return Promise.resolve(false); + const event = `${commandName}.${MetricEvents.ERROR_UPDATING_ESLINTRC_JSON}`; + const message = `Error updating .eslintrc.json: ${error}`; + + await this.showMessage(message); + telemetryService.sendException(event, message); + + return false; } if (modifiedDevDependencies) { + telemetryService.sendCommandEvent( + commandName, + process.hrtime(), + { metricEvents: MetricEvents.UPDATED_PACKAGE_JSON } + ); this.showMessage( `Updated package.json to include offline linting packages and dependencies.`, MessageType.InformationOk @@ -118,6 +156,11 @@ export class ConfigureLintingToolsCommand { } if (modifiedEslintrc) { + telemetryService.sendCommandEvent( + commandName, + process.hrtime(), + { metricEvents: MetricEvents.UPDATED_ESLINTRC_JSON } + ); this.showMessage( `Updated .eslintrc.json to include recommended linting rules.`, MessageType.InformationOk @@ -132,19 +175,27 @@ export class ConfigureLintingToolsCommand { } if (!modifiedDevDependencies && !modifiedEslintrc) { + telemetryService.sendCommandEvent( + commandName, + process.hrtime(), + { metricEvents: MetricEvents.ALREADY_CONFIGURED } + ); this.showMessage( `All offline linting packages and dependencies are already configured in your project. No update has been made to package.json.`, MessageType.InformationOk ); } - return Promise.resolve(true); + return true; } } catch (error) { - await this.showMessage( - `There was an error trying to update either the offline linting dependencies or linting configuration: ${error}` - ); - return Promise.resolve(false); + const event = `${commandName}.${MetricEvents.GENERAL_ERROR}`; + const message = `There was an error trying to update either the offline linting dependencies or linting configuration: ${error}`; + + await this.showMessage(message); + telemetryService.sendException(event, message); + + return false; } } @@ -250,11 +301,8 @@ export class ConfigureLintingToolsCommand { } export function registerCommand(context: ExtensionContext) { - const disposable = commands.registerCommand( - configureLintingToolsCommand, - async () => { - await ConfigureLintingToolsCommand.configure(); - } - ); + const disposable = commands.registerCommand(commandName, async () => { + await ConfigureLintingToolsCommand.configure(); + }); context.subscriptions.push(disposable); } diff --git a/src/commands/wizard/configureProjectCommand.ts b/src/commands/wizard/configureProjectCommand.ts index fd320b7f..c926b25a 100644 --- a/src/commands/wizard/configureProjectCommand.ts +++ b/src/commands/wizard/configureProjectCommand.ts @@ -9,6 +9,8 @@ import { Uri, WebviewPanel, commands, l10n, window } from 'vscode'; import * as process from 'process'; import { CommonUtils } from '@salesforce/lwc-dev-mobile-core/lib/common/CommonUtils'; import { InstructionsWebviewProvider } from '../../webviews/instructions'; +import { wizardCommand } from './onboardingWizard'; +import { CoreExtensionService } from '../../services/CoreExtensionService'; export type ProjectManagementChoiceAction = (panel?: WebviewPanel) => void; @@ -162,6 +164,11 @@ export class ConfigureProjectCommand { } async configureProject(): Promise { + const telemetryService = CoreExtensionService.getTelemetryService(); + + // Send marker to record that the command got executed. + telemetryService.sendCommandEvent(wizardCommand, process.hrtime()); + return new Promise((resolve) => { this.projectConfigurationProcessor.getProjectManagementChoice( async (panel) => { diff --git a/src/commands/wizard/onboardingWizard.ts b/src/commands/wizard/onboardingWizard.ts index f255595a..4b2c3fbb 100644 --- a/src/commands/wizard/onboardingWizard.ts +++ b/src/commands/wizard/onboardingWizard.ts @@ -14,7 +14,7 @@ import { AuthorizeCommand } from './authorizeCommand'; import { InstructionsWebviewProvider } from '../../webviews/instructions'; import { LwcGenerationCommand } from './lwcGenerationCommand'; -const wizardCommand = 'salesforcedx-vscode-offline-app.onboardingWizard'; +export const wizardCommand = 'salesforcedx-vscode-offline-app.onboardingWizard'; const onboardingWizardStateKey = 'salesforcedx-vscode-offline-app.onboardingWizard.projectCreationState'; diff --git a/src/extension.ts b/src/extension.ts index 2602b320..2a1eb924 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,7 +23,7 @@ import { export function activate(context: vscode.ExtensionContext) { // We need to do this first in case any other services need access to those provided by the core extension try { - CoreExtensionService.loadDependencies(); + CoreExtensionService.loadDependencies(context); } catch (err) { console.error(err); vscode.window.showErrorMessage( diff --git a/src/services/CoreExtensionService.ts b/src/services/CoreExtensionService.ts index a07acdeb..23dac147 100644 --- a/src/services/CoreExtensionService.ts +++ b/src/services/CoreExtensionService.ts @@ -5,9 +5,14 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { extensions } from 'vscode'; +import { ExtensionContext, extensions } from 'vscode'; import { satisfies, valid } from 'semver'; -import type { CoreExtensionApi, WorkspaceContext } from '../types'; +import type { + CoreExtensionApi, + WorkspaceContext, + SalesforceProjectConfig, + TelemetryService +} from '../types'; import { CORE_EXTENSION_ID, MINIMUM_REQUIRED_VERSION_CORE_EXTENSION @@ -16,14 +21,18 @@ import { const NOT_INITIALIZED_ERROR = 'CoreExtensionService not initialized'; const CORE_EXTENSION_NOT_FOUND = 'Core extension not found'; const WORKSPACE_CONTEXT_NOT_FOUND = 'Workspace Context not found'; +const SALESFORCE_PROJECT_CONFIG_NOT_FOUND = 'SalesforceProjectConfig not found'; +const TELEMETRY_SERVICE_NOT_FOUND = 'TelemetryService not found'; const coreExtensionMinRequiredVersionError = 'You are running an older version of the Salesforce CLI Integration VSCode Extension. Please update the Salesforce Extension pack and try again.'; export class CoreExtensionService { private static initialized = false; private static workspaceContext: WorkspaceContext; + private static salesforceProjectConfig: SalesforceProjectConfig; + private static telemetryService: TelemetryService; - static loadDependencies() { + static loadDependencies(context: ExtensionContext) { if (!CoreExtensionService.initialized) { const coreExtension = extensions.getExtension(CORE_EXTENSION_ID); if (!coreExtension) { @@ -45,6 +54,15 @@ export class CoreExtensionService { coreExtensionApi?.services.WorkspaceContext ); + CoreExtensionService.initializeSalesforceProjectConfig( + coreExtensionApi?.services.SalesforceProjectConfig + ); + + CoreExtensionService.initializeTelemetryService( + coreExtensionApi?.services.TelemetryService, + context + ); + CoreExtensionService.initialized = true; } } @@ -59,6 +77,33 @@ export class CoreExtensionService { workspaceContext.getInstance(false); } + private static initializeSalesforceProjectConfig( + salesforceProjectConfig: SalesforceProjectConfig | undefined + ) { + if (!salesforceProjectConfig) { + throw new Error(SALESFORCE_PROJECT_CONFIG_NOT_FOUND); + } + CoreExtensionService.salesforceProjectConfig = salesforceProjectConfig; + } + + private static initializeTelemetryService( + telemetryService: TelemetryService | undefined, + context: ExtensionContext + ) { + if (!telemetryService) { + throw new Error(TELEMETRY_SERVICE_NOT_FOUND); + } + const { aiKey, name, version } = context.extension.packageJSON; + CoreExtensionService.telemetryService = + telemetryService.getInstance(name); + CoreExtensionService.telemetryService.initializeService( + context, + name, + aiKey, + version + ); + } + private static isAboveMinimumRequiredVersion( minRequiredVersion: string, actualVersion: string @@ -80,4 +125,18 @@ export class CoreExtensionService { } throw new Error(NOT_INITIALIZED_ERROR); } + + static getSalesforceProjectConfig(): SalesforceProjectConfig { + if (CoreExtensionService.initialized) { + return CoreExtensionService.salesforceProjectConfig; + } + throw new Error(NOT_INITIALIZED_ERROR); + } + + static getTelemetryService(): TelemetryService { + if (CoreExtensionService.initialized) { + return CoreExtensionService.telemetryService; + } + throw new Error(NOT_INITIALIZED_ERROR); + } } diff --git a/src/types/CoreExtensionApi.ts b/src/types/CoreExtensionApi.ts index 0d9f0370..610dc77b 100644 --- a/src/types/CoreExtensionApi.ts +++ b/src/types/CoreExtensionApi.ts @@ -6,10 +6,16 @@ */ import { WorkspaceContext } from './WorkspaceContext'; +import { SalesforceProjectConfig } from './SalesforceProjectConfig'; +import { TelemetryService } from './TelemetryService'; export interface CoreExtensionApi { services: { // eslint-disable-next-line @typescript-eslint/naming-convention WorkspaceContext: WorkspaceContext; + // eslint-disable-next-line @typescript-eslint/naming-convention + SalesforceProjectConfig: SalesforceProjectConfig; + // eslint-disable-next-line @typescript-eslint/naming-convention + TelemetryService: TelemetryService; }; } diff --git a/src/types/SalesforceProjectConfig.ts b/src/types/SalesforceProjectConfig.ts new file mode 100644 index 00000000..54d87a00 --- /dev/null +++ b/src/types/SalesforceProjectConfig.ts @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { SfProjectJson } from '@salesforce/core'; + +export interface SalesforceProjectConfig { + getInstance(): Promise; + getValue(key: string): Promise; +} diff --git a/src/types/TelemetryService.ts b/src/types/TelemetryService.ts new file mode 100644 index 00000000..7b0466aa --- /dev/null +++ b/src/types/TelemetryService.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + **/ + +import { ExtensionContext } from 'vscode'; + +export interface Measurements { + [key: string]: number; +} + +export interface Properties { + [key: string]: string; +} + +//Microsoft Telemetry Reporter used for AppInsights +export type TelemetryReporter = { + sendTelemetryEvent( + eventName: string, + properties?: { [key: string]: string }, + measurements?: { [key: string]: number } + ): void; + + sendExceptionEvent( + exceptionName: string, + exceptionMessage: string, + measurements?: { [key: string]: number } + ): void; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dispose(): Promise; +}; + +// Note this is a subset of the TelemetryService interface from the core extension +export interface TelemetryService { + extensionName: string; + isTelemetryEnabled(): Promise; + getInstance(extensionName: string): TelemetryService; + getReporters(): TelemetryReporter[]; + initializeService( + extensionContext: ExtensionContext, + extensionName: string, + aiKey: string, + version: string + ): Promise; + sendExtensionActivationEvent(hrstart: [number, number]): void; + sendExtensionDeactivationEvent(): void; + sendCommandEvent( + commandName?: string, + hrstart?: [number, number], + properties?: Properties, + measurements?: Measurements + ): void; + sendException(name: string, message: string): void; + dispose(): void; +} diff --git a/src/types/index.ts b/src/types/index.ts index 9a264697..75393124 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,3 +8,5 @@ export * from './AuthFields'; export * from './CoreExtensionApi'; export * from './SingleRecordQueryOptions'; export * from './WorkspaceContext'; +export * from './SalesforceProjectConfig'; +export * from './TelemetryService'; diff --git a/test/suite/commands/lint/configureLintingToolsCommand.test.ts b/test/suite/commands/lint/configureLintingToolsCommand.test.ts index cc328174..0d3ca250 100644 --- a/test/suite/commands/lint/configureLintingToolsCommand.test.ts +++ b/test/suite/commands/lint/configureLintingToolsCommand.test.ts @@ -17,32 +17,71 @@ import { TempProjectDirManager, setupTempWorkspaceDirectoryStub } from '../../../TestHelper'; +import { Connection } from '@salesforce/core'; +import { CoreExtensionService } from '../../../../src/services'; suite('Configure Linting Tools Command Test Suite', () => { + + function stubTelemetryService( + sendExceptionStub: any, + sendCommandEventStub: any + ) { + const getTelemetryServiceInstance = { + extensionName: 'mockExtensionName', + isTelemetryEnabled: sinon.stub(), + getInstance: sinon.stub(), + getReporters: sinon.stub(), + initializeService: sinon.stub(), + sendExtensionActivationEvent: sinon.stub(), + sendExtensionDeactivationEvent: sinon.stub(), + sendCommandEvent: sendCommandEventStub, + sendException: sendExceptionStub, + dispose: sinon.stub() + }; + sinon + .stub(CoreExtensionService, 'getTelemetryService') + .returns(getTelemetryServiceInstance); + } + afterEach(function () { sinon.restore(); }); test('Configure linting cancelled because LWC folder does not exist', async () => { sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(false); + const sendExceptionStub = sinon.stub(); + const sendCommandEventStub = sinon.stub(); + stubTelemetryService(sendExceptionStub, sendCommandEventStub); const showErrorMessageStub = sinon.stub(window, 'showErrorMessage'); showErrorMessageStub.onCall(0).resolves({ title: 'OK' }); const result = await ConfigureLintingToolsCommand.configure(); assert.equal(result, false); + // Assert telemetry + assert.equal(sendExceptionStub.callCount, 1); + assert.equal(sendCommandEventStub.callCount, 1); }); test('Configure linting cancelled because package.json does not exist', async () => { sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(false); + const sendExceptionStub = sinon.stub(); + const sendCommandEventStub = sinon.stub(); + stubTelemetryService(sendExceptionStub, sendCommandEventStub); const showErrorMessageStub = sinon.stub(window, 'showErrorMessage'); showErrorMessageStub.onCall(0).resolves({ title: 'OK' }); const result = await ConfigureLintingToolsCommand.configure(); assert.equal(result, false); + // Assert telemetry + assert.equal(sendExceptionStub.callCount, 1); + assert.equal(sendCommandEventStub.callCount, 1); }); test('Configure linting cancelled by the user', async () => { sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(true); + const sendExceptionStub = sinon.stub(); + const sendCommandEventStub = sinon.stub(); + stubTelemetryService(sendExceptionStub, sendCommandEventStub); const showInformationMessageStub = sinon.stub( window, 'showInformationMessage' @@ -50,11 +89,17 @@ suite('Configure Linting Tools Command Test Suite', () => { showInformationMessageStub.onCall(0).resolves({ title: 'No' }); const result = await ConfigureLintingToolsCommand.configure(); assert.equal(result, false); + // Assert telemetry + assert.equal(sendExceptionStub.callCount, 0); + assert.equal(sendCommandEventStub.callCount, 1); }); test('Configure linting cancelled because updating pacakge.json failed', async () => { sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(true); + const sendExceptionStub = sinon.stub(); + const sendCommandEventStub = sinon.stub(); + stubTelemetryService(sendExceptionStub, sendCommandEventStub); const showInformationMessageStub = sinon.stub( window, 'showInformationMessage' @@ -67,11 +112,17 @@ suite('Configure Linting Tools Command Test Suite', () => { showErrorMessageStub.onCall(0).resolves({ title: 'OK' }); const result = await ConfigureLintingToolsCommand.configure(); assert.equal(result, false); + // Assert telemetry + assert.equal(sendExceptionStub.callCount, 1); + assert.equal(sendCommandEventStub.callCount, 1); }); test('Configure linting cancelled because updating .eslintrc.json failed', async () => { sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(true); + const sendExceptionStub = sinon.stub(); + const sendCommandEventStub = sinon.stub(); + stubTelemetryService(sendExceptionStub, sendCommandEventStub); const showInformationMessageStub = sinon.stub( window, 'showInformationMessage' @@ -87,11 +138,17 @@ suite('Configure Linting Tools Command Test Suite', () => { showErrorMessageStub.onCall(0).resolves({ title: 'OK' }); const result = await ConfigureLintingToolsCommand.configure(); assert.equal(result, false); + // Assert telemetry + assert.equal(sendExceptionStub.callCount, 1); + assert.equal(sendCommandEventStub.callCount, 1); }); test('Configure linting did not update package.json because plugin was already included in the dev dependency', async () => { sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(true); + const sendExceptionStub = sinon.stub(); + const sendCommandEventStub = sinon.stub(); + stubTelemetryService(sendExceptionStub, sendCommandEventStub); let showInformationMessageStub = sinon.stub( window, 'showInformationMessage' @@ -107,6 +164,9 @@ suite('Configure Linting Tools Command Test Suite', () => { showInformationMessageStub.onCall(0).resolves({ title: 'OK' }); const result = await ConfigureLintingToolsCommand.configure(); assert.equal(result, true); + // Assert telemetry + assert.equal(sendExceptionStub.callCount, 0); + assert.equal(sendCommandEventStub.callCount, 2); }); test('Configure linting updated package.json successfully', async () => { @@ -119,7 +179,9 @@ suite('Configure Linting Tools Command Test Suite', () => { ); const packageJson = { devDependencies: { lwc: '1.2.3' } }; WorkspaceUtils.setPackageJson(packageJson); - + const sendExceptionStub = sinon.stub(); + const sendCommandEventStub = sinon.stub(); + stubTelemetryService(sendExceptionStub, sendCommandEventStub); sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); let showInformationMessageStub = sinon.stub( window, @@ -141,6 +203,9 @@ suite('Configure Linting Tools Command Test Suite', () => { const result = await ConfigureLintingToolsCommand.configure(); assert.equal(result, true); + // Assert telemetry + assert.equal(sendExceptionStub.callCount, 0); + assert.equal(sendCommandEventStub.callCount, 3); const content = WorkspaceUtils.getPackageJson(); const updatedPackageJson = {