diff --git a/src/apexguru/apex-guru-service.ts b/src/apexguru/apex-guru-service.ts new file mode 100644 index 0000000..1de54bb --- /dev/null +++ b/src/apexguru/apex-guru-service.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import * as vscode from 'vscode'; +import * as fspromises from 'fs/promises'; +import { CoreExtensionService } from '../lib/core-extension-service'; +import * as Constants from '../lib/constants'; + +export async function isApexGuruEnabledInOrg(outputChannel: vscode.LogOutputChannel): Promise { + try { + const connection = await CoreExtensionService.getConnection(); + const response:ApexGuruAuthResponse = await connection.request({ + method: 'GET', + url: Constants.APEX_GURU_AUTH_ENDPOINT, + body: '' + }); + return response.status == 'Success'; + } catch(e) { + // This could throw an error for a variety of reasons. The API endpoint has not been deployed to the instance, org has no perms, timeouts etc,. + // In all of these scenarios, we return false. + const errMsg = e instanceof Error ? e.message : e as string; + outputChannel.error('***ApexGuru perm check failed with error:***' + errMsg); + outputChannel.show(); + return false; + } +} + +export async function runApexGuruOnFile(selection: vscode.Uri, outputChannel: vscode.LogOutputChannel) { + try { + const requestId = await initiateApexGuruRequest(selection, outputChannel); + // TODO: Logging the request Id for easy QA. Future stories will use this requestId to poll and retrieve the Apex Guru report. + outputChannel.appendLine('***Apex Guru request Id:***' + requestId); + } catch (e) { + const errMsg = e instanceof Error ? e.message : e as string; + outputChannel.appendLine('***Apex Guru initiate request failed***'); + outputChannel.appendLine(errMsg); + } +} + +export async function initiateApexGuruRequest(selection: vscode.Uri, outputChannel: vscode.LogOutputChannel): Promise { + const fileContent = await fileSystem.readFile(selection.fsPath); + const base64EncodedContent = Buffer.from(fileContent).toString('base64'); + const connection = await CoreExtensionService.getConnection(); + const response: ApexGuruInitialResponse = await connection.request({ + method: 'POST', + url: Constants.APEX_GURU_REQUEST, + body: JSON.stringify({ + classContent: base64EncodedContent + }) + }); + + if (response.status != 'new' && response.status != 'success') { + outputChannel.warn('***Apex Guru returned unexpected response:***' + response.status); + throw Error('***Apex Guru returned unexpected response:***' + response.status); + } + + const requestId = response.requestId; + return requestId; +} + +export const fileSystem = { + readFile: (path: string) => fspromises.readFile(path, 'utf8') +}; + +export type ApexGuruAuthResponse = { + status: string; +} + +export type ApexGuruInitialResponse = { + status: string; + requestId: string; + message: string; +} + +export type ApexGuruQueryResponse = { + status: string; + message: string; + report: string; +} + +export type ApexGuruProperty = { + name: string; + value: string; +}; + +export type ApexGuruReport = { + id: string; + type: string; + value: string; + properties: ApexGuruProperty[]; +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 181e7b9..c59a4f5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,7 +12,7 @@ import {ScanRunner} from './lib/scanner'; import {SettingsManager} from './lib/settings'; import {SfCli} from './lib/sf-cli'; -import {RuleResult, ApexGuruAuthResponse} from './types'; +import {RuleResult} from './types'; import {DiagnosticManager} from './lib/diagnostics'; import {messages} from './lib/messages'; import {Fixer} from './lib/fixer'; @@ -20,6 +20,7 @@ import { CoreExtensionService, TelemetryService } from './lib/core-extension-ser import * as Constants from './lib/constants'; import * as path from 'path'; import { SIGKILL } from 'constants'; +import * as ApexGuruFunctions from './apexguru/apex-guru-service' export type RunInfo = { diagnosticCollection?: vscode.DiagnosticCollection; @@ -53,7 +54,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { - try { - const connection = await CoreExtensionService.getConnection(); - const response:ApexGuruAuthResponse = await connection.request({ - method: 'GET', - url: Constants.APEX_GURU_AUTH_ENDPOINT, - body: '' - }); - return response.status == 'Success'; - } catch(e) { - // This could throw an error for a variety of reasons. The API endpoint has not been deployed to the instance, org has no perms, timeouts etc,. - // In all of these scenarios, we return false. - const errMsg = e instanceof Error ? e.message : e as string; - outputChannel.error('***ApexGuru perm check failed with error:***' + errMsg); - outputChannel.show(); - return false; - } -} - async function _runDfa(context: vscode.ExtensionContext) { if (violationsCacheExists()) { const choice = await vscode.window.showQuickPick( diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5dbdd64..6621422 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -34,6 +34,7 @@ export const WORKSPACE_DFA_PROCESS = 'dfaScanProcess'; // apex guru APIS export const APEX_GURU_AUTH_ENDPOINT = '/services/data/v62.0/apexguru/validate' +export const APEX_GURU_REQUEST = '/services/data/v62.0/apexguru/request' // feature gates export const APEX_GURU_FEATURE_FLAG_ENABLED = false; diff --git a/src/test/suite/apexguru/apex-guru-service.test.ts b/src/test/suite/apexguru/apex-guru-service.test.ts new file mode 100644 index 0000000..83e7706 --- /dev/null +++ b/src/test/suite/apexguru/apex-guru-service.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2024, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import {expect} from 'chai'; +import Sinon = require('sinon'); +import {CoreExtensionService} from '../../../lib/core-extension-service'; +import * as Constants from '../../../lib/constants'; +import * as ApexGuruFunctions from '../../../apexguru/apex-guru-service' + +// You can import and use all API from the 'vscode' module +// as well as import your extension to test it +import * as vscode from 'vscode'; + +suite('Apex Guru Test Suite', () => { + let outputChannel = vscode.window.createOutputChannel('Salesforce Code Analyzer', {log: true}); + + suite('#_isApexGuruEnabledInOrg', () => { + let getConnectionStub: Sinon.SinonStub; + let requestStub: Sinon.SinonStub; + + setup(() => { + getConnectionStub = Sinon.stub(CoreExtensionService, 'getConnection'); + requestStub = Sinon.stub(); + }); + + teardown(() => { + Sinon.restore(); + }); + + test('Returns true if response status is Success', async () => { + // ===== SETUP ===== + getConnectionStub.resolves({ + request: requestStub.resolves({ status: 'Success' }) + }); + + // ===== TEST ===== + const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(outputChannel); + + // ===== ASSERTIONS ===== + expect(result).to.be.true; + Sinon.assert.calledOnce(getConnectionStub); + Sinon.assert.calledOnce(requestStub); + Sinon.assert.calledWith(requestStub, { + method: 'GET', + url: Constants.APEX_GURU_AUTH_ENDPOINT, + body: '' + }); + }); + + test('Returns false if response status is not Success', async () => { + // ===== SETUP ===== + getConnectionStub.resolves({ + request: requestStub.resolves({ status: 'Failure' }) + }); + + // ===== TEST ===== + const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(outputChannel); + + // ===== ASSERTIONS ===== + expect(result).to.be.false; + Sinon.assert.calledOnce(getConnectionStub); + Sinon.assert.calledOnce(requestStub); + Sinon.assert.calledWith(requestStub, { + method: 'GET', + url: Constants.APEX_GURU_AUTH_ENDPOINT, + body: '' + }); + }); + + test('Returns false if an error is thrown', async () => { + // ===== SETUP ===== + getConnectionStub.resolves({ + request: requestStub.rejects(new Error('Resource not found')) + }); + + // ===== TEST ===== + const result = await ApexGuruFunctions.isApexGuruEnabledInOrg(outputChannel); + + // ===== ASSERTIONS ===== + expect(result).to.be.false; + Sinon.assert.calledOnce(getConnectionStub); + Sinon.assert.calledOnce(requestStub); + Sinon.assert.calledWith(requestStub, { + method: 'GET', + url: Constants.APEX_GURU_AUTH_ENDPOINT, + body: '' + }); + }); + }); + suite('#initiateApexGuruRequest', () => { + let getConnectionStub: Sinon.SinonStub; + let requestStub: Sinon.SinonStub; + let readFileStub: Sinon.SinonStub; + + setup(() => { + getConnectionStub = Sinon.stub(CoreExtensionService, 'getConnection'); + requestStub = Sinon.stub(); + readFileStub = Sinon.stub(ApexGuruFunctions.fileSystem, 'readFile'); + }); + + teardown(() => { + Sinon.restore(); + }); + + test('Returns requestId if response status is new', async () => { + // ===== SETUP ===== + getConnectionStub.resolves({ + request: requestStub.resolves({ status: 'new', requestId: '12345' }) + }); + readFileStub.resolves('console.log("Hello World");'); + + // ===== TEST ===== + const result = await ApexGuruFunctions.initiateApexGuruRequest(vscode.Uri.file('dummyPath'), outputChannel); + + // ===== ASSERTIONS ===== + expect(result).to.equal('12345'); + Sinon.assert.calledOnce(getConnectionStub); + Sinon.assert.calledOnce(requestStub); + Sinon.assert.calledOnce(readFileStub); + Sinon.assert.calledWith(requestStub, Sinon.match({ + method: 'POST', + url: Constants.APEX_GURU_REQUEST, + body: Sinon.match.string + })); + }); + + test('Logs warning if response status is not new', async () => { + // ===== SETUP ===== + getConnectionStub.resolves({ + request: requestStub.resolves({ status: 'failed' }) + }); + readFileStub.resolves('console.log("Hello World");'); + const outputChannelSpy = Sinon.spy(outputChannel, 'warn'); + + // ===== TEST ===== + try { + await ApexGuruFunctions.initiateApexGuruRequest(vscode.Uri.file('dummyPath'), outputChannel); + } catch (e) { + // ===== ASSERTIONS ===== + Sinon.assert.calledOnce(outputChannelSpy); + Sinon.assert.calledWith(outputChannelSpy, Sinon.match.string); + } + }); + }); +}); diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 009e565..752bf26 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -9,7 +9,7 @@ import {expect} from 'chai'; import path = require('path'); import {SfCli} from '../../lib/sf-cli'; import Sinon = require('sinon'); -import { _runAndDisplayPathless, _runAndDisplayDfa, _clearDiagnostics, _shouldProceedWithDfaRun, _stopExistingDfaRun, _isValidFileForAnalysis, _isApexGuruEnabledInOrg, verifyPluginInstallation, _clearDiagnosticsForSelectedFiles, _removeDiagnosticsInRange, RunInfo } from '../../extension'; +import { _runAndDisplayPathless, _runAndDisplayDfa, _clearDiagnostics, _shouldProceedWithDfaRun, _stopExistingDfaRun, _isValidFileForAnalysis, verifyPluginInstallation, _clearDiagnosticsForSelectedFiles, _removeDiagnosticsInRange, RunInfo } from '../../extension'; import {messages} from '../../lib/messages'; import {CoreExtensionService, TelemetryService} from '../../lib/core-extension-service'; import * as Constants from '../../lib/constants'; @@ -386,80 +386,6 @@ suite('Extension Test Suite', () => { }); }); - suite('#_isApexGuruEnabledInOrg', () => { - let getConnectionStub: Sinon.SinonStub; - let requestStub: Sinon.SinonStub; - - setup(() => { - getConnectionStub = Sinon.stub(CoreExtensionService, 'getConnection'); - requestStub = Sinon.stub(); - }); - - teardown(() => { - Sinon.restore(); - }); - - test('Returns true if response status is Success', async () => { - // ===== SETUP ===== - getConnectionStub.resolves({ - request: requestStub.resolves({ status: 'Success' }) - }); - - // ===== TEST ===== - const result = await _isApexGuruEnabledInOrg(); - - // ===== ASSERTIONS ===== - expect(result).to.be.true; - Sinon.assert.calledOnce(getConnectionStub); - Sinon.assert.calledOnce(requestStub); - Sinon.assert.calledWith(requestStub, { - method: 'GET', - url: Constants.APEX_GURU_AUTH_ENDPOINT, - body: '' - }); - }); - - test('Returns false if response status is not Success', async () => { - // ===== SETUP ===== - getConnectionStub.resolves({ - request: requestStub.resolves({ status: 'Failure' }) - }); - - // ===== TEST ===== - const result = await _isApexGuruEnabledInOrg(); - - // ===== ASSERTIONS ===== - expect(result).to.be.false; - Sinon.assert.calledOnce(getConnectionStub); - Sinon.assert.calledOnce(requestStub); - Sinon.assert.calledWith(requestStub, { - method: 'GET', - url: Constants.APEX_GURU_AUTH_ENDPOINT, - body: '' - }); - }); - - test('Returns false if an error is thrown', async () => { - // ===== SETUP ===== - getConnectionStub.resolves({ - request: requestStub.rejects(new Error('Resource not found')) - }); - - // ===== TEST ===== - const result = await _isApexGuruEnabledInOrg(); - - // ===== ASSERTIONS ===== - expect(result).to.be.false; - Sinon.assert.calledOnce(getConnectionStub); - Sinon.assert.calledOnce(requestStub); - Sinon.assert.calledWith(requestStub, { - method: 'GET', - url: Constants.APEX_GURU_AUTH_ENDPOINT, - body: '' - }); - }); - }); - suite('#isValidFileForAnalysis', () => { test('Returns true for valid files', async() => { // ===== SETUP ===== and ===== ASSERTIONS ===== diff --git a/src/test/suite/settings.test.ts b/src/test/suite/settings.test.ts new file mode 100644 index 0000000..3d9a6fa --- /dev/null +++ b/src/test/suite/settings.test.ts @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2024, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { expect } from 'chai'; +import * as Sinon from 'sinon'; +import * as vscode from 'vscode'; +import {SettingsManager} from '../../lib/settings'; + +suite('SettingsManager Test Suite', () => { + let getConfigurationStub: Sinon.SinonStub; + + setup(() => { + getConfigurationStub = Sinon.stub(vscode.workspace, 'getConfiguration'); + }); + + teardown(() => { + Sinon.restore(); + }); + + test('getPmdCustomConfigFile should return the customConfigFile setting', () => { + // ===== SETUP ===== + const mockCustomConfigFile = 'config/path/to/customConfigFile'; + getConfigurationStub.withArgs('codeAnalyzer.pMD').returns({ + get: Sinon.stub().returns(mockCustomConfigFile) + }); + + // ===== TEST ===== + const result = SettingsManager.getPmdCustomConfigFile(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockCustomConfigFile); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.pMD')).to.be.true; + }); + + test('getGraphEngineDisableWarningViolations should return the disableWarningViolations setting', () => { + // ===== SETUP ===== + const mockDisableWarningViolations = true; + getConfigurationStub.withArgs('codeAnalyzer.graphEngine').returns({ + get: Sinon.stub().returns(mockDisableWarningViolations) + }); + + // ===== TEST ===== + const result = SettingsManager.getGraphEngineDisableWarningViolations(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockDisableWarningViolations); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.graphEngine')).to.be.true; + }); + + test('getGraphEngineThreadTimeout should return the threadTimeout setting', () => { + // ===== SETUP ===== + const mockThreadTimeout = 30000; + getConfigurationStub.withArgs('codeAnalyzer.graphEngine').returns({ + get: Sinon.stub().returns(mockThreadTimeout) + }); + + // ===== TEST ===== + const result = SettingsManager.getGraphEngineThreadTimeout(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockThreadTimeout); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.graphEngine')).to.be.true; + }); + + test('getGraphEnginePathExpansionLimit should return the pathExpansionLimit setting', () => { + // ===== SETUP ===== + const mockPathExpansionLimit = 100; + getConfigurationStub.withArgs('codeAnalyzer.graphEngine').returns({ + get: Sinon.stub().returns(mockPathExpansionLimit) + }); + + // ===== TEST ===== + const result = SettingsManager.getGraphEnginePathExpansionLimit(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockPathExpansionLimit); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.graphEngine')).to.be.true; + }); + + test('getGraphEngineJvmArgs should return the jvmArgs setting', () => { + // ===== SETUP ===== + const mockJvmArgs = '-Xmx2048m'; + getConfigurationStub.withArgs('codeAnalyzer.graphEngine').returns({ + get: Sinon.stub().returns(mockJvmArgs) + }); + + // ===== TEST ===== + const result = SettingsManager.getGraphEngineJvmArgs(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockJvmArgs); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.graphEngine')).to.be.true; + }); + + test('getAnalyzeOnSave should return the analyzeOnSave enabled setting', () => { + // ===== SETUP ===== + const mockAnalyzeOnSaveEnabled = true; + getConfigurationStub.withArgs('codeAnalyzer.analyzeOnSave').returns({ + get: Sinon.stub().returns(mockAnalyzeOnSaveEnabled) + }); + + // ===== TEST ===== + const result = SettingsManager.getAnalyzeOnSave(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockAnalyzeOnSaveEnabled); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.analyzeOnSave')).to.be.true; + }); + + test('getAnalyzeOnOpen should return the analyzeOnOpen enabled setting', () => { + // ===== SETUP ===== + const mockAnalyzeOnOpenEnabled = false; + getConfigurationStub.withArgs('codeAnalyzer.analyzeOnOpen').returns({ + get: Sinon.stub().returns(mockAnalyzeOnOpenEnabled) + }); + + // ===== TEST ===== + const result = SettingsManager.getAnalyzeOnOpen(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockAnalyzeOnOpenEnabled); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.analyzeOnOpen')).to.be.true; + }); + + test('getEnginesToRun should return the engines setting', () => { + // ===== SETUP ===== + const mockEngines = 'engine1, engine2'; + getConfigurationStub.withArgs('codeAnalyzer.scanner').returns({ + get: Sinon.stub().returns(mockEngines) + }); + + // ===== TEST ===== + const result = SettingsManager.getEnginesToRun(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockEngines); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.scanner')).to.be.true; + }); + + test('getNormalizeSeverityEnabled should return the normalizeSeverity enabled setting', () => { + // ===== SETUP ===== + const mockNormalizeSeverityEnabled = true; + getConfigurationStub.withArgs('codeAnalyzer.normalizeSeverity').returns({ + get: Sinon.stub().returns(mockNormalizeSeverityEnabled) + }); + + // ===== TEST ===== + const result = SettingsManager.getNormalizeSeverityEnabled(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockNormalizeSeverityEnabled); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.normalizeSeverity')).to.be.true; + }); + + test('getRulesCategory should return the rules category setting', () => { + // ===== SETUP ===== + const mockRulesCategory = 'bestPractices'; + getConfigurationStub.withArgs('codeAnalyzer.rules').returns({ + get: Sinon.stub().returns(mockRulesCategory) + }); + + // ===== TEST ===== + const result = SettingsManager.getRulesCategory(); + + // ===== ASSERTIONS ===== + expect(result).to.equal(mockRulesCategory); + expect(getConfigurationStub.calledOnceWith('codeAnalyzer.rules')).to.be.true; + }); +}); \ No newline at end of file diff --git a/src/types.d.ts b/src/types.d.ts index 745f121..9d0eca8 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -74,8 +74,3 @@ export type AuthFields = { expirationDate?: string; tracksSource?: boolean; }; - -export type ApexGuruAuthResponse = { - status: string; -} -