diff --git a/packages/salesforcedx-vscode-apex/src/constants.ts b/packages/salesforcedx-vscode-apex/src/constants.ts index c2654e9703..3eb31b5a54 100644 --- a/packages/salesforcedx-vscode-apex/src/constants.ts +++ b/packages/salesforcedx-vscode-apex/src/constants.ts @@ -25,6 +25,9 @@ export const FAIL_RESULT = 'Fail'; export const SKIP_RESULT = 'Skip'; export const APEX_TESTS = 'ApexTests'; +export const API = { + doneIndexing: 'indexer/done' +}; export const UBER_JAR_NAME = 'apex-jorje-lsp.jar'; export const APEX_LSP_STARTUP = 'apexLSPStartup'; export const APEX_LSP_ORPHAN = 'apexLSPOrphan'; diff --git a/packages/salesforcedx-vscode-apex/src/index.ts b/packages/salesforcedx-vscode-apex/src/index.ts index 51193d3354..99a908f66a 100644 --- a/packages/salesforcedx-vscode-apex/src/index.ts +++ b/packages/salesforcedx-vscode-apex/src/index.ts @@ -11,6 +11,9 @@ import * as vscode from 'vscode'; import { ApexLanguageClient } from './apexLanguageClient'; import ApexLSPStatusBarItem from './apexLspStatusBarItem'; import { CodeCoverage, StatusBarToggle } from './codecoverage'; +import { API } from './constants'; +import { retrieveEnableSyncInitJobs } from './settings'; + import { forceAnonApexDebug, forceAnonApexExecute, @@ -30,10 +33,11 @@ import { import { SET_JAVA_DOC_LINK } from './constants'; import { workspaceContext } from './context'; import * as languageServer from './languageServer'; -import {languageServerOrphanHandler as lsoh} from './languageServerOrphanHandler'; +import { languageServerOrphanHandler as lsoh } from './languageServerOrphanHandler'; import { ClientStatus, enableJavaDocSymbols, + extensionUtils, getApexTests, getExceptionBreakpointInfo, getLineBreakpointInfo, @@ -45,10 +49,10 @@ import { getTestOutlineProvider } from './views/testOutlineProvider'; import { ApexTestRunner, TestRunType } from './views/testRunner'; let languageClient: ApexLanguageClient | undefined; -const languageServerStatusBarItem = new ApexLSPStatusBarItem(); export async function activate(extensionContext: vscode.ExtensionContext) { const extensionHRStart = process.hrtime(); + const languageServerStatusBarItem = new ApexLSPStatusBarItem(); const testOutlineProvider = getTestOutlineProvider(); if (vscode.workspace && vscode.workspace.workspaceFolders) { const apexDirPath = getTestResultsFolder( @@ -79,7 +83,7 @@ export async function activate(extensionContext: vscode.ExtensionContext) { await telemetryService.initializeService(extensionContext); // start the language server and client - await createLanguageClient(extensionContext); + await createLanguageClient(extensionContext, languageServerStatusBarItem); // Javadoc support enableJavaDocSymbols(); @@ -283,7 +287,10 @@ export async function deactivate() { telemetryService.sendExtensionDeactivationEvent(); } -async function createLanguageClient(extensionContext: vscode.ExtensionContext) { +async function createLanguageClient( + extensionContext: vscode.ExtensionContext, + languageServerStatusBarItem: ApexLSPStatusBarItem +) { // Initialize Apex language server try { const langClientHRStart = process.hrtime(); @@ -292,10 +299,6 @@ async function createLanguageClient(extensionContext: vscode.ExtensionContext) { ); if (languageClient) { - languageClient.onNotification('indexer/done', async () => { - await getTestOutlineProvider().refresh(); - languageServerReady(); - }); languageClient.errorHandler?.addListener('error', message => { languageServerStatusBarItem.error(message); }); @@ -318,12 +321,16 @@ async function createLanguageClient(extensionContext: vscode.ExtensionContext) { void lsoh.resolveAnyFoundOrphanLanguageServers(); await languageClient!.start(); - - const startTime = telemetryService.getEndHRTime(langClientHRStart); + // Client is running + const startTime = telemetryService.getEndHRTime(langClientHRStart); // Record the end time telemetryService.sendEventData('apexLSPStartup', undefined, { activationTime: startTime }); - languageClientUtils.setStatus(ClientStatus.Indexing, ''); + await indexerDoneHandler( + retrieveEnableSyncInitJobs(), + languageClient, + languageServerStatusBarItem + ); extensionContext.subscriptions.push(languageClient); } catch (e) { languageClientUtils.setStatus(ClientStatus.Error, e); @@ -340,8 +347,28 @@ async function createLanguageClient(extensionContext: vscode.ExtensionContext) { } } -export function languageServerReady() { - languageServerStatusBarItem.ready(); - languageClientUtils.setStatus(ClientStatus.Ready, ''); - languageClient?.errorHandler?.serviceHasStartedSuccessfully(); +// exported only for test +export async function indexerDoneHandler( + enableSyncInitJobs: boolean, + languageClient: ApexLanguageClient, + languageServerStatusBarItem: ApexLSPStatusBarItem +) { + // Listener is useful only in async mode + if (!enableSyncInitJobs) { + // The listener should be set after languageClient is ready + // Language client will get notified once async init jobs are done + languageClientUtils.setStatus(ClientStatus.Indexing, ''); + languageClient.onNotification(API.doneIndexing, async () => { + await extensionUtils.setClientReady( + languageClient, + languageServerStatusBarItem + ); + }); + } else { + // indexer must be running at the point + await extensionUtils.setClientReady( + languageClient, + languageServerStatusBarItem + ); + } } diff --git a/packages/salesforcedx-vscode-apex/src/languageServer.ts b/packages/salesforcedx-vscode-apex/src/languageServer.ts index 30887ec1be..97b65d4a4d 100644 --- a/packages/salesforcedx-vscode-apex/src/languageServer.ts +++ b/packages/salesforcedx-vscode-apex/src/languageServer.ts @@ -7,13 +7,18 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { Executable, LanguageClientOptions, RevealOutputChannelOn } from 'vscode-languageclient/node'; +import { + Executable, + LanguageClientOptions, + RevealOutputChannelOn +} from 'vscode-languageclient/node'; import { ApexErrorHandler } from './apexErrorHandler'; import { ApexLanguageClient } from './apexLanguageClient'; import { LSP_ERR, UBER_JAR_NAME } from './constants'; import { soqlMiddleware } from './embeddedSoql'; import { nls } from './messages'; import * as requirements from './requirements'; +import { retrieveEnableSyncInitJobs } from './settings'; import { telemetryService } from './telemetry'; const JDWP_DEBUG_PORT = 2739; @@ -69,7 +74,8 @@ async function createServer( args.push( '-Dtrace.protocol=false', `-Dapex.lsp.root.log.level=${LANGUAGE_SERVER_LOG_LEVEL}`, - `-agentlib:jdwp=transport=dt_socket,server=y,suspend=${SUSPEND_LANGUAGE_SERVER_STARTUP ? 'y' : 'n' + `-agentlib:jdwp=transport=dt_socket,server=y,suspend=${ + SUSPEND_LANGUAGE_SERVER_STARTUP ? 'y' : 'n' },address=*:${JDWP_DEBUG_PORT},quiet=y` ); if (process.env.YOURKIT_PROFILER_AGENT) { @@ -168,7 +174,8 @@ export function buildClientOptions(): LanguageClientOptions { protocol2Code: protocol2CodeConverter }, initializationOptions: { - enableEmbeddedSoqlCompletion: soqlExtensionInstalled + enableEmbeddedSoqlCompletion: soqlExtensionInstalled, + enableSynchronizedInitJobs: retrieveEnableSyncInitJobs() }, ...(soqlExtensionInstalled ? { middleware: soqlMiddleware } : {}), errorHandler: new ApexErrorHandler() diff --git a/packages/salesforcedx-vscode-apex/src/languageUtils/extensionUtils.ts b/packages/salesforcedx-vscode-apex/src/languageUtils/extensionUtils.ts new file mode 100644 index 0000000000..1eb9837105 --- /dev/null +++ b/packages/salesforcedx-vscode-apex/src/languageUtils/extensionUtils.ts @@ -0,0 +1,18 @@ +import { ApexLanguageClient } from '../apexLanguageClient'; +import ApexLSPStatusBarItem from '../apexLspStatusBarItem'; +import { getTestOutlineProvider } from '../views/testOutlineProvider'; +import { + ClientStatus, + languageClientUtils +} from './index'; + +const setClientReady = async (languageClient: ApexLanguageClient, languageServerStatusBarItem: ApexLSPStatusBarItem) => { + await getTestOutlineProvider().refresh(); + languageServerStatusBarItem.ready(); + languageClientUtils.setStatus(ClientStatus.Ready, ''); + languageClient?.errorHandler?.serviceHasStartedSuccessfully(); +}; + +export const extensionUtils = { + setClientReady +}; diff --git a/packages/salesforcedx-vscode-apex/src/languageUtils/index.ts b/packages/salesforcedx-vscode-apex/src/languageUtils/index.ts index d1f2d7d445..f49eec1762 100644 --- a/packages/salesforcedx-vscode-apex/src/languageUtils/index.ts +++ b/packages/salesforcedx-vscode-apex/src/languageUtils/index.ts @@ -18,3 +18,5 @@ export { export { enableJavaDocSymbols } from './javaDocSymbols'; export { languageServerUtils, ProcessDetail } from './languageServerUtils'; + +export { extensionUtils } from './extensionUtils'; diff --git a/packages/salesforcedx-vscode-apex/src/settings.ts b/packages/salesforcedx-vscode-apex/src/settings.ts index 622040529d..ca33658781 100644 --- a/packages/salesforcedx-vscode-apex/src/settings.ts +++ b/packages/salesforcedx-vscode-apex/src/settings.ts @@ -13,3 +13,9 @@ export function retrieveTestCodeCoverage(): boolean { .getConfiguration(SFDX_CORE_CONFIGURATION_NAME) .get('retrieve-test-code-coverage', false); } + +export function retrieveEnableSyncInitJobs(): boolean { + return vscode.workspace + .getConfiguration() + .get('salesforcedx-vscode-apex.wait-init-jobs', true); +} diff --git a/packages/salesforcedx-vscode-apex/test/jest/index.test.ts b/packages/salesforcedx-vscode-apex/test/jest/index.test.ts new file mode 100644 index 0000000000..16e13cc9eb --- /dev/null +++ b/packages/salesforcedx-vscode-apex/test/jest/index.test.ts @@ -0,0 +1,64 @@ +import { API } from '../../src/constants'; +import * as index from '../../src/index'; +import { languageClientUtils } from '../../src/languageUtils'; +import { extensionUtils } from '../../src/languageUtils/extensionUtils'; +import ApexLSPStatusBarItem from './../../src/apexLspStatusBarItem'; + +jest.mock('./../../src/apexLspStatusBarItem'); +describe('indexDoneHandler', () => { + let setStatusSpy: jest.SpyInstance; + let onNotificationSpy: jest.SpyInstance; + let mockLanguageClient: any; + let setClientReadySpy: jest.SpyInstance; + const apexLSPStatusBarItemMock = jest.mocked(ApexLSPStatusBarItem); + + beforeEach(() => { + setStatusSpy = jest + .spyOn(languageClientUtils, 'setStatus') + .mockReturnValue(); + mockLanguageClient = { + onNotification: jest.fn() + }; + onNotificationSpy = jest.spyOn(mockLanguageClient, 'onNotification'); + setClientReadySpy = jest + .spyOn(extensionUtils, 'setClientReady') + .mockResolvedValue(); + }); + + it('should call languageClientUtils.setStatus and set up event listener when enableSyncInitJobs is false', async () => { + const languageServerStatusBarItem = new ApexLSPStatusBarItem(); + await index.indexerDoneHandler( + false, + mockLanguageClient as any, + languageServerStatusBarItem + ); + expect(setStatusSpy).toHaveBeenCalledWith(1, ''); + expect(onNotificationSpy).toHaveBeenCalledWith( + API.doneIndexing, + expect.any(Function) + ); + expect(apexLSPStatusBarItemMock).toHaveBeenCalledTimes(1); + + const mockCallback = onNotificationSpy.mock.calls[0][1]; + + await mockCallback(); + expect(setClientReadySpy).toHaveBeenCalledWith( + mockLanguageClient, + languageServerStatusBarItem + ); + }); + + it('should call setClientReady when enableSyncInitJobs is true', async () => { + const languageServerStatusBarItem = new ApexLSPStatusBarItem(); + await index.indexerDoneHandler( + true, + mockLanguageClient as any, + languageServerStatusBarItem + ); + expect(setClientReadySpy).toHaveBeenCalledWith( + mockLanguageClient, + languageServerStatusBarItem + ); + expect(apexLSPStatusBarItemMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/salesforcedx-vscode-apex/test/jest/languageUtils/extensionUtils.test.ts b/packages/salesforcedx-vscode-apex/test/jest/languageUtils/extensionUtils.test.ts new file mode 100644 index 0000000000..194c517eb0 --- /dev/null +++ b/packages/salesforcedx-vscode-apex/test/jest/languageUtils/extensionUtils.test.ts @@ -0,0 +1,57 @@ +import ApexLSPStatusBarItem from '../../../src/apexLspStatusBarItem'; +import { ClientStatus, languageClientUtils } from '../../../src/languageUtils'; +import { extensionUtils } from '../../../src/languageUtils'; +import * as testOutlineProvider from '../../../src/views/testOutlineProvider'; + +jest.mock('../../../src/apexLspStatusBarItem'); +describe('extensionUtils Unit Tests.', () => { + const apexLspStatusBarItemMock = jest.mocked(ApexLSPStatusBarItem); + let refreshSpy: jest.SpyInstance; + let readySpy: jest.SpyInstance; + let setStatusSpy: jest.SpyInstance; + let serviceHasStartedSuccessfullySpy: jest.SpyInstance; + let languageServerStatusBarItem: ApexLSPStatusBarItem; + let getTestOutlineProviderSpy: jest.SpyInstance; + let mockTestOutlineProviderInst; + let mockLanguageClient: any; + + beforeEach(() => { + mockTestOutlineProviderInst = { + refresh: jest.fn(() => Promise.resolve()) + }; + getTestOutlineProviderSpy = jest + .spyOn(testOutlineProvider, 'getTestOutlineProvider') + .mockReturnValue(mockTestOutlineProviderInst as any); + + languageServerStatusBarItem = new ApexLSPStatusBarItem(); + refreshSpy = jest + .spyOn(mockTestOutlineProviderInst, 'refresh') + .mockResolvedValue(); + readySpy = jest.spyOn(languageServerStatusBarItem, 'ready'); + setStatusSpy = jest + .spyOn(languageClientUtils, 'setStatus') + .mockReturnValue(); + mockLanguageClient = { + errorHandler: { + serviceHasStartedSuccessfully: jest.fn() + } + }; + serviceHasStartedSuccessfullySpy = jest.spyOn( + mockLanguageClient.errorHandler, + 'serviceHasStartedSuccessfully' + ); + }); + + it('should be executed as expected', async () => { + await extensionUtils.setClientReady( + mockLanguageClient, + languageServerStatusBarItem + ); + expect(getTestOutlineProviderSpy).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); + expect(readySpy).toHaveBeenCalled(); + expect(setStatusSpy).toHaveBeenCalledWith(ClientStatus.Ready, ''); + expect(serviceHasStartedSuccessfullySpy).toHaveBeenCalled(); + expect(apexLspStatusBarItemMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/salesforcedx-vscode-apex/test/jest/settings.test.ts b/packages/salesforcedx-vscode-apex/test/jest/settings.test.ts new file mode 100644 index 0000000000..7aa8004b49 --- /dev/null +++ b/packages/salesforcedx-vscode-apex/test/jest/settings.test.ts @@ -0,0 +1,48 @@ +import { SFDX_CORE_CONFIGURATION_NAME } from '@salesforce/salesforcedx-utils-vscode'; +import * as vscode from 'vscode'; +import { + retrieveEnableSyncInitJobs, + retrieveTestCodeCoverage +} from '../../src/settings'; + +describe('settings Unit Tests.', () => { + const vscodeMocked = jest.mocked(vscode); + let getConfigurationMock: jest.SpyInstance; + let getFn: jest.Mock; + + beforeEach(() => { + getConfigurationMock = jest.spyOn( + vscodeMocked.workspace, + 'getConfiguration' + ); + getFn = jest.fn(); + }); + + it('Should be able to get retrieveTestCodeCoverage setting.', () => { + getConfigurationMock.mockReturnValue({ + get: getFn.mockReturnValue(false) + } as any); + + const result = retrieveTestCodeCoverage(); + + expect(result).toBe(false); + expect(getConfigurationMock).toHaveBeenCalledWith( + SFDX_CORE_CONFIGURATION_NAME + ); + expect(getFn).toHaveBeenCalledWith('retrieve-test-code-coverage', false); + }); + + it('Should be able to get retrieveEnableSyncInitJobs setting.', () => { + getConfigurationMock.mockReturnValue({ + get: getFn.mockReturnValue(true) + } as any); + + const result = retrieveEnableSyncInitJobs(); + expect(result).toBe(true); + expect(getConfigurationMock).toHaveBeenCalledWith(); + expect(getFn).toHaveBeenCalledWith( + 'salesforcedx-vscode-apex.wait-init-jobs', + true + ); + }); +}); diff --git a/scripts/setup-jest.ts b/scripts/setup-jest.ts index b71ff3e20a..5fed95bce0 100644 --- a/scripts/setup-jest.ts +++ b/scripts/setup-jest.ts @@ -6,6 +6,39 @@ class EventEmitter { public fire = (e: any) => this.listeners.forEach(listener => listener(e)); } +class Uri { + public static parse = jest.fn(); + public static file = jest.fn(); + public static joinPath = jest.fn(); +} + +const mockLanguageStatusItem = { + id: jest.fn(), + name: jest.fn(), + selector: jest.fn(), + severity: jest.fn(), + text: jest.fn(), + detail: jest.fn(), + busy: jest.fn(), + command: jest.fn(), + accessibilityInformation: jest.fn() +}; + +const mockCreateLanguageStatusItem = jest.fn(); +mockCreateLanguageStatusItem.mockReturnValue(mockLanguageStatusItem); + +enum LanguageStatusSeverity { + Information = 0, + Warning = 1, + Error = 2 +} + +enum TreeItemCollapsibleState { + None = 0, + Collapsed = 1, + Expanded = 2 +} + const getMockVSCode = () => { return { CancellationTokenSource: class { @@ -42,13 +75,10 @@ const getMockVSCode = () => { getExtension: jest.fn() }, languages: { - createDiagnosticCollection: jest.fn() - }, - Uri: { - parse: jest.fn(), - file: jest.fn(), - joinPath: jest.fn() + createDiagnosticCollection: jest.fn(), + createLanguageStatusItem: mockCreateLanguageStatusItem }, + Uri, Position: jest.fn(), ProgressLocation: { SourceControl: 1, @@ -73,7 +103,8 @@ const getMockVSCode = () => { OutputChannel: { show: jest.fn() }, - createStatusBarItem: jest.fn() + createStatusBarItem: jest.fn(), + createTextEditorDecorationType: jest.fn() }, workspace: { getConfiguration: () => { @@ -91,8 +122,61 @@ const getMockVSCode = () => { workspaceFolders: [], fs: { writeFile: jest.fn() - } - } + }, + registerTextDocumentContentProvider: jest.fn() + }, + CompletionItem: class { + public constructor(label: string) {} + }, + CodeLens: class { + public constructor(range: Range) {} + }, + DocumentLink: class { + public constructor(range: Range, target?: Uri) {} + }, + CodeAction: class { + public constructor(title: string, data?: any) {} + }, + Diagnostic: class { + public constructor(range: Range, message: string, severity?: any) {} + }, + CallHierarchyItem: class { + public constructor( + kind: any, + name: string, + detail: string, + uri: Uri, + range: Range, + selectionRange: Range + ) {} + }, + TypeHierarchyItem: class { + public constructor( + kind: any, + name: string, + detail: string, + uri: Uri, + range: Range, + selectionRange: Range + ) {} + }, + SymbolInformation: class { + public constructor( + name: string, + kind: any, + range: Range, + uri?: Uri, + containerName?: string + ) {} + }, + InlayHint: class { + public constructor(position: any, label: any, kind?: any) {} + }, + CancellationError: class { + public constructor() {} + }, + LanguageStatusSeverity, + TreeItemCollapsibleState }; };