Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions messages/action-summary-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,7 @@ Found %d violation(s) across %d file(s):
# run-action.outfiles-total

Results written to:

# run-action.apexguru-analysis-mode

ApexGuru analysis mode: %s
23 changes: 1 addition & 22 deletions src/lib/actions/RunAction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Org, SfError} from '@salesforce/core';
import {SfError} from '@salesforce/core';
import {
CodeAnalyzer,
CodeAnalyzerConfig,
Expand Down Expand Up @@ -54,11 +54,6 @@ export class RunAction {
}

public async execute(input: RunInput): Promise<void> {
// Validate org authentication upfront before config creation
if (input['target-org']) {
await this.validateTargetOrg(input['target-org']);
}

const cliOverrides = (input['no-suppressions'] !== undefined || input['target-org'] !== undefined)
? {
noSuppressions: input['no-suppressions'],
Expand Down Expand Up @@ -116,22 +111,6 @@ export class RunAction {
return new RunAction(dependencies);
}

private async validateTargetOrg(orgNameOrAlias: string): Promise<void> {
try {
await Org.create({ aliasOrUsername: orgNameOrAlias });
} catch (error) {
if (error instanceof Error) {
const errorMessage = [
`Org '${orgNameOrAlias}' not found or not authenticated.`,
` • Run 'sf org list' to see available orgs`,
` • Run 'sf org login web --alias ${orgNameOrAlias}' to authenticate a new org`
].join('\n');
throw new SfError(errorMessage, 'OrgAuthenticationError');
}
throw error;
}
}

private emitEngineTelemetry(ruleSelection: RuleSelection, results: RunResults, coreEngineNames: string[]): void {
const selectedEngineNames: Set<string> = new Set(ruleSelection.getEngineNames());
for (const coreEngineName of coreEngineNames) {
Expand Down
23 changes: 23 additions & 0 deletions src/lib/viewers/ActionSummaryViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import {Display} from '../Display.js';
import {toStyledHeader, indent} from '../utils/StylingUtil.js';
import {BundleName, getMessage} from '../messages.js';


const APEXGURU_ENGINE_NAME = 'apexguru';

const APEXGURU_ANALYSIS_MODE_LABELS: Record<string, string> = {
full: 'ADVANCED',
static: 'BASIC'
};

abstract class AbstractActionSummaryViewer {
protected readonly display: Display;

Expand Down Expand Up @@ -113,6 +121,7 @@ export class RunActionSummaryViewer extends AbstractActionSummaryViewer {
} else {
this.displayResultsSummary(results);
}
this.displayApexGuruAnalysisMode(results);
this.displayLineSeparator();

if (outfiles.length > 0) {
Expand All @@ -137,6 +146,20 @@ export class RunActionSummaryViewer extends AbstractActionSummaryViewer {
}
}


private displayApexGuruAnalysisMode(results: RunResults): void {
if (!results.getEngineNames().includes(APEXGURU_ENGINE_NAME)) {
return;
}
const insights = results.getEngineInsights(APEXGURU_ENGINE_NAME);
const analysisMode = insights?.['analysisMode'];
if (typeof analysisMode !== 'string') {
return;
}
const label = APEXGURU_ANALYSIS_MODE_LABELS[analysisMode] ?? analysisMode.toUpperCase();
this.display.displayLog(getMessage(BundleName.ActionSummaryViewer, 'run-action.apexguru-analysis-mode', [label]));
}

private countUniqueFiles(violations: Violation[]): number {
const fileSet: Set<string> = new Set();
violations.forEach(v => {
Expand Down
153 changes: 96 additions & 57 deletions test/lib/actions/RunAction.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import ansis from 'ansis';
import {Org, SfError} from '@salesforce/core';
import {SfError} from '@salesforce/core';
import {SeverityLevel} from '@salesforce/code-analyzer-core';
import {SpyResultsViewer} from '../../stubs/SpyResultsViewer.js';
import {SpyResultsWriter} from '../../stubs/SpyResultsWriter.js';
import {SpyDisplay, DisplayEventType} from '../../stubs/SpyDisplay.js';
import {StubDefaultConfigFactory} from '../../stubs/StubCodeAnalyzerConfigFactories.js';
import {ConfigurableStubEnginePlugin1, StubEngine1, TargetDependentEngine1} from '../../stubs/StubEnginePlugins.js';
import {ConfigurableStubEnginePlugin1, StubApexGuruEngine, StubEngine1, TargetDependentEngine1} from '../../stubs/StubEnginePlugins.js';
import {RunAction, RunInput, RunDependencies} from '../../../src/lib/actions/RunAction.js';
import {RunActionSummaryViewer} from '../../../src/lib/viewers/ActionSummaryViewer.js';
import {
Expand Down Expand Up @@ -365,6 +365,92 @@ describe('RunAction tests', () => {
expect(displayedLogEvents).toContain(preExecutionGoldfileContents);
expect(displayedLogEvents).toContain(goldfileContents);
});

describe('ApexGuru analysis mode', () => {
let apexGuruEngine: StubApexGuruEngine;

beforeEach(() => {
apexGuruEngine = new StubApexGuruEngine({});
stubEnginePlugin.addEngine(apexGuruEngine);
});

it.each([
{analysisMode: 'full', expectedLabel: 'ADVANCED'},
{analysisMode: 'static', expectedLabel: 'BASIC'}
])('When ApexGuru insights report analysis mode "$analysisMode", the terminal summary shows $expectedLabel', async ({analysisMode, expectedLabel}) => {
// ==== SETUP ====
apexGuruEngine.resultsToReturn = {
violations: [],
insights: {
status: 'completed',
analysisMode
}
};
const input: RunInput = {
'rule-selector': ['all'],
'workspace': ['.'],
'output-file': []
};

// ==== TESTED BEHAVIOR ====
await action.execute(input);

// ==== ASSERTIONS ====
const displayEvents = spyDisplay.getDisplayEvents();
const displayedLogEvents = ansis.strip(displayEvents
.filter(e => e.type === DisplayEventType.LOG)
.map(e => e.data)
.join('\n'));
expect(displayedLogEvents).toContain(`ApexGuru analysis mode: ${expectedLabel}`);
});

it('When ApexGuru ran but did not report an analysis mode, no analysis mode line is shown', async () => {
// ==== SETUP ====
apexGuruEngine.resultsToReturn = {
violations: [],
insights: {
status: 'completed'
}
};
const input: RunInput = {
'rule-selector': ['all'],
'workspace': ['.'],
'output-file': []
};

// ==== TESTED BEHAVIOR ====
await action.execute(input);

// ==== ASSERTIONS ====
const displayEvents = spyDisplay.getDisplayEvents();
const displayedLogEvents = ansis.strip(displayEvents
.filter(e => e.type === DisplayEventType.LOG)
.map(e => e.data)
.join('\n'));
expect(displayedLogEvents).not.toContain('ApexGuru analysis mode');
});

it('When ApexGuru did not run, no analysis mode line is shown', async () => {
// ==== SETUP ====
// Remove ApexGuru from the plugin's available engines by only selecting rules from other engines.
const input: RunInput = {
'rule-selector': ['stubEngine1'],
'workspace': ['.'],
'output-file': []
};

// ==== TESTED BEHAVIOR ====
await action.execute(input);

// ==== ASSERTIONS ====
const displayEvents = spyDisplay.getDisplayEvents();
const displayedLogEvents = ansis.strip(displayEvents
.filter(e => e.type === DisplayEventType.LOG)
.map(e => e.data)
.join('\n'));
expect(displayedLogEvents).not.toContain('ApexGuru analysis mode');
});
});
});

describe('Telemetry Emission', () => {
Expand Down Expand Up @@ -481,10 +567,12 @@ describe('RunAction tests', () => {
});

describe('target-org', () => {
// Note: RunAction no longer validates org authentication upfront. A bad/unauthenticated
// --target-org is instead handled gracefully by the ApexGuru engine itself (which is the
// only engine that consumes target-org), skipping with a warning rather than aborting the
// whole `run` command. See ApexGuruAuthService/ApexGuruEngine in code-analyzer-core for that
// behavior and its tests.
it('RunInput accepts target-org field', async () => {
// Mock Org.create to succeed
vi.spyOn(Org, 'create').mockResolvedValue({} as Org);

const input: RunInput = {
'rule-selector': ['all'],
'workspace': ['.'],
Expand All @@ -499,9 +587,6 @@ describe('RunAction tests', () => {
});

it('target-org is passed through to engine config', async () => {
// Mock Org.create to succeed
vi.spyOn(Org, 'create').mockResolvedValue({} as Org);

const targetOrg = 'my-test-org';
const configFactorySpy = vi.spyOn(dependencies.configFactory, 'create');

Expand All @@ -523,53 +608,8 @@ describe('RunAction tests', () => {
);
});

it('validates org authentication before config creation', async () => {
const targetOrg = 'test-org';
const orgSpy = vi.spyOn(Org, 'create').mockResolvedValue({} as Org);

const input: RunInput = {
'rule-selector': ['all'],
'workspace': ['.'],
'output-file': [],
'target-org': targetOrg
};

await action.execute(input);

// Verify Org.create was called with the org alias/username
expect(orgSpy).toHaveBeenCalledWith({ aliasOrUsername: targetOrg });
});

it('throws clear error when org is not authenticated', async () => {
const targetOrg = 'unauthenticated-org';
// Mock Org.create to throw an error (org not found/authenticated)
vi.spyOn(Org, 'create').mockRejectedValue(new Error('No authorization information found'));

const input: RunInput = {
'rule-selector': ['all'],
'workspace': ['.'],
'output-file': [],
'target-org': targetOrg
};

// Execute and expect it to throw
let thrownError: Error | null = null;
try {
await action.execute(input);
} catch (e) {
thrownError = e as Error;
}

// Verify the error is an SfError with actionable message
expect(thrownError).toBeInstanceOf(SfError);
expect((thrownError as SfError).name).toEqual('OrgAuthenticationError');
expect((thrownError as SfError).message).toContain(`Org '${targetOrg}' not found or not authenticated`);
expect((thrownError as SfError).message).toContain('sf org list');
expect((thrownError as SfError).message).toContain('sf org login web');
});

it('does not validate org when target-org is not provided', async () => {
const orgSpy = vi.spyOn(Org, 'create');
it('does not include a target-org override when target-org is not provided', async () => {
const configFactorySpy = vi.spyOn(dependencies.configFactory, 'create');

const input: RunInput = {
'rule-selector': ['all'],
Expand All @@ -580,8 +620,7 @@ describe('RunAction tests', () => {

await action.execute(input);

// Verify Org.create was NOT called
expect(orgSpy).not.toHaveBeenCalled();
expect(configFactorySpy).toHaveBeenCalledWith(undefined, undefined);
});
});
});
Expand Down
35 changes: 35 additions & 0 deletions test/stubs/StubEnginePlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,41 @@ export class StubEnginePluginWithTargetDependentEngine extends EngineApi.EngineP
}
}


export class StubApexGuruEngine extends EngineApi.Engine {
readonly runRulesCallHistory: {ruleNames: string[], runOptions: EngineApi.RunOptions}[] = [];
resultsToReturn: EngineApi.EngineRunResults = { violations: [] }

constructor(_config: EngineApi.ConfigObject) {
super();
}

getName(): string {
return "apexguru";
}

getEngineVersion(): Promise<string> {
return Promise.resolve("1.0.0");
}

describeRules(): Promise<EngineApi.RuleDescription[]> {
return Promise.resolve([
{
name: "stubApexGuruRuleA",
severityLevel: EngineApi.SeverityLevel.High,
tags: ["Recommended", "Performance"],
description: "Some description for stubApexGuruRuleA",
resourceUrls: ["https://example.com/stubApexGuruRuleA"]
}
]);
}

runRules(ruleNames: string[], runOptions: EngineApi.RunOptions): Promise<EngineApi.EngineRunResults> {
this.runRulesCallHistory.push({ruleNames, runOptions});
return Promise.resolve(this.resultsToReturn);
}
}

export class TargetDependentEngine1 extends EngineApi.Engine {
readonly runRulesCallHistory: {ruleNames: string[], runOptions: EngineApi.RunOptions}[] = [];
constructor(_config: EngineApi.ConfigObject) {
Expand Down
Loading