diff --git a/README.md b/README.md index f33d87e..9ce0dbc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - [License](#license) -A Salesforce CLI plugin to transform the Apex code coverage JSON files created during deployments and test runs into SonarQube format or Cobertura format. +A Salesforce CLI plugin to transform the Apex code coverage JSON files created during deployments and test runs into SonarQube, Cobertura, or Clover format. ## Install @@ -36,7 +36,7 @@ When the plugin is unable to find the Apex file from the Salesforce CLI coverage ## Creating Code Coverage Files with the Salesforce CLI -**This tool will only support the "json" coverage format from the Salesforce CLI. Do not use the "json-summary" or "cobertura" format from the Salesforce CLI.** +**This tool will only support the "json" coverage format from the Salesforce CLI. Do not use the "json-summary", "clover", or "cobertura" format from the Salesforce CLI.** To create the code coverage JSON when deploying or validating, append `--coverage-formatters json --results-dir "coverage"` to the `sf project deploy` command. This will create a coverage JSON in this relative path - `coverage/coverage/coverage.json`. @@ -72,19 +72,21 @@ FLAGS -x, --xml= Path to the code coverage XML file that will be created by this plugin. [default: "coverage.xml"] -f, --format= Output format for the code coverage format. - Valid options are "sonar" or "cobertura". + Valid options are "sonar", "clover", or "cobertura". [default: "sonar"] GLOBAL FLAGS --json Format output as json. DESCRIPTION - Transform the Apex code coverage JSON file created by the Salesforce CLI deploy and test command into SonarQube or Cobertura format. + Transform the Apex code coverage JSON file created by the Salesforce CLI deploy and test command into SonarQube, Clover, or Cobertura format. EXAMPLES $ sf acc-transformer transform -j "coverage.json" -x "coverage.xml" -f "sonar" $ sf acc-transformer transform -j "coverage.json" -x "coverage.xml" -f "cobertura" + + $ sf acc-transformer transform -j "coverage.json" -x "coverage.xml" -f "clover" ``` ## Hook @@ -109,7 +111,7 @@ The `.apexcodecovtransformer.config.json` should look like this: - `deployCoverageJsonPath` is required to use the hook after deployments and should be the path to the code coverage JSON created by the Salesforce CLI deployment command. Recommend using a relative path. - `testCoverageJsonPath` is required to use the hook after test runs and should be the path to the code coverage JSON created by the Salesforce CLI test command. Recommend using a relative path. - `coverageXmlPath` is optional and should be the path to the code coverage XML created by this plugin. Recommend using a relative path. If this isn't provided, it will default to `coverage.xml` in the working directory. -- `format` is optional and should be the intended output format for the code coverage XML created by this plugin. Options are "sonar" or "cobertura". If this isn't provided, it will default to "sonar". +- `format` is optional and should be the intended output format for the code coverage XML created by this plugin. Options are "sonar", "clover", or "cobertura". If this isn't provided, it will default to "sonar". If the `.apexcodecovtransformer.config.json` file isn't found, the hook will be skipped. @@ -315,6 +317,85 @@ and this format for Cobertura: ``` +and this format for Clover: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + ## Issues If you encounter any issues, please create an issue in the repository's [issue tracker](https://github.com/mcarvin8/apex-code-coverage-transformer/issues). Please also create issues to suggest any new features. diff --git a/messages/transformer.transform.md b/messages/transformer.transform.md index 2634c91..21334b2 100644 --- a/messages/transformer.transform.md +++ b/messages/transformer.transform.md @@ -1,15 +1,16 @@ # summary -Transforms the Code Coverage JSON into SonarQube or Cobertura format. +Transforms the Code Coverage JSON into SonarQube, Clover, or Cobertura format. # description -Transform the Apex code coverage JSON file created by the Salesforce CLI deploy and test command into SonarQube or Cobertura format. +Transform the Apex code coverage JSON file created by the Salesforce CLI deploy and test command into SonarQube, Clover, or Cobertura format. # examples - `sf acc-transformer transform -j "coverage.json" -x "coverage.xml" -f "sonar"` - `sf acc-transformer transform -j "coverage.json" -x "coverage.xml" -f "cobertura"` +- `sf acc-transformer transform -j "coverage.json" -x "coverage.xml" -f "clover"` # flags.coverage-json.summary diff --git a/src/commands/acc-transformer/transform.ts b/src/commands/acc-transformer/transform.ts index d8ce949..a6a2409 100644 --- a/src/commands/acc-transformer/transform.ts +++ b/src/commands/acc-transformer/transform.ts @@ -38,7 +38,7 @@ export default class TransformerTransform extends SfCommand\n\n${xml}`; + } else if (format === 'clover') { + xml = `\n${xml}`; } return xml; diff --git a/src/helpers/setCoveredLinesClover.ts b/src/helpers/setCoveredLinesClover.ts new file mode 100644 index 0000000..d06ba18 --- /dev/null +++ b/src/helpers/setCoveredLinesClover.ts @@ -0,0 +1,54 @@ +'use strict'; + +import { join } from 'node:path'; +import { getTotalLines } from './getTotalLines.js'; +import { CloverFile, CloverLine } from './types.js'; + +export async function setCoveredLinesClover( + coveredLines: number[], + uncoveredLines: number[], + repoRoot: string, + filePath: string, + fileObj: CloverFile +): Promise { + const randomLines: number[] = []; + const totalLines = await getTotalLines(join(repoRoot, filePath)); + + for (const coveredLine of coveredLines) { + if (coveredLine > totalLines) { + for (let randomLineNumber = 1; randomLineNumber <= totalLines; randomLineNumber++) { + if ( + !uncoveredLines.includes(randomLineNumber) && + !coveredLines.includes(randomLineNumber) && + !randomLines.includes(randomLineNumber) + ) { + const randomLine: CloverLine = { + '@num': randomLineNumber, + '@count': 1, + '@type': 'stmt', + }; + fileObj.line.push(randomLine); + randomLines.push(randomLineNumber); + break; + } + } + } else { + const coveredLineObj: CloverLine = { + '@num': coveredLine, + '@count': 1, + '@type': 'stmt', + }; + fileObj.line.push(coveredLineObj); + } + } + + // Update Clover file-level metrics + fileObj.metrics['@statements'] += coveredLines.length + uncoveredLines.length; + fileObj.metrics['@coveredstatements'] += coveredLines.length; + + // Optionally calculate derived metrics + fileObj.metrics['@conditionals'] ??= 0; // Add default if missing + fileObj.metrics['@coveredconditionals'] ??= 0; + fileObj.metrics['@methods'] ??= 0; + fileObj.metrics['@coveredmethods'] ??= 0; +} diff --git a/src/helpers/transformDeployCoverageReport.ts b/src/helpers/transformDeployCoverageReport.ts index 1c27df0..2478b97 100644 --- a/src/helpers/transformDeployCoverageReport.ts +++ b/src/helpers/transformDeployCoverageReport.ts @@ -5,14 +5,17 @@ import { DeployCoverageData, SonarCoverageObject, CoberturaCoverageObject, + CloverCoverageObject, SonarClass, CoberturaClass, CoberturaPackage, + CloverFile, } from './types.js'; import { getPackageDirectories } from './getPackageDirectories.js'; import { findFilePath } from './findFilePath.js'; import { setCoveredLinesSonar } from './setCoveredLinesSonar.js'; import { setCoveredLinesCobertura } from './setCoveredLinesCobertura.js'; +import { setCoveredLinesClover } from './setCoveredLinesClover.js'; import { normalizePathToUnix } from './normalizePathToUnix.js'; import { generateXml } from './generateXml.js'; @@ -20,7 +23,7 @@ export async function transformDeployCoverageReport( data: DeployCoverageData, format: string ): Promise<{ xml: string; warnings: string[]; filesProcessed: number }> { - if (!['sonar', 'cobertura'].includes(format)) { + if (!['sonar', 'cobertura', 'clover'].includes(format)) { throw new Error(`Unsupported format: ${format}`); } @@ -29,13 +32,13 @@ export async function transformDeployCoverageReport( const { repoRoot, packageDirectories } = await getPackageDirectories(); // Initialize format-specific coverage objects - let coverageObj: SonarCoverageObject | CoberturaCoverageObject; + let coverageObj: SonarCoverageObject | CoberturaCoverageObject | CloverCoverageObject; if (format === 'sonar') { coverageObj = { coverage: { '@version': '1', file: [] }, } as SonarCoverageObject; - } else { + } else if (format === 'cobertura') { coverageObj = { coverage: { '@lines-valid': 0, @@ -51,6 +54,34 @@ export async function transformDeployCoverageReport( packages: { package: [] }, }, } as CoberturaCoverageObject; + } else { + coverageObj = { + coverage: { + '@generated': Date.now(), + '@clover': '3.2.0', + project: { + '@timestamp': Date.now(), + '@name': 'All files', + metrics: { + '@statements': 0, + '@coveredstatements': 0, + '@conditionals': 0, + '@coveredconditionals': 0, + '@methods': 0, + '@coveredmethods': 0, + '@elements': 0, + '@coveredelements': 0, + '@complexity': 0, + '@loc': 0, + '@ncloc': 0, + '@packages': 0, + '@files': 0, + '@classes': 0, + }, + file: [], + }, + }, + } as CloverCoverageObject; } const packageObj = @@ -94,7 +125,7 @@ export async function transformDeployCoverageReport( repoRoot, coverageObj as SonarCoverageObject ); - } else { + } else if (format === 'cobertura') { await handleCoberturaFormat( relativeFilePath, formattedFileName, @@ -104,6 +135,15 @@ export async function transformDeployCoverageReport( coverageObj as CoberturaCoverageObject, packageObj! ); + } else { + await handleCloverFormat( + relativeFilePath, + formattedFileName, + uncoveredLines, + coveredLines, + repoRoot, + coverageObj as CloverCoverageObject + ); } filesProcessed++; @@ -169,3 +209,44 @@ async function handleCoberturaFormat( ); coverageObj.coverage['@line-rate'] = packageObj['@line-rate']; } + +async function handleCloverFormat( + filePath: string, + fileName: string, + uncoveredLines: number[], + coveredLines: number[], + repoRoot: string, + coverageObj: CloverCoverageObject +): Promise { + const cloverFile: CloverFile = { + '@name': fileName, + '@path': normalizePathToUnix(filePath), + metrics: { + '@statements': uncoveredLines.length + coveredLines.length, + '@coveredstatements': coveredLines.length, + '@conditionals': 0, + '@coveredconditionals': 0, + '@methods': 0, + '@coveredmethods': 0, + }, + line: [ + ...uncoveredLines.map((lineNumber) => ({ + '@num': lineNumber, + '@count': 0, + '@type': 'stmt', + })), + ], + }; + + await setCoveredLinesClover(coveredLines, uncoveredLines, repoRoot, filePath, cloverFile); + + coverageObj.coverage.project.file.push(cloverFile); + const projectMetrics = coverageObj.coverage.project.metrics; + + projectMetrics['@statements'] += uncoveredLines.length + coveredLines.length; + projectMetrics['@coveredstatements'] += coveredLines.length; + projectMetrics['@elements'] += uncoveredLines.length + coveredLines.length; + projectMetrics['@coveredelements'] += coveredLines.length; + projectMetrics['@files'] += 1; + projectMetrics['@classes'] += 1; +} diff --git a/src/helpers/transformTestCoverageReport.ts b/src/helpers/transformTestCoverageReport.ts index a4a651a..bc3570e 100644 --- a/src/helpers/transformTestCoverageReport.ts +++ b/src/helpers/transformTestCoverageReport.ts @@ -4,10 +4,12 @@ import { TestCoverageData, SonarCoverageObject, - SonarClass, - CoberturaPackage, CoberturaCoverageObject, + CloverCoverageObject, + SonarClass, CoberturaClass, + CoberturaPackage, + CloverFile, } from './types.js'; import { getPackageDirectories } from './getPackageDirectories.js'; import { findFilePath } from './findFilePath.js'; @@ -18,7 +20,7 @@ export async function transformTestCoverageReport( testCoverageData: TestCoverageData[], format: string ): Promise<{ xml: string; warnings: string[]; filesProcessed: number }> { - if (!['sonar', 'cobertura'].includes(format)) { + if (!['sonar', 'cobertura', 'clover'].includes(format)) { throw new Error(`Unsupported format: ${format}`); } @@ -27,13 +29,13 @@ export async function transformTestCoverageReport( const { repoRoot, packageDirectories } = await getPackageDirectories(); // Initialize format-specific coverage objects - let coverageObj: SonarCoverageObject | CoberturaCoverageObject; + let coverageObj: SonarCoverageObject | CoberturaCoverageObject | CloverCoverageObject; if (format === 'sonar') { coverageObj = { coverage: { '@version': '1', file: [] }, } as SonarCoverageObject; - } else { + } else if (format === 'cobertura') { coverageObj = { coverage: { '@lines-valid': 0, @@ -49,6 +51,34 @@ export async function transformTestCoverageReport( packages: { package: [] }, }, } as CoberturaCoverageObject; + } else { + coverageObj = { + coverage: { + '@generated': Date.now(), + '@clover': '3.2.0', + project: { + '@timestamp': Date.now(), + '@name': 'All files', + metrics: { + '@statements': 0, + '@coveredstatements': 0, + '@conditionals': 0, + '@coveredconditionals': 0, + '@methods': 0, + '@coveredmethods': 0, + '@elements': 0, + '@coveredelements': 0, + '@complexity': 0, + '@loc': 0, + '@ncloc': 0, + '@packages': 0, + '@files': 0, + '@classes': 0, + }, + file: [], + }, + }, + } as CloverCoverageObject; } const packageObj = @@ -92,7 +122,7 @@ export async function transformTestCoverageReport( if (format === 'sonar') { handleSonarFormat(relativeFilePath, lines, coverageObj as SonarCoverageObject); - } else { + } else if (format === 'cobertura') { handleCoberturaFormat( relativeFilePath, formattedFileName, @@ -102,6 +132,15 @@ export async function transformTestCoverageReport( coverageObj as CoberturaCoverageObject, packageObj! ); + } else { + handleCloverFormat( + relativeFilePath, + formattedFileName, + lines, + uncoveredLines, + coveredLines, + coverageObj as CloverCoverageObject + ); } filesProcessed++; @@ -163,3 +202,44 @@ function handleCoberturaFormat( ); coverageObj.coverage['@line-rate'] = packageObj['@line-rate']; } + +function handleCloverFormat( + filePath: string, + fileName: string, + lines: Record, + uncoveredLines: number[], + coveredLines: number[], + coverageObj: CloverCoverageObject +): void { + const cloverFile: CloverFile = { + '@name': fileName, + '@path': normalizePathToUnix(filePath), + metrics: { + '@statements': uncoveredLines.length + coveredLines.length, + '@coveredstatements': coveredLines.length, + '@conditionals': 0, + '@coveredconditionals': 0, + '@methods': 0, + '@coveredmethods': 0, + }, + line: [], + }; + + for (const [lineNumber, isCovered] of Object.entries(lines)) { + cloverFile.line.push({ + '@num': Number(lineNumber), + '@count': isCovered === 1 ? 1 : 0, + '@type': 'stmt', + }); + } + + coverageObj.coverage.project.file.push(cloverFile); + const projectMetrics = coverageObj.coverage.project.metrics; + + projectMetrics['@statements'] += uncoveredLines.length + coveredLines.length; + projectMetrics['@coveredstatements'] += coveredLines.length; + projectMetrics['@elements'] += uncoveredLines.length + coveredLines.length; + projectMetrics['@coveredelements'] += coveredLines.length; + projectMetrics['@files'] += 1; + projectMetrics['@classes'] += 1; +} diff --git a/src/helpers/types.ts b/src/helpers/types.ts index a27fa34..371a720 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -104,3 +104,55 @@ export type CoberturaCoverageObject = { }; }; }; + +export type CloverLine = { + '@num': number; + '@count': number; + '@type': string; +}; + +export type CloverFile = { + '@name': string; + '@path': string; + metrics: { + '@statements': number; + '@coveredstatements': number; + '@conditionals': number; + '@coveredconditionals': number; + '@methods': number; + '@coveredmethods': number; + }; + line: CloverLine[]; +}; + +export type CloverProjectMetrics = { + '@statements': number; + '@coveredstatements': number; + '@conditionals': number; + '@coveredconditionals': number; + '@methods': number; + '@coveredmethods': number; + '@elements': number; + '@coveredelements': number; + '@complexity': number; + '@loc': number; + '@ncloc': number; + '@packages': number; + '@files': number; + '@classes': number; +}; + +export type CloverProject = { + '@timestamp': number; + '@name': string; + metrics: CloverProjectMetrics; + file: CloverFile[]; +}; + +export type CloverCoverageObject = { + coverage: { + '@generated': number; + '@clover': string; + project: CloverProject; + }; +}; diff --git a/test/commands/acc-transformer/transform.nut.ts b/test/commands/acc-transformer/transform.nut.ts index 1181d29..e7274b0 100644 --- a/test/commands/acc-transformer/transform.nut.ts +++ b/test/commands/acc-transformer/transform.nut.ts @@ -23,6 +23,9 @@ describe('acc-transformer transform NUTs', () => { const coberturaXmlPath1 = resolve('cobertura1.xml'); const coberturaXmlPath2 = resolve('cobertura2.xml'); const coberturaXmlPath3 = resolve('cobertura3.xml'); + const cloverXmlPath1 = resolve('clover1.xml'); + const cloverXmlPath2 = resolve('clover2.xml'); + const cloverXmlPath3 = resolve('clover3.xml'); const sfdxConfigFile = resolve('sfdx-project.json'); const configFile = { @@ -55,6 +58,9 @@ describe('acc-transformer transform NUTs', () => { await rm(coberturaXmlPath1); await rm(coberturaXmlPath2); await rm(coberturaXmlPath3); + await rm(cloverXmlPath1); + await rm(cloverXmlPath2); + await rm(cloverXmlPath3); }); it('runs transform on the deploy coverage file without file extensions in Sonar format.', async () => { @@ -128,4 +134,24 @@ describe('acc-transformer transform NUTs', () => { expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${coberturaXmlPath3}`); }); + it('runs transform on the deploy coverage file without file extensions in Clover format.', async () => { + const command = `acc-transformer transform --coverage-json "${deployCoverageNoExts}" --xml "${cloverXmlPath1}" --format clover`; + const output = execCmd(command, { ensureExitCode: 0 }).shellOutput.stdout; + + expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${cloverXmlPath1}`); + }); + + it('runs transform on the deploy coverage file with file extensions in Clover format.', async () => { + const command = `acc-transformer transform --coverage-json "${deployCoverageWithExts}" --xml "${cloverXmlPath2}" --format clover`; + const output = execCmd(command, { ensureExitCode: 0 }).shellOutput.stdout; + + expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${cloverXmlPath2}`); + }); + + it('runs transform on the test coverage file in Clover format.', async () => { + const command = `acc-transformer transform --coverage-json "${testCoverage}" --xml "${cloverXmlPath3}" --format clover`; + const output = execCmd(command, { ensureExitCode: 0 }).shellOutput.stdout; + + expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${cloverXmlPath3}`); + }); }); diff --git a/test/commands/acc-transformer/transform.test.ts b/test/commands/acc-transformer/transform.test.ts index 95cf9d8..18958ac 100644 --- a/test/commands/acc-transformer/transform.test.ts +++ b/test/commands/acc-transformer/transform.test.ts @@ -26,6 +26,9 @@ describe('main', () => { const coberturaXmlPath1 = resolve('cobertura1.xml'); const coberturaXmlPath2 = resolve('cobertura2.xml'); const coberturaXmlPath3 = resolve('cobertura3.xml'); + const cloverXmlPath1 = resolve('clover1.xml'); + const cloverXmlPath2 = resolve('clover2.xml'); + const cloverXmlPath3 = resolve('clover3.xml'); const sfdxConfigFile = resolve('sfdx-project.json'); const configFile = { @@ -64,6 +67,9 @@ describe('main', () => { await rm(coberturaXmlPath1); await rm(coberturaXmlPath2); await rm(coberturaXmlPath3); + await rm(cloverXmlPath1); + await rm(cloverXmlPath2); + await rm(cloverXmlPath3); }); it('transform the test JSON file without file extensions into Sonar format without any warnings.', async () => { @@ -201,4 +207,57 @@ describe('main', () => { .join('\n'); expect(warnings).to.include(''); }); + it('transform the test JSON file without file extensions into Clover format without any warnings.', async () => { + await TransformerTransform.run([ + '--coverage-json', + deployCoverageNoExts, + '--xml', + cloverXmlPath1, + '--format', + 'clover', + ]); + const output = sfCommandStubs.log + .getCalls() + .flatMap((c) => c.args) + .join('\n'); + expect(output).to.include(`The coverage XML has been written to ${cloverXmlPath1}`); + const warnings = sfCommandStubs.warn + .getCalls() + .flatMap((c) => c.args) + .join('\n'); + expect(warnings).to.include(''); + }); + it('transform the test JSON file with file extensions into Clover format without any warnings.', async () => { + await TransformerTransform.run([ + '--coverage-json', + deployCoverageWithExts, + '--xml', + cloverXmlPath2, + '--format', + 'clover', + ]); + const output = sfCommandStubs.log + .getCalls() + .flatMap((c) => c.args) + .join('\n'); + expect(output).to.include(`The coverage XML has been written to ${cloverXmlPath2}`); + const warnings = sfCommandStubs.warn + .getCalls() + .flatMap((c) => c.args) + .join('\n'); + expect(warnings).to.include(''); + }); + it('transform the JSON file from a test command into Clover format without any warnings.', async () => { + await TransformerTransform.run(['--coverage-json', testCoverage, '--xml', cloverXmlPath3, '--format', 'clover']); + const output = sfCommandStubs.log + .getCalls() + .flatMap((c) => c.args) + .join('\n'); + expect(output).to.include(`The coverage XML has been written to ${cloverXmlPath3}`); + const warnings = sfCommandStubs.warn + .getCalls() + .flatMap((c) => c.args) + .join('\n'); + expect(warnings).to.include(''); + }); });