From 5f48b777f1ccd003d650c50ef87a0b24e2b4a73f Mon Sep 17 00:00:00 2001 From: Matt Carvin <90224411+mcarvin8@users.noreply.github.com> Date: Thu, 9 May 2024 17:16:09 -0400 Subject: [PATCH] fix: add support for coverage JSONs created by `sf apex run test` --- README.md | 21 ++++--- messages/transformer.transform.md | 10 +++- .../transformer/transform.ts | 33 +++++++++-- ...rt.ts => transformDeployCoverageReport.ts} | 6 +- src/helpers/transformTestCoverageReport.ts | 47 +++++++++++++++ src/helpers/types.ts | 11 +++- test/commands/transformer/unit.test.ts | 24 ++++++-- ...json => deploy_coverage_no_file_exts.json} | 0 ...on => deploy_coverage_with_file_exts.json} | 0 test/test_coverage.json | 59 +++++++++++++++++++ 10 files changed, 189 insertions(+), 22 deletions(-) rename src/helpers/{convertToGenericCoverageReport.ts => transformDeployCoverageReport.ts} (92%) create mode 100644 src/helpers/transformTestCoverageReport.ts rename test/{coverage_no_file_exts.json => deploy_coverage_no_file_exts.json} (100%) rename test/{coverage_with_file_exts.json => deploy_coverage_with_file_exts.json} (100%) create mode 100644 test/test_coverage.json diff --git a/README.md b/README.md index cd675a7..f93de68 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![NPM](https://img.shields.io/npm/v/apex-code-coverage-transformer.svg?label=apex-code-coverage-transformer)](https://www.npmjs.com/package/apex-code-coverage-transformer) [![Downloads/week](https://img.shields.io/npm/dw/apex-code-coverage-transformer.svg)](https://npmjs.org/package/apex-code-coverage-transformer) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://raw.githubusercontent.com/mcarvin8/apex-code-coverage-transformer/main/LICENSE.md) -The `apex-code-coverage-transformer` is a Salesforce CLI plugin to transform the Apex Code Coverage JSON files created during deployments into the Generic Test Coverage Format (XML). This format is accepted by static code analysis tools like SonarQube. +The `apex-code-coverage-transformer` is a Salesforce CLI plugin to transform the Apex Code Coverage JSON files created during deployments and test runs into the Generic Test Coverage Format (XML). This format is accepted by static code analysis tools like SonarQube. This plugin requires [git](https://git-scm.com/downloads) to be installed and that it can be called using the command `git`. @@ -10,17 +10,21 @@ This plugin supports code coverage metrics created for Apex Classes and Apex Tri This plugin is intended for users who deploy their Apex codebase from a git-based repository and use SonarQube for code quality. This plugin will work if you run local tests or run all tests in an org, including tests that originate from installed managed and unlocked packages. SonarQube relies on file-paths to map code coverage to the files in their file explorer interface. Since files from managed and unlocked packages aren't retrieved into git-based Salesforce repositories, these files cannot be included in your SonarQube scans. If your Apex code coverage JSON output includes managed/unlocked package files, they will not be added to the coverage XML created by this plugin. A warning will be printed for each file not found in a package directory in your git repository. See [Errors and Warnings](https://github.com/mcarvin8/apex-code-coverage-transformer?tab=readme-ov-file#errors-and-warnings) for more information. -To create the code coverage JSON during a Salesforce CLI deployment/validation, append `--coverage-formatters json --results-dir coverage` to the `sf project deploy` command: +To create the code coverage JSON during a Salesforce CLI deployment/validation, 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`. ``` sf project deploy [start/validate] -x manifest/package.xml -l RunSpecifiedTests -t {testclasses} --verbose --coverage-formatters json --results-dir coverage ``` -This will create a coverage JSON in this relative path - `coverage/coverage/coverage.json` +To create the code coverage JSON when running tests directly in the org, append `-c -r json` to the `sf apex run test` command. -This JSON isn't accepted by SonarQube automatically for git-based Salesforce repositories and needs to be converted using this plugin. +``` +sf apex run test -c -r json +``` + +The code coverage JSONs created by the Salesforce CLI aren't accepted by SonarQube automatically for git-based Salesforce repositories and needs to be converted using this plugin. -**Disclaimer**: Due to existing bugs with how the Salesforce CLI reports covered lines (see [5511](https://github.com/forcedotcom/salesforcedx-vscode/issues/5511) and [1568](https://github.com/forcedotcom/cli/issues/1568)), to add support for covered lines in this plugin, I had to add a function to re-number out-of-range covered lines the CLI may report (ex: line 100 in a 98-line Apex Class is reported back as covered by the Salesforce CLI deploy command). Salesforce's coverage result may also include extra lines as covered (ex: 120 lines are included in the coverage report for a 100 line file), so the coverage percentage may vary based on how many lines the API returns in the coverage report. Once Salesforce fixes the API to correctly return covered lines in the deploy command, this function will be removed. +**Disclaimer**: Due to existing bugs with how the Salesforce CLI reports covered lines during deployments (see [5511](https://github.com/forcedotcom/salesforcedx-vscode/issues/5511) and [1568](https://github.com/forcedotcom/cli/issues/1568)), to add support for covered lines in this plugin for deployment coverage files, I had to add a function to re-number out-of-range covered lines the CLI may report (ex: line 100 in a 98-line Apex Class is reported back as covered by the Salesforce CLI deploy command). Salesforce's coverage result may also include extra lines as covered (ex: 120 lines are included in the coverage report for a 100 line file), so the coverage percentage may vary based on how many lines the API returns in the coverage report. Once Salesforce fixes the API to correctly return covered lines in the deploy command, this function will be removed. ## Install @@ -40,11 +44,12 @@ This command needs to be ran somewhere inside your Salesforce DX git repository, ``` USAGE - $ sf apex-code-coverage transformer transform -j -x [--json] + $ sf apex-code-coverage transformer transform -j -x -c [--json] FLAGS -j, --coverage-json= Path to the code coverage JSON file created by the Salesforce CLI deployment command. - -x, --xml= [default: coverage.xml] Path to code coverage XML file that will be created by this plugin. + -x, --xml= [default: "coverage.xml"] Path to code coverage XML file that will be created by this plugin. + -c, --command= [default: "deploy"] The type of Salesforce CLI command you are running. Valid options: "deploy" or "test". GLOBAL FLAGS --json Format output as json. @@ -53,7 +58,7 @@ DESCRIPTION This plugin will convert the code coverage JSON file created by the Salesforce CLI during Apex deployments into an XML accepted by tools like SonarQube. EXAMPLES - $ sf apex-code-coverage transformer transform -j "coverage.json" -x "coverage.xml" + $ sf apex-code-coverage transformer transform -j "coverage.json" -x "coverage.xml" -c "deploy" ``` ## Hook diff --git a/messages/transformer.transform.md b/messages/transformer.transform.md index 569ab2a..b247a08 100644 --- a/messages/transformer.transform.md +++ b/messages/transformer.transform.md @@ -8,7 +8,7 @@ This plugin will convert the code coverage JSON file created by the Salesforce C # examples -- `sf apex-code-coverage transformer transform -j "coverage.json" -x "coverage.xml"` +- `sf apex-code-coverage transformer transform -j "coverage.json" -x "coverage.xml" -c "deploy"` # flags.coverage-json.summary @@ -17,3 +17,11 @@ Path to the code coverage JSON file created by the Salesforce CLI deployment com # flags.xml.summary Path to code coverage XML file that will be created by this plugin. + +# flags.xml.summary + +Path to code coverage XML file that will be created by this plugin. + +# flags.command.summary + +The type of Salesforce CLI command you are running. diff --git a/src/commands/apex-code-coverage/transformer/transform.ts b/src/commands/apex-code-coverage/transformer/transform.ts index b16a067..5ff7a41 100644 --- a/src/commands/apex-code-coverage/transformer/transform.ts +++ b/src/commands/apex-code-coverage/transformer/transform.ts @@ -5,8 +5,9 @@ import { writeFile, readFile } from 'node:fs/promises'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; -import { CoverageData } from '../../../helpers/types.js'; -import { convertToGenericCoverageReport } from '../../../helpers/convertToGenericCoverageReport.js'; +import { DeployCoverageData, TestCoverageData } from '../../../helpers/types.js'; +import { transformDeployCoverageReport } from '../../../helpers/transformDeployCoverageReport.js'; +import { transformTestCoverageReport } from '../../../helpers/transformTestCoverageReport.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('apex-code-coverage-transformer', 'transformer.transform'); @@ -34,16 +35,38 @@ export default class TransformerTransform extends SfCommand { const { flags } = await this.parse(TransformerTransform); const jsonFilePath = resolve(flags['coverage-json']); const xmlFilePath = resolve(flags['xml']); - + const commandType = flags['command']; const jsonData = await readFile(jsonFilePath, 'utf-8'); - const coverageData = JSON.parse(jsonData) as CoverageData; - const { xml: xmlData, warnings, filesProcessed } = await convertToGenericCoverageReport(coverageData); + + let xmlData: string; + let warnings: string[] = []; + let filesProcessed: number = 0; + if (commandType === 'test') { + const coverageData = JSON.parse(jsonData) as TestCoverageData[]; + const result = await transformTestCoverageReport(coverageData); + xmlData = result.xml; + warnings = result.warnings; + filesProcessed = result.filesProcessed; + } else { + const coverageData = JSON.parse(jsonData) as DeployCoverageData; + const result = await transformDeployCoverageReport(coverageData); + xmlData = result.xml; + warnings = result.warnings; + filesProcessed = result.filesProcessed; + } // Print warnings if any if (warnings.length > 0) { diff --git a/src/helpers/convertToGenericCoverageReport.ts b/src/helpers/transformDeployCoverageReport.ts similarity index 92% rename from src/helpers/convertToGenericCoverageReport.ts rename to src/helpers/transformDeployCoverageReport.ts index e0fd9b7..8dacf35 100644 --- a/src/helpers/convertToGenericCoverageReport.ts +++ b/src/helpers/transformDeployCoverageReport.ts @@ -3,14 +3,14 @@ import { create } from 'xmlbuilder2'; -import { CoverageData, CoverageObject, FileObject } from './types.js'; +import { DeployCoverageData, CoverageObject, FileObject } from './types.js'; import { getPackageDirectories } from './getPackageDirectories.js'; import { findFilePath } from './findFilePath.js'; import { setCoveredLines } from './setCoveredLines.js'; import { normalizePathToUnix } from './normalizePathToUnix.js'; -export async function convertToGenericCoverageReport( - data: CoverageData +export async function transformDeployCoverageReport( + data: DeployCoverageData ): Promise<{ xml: string; warnings: string[]; filesProcessed: number }> { const coverageObj: CoverageObject = { coverage: { '@version': '1', file: [] } }; const warnings: string[] = []; diff --git a/src/helpers/transformTestCoverageReport.ts b/src/helpers/transformTestCoverageReport.ts new file mode 100644 index 0000000..3b09368 --- /dev/null +++ b/src/helpers/transformTestCoverageReport.ts @@ -0,0 +1,47 @@ +'use strict'; +/* eslint-disable no-await-in-loop */ + +import { create } from 'xmlbuilder2'; + +import { TestCoverageData, CoverageObject, FileObject } from './types.js'; +import { getPackageDirectories } from './getPackageDirectories.js'; +import { findFilePath } from './findFilePath.js'; +import { normalizePathToUnix } from './normalizePathToUnix.js'; + +export async function transformTestCoverageReport( + testCoverageData: TestCoverageData[] +): Promise<{ xml: string; warnings: string[]; filesProcessed: number }> { + const coverageObj: CoverageObject = { coverage: { '@version': '1', file: [] } }; + const warnings: string[] = []; + let filesProcessed: number = 0; + const { repoRoot, packageDirectories } = await getPackageDirectories(); + + if (!Array.isArray(testCoverageData)) { + testCoverageData = [testCoverageData]; + } + + for (const data of testCoverageData) { + const { name, lines } = data; + const formattedFileName = name.replace(/no-map[\\/]+/, ''); + const relativeFilePath = await findFilePath(formattedFileName, packageDirectories, repoRoot); + if (relativeFilePath === undefined) { + warnings.push(`The file name ${formattedFileName} was not found in any package directory.`); + continue; + } + const fileObj: FileObject = { + '@path': normalizePathToUnix(relativeFilePath), + lineToCover: [], + }; + + for (const [lineNumber, isCovered] of Object.entries(lines)) { + fileObj.lineToCover.push({ + '@lineNumber': Number(lineNumber), + '@covered': `${isCovered === 1}`, + }); + } + filesProcessed++; + coverageObj.coverage.file.push(fileObj); + } + const xml = create(coverageObj).end({ prettyPrint: true, indent: ' ' }); + return { xml, warnings, filesProcessed }; +} diff --git a/src/helpers/types.ts b/src/helpers/types.ts index ae1d7a9..e2db154 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -1,6 +1,6 @@ 'use strict'; -export interface CoverageData { +export interface DeployCoverageData { [className: string]: { fnMap: Record; branchMap: Record; @@ -18,6 +18,15 @@ export interface CoverageData { }; } +export interface TestCoverageData { + id: string; + name: string; + totalLines: number; + lines: Record; + totalCovered: number; + coveredPercent: number; +} + export interface SfdxProject { packageDirectories: Array<{ path: string }>; } diff --git a/test/commands/transformer/unit.test.ts b/test/commands/transformer/unit.test.ts index 1a49520..95fc9ae 100644 --- a/test/commands/transformer/unit.test.ts +++ b/test/commands/transformer/unit.test.ts @@ -14,11 +14,13 @@ describe('main', () => { let sfCommandStubs: ReturnType; const baselineClassPath = resolve('test/baselines/classes/AccountProfile.cls'); const baselineTriggerPath = resolve('test/baselines/triggers/AccountTrigger.trigger'); - const coverageJsonPathNoExts = resolve('test/coverage_no_file_exts.json'); - const coverageJsonPathWithExts = resolve('test/coverage_with_file_exts.json'); + const deployCoverageNoExts = resolve('test/deploy_coverage_no_file_exts.json'); + const deployCoverageWithExts = resolve('test/deploy_coverage_with_file_exts.json'); + const testCoverage = resolve('test/test_coverage.json'); const baselineXmlPath = resolve('test/coverage_baseline.xml'); const testXmlPath1 = resolve('coverage1.xml'); const testXmlPath2 = resolve('coverage2.xml'); + const testXmlPath3 = resolve('coverage3.xml'); const sfdxConfigFile = resolve('sfdx-project.json'); const configFile = { @@ -52,11 +54,12 @@ describe('main', () => { await rm('packaged', { recursive: true }); await rm(testXmlPath1); await rm(testXmlPath2); + await rm(testXmlPath3); await rm(sfdxConfigFile); }); it('transform the test JSON file without file extensions into the generic test coverage format without any warnings.', async () => { - await TransformerTransform.run(['--coverage-json', coverageJsonPathNoExts, '--xml', testXmlPath1]); + await TransformerTransform.run(['--coverage-json', deployCoverageNoExts, '--xml', testXmlPath1]); const output = sfCommandStubs.log .getCalls() .flatMap((c) => c.args) @@ -69,7 +72,7 @@ describe('main', () => { expect(warnings).to.include(''); }); it('transform the test JSON file with file extensions into the generic test coverage format without any warnings.', async () => { - await TransformerTransform.run(['--coverage-json', coverageJsonPathWithExts, '--xml', testXmlPath2]); + await TransformerTransform.run(['--coverage-json', deployCoverageWithExts, '--xml', testXmlPath2]); const output = sfCommandStubs.log .getCalls() .flatMap((c) => c.args) @@ -81,6 +84,19 @@ describe('main', () => { .join('\n'); expect(warnings).to.include(''); }); + it('transform the JSON file from a test command into the generic test coverage format without any warnings.', async () => { + await TransformerTransform.run(['--coverage-json', testCoverage, '--xml', testXmlPath3, '-c', 'test']); + const output = sfCommandStubs.log + .getCalls() + .flatMap((c) => c.args) + .join('\n'); + expect(output).to.include(`The XML data has been written to ${testXmlPath3}`); + const warnings = sfCommandStubs.warn + .getCalls() + .flatMap((c) => c.args) + .join('\n'); + expect(warnings).to.include(''); + }); it('confirm the 2 XML files created in the previous tests are the same as the baseline.', async () => { const xmlContent1 = await readFile(testXmlPath1, 'utf-8'); const xmlContent2 = await readFile(testXmlPath2, 'utf-8'); diff --git a/test/coverage_no_file_exts.json b/test/deploy_coverage_no_file_exts.json similarity index 100% rename from test/coverage_no_file_exts.json rename to test/deploy_coverage_no_file_exts.json diff --git a/test/coverage_with_file_exts.json b/test/deploy_coverage_with_file_exts.json similarity index 100% rename from test/coverage_with_file_exts.json rename to test/deploy_coverage_with_file_exts.json diff --git a/test/test_coverage.json b/test/test_coverage.json new file mode 100644 index 0000000..66421be --- /dev/null +++ b/test/test_coverage.json @@ -0,0 +1,59 @@ +[ + { + "id": "01p9X00000DKqDQQA1", + "name": "AccountProfile", + "totalLines": 28, + "lines": { + "9": 1, + "10": 1, + "11": 1, + "12": 1, + "14": 1, + "17": 1, + "20": 1, + "21": 1, + "24": 1, + "25": 1, + "26": 1, + "27": 1, + "28": 1, + "29": 1, + "30": 1, + "32": 1, + "43": 1, + "44": 1, + "52": 1, + "53": 1, + "54": 1, + "55": 1, + "56": 1, + "57": 1, + "59": 1, + "60": 1, + "61": 1, + "62": 1 + }, + "totalCovered": 28, + "coveredPercent": 100 + }, + { + "id": "01p9X00000DKqCiQAL", + "name": "AccountTrigger", + "totalLines": 11, + "lines": { + "11": 1, + "13": 1, + "14": 1, + "15": 1, + "18": 1, + "20": 1, + "21": 1, + "22": 1, + "23": 1, + "25": 0, + "26": 1 + }, + "totalCovered": 10, + "coveredPercent": 91 + } +]