diff --git a/package.json b/package.json index 51fa28b..22c5f2d 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "color": "#ECECEC", "theme": "light" }, - "version": "1.20.0", + "version": "1.21.0-SNAPSHOT", "publisher": "salesforce", "license": "BSD-3-Clause", "engines": { diff --git a/src/extension.ts b/src/extension.ts index 01a5ef0..69dd234 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ import {ExternalServiceProvider} from "./lib/external-services/external-service- import {Logger, LoggerImpl} from "./lib/logger"; import {TelemetryService} from "./lib/external-services/telemetry-service"; import {CodeAnalyzerRunAction} from "./lib/code-analyzer-run-action"; +import {InsightsHandler} from "./lib/insights-handler"; import {A4DFixActionProvider} from "./lib/agentforce/a4d-fix-action-provider"; import {ScanManager} from './lib/scan-manager'; import {A4DFixAction} from './lib/agentforce/a4d-fix-action'; @@ -113,7 +114,8 @@ export async function activate(context: vscode.ExtensionContext): Promise = new Set(); - constructor(taskWithProgressRunner: TaskWithProgressRunner, codeAnalyzer: CodeAnalyzer, diagnosticManager: DiagnosticManager, diagnosticFactory: DiagnosticFactory, telemetryService: TelemetryService, logger: Logger, display: Display, windowManager: WindowManager) { + constructor(taskWithProgressRunner: TaskWithProgressRunner, codeAnalyzer: CodeAnalyzer, diagnosticManager: DiagnosticManager, diagnosticFactory: DiagnosticFactory, telemetryService: TelemetryService, logger: Logger, display: Display, windowManager: WindowManager, insightsHandler: InsightsHandler) { this.taskWithProgressRunner = taskWithProgressRunner; this.codeAnalyzer = codeAnalyzer; this.diagnosticManager = diagnosticManager; @@ -34,6 +36,7 @@ export class CodeAnalyzerRunAction { this.logger = logger; this.display = display; this.windowManager = windowManager; + this.insightsHandler = insightsHandler; } /** @@ -64,7 +67,8 @@ export class CodeAnalyzerRunAction { increment: 20 }); this.logger.log(messages.info.scanningWith(await this.codeAnalyzer.getVersion())); - const violations: Violation[] = await this.codeAnalyzer.scan(workspace); + const scanResults: ScanResults = await this.codeAnalyzer.scan(workspace); + const violations: Violation[] = scanResults.violations; progressReporter.reportProgress({ message: messages.scanProgressReport.processingResults, @@ -108,6 +112,8 @@ export class CodeAnalyzerRunAction { this.diagnosticManager.addDiagnostics(diagnostics); void this.displayResults(targetedFiles.length, violationsWithFileLocation); + this.insightsHandler.handleInsights(scanResults.insights, () => { void this.run(commandName, workspace, trigger); }); + this.telemetryService.sendCommandEvent(Constants.TELEM_SUCCESSFUL_STATIC_ANALYSIS, { commandName: commandName, duration: (Date.now() - startTime).toString(), diff --git a/src/lib/code-analyzer.ts b/src/lib/code-analyzer.ts index d77a4f3..e932704 100644 --- a/src/lib/code-analyzer.ts +++ b/src/lib/code-analyzer.ts @@ -18,6 +18,21 @@ import * as path from 'node:path'; type ResultsJson = { runDir: string; violations: Violation[]; + insights?: Record; +}; + +export type EngineInsight = { + status: 'completed' | 'skipped'; + error?: { + code: string; + message: string; + remediation: string; + }; +}; + +export type ScanResults = { + violations: Violation[]; + insights?: Record; }; type RulesJson = { @@ -35,7 +50,7 @@ type RuleDescription = { export interface CodeAnalyzer { validateEnvironment(): Promise; - scan(workspace: Workspace): Promise; + scan(workspace: Workspace): Promise; getVersion(): Promise; getRuleDescriptionFor(engineName: string, ruleName: string): Promise; } @@ -116,7 +131,7 @@ export class CodeAnalyzerImpl implements CodeAnalyzer { return this.ruleDescriptionMap; } - public async scan(workspace: Workspace): Promise { + public async scan(workspace: Workspace): Promise { await this.validateEnvironment(); const ruleSelector: string = this.settingsManager.getCodeAnalyzerRuleSelectors(); @@ -159,7 +174,10 @@ export class CodeAnalyzerImpl implements CodeAnalyzer { const resultsJsonStr: string = await fs.promises.readFile(outputFile, 'utf-8'); const resultsJson: ResultsJson = JSON.parse(resultsJsonStr) as ResultsJson; - return this.processResults(resultsJson); + return { + violations: this.processResults(resultsJson), + insights: resultsJson.insights + }; } private processResults(resultsJson: ResultsJson): Violation[] { diff --git a/src/lib/display.ts b/src/lib/display.ts index 045637e..8307c34 100644 --- a/src/lib/display.ts +++ b/src/lib/display.ts @@ -7,7 +7,7 @@ export type DisplayButton = { } export interface Display { - displayInfo(infoMsg: string): void; + displayInfo(infoMsg: string, ...buttons: DisplayButton[]): void; displayWarning(warnMsg: string, ...buttons: DisplayButton[]): void; displayError(errorMsg: string, ...buttons: DisplayButton[]): void; } @@ -19,9 +19,17 @@ export class VSCodeDisplay implements Display { this.logger = logger; } - displayInfo(infoMsg: string): void { - // Not waiting for promise because we didn't add buttons and don't care if user ignores the message. - void vscode.window.showInformationMessage(infoMsg); + displayInfo(infoMsg: string, ...buttons: DisplayButton[]): void { + if (buttons.length > 0) { + void vscode.window.showInformationMessage(infoMsg, ...buttons.map(b => b.text)).then(selectedText => { + const selectedButton: DisplayButton = buttons.find(b => b.text === selectedText); + if (selectedButton) { + selectedButton.callback(); + } + }); + } else { + void vscode.window.showInformationMessage(infoMsg); + } this.logger.log(infoMsg); } diff --git a/src/lib/insights-handler.ts b/src/lib/insights-handler.ts new file mode 100644 index 0000000..7bd17c1 --- /dev/null +++ b/src/lib/insights-handler.ts @@ -0,0 +1,122 @@ +import * as vscode from "vscode"; +import {EngineInsight} from "./code-analyzer"; +import {Display, DisplayButton} from "./display"; +import {Logger} from "./logger"; +import {messages} from "./messages"; +import {ExternalServiceProvider} from "./external-services/external-service-provider"; +import {WindowManager} from "./vscode-api"; + +const ISSUES_URL = 'https://github.com/forcedotcom/sfdx-code-analyzer-vscode/issues/new'; + +export class InsightsHandler { + private readonly display: Display; + private readonly logger: Logger; + private readonly externalServiceProvider: ExternalServiceProvider; + private readonly windowManager: WindowManager; + private readonly suppressedErrorCodes: Set = new Set(); + + constructor(display: Display, logger: Logger, externalServiceProvider: ExternalServiceProvider, windowManager: WindowManager) { + this.display = display; + this.logger = logger; + this.externalServiceProvider = externalServiceProvider; + this.windowManager = windowManager; + } + + handleInsights(insights: Record | undefined, retriggerScan: () => void): void { + if (!insights || !insights.apexguru) { + return; + } + + const apexguruInsight = insights.apexguru; + if (apexguruInsight.status !== 'skipped') { + return; + } + + const error = apexguruInsight.error; + if (!error) { + return; + } + + if (this.suppressedErrorCodes.has(error.code)) { + return; + } + + this.suppressedErrorCodes.add(error.code); + + switch (error.code) { + case 'NO_ORG_CONNECTION': + this.handleNoOrgConnection(error.message, error.remediation); + break; + case 'API_UNAVAILABLE': + this.handleApiUnavailable(error.message, error.remediation, retriggerScan); + break; + case 'UNEXPECTED_ERROR': + this.handleUnexpectedError(error.message, error.remediation); + break; + default: + this.logger.warn(`Unknown insight error code: ${error.code}`); + } + } + + private handleNoOrgConnection(message: string, remediation: string): void { + const connectOrgButton: DisplayButton = { + text: messages.insights.buttons.connectOrg, + callback: () => this.triggerConnectOrg(remediation) + }; + + this.display.displayInfo( + messages.insights.apexGuruSkipped.noOrgConnection(remediation), + connectOrgButton + ); + this.logger.log(message); + } + + private handleApiUnavailable(message: string, remediation: string, retriggerScan: () => void): void { + const retryScanButton: DisplayButton = { + text: messages.insights.buttons.retryScan, + callback: retriggerScan + }; + const detailsButton: DisplayButton = { + text: messages.insights.buttons.details, + callback: () => { + this.logger.log(remediation); + this.windowManager.showLogOutputWindow(); + } + }; + + this.display.displayInfo( + messages.insights.apexGuruSkipped.apiUnavailable(message), + retryScanButton, + detailsButton + ); + this.logger.log(message); + } + + private handleUnexpectedError(message: string, _remediation: string): void { + const viewDetailsButton: DisplayButton = { + text: messages.insights.buttons.viewDetails, + callback: () => this.windowManager.showLogOutputWindow() + }; + const reportIssueButton: DisplayButton = { + text: messages.insights.buttons.reportIssue, + callback: () => this.windowManager.showExternalUrl(ISSUES_URL) + }; + + this.display.displayInfo( + messages.insights.apexGuruSkipped.unexpectedError(message), + viewDetailsButton, + reportIssueButton + ); + this.logger.log(message); + } + + private triggerConnectOrg(remediation: string): void { + void this.externalServiceProvider.isOrgConnectionServiceAvailable().then(available => { + if (available) { + void vscode.commands.executeCommand('sfdx.authorize.org'); + } else { + this.display.displayInfo(messages.insights.fallback.connectOrgManual(remediation)); + } + }); + } +} diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 6a37d3d..86963b8 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -85,6 +85,23 @@ export const messages = { sfMissing: "To use the Salesforce Code Analyzer extension, first install Salesforce CLI.", coreExtensionServiceUninitialized: "CoreExtensionService.ts didn't initialize. Log a new issue on Salesforce Code Analyzer VS Code extension repo: https://github.com/forcedotcom/sfdx-code-analyzer-vscode/issues" }, + insights: { + apexGuruSkipped: { + noOrgConnection: (remediation: string) => `ApexGuru analysis was skipped because no org is connected. ${remediation}`, + apiUnavailable: (message: string) => `ApexGuru analysis was skipped because the service is currently unavailable. ${message}`, + unexpectedError: (message: string) => `ApexGuru analysis was skipped due to an unexpected error. ${message}` + }, + buttons: { + connectOrg: 'Connect Org', + retryScan: 'Retry Scan', + details: 'Details', + viewDetails: 'View Details', + reportIssue: 'Report Issue' + }, + fallback: { + connectOrgManual: (remediation: string) => `To connect an org manually, run the following in your terminal: ${remediation}` + } + }, buttons: { learnMore: 'Learn more', showError: 'Show error', diff --git a/test/lib/code-analyzer-run-action.test.ts b/test/lib/code-analyzer-run-action.test.ts index 9922607..40e17c6 100644 --- a/test/lib/code-analyzer-run-action.test.ts +++ b/test/lib/code-analyzer-run-action.test.ts @@ -15,6 +15,16 @@ import {messages} from "../../src/lib/messages"; import * as Constants from '../../src/lib/constants'; import {Workspace} from "../../src/lib/workspace"; import { APEX_GURU_ENGINE_NAME } from "../../src/lib/apexguru/apex-guru-service"; +import {InsightsHandler} from "../../src/lib/insights-handler"; +import {EngineInsight} from "../../src/lib/code-analyzer"; + +class SpyInsightsHandler { + handleInsightsCallHistory: { insights: Record | undefined, retriggerScan: () => void }[] = []; + + handleInsights(insights: Record | undefined, retriggerScan: () => void): void { + this.handleInsightsCallHistory.push({insights, retriggerScan}); + } +} describe('Tests for CodeAnalyzerRunAction', () => { let sampleWorkspace: Workspace; @@ -26,6 +36,7 @@ describe('Tests for CodeAnalyzerRunAction', () => { let logger: SpyLogger; let display: SpyDisplay; let windowManager: SpyWindowManager; + let insightsHandler: SpyInsightsHandler; let codeAnalyzerRunAction: CodeAnalyzerRunAction; beforeEach(async () => { @@ -41,8 +52,9 @@ describe('Tests for CodeAnalyzerRunAction', () => { logger = new SpyLogger(); display = new SpyDisplay(); windowManager = new SpyWindowManager(); + insightsHandler = new SpyInsightsHandler(); codeAnalyzerRunAction = new CodeAnalyzerRunAction(taskWithProgressRunner, codeAnalyzer, diagnosticManager, - diagnosticFactory, telemetryService, logger, display, windowManager); + diagnosticFactory, telemetryService, logger, display, windowManager, insightsHandler as unknown as InsightsHandler); }); it('When scan results in violations that are not associated with a file location, then show violation as display messages', async () => { @@ -67,8 +79,8 @@ describe('Tests for CodeAnalyzerRunAction', () => { {msg: '[engineE:ruleE] messageE', buttons: []}, ]); expect(display.displayInfoCallHistory).toEqual([ - {msg: '[engineF:ruleF] messageF'}, - {msg: 'Scan complete. Analyzed 1 files. 1 violations found in 1 files.'} + {msg: '[engineF:ruleF] messageF', buttons: []}, + {msg: 'Scan complete. Analyzed 1 files. 1 violations found in 1 files.', buttons: []} ]); // Sanity check, good violations still make it @@ -87,7 +99,7 @@ describe('Tests for CodeAnalyzerRunAction', () => { expect(display.displayErrorCallHistory[0].msg).toEqual(messages.error.engineUninstantiable(engine)); expect(display.displayWarningCallHistory).toEqual([]); expect(display.displayInfoCallHistory).toEqual([ - {msg: 'Scan complete. Analyzed 1 files. 0 violations found in 0 files.'} + {msg: 'Scan complete. Analyzed 1 files. 0 violations found in 0 files.', buttons: []} ]); }); @@ -282,6 +294,60 @@ describe('Tests for CodeAnalyzerRunAction', () => { }); }); + describe('Insights handling integration', () => { + it('When scan returns insights with apexguru skipped, InsightsHandler is invoked', async () => { + codeAnalyzer.scanInsightsReturnValue = { + apexguru: { + status: 'skipped', + error: {code: 'NO_ORG_CONNECTION', message: 'No org', remediation: 'sf org login web'} + } + }; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + expect(insightsHandler.handleInsightsCallHistory).toHaveLength(1); + expect(insightsHandler.handleInsightsCallHistory[0].insights).toEqual(codeAnalyzer.scanInsightsReturnValue); + }); + + it('When scan returns insights with apexguru completed, InsightsHandler is still invoked (handler decides)', async () => { + codeAnalyzer.scanInsightsReturnValue = { + apexguru: {status: 'completed'} + }; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + expect(insightsHandler.handleInsightsCallHistory).toHaveLength(1); + expect(insightsHandler.handleInsightsCallHistory[0].insights).toEqual(codeAnalyzer.scanInsightsReturnValue); + }); + + it('When scan returns no insights field (old CLI), InsightsHandler is invoked with undefined', async () => { + codeAnalyzer.scanInsightsReturnValue = undefined; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + expect(insightsHandler.handleInsightsCallHistory).toHaveLength(1); + expect(insightsHandler.handleInsightsCallHistory[0].insights).toBeUndefined(); + }); + + it('When apexguru key not present in insights, InsightsHandler is invoked (handler decides)', async () => { + codeAnalyzer.scanInsightsReturnValue = { + pmd: {status: 'completed'} + }; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + expect(insightsHandler.handleInsightsCallHistory).toHaveLength(1); + expect(insightsHandler.handleInsightsCallHistory[0].insights).toEqual({pmd: {status: 'completed'}}); + }); + + it('retriggerScan callback is a function that re-invokes the run action', async () => { + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + expect(insightsHandler.handleInsightsCallHistory).toHaveLength(1); + expect(typeof insightsHandler.handleInsightsCallHistory[0].retriggerScan).toBe('function'); + }); + }); + // TODO: Eventually, we want to add in the rest of the tests for all the other cases. }); diff --git a/test/lib/code-analyzer.test.ts b/test/lib/code-analyzer.test.ts index a675390..4b3b01c 100644 --- a/test/lib/code-analyzer.test.ts +++ b/test/lib/code-analyzer.test.ts @@ -1,4 +1,4 @@ -import {CodeAnalyzer, CodeAnalyzerImpl} from "../../src/lib/code-analyzer"; +import {CodeAnalyzer, CodeAnalyzerImpl, ScanResults} from "../../src/lib/code-analyzer"; import * as semver from "semver"; import * as stubs from "../stubs"; import {messages} from "../../src/lib/messages"; @@ -141,7 +141,7 @@ describe('Tests for the CodeAnalyzerImpl class', () => { ['/my/project/dummyFile1.cls', '/my/project/dummyFile2.cls', '/my/project/subfolder'], vscodeWorkspace, fileHandler); // Call scan - const violations: Violation[] = await codeAnalyzer.scan(workspace); + const scanResults: ScanResults = await codeAnalyzer.scan(workspace); // Check that we made the CLI call expect(cliCommandExecutor.execCallHistory.length).toBeGreaterThanOrEqual(1); @@ -162,7 +162,7 @@ describe('Tests for the CodeAnalyzerImpl class', () => { "-f", prePopulatedResultsJsonFile ]); - expect(violations).toEqual([expectedViolation1, expectedViolation2]); + expect(scanResults.violations).toEqual([expectedViolation1, expectedViolation2]); }); it('When running a scan with version 5.12.0, then confirm we call the cli and process the results correctly using both --workspace and --target', async () => { @@ -175,7 +175,7 @@ describe('Tests for the CodeAnalyzerImpl class', () => { // Call scan const workspace: Workspace = await Workspace.fromTargetPaths( ['/my/project/dummyFile1.cls', '/my/project/dummyFile2.cls'], vscodeWorkspace, fileHandler); - const violations: Violation[] = await codeAnalyzer.scan(workspace); + const scanResults: ScanResults = await codeAnalyzer.scan(workspace); // Check that we made the CLI call expect(cliCommandExecutor.execCallHistory.length).toBeGreaterThanOrEqual(1); @@ -195,7 +195,7 @@ describe('Tests for the CodeAnalyzerImpl class', () => { "-f", prePopulatedResultsJsonFile ]); - expect(violations).toEqual([expectedViolation1, expectedViolation2]); + expect(scanResults.violations).toEqual([expectedViolation1, expectedViolation2]); }); it('When no vscode workspace exist because the user probably just opened a single file, verify the files make up the workspace', async () => { @@ -209,7 +209,7 @@ describe('Tests for the CodeAnalyzerImpl class', () => { const workspace: Workspace = await Workspace.fromTargetPaths( ['/my/project/dummyFile1.cls', '/my/project/dummyFile2.cls', '/my/project/subfolder'], vscodeWorkspace, fileHandler); - const violations: Violation[] = await codeAnalyzer.scan(workspace); + const scanResults: ScanResults = await codeAnalyzer.scan(workspace); // Check that we made the CLI call expect(cliCommandExecutor.execCallHistory.length).toBeGreaterThanOrEqual(1); @@ -229,7 +229,7 @@ describe('Tests for the CodeAnalyzerImpl class', () => { "-f", prePopulatedResultsJsonFile ]); - expect(violations).toEqual([expectedViolation1, expectedViolation2]); + expect(scanResults.violations).toEqual([expectedViolation1, expectedViolation2]); }); it('When scan results contain fixes and suggestions with relative paths, then their locations are made absolute', async () => { @@ -242,18 +242,18 @@ describe('Tests for the CodeAnalyzerImpl class', () => { const workspace: Workspace = await Workspace.fromTargetPaths( ['/my/project/dummyFile1.js'], vscodeWorkspace, fileHandler); - const violations: Violation[] = await codeAnalyzer.scan(workspace); + const scanResults: ScanResults = await codeAnalyzer.scan(workspace); // The first violation should have fixes and suggestions with absolute paths - expect(violations[0].fixes).toHaveLength(1); - expect(violations[0].fixes[0].location.file).toEqual(path.normalize("/my/project/dummyFile1.js")); + expect(scanResults.violations[0].fixes).toHaveLength(1); + expect(scanResults.violations[0].fixes[0].location.file).toEqual(path.normalize("/my/project/dummyFile1.js")); - expect(violations[0].suggestions).toHaveLength(1); - expect(violations[0].suggestions[0].location.file).toEqual(path.normalize("/my/project/dummyFile1.js")); + expect(scanResults.violations[0].suggestions).toHaveLength(1); + expect(scanResults.violations[0].suggestions[0].location.file).toEqual(path.normalize("/my/project/dummyFile1.js")); // The second violation should have no fixes or suggestions - expect(violations[1].fixes).toBeUndefined(); - expect(violations[1].suggestions).toBeUndefined(); + expect(scanResults.violations[1].fixes).toBeUndefined(); + expect(scanResults.violations[1].suggestions).toBeUndefined(); }); it('When CLI supports fixes/suggestions flags and settings are enabled, then they are included in scan command', async () => { diff --git a/test/lib/insights-handler.integration.test.ts b/test/lib/insights-handler.integration.test.ts new file mode 100644 index 0000000..721c649 --- /dev/null +++ b/test/lib/insights-handler.integration.test.ts @@ -0,0 +1,171 @@ +import * as vscode from "vscode"; +import {InsightsHandler} from "../../src/lib/insights-handler"; +import {CodeAnalyzerRunAction} from "../../src/lib/code-analyzer-run-action"; +import {EngineInsight, ScanResults} from "../../src/lib/code-analyzer"; +import { + FakeTaskWithProgressRunner, + SpyDisplay, + SpyLogger, + SpyTelemetryService, + SpyWindowManager, + StubCodeAnalyzer, + StubFileHandler, + StubSettingsManager, + StubVscodeWorkspace +} from "../stubs"; +import {DiagnosticManager, DiagnosticManagerImpl} from "../../src/lib/diagnostics"; +import {FakeDiagnosticCollection} from "../vscode-stubs"; +import {ExternalServiceProvider} from "../../src/lib/external-services/external-service-provider"; +import {Workspace} from "../../src/lib/workspace"; +import {messages} from "../../src/lib/messages"; + +class StubExternalServiceProvider { + isOrgConnectionServiceAvailableReturnValue: boolean = true; + + async isOrgConnectionServiceAvailable(): Promise { + return this.isOrgConnectionServiceAvailableReturnValue; + } +} + +describe('InsightsHandler integration tests - full skip-banner lifecycle', () => { + let display: SpyDisplay; + let logger: SpyLogger; + let windowManager: SpyWindowManager; + let externalServiceProvider: StubExternalServiceProvider; + let codeAnalyzer: StubCodeAnalyzer; + let codeAnalyzerRunAction: CodeAnalyzerRunAction; + let sampleWorkspace: Workspace; + + beforeEach(async () => { + display = new SpyDisplay(); + logger = new SpyLogger(); + windowManager = new SpyWindowManager(); + externalServiceProvider = new StubExternalServiceProvider(); + codeAnalyzer = new StubCodeAnalyzer(); + + const taskWithProgressRunner = new FakeTaskWithProgressRunner(); + const diagnosticCollection = new FakeDiagnosticCollection(); + const settingsManager = new StubSettingsManager(); + const diagnosticManager: DiagnosticManager = new DiagnosticManagerImpl(diagnosticCollection, settingsManager); + const diagnosticFactory = (diagnosticManager as DiagnosticManagerImpl).diagnosticFactory; + const telemetryService = new SpyTelemetryService(); + + const insightsHandler = new InsightsHandler( + display, logger, + externalServiceProvider as unknown as ExternalServiceProvider, + windowManager + ); + + codeAnalyzerRunAction = new CodeAnalyzerRunAction( + taskWithProgressRunner, codeAnalyzer, diagnosticManager, + diagnosticFactory, telemetryService, logger, display, windowManager, insightsHandler + ); + + sampleWorkspace = await Workspace.fromTargetPaths(['someFile.cls'], new StubVscodeWorkspace(), new StubFileHandler()); + }); + + it('CLI returns with insights.apexguru.status=skipped and error.code=NO_ORG_CONNECTION -> info banner appears with Connect Org button', async () => { + codeAnalyzer.scanInsightsReturnValue = { + apexguru: { + status: 'skipped', + error: {code: 'NO_ORG_CONNECTION', message: 'No org connected', remediation: 'sf org login web'} + } + }; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + const infoBanners = display.displayInfoCallHistory.filter(h => h.buttons.length > 0); + expect(infoBanners).toHaveLength(1); + expect(infoBanners[0].msg).toContain('no org is connected'); + expect(infoBanners[0].buttons[0].text).toEqual(messages.insights.buttons.connectOrg); + }); + + it('User dismisses banner -> same error code suppressed on next scan', async () => { + codeAnalyzer.scanInsightsReturnValue = { + apexguru: { + status: 'skipped', + error: {code: 'NO_ORG_CONNECTION', message: 'No org', remediation: 'sf org login web'} + } + }; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + const firstBannerCount = display.displayInfoCallHistory.filter(h => h.buttons.length > 0).length; + expect(firstBannerCount).toEqual(1); + + // Run scan again - should be suppressed + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + const secondBannerCount = display.displayInfoCallHistory.filter(h => h.buttons.length > 0).length; + expect(secondBannerCount).toEqual(1); // Still 1, suppressed + }); + + it('Different error code API_UNAVAILABLE still shows banner after NO_ORG_CONNECTION suppressed', async () => { + codeAnalyzer.scanInsightsReturnValue = { + apexguru: { + status: 'skipped', + error: {code: 'NO_ORG_CONNECTION', message: 'No org', remediation: 'sf org login web'} + } + }; + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + // Change to API_UNAVAILABLE + codeAnalyzer.scanInsightsReturnValue = { + apexguru: { + status: 'skipped', + error: {code: 'API_UNAVAILABLE', message: 'Service down', remediation: 'Retry'} + } + }; + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + const banners = display.displayInfoCallHistory.filter(h => h.buttons.length > 0); + expect(banners).toHaveLength(2); // Both shown + expect(banners[0].buttons[0].text).toEqual(messages.insights.buttons.connectOrg); + expect(banners[1].buttons[0].text).toEqual(messages.insights.buttons.retryScan); + }); + + it('CLI returns without insights field (older CLI version) -> no crash, no banner, scan completes normally', async () => { + codeAnalyzer.scanInsightsReturnValue = undefined; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + const banners = display.displayInfoCallHistory.filter(h => h.buttons.length > 0); + expect(banners).toHaveLength(0); + // Scan completed normally - displayed results info + expect(display.displayInfoCallHistory.some(h => h.msg.includes('Scan complete'))).toBe(true); + }); + + it('CLI returns with insights.apexguru.status=completed -> no banner', async () => { + codeAnalyzer.scanInsightsReturnValue = { + apexguru: {status: 'completed'} + }; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + const banners = display.displayInfoCallHistory.filter(h => h.buttons.length > 0); + expect(banners).toHaveLength(0); + }); + + it('User clicks Retry Scan -> scan re-triggers', async () => { + codeAnalyzer.scanInsightsReturnValue = { + apexguru: { + status: 'skipped', + error: {code: 'API_UNAVAILABLE', message: 'Service down', remediation: 'Retry'} + } + }; + + await codeAnalyzerRunAction.run('dummyCommandName', sampleWorkspace); + + const banners = display.displayInfoCallHistory.filter(h => h.buttons.length > 0); + expect(banners).toHaveLength(1); + + // Clear insights for next scan so we can detect it happened + codeAnalyzer.scanInsightsReturnValue = undefined; + + // Click Retry Scan - the callback fires and forgets; give it a tick to complete + banners[0].buttons[0].callback(); + await new Promise(resolve => setTimeout(resolve, 0)); + + // Verify scan ran again (displayed results again) + const scanCompleteMsgs = display.displayInfoCallHistory.filter(h => h.msg.includes('Scan complete')); + expect(scanCompleteMsgs.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/test/lib/insights-handler.test.ts b/test/lib/insights-handler.test.ts new file mode 100644 index 0000000..847a771 --- /dev/null +++ b/test/lib/insights-handler.test.ts @@ -0,0 +1,276 @@ +import * as vscode from "vscode"; +import {InsightsHandler} from "../../src/lib/insights-handler"; +import {EngineInsight} from "../../src/lib/code-analyzer"; +import {SpyDisplay, SpyLogger, SpyWindowManager} from "../stubs"; +import {messages} from "../../src/lib/messages"; +import {ExternalServiceProvider} from "../../src/lib/external-services/external-service-provider"; + +class StubExternalServiceProvider { + isOrgConnectionServiceAvailableReturnValue: boolean = true; + + async isOrgConnectionServiceAvailable(): Promise { + return this.isOrgConnectionServiceAvailableReturnValue; + } +} + +describe('Tests for InsightsHandler', () => { + let display: SpyDisplay; + let logger: SpyLogger; + let externalServiceProvider: StubExternalServiceProvider; + let windowManager: SpyWindowManager; + let insightsHandler: InsightsHandler; + let retriggerScanCallCount: number; + let retriggerScan: () => void; + + beforeEach(() => { + display = new SpyDisplay(); + logger = new SpyLogger(); + externalServiceProvider = new StubExternalServiceProvider(); + windowManager = new SpyWindowManager(); + insightsHandler = new InsightsHandler( + display, logger, + externalServiceProvider as unknown as ExternalServiceProvider, + windowManager + ); + retriggerScanCallCount = 0; + retriggerScan = () => { retriggerScanCallCount++; }; + }); + + it('When insights is undefined, then no banner is shown', () => { + insightsHandler.handleInsights(undefined, retriggerScan); + expect(display.displayInfoCallHistory).toHaveLength(0); + }); + + it('When insights has no apexguru key, then no banner is shown', () => { + const insights: Record = { + pmd: {status: 'completed'} + }; + insightsHandler.handleInsights(insights, retriggerScan); + expect(display.displayInfoCallHistory).toHaveLength(0); + }); + + it('When apexguru status is completed, then no banner is shown', () => { + const insights: Record = { + apexguru: {status: 'completed'} + }; + insightsHandler.handleInsights(insights, retriggerScan); + expect(display.displayInfoCallHistory).toHaveLength(0); + }); + + it('When apexguru status is skipped with NO_ORG_CONNECTION, then info banner is shown with Connect Org button', () => { + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'NO_ORG_CONNECTION', + message: 'No org connected', + remediation: 'sf org login web' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + + expect(display.displayInfoCallHistory).toHaveLength(1); + expect(display.displayInfoCallHistory[0].msg).toContain('no org is connected'); + expect(display.displayInfoCallHistory[0].buttons).toHaveLength(1); + expect(display.displayInfoCallHistory[0].buttons[0].text).toEqual(messages.insights.buttons.connectOrg); + }); + + it('When apexguru status is skipped with API_UNAVAILABLE, then info banner is shown with Retry Scan and Details buttons', () => { + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'API_UNAVAILABLE', + message: 'Service temporarily down', + remediation: 'Try again later' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + + expect(display.displayInfoCallHistory).toHaveLength(1); + expect(display.displayInfoCallHistory[0].msg).toContain('service is currently unavailable'); + expect(display.displayInfoCallHistory[0].buttons).toHaveLength(2); + expect(display.displayInfoCallHistory[0].buttons[0].text).toEqual(messages.insights.buttons.retryScan); + expect(display.displayInfoCallHistory[0].buttons[1].text).toEqual(messages.insights.buttons.details); + }); + + it('When apexguru status is skipped with UNEXPECTED_ERROR, then info banner is shown with View Details and Report Issue buttons', () => { + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'UNEXPECTED_ERROR', + message: 'Something went wrong', + remediation: 'Contact support' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + + expect(display.displayInfoCallHistory).toHaveLength(1); + expect(display.displayInfoCallHistory[0].msg).toContain('unexpected error'); + expect(display.displayInfoCallHistory[0].buttons).toHaveLength(2); + expect(display.displayInfoCallHistory[0].buttons[0].text).toEqual(messages.insights.buttons.viewDetails); + expect(display.displayInfoCallHistory[0].buttons[1].text).toEqual(messages.insights.buttons.reportIssue); + }); + + it('After a banner is shown for an error code, the same code does not produce a banner again (session suppression)', () => { + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'NO_ORG_CONNECTION', + message: 'No org connected', + remediation: 'sf org login web' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + expect(display.displayInfoCallHistory).toHaveLength(1); + + insightsHandler.handleInsights(insights, retriggerScan); + expect(display.displayInfoCallHistory).toHaveLength(1); // Still 1, suppressed + }); + + it('Different error codes are suppressed independently', () => { + const noOrgInsights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'NO_ORG_CONNECTION', + message: 'No org', + remediation: 'sf org login web' + } + } + }; + const apiInsights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'API_UNAVAILABLE', + message: 'Service down', + remediation: 'Retry later' + } + } + }; + + insightsHandler.handleInsights(noOrgInsights, retriggerScan); + expect(display.displayInfoCallHistory).toHaveLength(1); + + // Same code suppressed + insightsHandler.handleInsights(noOrgInsights, retriggerScan); + expect(display.displayInfoCallHistory).toHaveLength(1); + + // Different code still shows + insightsHandler.handleInsights(apiInsights, retriggerScan); + expect(display.displayInfoCallHistory).toHaveLength(2); + }); + + it('Connect Org button triggers vscode.commands.executeCommand when Core Extension is available', async () => { + externalServiceProvider.isOrgConnectionServiceAvailableReturnValue = true; + + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'NO_ORG_CONNECTION', + message: 'No org', + remediation: 'sf org login web' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + display.displayInfoCallHistory[0].buttons[0].callback(); + + // Allow the async promise to resolve + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('sfdx.authorize.org'); + }); + + it('Connect Org button shows fallback message when Core Extension is NOT available', async () => { + externalServiceProvider.isOrgConnectionServiceAvailableReturnValue = false; + + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'NO_ORG_CONNECTION', + message: 'No org', + remediation: 'sf org login web' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + display.displayInfoCallHistory[0].buttons[0].callback(); + + // Allow the async promise to resolve + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(display.displayInfoCallHistory).toHaveLength(2); + expect(display.displayInfoCallHistory[1].msg).toContain('sf org login web'); + }); + + it('Retry Scan button re-triggers the scan', () => { + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'API_UNAVAILABLE', + message: 'Service down', + remediation: 'Retry later' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + display.displayInfoCallHistory[0].buttons[0].callback(); + + expect(retriggerScanCallCount).toEqual(1); + }); + + it('View Details button shows log output window', () => { + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'UNEXPECTED_ERROR', + message: 'Something went wrong', + remediation: 'Contact support' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + display.displayInfoCallHistory[0].buttons[0].callback(); + + expect(windowManager.showLogOutputWindowCallCount).toEqual(1); + }); + + it('Report Issue button opens issues URL', () => { + const insights: Record = { + apexguru: { + status: 'skipped', + error: { + code: 'UNEXPECTED_ERROR', + message: 'Something went wrong', + remediation: 'Contact support' + } + } + }; + + insightsHandler.handleInsights(insights, retriggerScan); + display.displayInfoCallHistory[0].buttons[1].callback(); + + expect(windowManager.showExternalUrlCallHistory).toHaveLength(1); + expect(windowManager.showExternalUrlCallHistory[0].url).toContain('github.com/forcedotcom/sfdx-code-analyzer-vscode/issues'); + }); +}); diff --git a/test/lib/messages.test.ts b/test/lib/messages.test.ts new file mode 100644 index 0000000..6dc4171 --- /dev/null +++ b/test/lib/messages.test.ts @@ -0,0 +1,37 @@ +import {messages} from "../../src/lib/messages"; + +describe('Tests for messages', () => { + describe('insights messages', () => { + it('apexGuruSkipped.noOrgConnection produces expected output', () => { + const result = messages.insights.apexGuruSkipped.noOrgConnection('Run sf org login web'); + expect(result).toContain('no org is connected'); + expect(result).toContain('Run sf org login web'); + }); + + it('apexGuruSkipped.apiUnavailable produces expected output', () => { + const result = messages.insights.apexGuruSkipped.apiUnavailable('Service is down'); + expect(result).toContain('service is currently unavailable'); + expect(result).toContain('Service is down'); + }); + + it('apexGuruSkipped.unexpectedError produces expected output', () => { + const result = messages.insights.apexGuruSkipped.unexpectedError('Something went wrong'); + expect(result).toContain('unexpected error'); + expect(result).toContain('Something went wrong'); + }); + + it('buttons contain expected labels', () => { + expect(messages.insights.buttons.connectOrg).toEqual('Connect Org'); + expect(messages.insights.buttons.retryScan).toEqual('Retry Scan'); + expect(messages.insights.buttons.details).toEqual('Details'); + expect(messages.insights.buttons.viewDetails).toEqual('View Details'); + expect(messages.insights.buttons.reportIssue).toEqual('Report Issue'); + }); + + it('fallback.connectOrgManual produces expected output', () => { + const result = messages.insights.fallback.connectOrgManual('sf org login web'); + expect(result).toContain('sf org login web'); + expect(result).toContain('run the following in your terminal'); + }); + }); +}); diff --git a/test/stubs.ts b/test/stubs.ts index 3115837..82ab0ed 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -5,6 +5,7 @@ import {Logger} from "../src/lib/logger"; import {LLMService, LLMServiceProvider} from "../src/lib/external-services/llm-service"; import {Violation} from "../src/lib/diagnostics"; import {Display, DisplayButton} from "../src/lib/display"; +import {ScanResults, EngineInsight} from "../src/lib/code-analyzer"; import {UnifiedDiffService} from "../src/lib/unified-diff-service"; import {TextDocument} from "vscode"; import {SettingsManager} from "../src/lib/settings"; @@ -77,10 +78,10 @@ export class SpyLogger implements Logger { } export class SpyDisplay implements Display { - displayInfoCallHistory: { msg: string }[] = []; + displayInfoCallHistory: { msg: string, buttons: DisplayButton[] }[] = []; - displayInfo(msg: string): void { - this.displayInfoCallHistory.push({msg}); + displayInfo(msg: string, ...buttons: DisplayButton[]): void { + this.displayInfoCallHistory.push({msg, buttons}); } displayWarningCallHistory: { msg: string, buttons: DisplayButton[] }[] = []; @@ -147,9 +148,13 @@ export class StubCodeAnalyzer implements CodeAnalyzer { } scanReturnValue: Violation[] = []; + scanInsightsReturnValue: Record | undefined = undefined; - scan(_workspace: Workspace): Promise { - return Promise.resolve(this.scanReturnValue); + scan(_workspace: Workspace): Promise { + return Promise.resolve({ + violations: this.scanReturnValue, + insights: this.scanInsightsReturnValue + }); } getScannerNameReturnValue: string = 'dummyScannerName'; @@ -170,7 +175,7 @@ export class ThrowingCodeAnalyzer implements CodeAnalyzer { throw new Error("Error from validateEnvironment"); } - scan(_workspace: Workspace): Promise { + scan(_workspace: Workspace): Promise { throw new Error("Error from scan"); }