Skip to content

Commit

Permalink
fix: add support for coverage JSONs created by sf apex run test
Browse files Browse the repository at this point in the history
  • Loading branch information
mcarvin8 committed May 9, 2024
1 parent 910c7db commit 5f48b77
Show file tree
Hide file tree
Showing 10 changed files with 189 additions and 22 deletions.
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,29 @@

[![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`.

This plugin supports code coverage metrics created for Apex Classes and Apex Triggers. This also supports multiple package directories as listed in your project's `sfdx-project.json` configuration, assuming unique file-names are used in your package directories.

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

Expand All @@ -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 <value> -x <value> [--json]
$ sf apex-code-coverage transformer transform -j <value> -x <value> -c <value> [--json]
FLAGS
-j, --coverage-json=<value> Path to the code coverage JSON file created by the Salesforce CLI deployment command.
-x, --xml=<value> [default: coverage.xml] Path to code coverage XML file that will be created by this plugin.
-x, --xml=<value> [default: "coverage.xml"] Path to code coverage XML file that will be created by this plugin.
-c, --command=<value> [default: "deploy"] The type of Salesforce CLI command you are running. Valid options: "deploy" or "test".
GLOBAL FLAGS
--json Format output as json.
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion messages/transformer.transform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
33 changes: 28 additions & 5 deletions src/commands/apex-code-coverage/transformer/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -34,16 +35,38 @@ export default class TransformerTransform extends SfCommand<TransformerTransform
exists: false,
default: 'coverage.xml',
}),
command: Flags.string({
summary: messages.getMessage('flags.command.summary'),
char: 'c',
required: true,
default: 'deploy',
options: ['deploy', 'test'],
}),
};

public async run(): Promise<TransformerTransformResult> {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
47 changes: 47 additions & 0 deletions src/helpers/transformTestCoverageReport.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
11 changes: 10 additions & 1 deletion src/helpers/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict';

export interface CoverageData {
export interface DeployCoverageData {
[className: string]: {
fnMap: Record<string, unknown>;
branchMap: Record<string, unknown>;
Expand All @@ -18,6 +18,15 @@ export interface CoverageData {
};
}

export interface TestCoverageData {
id: string;
name: string;
totalLines: number;
lines: Record<string, number>;
totalCovered: number;
coveredPercent: number;
}

export interface SfdxProject {
packageDirectories: Array<{ path: string }>;
}
Expand Down
24 changes: 20 additions & 4 deletions test/commands/transformer/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ describe('main', () => {
let sfCommandStubs: ReturnType<typeof stubSfCommandUx>;
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 = {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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');
Expand Down
File renamed without changes.
File renamed without changes.
59 changes: 59 additions & 0 deletions test/test_coverage.json
Original file line number Diff line number Diff line change
@@ -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
}
]

0 comments on commit 5f48b77

Please sign in to comment.