diff --git a/README.md b/README.md index 05dc1c92..660cf45c 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Happy debugging! +- [`sf apex get compile-status`](#sf-apex-get-compile-status) - [`sf apex get log`](#sf-apex-get-log) - [`sf apex get test`](#sf-apex-get-test) - [`sf apex list log`](#sf-apex-list-log) @@ -96,6 +97,39 @@ Happy debugging! - [`sf logic get test`](#sf-logic-get-test) - [`sf logic run test`](#sf-logic-run-test) +## `sf apex get compile-status` + +Fetches any Apex in your org with compilation status issues. + +``` +USAGE + $ sf apex get compile-status -o [--json] [--flags-dir ] [--api-version ] + +FLAGS + -o, --target-org= (required) Username or alias of the target org. Not required if the `target-org` + configuration variable is already set. + --api-version= Override the api version used for api requests made by this command + +GLOBAL FLAGS + --flags-dir= Import flag values from a directory. + --json Format output as json. + +DESCRIPTION + Fetches invalid Apex or Apex with compiler warnings, and reports the current compilation status. + + +EXAMPLES + Fetches invalid Apex or Apex with compilation warnings in your default org: + + $ sf apex get compile-status + + Fetch invalid Apex or Apex with compilation warnings in the org with the specified username: + + $ sf apex get compile-status --target-org me@my.org +``` + +_See code: [src/commands/apex/get/compile-status.ts](https://github.com/salesforcecli/plugin-apex/blob/4.3.0/src/commands/apex/get/compile-status.ts)_ + ## `sf apex get log` Fetch the specified log or given number of most recent logs from the org. diff --git a/command-snapshot.json b/command-snapshot.json index 7459571d..82056040 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -1,4 +1,12 @@ [ + { + "alias": [], + "command": "apex:get:compile-status", + "flagAliases": ["apiversion", "targetusername", "u"], + "flagChars": ["o"], + "flags": ["api-version", "flags-dir", "json", "target-org"], + "plugin": "@salesforce/plugin-apex" + }, { "alias": ["force:apex:log:get"], "command": "apex:get:log", diff --git a/messages/get-compile-status.md b/messages/get-compile-status.md new file mode 100644 index 00000000..e524cb11 --- /dev/null +++ b/messages/get-compile-status.md @@ -0,0 +1,21 @@ +# summary + +Fetches the compilation status for Apex classes that are currently invalid or have compilation warnings. + +# description + +Return compile results for only Apex classes and triggers with validation errors or warnings, instead of recompiling the entire org. + +# examples + +- Fetch invalid Apex classes or classes that have compilation warnings for a target org + + <%= config.bin %> <%= command.id %> --target-org me@my.org + +# noResultsFound + +No Apex compiler issues found. + +# minimumApiVersionNotMet + +Apex compile results available in API versions 68.0 and above diff --git a/package.json b/package.json index 8156f88f..806a9b65 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "description": "Use the apex commands to create Apex classes, execute anonymous blocks, view your logs, run Apex tests, and view Apex test results.", "subtopics": { "get": { - "description": "Get debug logs or test results." + "description": "Get debug logs, Apex class compile status, or test results." }, "list": { "description": "List debug logs." diff --git a/schemas/apex-get-compile__status.json b/schemas/apex-get-compile__status.json new file mode 100644 index 00000000..96e58835 --- /dev/null +++ b/schemas/apex-get-compile__status.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/CompilationResult", + "definitions": { + "CompilationResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/InvalidApexClassResult" + } + } + }, + "required": ["status", "results"], + "additionalProperties": false + }, + "InvalidApexClassResult": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "problems": { + "type": "array", + "items": { + "$ref": "#/definitions/ApexClassProblem" + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/ApexClassProblem" + } + } + }, + "required": ["name", "namespace", "success", "problems", "warnings"], + "additionalProperties": false + }, + "ApexClassProblem": { + "type": "object", + "properties": { + "line": { + "type": "number" + }, + "column": { + "type": "number" + }, + "message": { + "type": "string" + } + }, + "required": ["line", "column", "message"], + "additionalProperties": false + } + } +} diff --git a/src/commands/apex/get/compile-status.ts b/src/commands/apex/get/compile-status.ts new file mode 100644 index 00000000..02cdccd5 --- /dev/null +++ b/src/commands/apex/get/compile-status.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + orgApiVersionFlagWithDeprecations, + requiredOrgFlagWithDeprecations, + SfCommand, +} from '@salesforce/sf-plugins-core'; +import { Messages, SfError } from '@salesforce/core'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-apex', 'get-compile-status'); + +export type ApexClassProblem = { + line: number; + column: number; + message: string; +}; + +export type InvalidApexClassResult = { + name: string; + namespace: string; + success: boolean; + problems: ApexClassProblem[]; + warnings: ApexClassProblem[]; +}; + +export type CompilationResult = { + status: string; + results: InvalidApexClassResult[]; +}; + +export default class GetCompileStatus extends SfCommand { + public static readonly flags = { + 'target-org': requiredOrgFlagWithDeprecations, + 'api-version': orgApiVersionFlagWithDeprecations, + }; + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public async run(): Promise { + const { flags } = await this.parse(GetCompileStatus); + const apiVersion = flags['api-version']; + if (apiVersion && Number(apiVersion.replace('.0', '')) < 68) { + throw new SfError(messages.getMessage('minimumApiVersionNotMet')); + } + + this.spinner.start('Retrieving Apex compilation results'); + + const connection = flags['target-org'].getConnection(apiVersion); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const invalidApexResponse = (await connection.tooling.request({ + // eslint-disable-next-line no-underscore-dangle + url: `${connection.tooling._baseUrl()}/apexCompileResults`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + })) as CompilationResult; + + this.spinner.stop(); + + // the actual command output is only displayed when --json is passed as a flag + // so we format the results for everyone else + if (!this.jsonEnabled()) { + if (invalidApexResponse.results.length > 0) { + const tableFormatter = (tableItems: ApexClassProblem[]): string => + tableItems.map((p) => `L${p.line}:C${p.column} - ${p.message}`).join('\n'); + this.table({ + columns: ['name', 'namespace', 'success', 'problems', 'warnings'], + data: invalidApexResponse.results.map((result) => ({ + ...result, + problems: tableFormatter(result.problems), + warnings: tableFormatter(result.warnings), + })), + title: 'Compile Status Results:', + overflow: 'wrap', + }); + } else { + this.log(messages.getMessage('noResultsFound')); + } + } + + return invalidApexResponse; + } +} diff --git a/test/commands/apex/get/compile-status.nut.ts b/test/commands/apex/get/compile-status.nut.ts new file mode 100644 index 00000000..a21bdb18 --- /dev/null +++ b/test/commands/apex/get/compile-status.nut.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; +import { config, expect } from 'chai'; +import type { CompilationResult } from '../../../../src/commands/apex/get/compile-status.js'; + +config.truncateThreshold = 0; + +const APEX_META_XML = [ + '', + '', + ' 62.0', + ' Active', + '', +].join('\n'); + +const HELPER_CLASS = [ + 'public with sharing class CompileStatusHelper {', + " public static final String SOME_CONSTANT = 'hello';", + ' public static void doSomething(String a, String b) {', + ' System.debug(a + b);', + ' }', + ' public CompileStatusHelper(String arg) {', + ' System.debug(arg);', + ' }', + '}', +].join('\n'); + +const BROKEN_CLASS = [ + 'public with sharing class CompileStatusBroken {', + ' public static String getBadVariable() {', + ' return CompileStatusHelper.SOME_CONSTANT;', + ' }', + ' public static void callBadMethod() {', + " CompileStatusHelper.doSomething('param1', 'param2');", + ' }', + ' public static void callBadConstructor() {', + " CompileStatusHelper obj = new CompileStatusHelper('arg1');", + ' }', + '}', +].join('\n'); + +describe('apex get compile-status NUT', () => { + let session: TestSession; + let classesDir: string; + + before(async () => { + session = await TestSession.create({ + project: { + gitClone: 'https://github.com/trailheadapps/dreamhouse-lwc.git', + }, + devhubAuthStrategy: 'AUTO', + }); + + classesDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'classes'); + + // TODO(2026-10-10): remove --release preview after GA release + execCmd( + 'org:create:scratch -f config/project-scratch-def.json --set-default --alias org --release preview --wait 10 --duration-days 1', + { ensureExitCode: 0, cli: 'sf' } + ); + + execCmd('project:deploy:start -o org --source-dir force-app', { ensureExitCode: 0, cli: 'sf' }); + }); + + after(async () => { + try { + execCmd('org:delete:scratch -o org --no-prompt', { cli: 'sf' }); + } catch { + // best-effort cleanup + } + await session?.zip(undefined, 'artifacts'); + await session?.clean(); + }); + + describe('clean org', () => { + it('reports no compile issues', () => { + const result = execCmd('apex:get:compile-status', { ensureExitCode: 0 }).shellOutput.stdout; + expect(result).to.include('No Apex compiler issues found'); + }); + + it('returns empty results with --json', () => { + const result = execCmd('apex:get:compile-status --json', { ensureExitCode: 0 }).jsonOutput + ?.result; + expect(result?.results).to.be.an('array').with.lengthOf(0); + }); + }); + + describe('org with broken apex references', () => { + before(() => { + fs.writeFileSync(path.join(classesDir, 'CompileStatusHelper.cls'), HELPER_CLASS); + fs.writeFileSync(path.join(classesDir, 'CompileStatusHelper.cls-meta.xml'), APEX_META_XML); + fs.writeFileSync(path.join(classesDir, 'CompileStatusBroken.cls'), BROKEN_CLASS); + fs.writeFileSync(path.join(classesDir, 'CompileStatusBroken.cls-meta.xml'), APEX_META_XML); + + execCmd('project:deploy:start -o org --source-dir force-app/main/default/classes', { + ensureExitCode: 0, + cli: 'sf', + }); + + execCmd('project:delete:source -o org -m ApexClass:CompileStatusHelper --no-prompt', { + ensureExitCode: 0, + cli: 'sf', + }); + }); + + it('shows the broken class in human-readable output', () => { + const result = execCmd('apex:get:compile-status', { ensureExitCode: 0 }).shellOutput.stdout; + expect(result).to.include('CompileStatusBroken'); + expect(result).to.include('CompileStatusHelper'); + }); + + it('returns problems for the broken class with --json', () => { + const result = execCmd('apex:get:compile-status --json', { ensureExitCode: 0 }).jsonOutput + ?.result; + expect(result).to.have.property('status'); + expect(result?.results).to.be.an('array').with.length.greaterThan(0); + + const brokenResult = result?.results.find((r) => r.name === 'CompileStatusBroken'); + expect(brokenResult).to.not.be.undefined; + expect(brokenResult?.success).to.equal(false); + expect(brokenResult?.problems).to.be.an('array').with.length.greaterThan(0); + + const problemMessages = brokenResult?.problems.map((p) => p.message) ?? []; + expect(problemMessages.some((m) => m.includes('CompileStatusHelper'))).to.be.true; + expect(brokenResult?.warnings).to.be.an('array'); + }); + + it('includes line and column info in problems', () => { + const result = execCmd('apex:get:compile-status --json', { ensureExitCode: 0 }).jsonOutput + ?.result; + const brokenResult = result?.results.find((r) => r.name === 'CompileStatusBroken'); + expect(brokenResult).to.not.be.undefined; + expect(brokenResult?.problems).to.be.an('array').that.is.not.empty; + const problem = brokenResult?.problems[0]; + expect(problem).to.have.property('line').that.is.a('number'); + expect(problem).to.have.property('column').that.is.a('number'); + expect(problem).to.have.property('message').that.is.a('string'); + }); + }); +}); diff --git a/test/commands/apex/get/compile-status.test.ts b/test/commands/apex/get/compile-status.test.ts new file mode 100644 index 00000000..52093807 --- /dev/null +++ b/test/commands/apex/get/compile-status.test.ts @@ -0,0 +1,339 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { stubSfCommandUx } from '@salesforce/sf-plugins-core'; +import { Org, SfError } from '@salesforce/core'; +import GetCompileStatus from '../../../../src/commands/apex/get/compile-status.js'; +import type { CompilationResult } from '../../../../src/commands/apex/get/compile-status.js'; + +describe('apex:get:compile-status', () => { + let sandbox: sinon.SinonSandbox; + let uxStub: ReturnType; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + uxStub = stubSfCommandUx(sandbox); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('returns compilation result with no invalid classes', async () => { + const mockCompilationResult: CompilationResult = { + status: 'success', + results: [], + }; + + const org = { + getConnection: sandbox.stub().returns({ + tooling: { + request: sandbox.stub().resolves(mockCompilationResult), + _baseUrl: sandbox.stub().returns('/services/data/v68.0/tooling'), + }, + }), + }; + + sandbox.stub(Org, 'create').resolves(org as unknown as Org); + + const result = await GetCompileStatus.run([]); + expect(result).to.deep.equal(mockCompilationResult); + expect(uxStub.log.called).to.be.true; + }); + + it('throws when API version beneath 68', async () => { + const mockCompilationResult: CompilationResult = { + status: 'success', + results: [], + }; + + const org = { + getConnection: sandbox.stub().returns({ + tooling: { + request: sandbox.stub().resolves(mockCompilationResult), + _baseUrl: sandbox.stub().returns('/services/data/v68.0/tooling'), + }, + }), + }; + + sandbox.stub(Org, 'create').resolves(org as unknown as Org); + + try { + await GetCompileStatus.run(['--api-version', '67.0']); + expect.fail('Expected SfError to be thrown'); + } catch (err) { + expect(err).to.be.instanceOf(SfError, 'Should be SfError'); + expect((err as SfError).message).to.eq('Apex compile results available in API versions 68.0 and above'); + } + }); + + it('displays invalid classes in table format without --json flag', async () => { + const mockCompilationResult: CompilationResult = { + status: 'success', + results: [ + { + name: 'TestClass1', + namespace: '', + success: false, + problems: [ + { + line: 10, + column: 5, + message: 'Unexpected token: }', + }, + ], + warnings: [], + }, + { + name: 'TestClass2', + namespace: 'myNamespace', + success: false, + problems: [ + { + line: 25, + column: 1, + message: 'Invalid class declaration', + }, + { + line: 30, + column: 10, + message: 'Syntax error', + }, + ], + warnings: [], + }, + ], + }; + + const org = { + getConnection: sandbox.stub().returns({ + tooling: { + request: sandbox.stub().resolves(mockCompilationResult), + _baseUrl: sandbox.stub().returns('/services/data/v68.0/tooling'), + }, + }), + }; + + sandbox.stub(Org, 'create').resolves(org as unknown as Org); + + const result = await GetCompileStatus.run([]); + expect(result).to.deep.equal(mockCompilationResult); + expect(uxStub.table.calledOnce).to.be.true; + + const tableCall = uxStub.table.firstCall; + expect(tableCall.args[0]).to.deep.include({ + columns: ['name', 'namespace', 'success', 'problems', 'warnings'], + title: 'Compile Status Results:', + }); + + const tableData = (tableCall.args[0] as Record).data as Array>; + expect(tableData).to.have.lengthOf(2); + expect(tableData[0].name).to.equal('TestClass1'); + expect(tableData[0].problems).to.be.a('string'); + expect(tableData[0].problems).to.equal('L10:C5 - Unexpected token: }'); + + const secondClassProblems = (tableData[1].problems as string).split('\n'); + expect(secondClassProblems).to.deep.equal(['L25:C1 - Invalid class declaration', 'L30:C10 - Syntax error']); + }); + + it('returns compilation result with --json flag without displaying table', async () => { + const mockCompilationResult: CompilationResult = { + status: 'success', + results: [ + { + name: 'TestClass1', + namespace: '', + success: false, + warnings: [], + problems: [ + { + line: 10, + column: 5, + message: 'Unexpected token: }', + }, + ], + }, + ], + }; + + const org = { + getConnection: sandbox.stub().returns({ + tooling: { + request: sandbox.stub().resolves(mockCompilationResult), + _baseUrl: sandbox.stub().returns('/services/data/v68.0/tooling'), + }, + }), + }; + + sandbox.stub(Org, 'create').resolves(org as unknown as Org); + sandbox.stub(GetCompileStatus.prototype, 'jsonEnabled').returns(true); + + const result = await GetCompileStatus.run(['--json']); + expect(result).to.deep.equal(mockCompilationResult); + expect(uxStub.table.called).to.be.false; + }); + + it('makes correct API request to apexCompileResults endpoint', async () => { + const requestStub = sandbox.stub().resolves({ + status: 'success', + results: [], + }); + + const org = { + getConnection: sandbox.stub().returns({ + tooling: { + request: requestStub, + _baseUrl: sandbox.stub().returns('/services/data/v68.0/tooling'), + }, + }), + }; + + sandbox.stub(Org, 'create').resolves(org as unknown as Org); + + await GetCompileStatus.run([]); + + expect(requestStub.calledOnce).to.be.true; + const callArgs = requestStub.firstCall.args[0]; + expect(callArgs.url).to.include('/apexCompileResults'); + expect(callArgs.method).to.equal('POST'); + expect(callArgs.headers['Content-Type']).to.equal('application/json'); + expect(callArgs.body).to.equal('{}'); + }); + + it('handles multiple problems per class', async () => { + const mockCompilationResult: CompilationResult = { + status: 'success', + results: [ + { + name: 'ComplexClass', + namespace: 'testNamespace', + success: false, + warnings: [], + problems: [ + { + line: 5, + column: 1, + message: 'Error 1', + }, + { + line: 15, + column: 20, + message: 'Error 2', + }, + { + line: 42, + column: 8, + message: 'Error 3', + }, + ], + }, + ], + }; + + const org = { + getConnection: sandbox.stub().returns({ + tooling: { + request: sandbox.stub().resolves(mockCompilationResult), + _baseUrl: sandbox.stub().returns('/services/data/v68.0/tooling'), + }, + }), + }; + + sandbox.stub(Org, 'create').resolves(org as unknown as Org); + + const result = await GetCompileStatus.run([]); + expect(result.results[0].problems).to.have.lengthOf(3); + expect(result.results[0].problems[1].line).to.equal(15); + expect(result.results[0].problems[2].message).to.equal('Error 3'); + }); + + it('handles classes with empty namespace', async () => { + const mockCompilationResult: CompilationResult = { + status: 'success', + results: [ + { + name: 'LocalClass', + namespace: '', + success: false, + warnings: [], + problems: [ + { + line: 1, + column: 1, + message: 'Error', + }, + ], + }, + ], + }; + + const org = { + getConnection: sandbox.stub().returns({ + tooling: { + request: sandbox.stub().resolves(mockCompilationResult), + _baseUrl: sandbox.stub().returns('/services/data/v68.0/tooling'), + }, + }), + }; + + sandbox.stub(Org, 'create').resolves(org as unknown as Org); + + const result = await GetCompileStatus.run([]); + expect(result.results[0].namespace).to.equal(''); + }); + + it('formats problem objects as readable strings in table display', async () => { + const mockCompilationResult: CompilationResult = { + status: 'success', + results: [ + { + name: 'TestClass', + namespace: '', + success: false, + problems: [ + { + line: 10, + column: 5, + message: 'Test message', + }, + ], + warnings: [], + }, + ], + }; + + const org = { + getConnection: sandbox.stub().returns({ + tooling: { + request: sandbox.stub().resolves(mockCompilationResult), + _baseUrl: sandbox.stub().returns('/services/data/v68.0/tooling'), + }, + }), + }; + + sandbox.stub(Org, 'create').resolves(org as unknown as Org); + + await GetCompileStatus.run([]); + + const tableCall = uxStub.table.firstCall; + const tableData = (tableCall.args[0] as Record).data as Array>; + const problemString = tableData[0].problems as string; + + expect(problemString).to.equal('L10:C5 - Test message'); + }); +});