diff --git a/.gitignore b/.gitignore index 77e6d98..480b6e9 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,7 @@ node_modules oclif.manifest.json -oclif.lock \ No newline at end of file +oclif.lock +*.log +stderr*.txt +stdout*.txt diff --git a/README.md b/README.md index 1b9d279..9b569e9 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,11 @@ This command needs to be ran somewhere inside your Salesforce DX git repository, ``` USAGE - $ sf acc-transformer transform -j -x -c [--json] + $ sf acc-transformer transform -j -x [--json] FLAGS -j, --coverage-json= Path to the code coverage JSON file created by the Salesforce CLI deployment or test command. -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. @@ -57,7 +56,7 @@ DESCRIPTION This plugin will convert the code coverage JSON file created by the Salesforce CLI during Apex deployments and test runs into an XML accepted by tools like SonarQube. EXAMPLES - $ sf acc-transformer transform -j "coverage.json" -x "coverage.xml" -c "deploy" + $ sf acc-transformer transform -j "coverage.json" -x "coverage.xml" ``` ## Hook @@ -102,6 +101,12 @@ Warning: The file name AccountProfile was not found in any package directory. Warning: None of the files listed in the coverage JSON were processed. The coverage XML will be empty. ``` +The code coverage JSON files created by the Salesforce CLI deployment commands follow a different format than the format created by the test commands. If the code coverage JSON file does not match one of the 2 expected coverage data types, the plugin will fail with: + +``` +Error (1): The provided JSON does not match a known coverage data format from the Salesforce deploy or test command. +``` + If the `sfdx-project.json` file was not found in your repository's root folder, the plugin will fail with: ``` diff --git a/messages/transformer.transform.md b/messages/transformer.transform.md index c4fdc9e..2ac2bbe 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 acc-transformer transform -j "coverage.json" -x "coverage.xml" -c "deploy"` +- `sf acc-transformer transform -j "coverage.json" -x "coverage.xml"` # flags.coverage-json.summary @@ -17,7 +17,3 @@ Path to the code coverage JSON file created by the Salesforce CLI deployment or # 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/acc-transformer/transform.ts b/src/commands/acc-transformer/transform.ts index 55877ec..ee1fff0 100644 --- a/src/commands/acc-transformer/transform.ts +++ b/src/commands/acc-transformer/transform.ts @@ -8,6 +8,7 @@ import { Messages } from '@salesforce/core'; import { DeployCoverageData, TestCoverageData, TransformerTransformResult } from '../../helpers/types.js'; import { transformDeployCoverageReport } from '../../helpers/transformDeployCoverageReport.js'; import { transformTestCoverageReport } from '../../helpers/transformTestCoverageReport.js'; +import { checkCoverageDataType } from '../../helpers/setCoverageDataType.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('apex-code-coverage-transformer', 'transformer.transform'); @@ -31,37 +32,35 @@ 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'); 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); + const parsedData = JSON.parse(jsonData) as DeployCoverageData | TestCoverageData[]; + const commandType = checkCoverageDataType(parsedData); + + // Determine the type of coverage data using type guards + if (commandType === 'TestCoverageData') { + const result = await transformTestCoverageReport(parsedData as TestCoverageData[]); xmlData = result.xml; warnings = result.warnings; filesProcessed = result.filesProcessed; - } else { - const coverageData = JSON.parse(jsonData) as DeployCoverageData; - const result = await transformDeployCoverageReport(coverageData); + } else if (commandType === 'DeployCoverageData') { + const result = await transformDeployCoverageReport(parsedData as DeployCoverageData); xmlData = result.xml; warnings = result.warnings; filesProcessed = result.filesProcessed; + } else { + this.error( + 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.' + ); } // Print warnings if any diff --git a/src/helpers/setCoverageDataType.ts b/src/helpers/setCoverageDataType.ts new file mode 100644 index 0000000..0e74f32 --- /dev/null +++ b/src/helpers/setCoverageDataType.ts @@ -0,0 +1,82 @@ +'use strict'; + +import { DeployCoverageData, TestCoverageData } from './types.js'; + +// Type guard for DeployCoverageData +export function isDeployCoverageData(data: unknown): data is DeployCoverageData { + if (typeof data !== 'object' || data === null) return false; + + return Object.entries(data).every(([, item]) => { + if (typeof item !== 'object' || item === null) return false; + + const { path, fnMap, branchMap, f, b, s, statementMap } = item as { + path: unknown; + fnMap: unknown; + branchMap: unknown; + f: unknown; + b: unknown; + s: unknown; + statementMap: unknown; + }; + + if ( + typeof path !== 'string' || + typeof fnMap !== 'object' || + typeof branchMap !== 'object' || + typeof f !== 'object' || + typeof b !== 'object' || + typeof s !== 'object' || + typeof statementMap !== 'object' || + statementMap === null + ) { + return false; + } + + return Object.values(statementMap).every((statement) => { + if (typeof statement !== 'object' || statement === null) return false; + const { start, end } = statement as { start: unknown; end: unknown }; + + return ( + typeof start === 'object' && + start !== null && + typeof (start as { line: unknown }).line === 'number' && + typeof (start as { column: unknown }).column === 'number' && + typeof end === 'object' && + end !== null && + typeof (end as { line: unknown }).line === 'number' && + typeof (end as { column: unknown }).column === 'number' + ); + }); + }); +} + +// Type guard for a single TestCoverageData +export function isSingleTestCoverageData(data: unknown): data is TestCoverageData { + return ( + typeof data === 'object' && + data !== null && + typeof (data as TestCoverageData).id === 'string' && + typeof (data as TestCoverageData).name === 'string' && + typeof (data as TestCoverageData).totalLines === 'number' && + typeof (data as TestCoverageData).lines === 'object' && + typeof (data as TestCoverageData).totalCovered === 'number' && + typeof (data as TestCoverageData).coveredPercent === 'number' && + Object.values((data as TestCoverageData).lines).every((line: unknown) => typeof line === 'number') + ); +} + +// Type guard for TestCoverageData array +export function isTestCoverageDataArray(data: unknown): data is TestCoverageData[] { + return Array.isArray(data) && data.every(isSingleTestCoverageData); +} + +export function checkCoverageDataType( + data: DeployCoverageData | TestCoverageData[] +): 'DeployCoverageData' | 'TestCoverageData' | 'Unknown' { + if (isDeployCoverageData(data)) { + return 'DeployCoverageData'; + } else if (isTestCoverageDataArray(data)) { + return 'TestCoverageData'; + } + return 'Unknown'; +} diff --git a/src/hooks/postrun.ts b/src/hooks/postrun.ts index f890f0c..5b6a618 100644 --- a/src/hooks/postrun.ts +++ b/src/hooks/postrun.ts @@ -58,7 +58,5 @@ export const postrun: Hook<'postrun'> = async function (options) { commandArgs.push(coverageJsonPath); commandArgs.push('--xml'); commandArgs.push(coverageXmlPath); - commandArgs.push('--command'); - commandArgs.push(commandType); await TransformerTransform.run(commandArgs); }; diff --git a/test/commands/acc-transformer/transform.nut.ts b/test/commands/acc-transformer/transform.nut.ts index d66300b..d32aa7a 100644 --- a/test/commands/acc-transformer/transform.nut.ts +++ b/test/commands/acc-transformer/transform.nut.ts @@ -14,10 +14,13 @@ describe('acc-transformer transform NUTs', () => { 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 invalidJson = resolve('test/invalid.json'); + const deployBaselineXmlPath = resolve('test/deploy_coverage_baseline.xml'); + const testBaselineXmlPath = resolve('test/test_coverage_baseline.xml'); + const coverageXmlPath1 = resolve('coverage1.xml'); + const coverageXmlPath2 = resolve('coverage2.xml'); + const coverageXmlPath3 = resolve('coverage3.xml'); + const sfdxConfigFile = resolve('sfdx-project.json'); const configFile = { packageDirectories: [{ path: 'force-app', default: true }, { path: 'packaged' }], @@ -29,7 +32,7 @@ describe('acc-transformer transform NUTs', () => { before(async () => { session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); - await writeFile('sfdx-project.json', configJsonString); + await writeFile(sfdxConfigFile, configJsonString); await mkdir('force-app/main/default/classes', { recursive: true }); await mkdir('packaged/triggers', { recursive: true }); await copyFile(baselineClassPath, 'force-app/main/default/classes/AccountProfile.cls'); @@ -38,49 +41,65 @@ describe('acc-transformer transform NUTs', () => { after(async () => { await session?.clean(); - await rm('sfdx-project.json'); + await rm(sfdxConfigFile); await rm('force-app/main/default/classes/AccountProfile.cls'); await rm('packaged/triggers/AccountTrigger.trigger'); await rm('force-app', { recursive: true }); await rm('packaged', { recursive: true }); - await rm(testXmlPath1); - await rm(testXmlPath2); - await rm(testXmlPath3); + await rm(coverageXmlPath1); + await rm(coverageXmlPath2); + await rm(coverageXmlPath3); }); it('runs transform on the deploy coverage file without file extensions.', async () => { - const command = `acc-transformer transform --coverage-json "${deployCoverageNoExts}" --xml "${testXmlPath1}"`; + const command = `acc-transformer transform --coverage-json "${deployCoverageNoExts}" --xml "${coverageXmlPath1}"`; const output = execCmd(command, { ensureExitCode: 0 }).shellOutput.stdout; - expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${testXmlPath1}`); + expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${coverageXmlPath1}`); }); it('runs transform on the deploy coverage file with file extensions.', async () => { - const command = `acc-transformer transform --coverage-json "${deployCoverageWithExts}" --xml "${testXmlPath2}"`; + const command = `acc-transformer transform --coverage-json "${deployCoverageWithExts}" --xml "${coverageXmlPath2}"`; const output = execCmd(command, { ensureExitCode: 0 }).shellOutput.stdout; - expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${testXmlPath2}`); + expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${coverageXmlPath2}`); }); it('runs transform on the test coverage file.', async () => { - const command = `acc-transformer transform --coverage-json "${testCoverage}" --xml "${testXmlPath3}"`; + const command = `acc-transformer transform --coverage-json "${testCoverage}" --xml "${coverageXmlPath3}"`; const output = execCmd(command, { ensureExitCode: 0 }).shellOutput.stdout; - expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${testXmlPath3}`); + expect(output.replace('\n', '')).to.equal(`The coverage XML has been written to ${coverageXmlPath3}`); }); - 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'); - const baselineXmlContent = await readFile(baselineXmlPath, 'utf-8'); + it('confirms a failure on an invalid JSON file.', async () => { + const command = `acc-transformer transform --coverage-json "${invalidJson}"`; + const error = execCmd(command, { ensureExitCode: 2 }).shellOutput.stderr; + + expect(error.replace('\n', '')).to.contain( + 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.' + ); + }); + + it('confirm the XML files created are the same as the baselines.', async () => { + const deployXml1 = await readFile(coverageXmlPath1, 'utf-8'); + const deployXml2 = await readFile(coverageXmlPath2, 'utf-8'); + const testXml = await readFile(coverageXmlPath3, 'utf-8'); + const deployBaselineXmlContent = await readFile(deployBaselineXmlPath, 'utf-8'); + const testBaselineXmlContent = await readFile(testBaselineXmlPath, 'utf-8'); + strictEqual( + deployXml1, + deployBaselineXmlContent, + `File content is different between ${coverageXmlPath1} and ${deployBaselineXmlPath}` + ); strictEqual( - xmlContent1, - baselineXmlContent, - `File content is different between ${testXmlPath1} and ${baselineXmlPath}` + deployXml2, + deployBaselineXmlContent, + `File content is different between ${coverageXmlPath2} and ${deployBaselineXmlPath}` ); strictEqual( - xmlContent2, - baselineXmlContent, - `File content is different between ${testXmlPath2} and ${baselineXmlPath}` + testXml, + testBaselineXmlContent, + `File content is different between ${coverageXmlPath2} and ${testBaselineXmlPath}` ); }); }); diff --git a/test/commands/acc-transformer/transform.test.ts b/test/commands/acc-transformer/transform.test.ts index eede321..667ecb6 100644 --- a/test/commands/acc-transformer/transform.test.ts +++ b/test/commands/acc-transformer/transform.test.ts @@ -17,10 +17,12 @@ describe('main', () => { 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 invalidJson = resolve('test/invalid.json'); + const deployBaselineXmlPath = resolve('test/deploy_coverage_baseline.xml'); + const testBaselineXmlPath = resolve('test/test_coverage_baseline.xml'); + const coverageXmlPath1 = resolve('coverage1.xml'); + const coverageXmlPath2 = resolve('coverage2.xml'); + const coverageXmlPath3 = resolve('coverage3.xml'); const sfdxConfigFile = resolve('sfdx-project.json'); const configFile = { @@ -48,23 +50,23 @@ describe('main', () => { }); after(async () => { + await rm(sfdxConfigFile); await rm('force-app/main/default/classes/AccountProfile.cls'); await rm('packaged/triggers/AccountTrigger.trigger'); await rm('force-app', { recursive: true }); await rm('packaged', { recursive: true }); - await rm(testXmlPath1); - await rm(testXmlPath2); - await rm(testXmlPath3); - await rm(sfdxConfigFile); + await rm(coverageXmlPath1); + await rm(coverageXmlPath2); + await rm(coverageXmlPath3); }); it('transform the test JSON file without file extensions into the generic test coverage format without any warnings.', async () => { - await TransformerTransform.run(['--coverage-json', deployCoverageNoExts, '--xml', testXmlPath1]); + await TransformerTransform.run(['--coverage-json', deployCoverageNoExts, '--xml', coverageXmlPath1]); const output = sfCommandStubs.log .getCalls() .flatMap((c) => c.args) .join('\n'); - expect(output).to.include(`The coverage XML has been written to ${testXmlPath1}`); + expect(output).to.include(`The coverage XML has been written to ${coverageXmlPath1}`); const warnings = sfCommandStubs.warn .getCalls() .flatMap((c) => c.args) @@ -72,12 +74,12 @@ 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', deployCoverageWithExts, '--xml', testXmlPath2]); + await TransformerTransform.run(['--coverage-json', deployCoverageWithExts, '--xml', coverageXmlPath2]); const output = sfCommandStubs.log .getCalls() .flatMap((c) => c.args) .join('\n'); - expect(output).to.include(`The coverage XML has been written to ${testXmlPath2}`); + expect(output).to.include(`The coverage XML has been written to ${coverageXmlPath2}`); const warnings = sfCommandStubs.warn .getCalls() .flatMap((c) => c.args) @@ -85,31 +87,52 @@ describe('main', () => { 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']); + await TransformerTransform.run(['--coverage-json', testCoverage, '--xml', coverageXmlPath3]); const output = sfCommandStubs.log .getCalls() .flatMap((c) => c.args) .join('\n'); - expect(output).to.include(`The coverage XML has been written to ${testXmlPath3}`); + expect(output).to.include(`The coverage XML has been written to ${coverageXmlPath3}`); 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'); - const baselineXmlContent = await readFile(baselineXmlPath, 'utf-8'); + it('confirms a failure on an invalid JSON file.', async () => { + try { + await TransformerTransform.run(['--coverage-json', invalidJson]); + throw new Error('Command did not fail as expected'); + } catch (error) { + if (error instanceof Error) { + expect(error.message).to.include( + 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.' + ); + } else { + throw new Error('An unknown error type was thrown.'); + } + } + }); + it('confirm the XML files created are the same as the baselines.', async () => { + const deployXml1 = await readFile(coverageXmlPath1, 'utf-8'); + const deployXml2 = await readFile(coverageXmlPath2, 'utf-8'); + const testXml = await readFile(coverageXmlPath3, 'utf-8'); + const deployBaselineXmlContent = await readFile(deployBaselineXmlPath, 'utf-8'); + const testBaselineXmlContent = await readFile(testBaselineXmlPath, 'utf-8'); + strictEqual( + deployXml1, + deployBaselineXmlContent, + `File content is different between ${coverageXmlPath1} and ${deployBaselineXmlPath}` + ); strictEqual( - xmlContent1, - baselineXmlContent, - `File content is different between ${testXmlPath1} and ${baselineXmlPath}` + deployXml2, + deployBaselineXmlContent, + `File content is different between ${coverageXmlPath2} and ${deployBaselineXmlPath}` ); strictEqual( - xmlContent2, - baselineXmlContent, - `File content is different between ${testXmlPath2} and ${baselineXmlPath}` + testXml, + testBaselineXmlContent, + `File content is different between ${coverageXmlPath2} and ${testBaselineXmlPath}` ); }); }); diff --git a/test/coverage_baseline.xml b/test/deploy_coverage_baseline.xml similarity index 100% rename from test/coverage_baseline.xml rename to test/deploy_coverage_baseline.xml diff --git a/test/invalid.json b/test/invalid.json new file mode 100644 index 0000000..d7eefc2 --- /dev/null +++ b/test/invalid.json @@ -0,0 +1,5 @@ +{ + "no-map/AccountTrigger.trigger": { + "path": "no-map/AccountTrigger.trigger" + } +} diff --git a/test/test_coverage_baseline.xml b/test/test_coverage_baseline.xml new file mode 100644 index 0000000..17ef07f --- /dev/null +++ b/test/test_coverage_baseline.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file