diff --git a/messages/transformer.transform.md b/messages/transformer.transform.md index 6995729..dbb7067 100644 --- a/messages/transformer.transform.md +++ b/messages/transformer.transform.md @@ -31,3 +31,11 @@ Output format for the coverage report. # flags.ignore-package-directory.summary Ignore a package directory when looking for matching files in the coverage report. + +# flags.min-coverage.summary + +Minimum required line coverage percentage (0–100). The command exits with an error if overall coverage is below this threshold. Reports are still written before the check. + +# flags.max-annotations.summary + +Maximum number of GitHub Actions ::warning annotations to emit when using --format github-actions. Defaults to 50. Annotations beyond this limit are summarised in a ::notice line. diff --git a/src/commands/acc-transformer/transform.ts b/src/commands/acc-transformer/transform.ts index 0f20b23..e3589b8 100644 --- a/src/commands/acc-transformer/transform.ts +++ b/src/commands/acc-transformer/transform.ts @@ -5,6 +5,7 @@ import { Messages } from '@salesforce/core'; import { TransformerTransformResult } from '../../utils/types.js'; import { transformCoverageReport } from '../../transformers/coverageTransformer.js'; import { formatOptions } from '../../utils/constants.js'; +import { DEFAULT_MAX_ANNOTATIONS } from '../../transformers/generators/generateGitHubActions.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('apex-code-coverage-transformer', 'transformer.transform'); @@ -39,6 +40,19 @@ export default class TransformerTransform extends SfCommand { @@ -49,10 +63,15 @@ export default class TransformerTransform extends SfCommand 0) { warnings.forEach((warning) => { @@ -60,7 +79,6 @@ export default class TransformerTransform extends SfCommand { + options?: TransformOptions, +): Promise<{ finalPaths: string[]; warnings: string[]; lineRate: number }> { const warnings: string[] = []; const finalPaths: string[] = []; const formatAmount: number = formats.length; - let filesProcessed = 0; const jsonData = await tryReadJson(jsonFilePath, warnings); - if (!jsonData) return { finalPaths: [outputReportPath], warnings }; + if (!jsonData) return { finalPaths: [outputReportPath], warnings, lineRate: 0 }; const parsedData = JSON.parse(jsonData) as CoverageInput; const { repoRoot, packageDirectories } = await getPackageDirectories(ignoreDirs); @@ -48,27 +56,39 @@ export async function transformCoverageReport( filePathCache, }; + let processResult: ProcessResult; + if (commandType === 'DeployCoverageData') { - filesProcessed = await processDeployCoverage(parsedData as DeployCoverageData, context); + processResult = await processDeployCoverage(parsedData as DeployCoverageData, context); } else if (commandType === 'TestCoverageData') { - filesProcessed = await processTestCoverage(parsedData as TestCoverageData[], context); + processResult = await processTestCoverage(parsedData as TestCoverageData[], context); } else { throw new Error( 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.', ); } - if (filesProcessed === 0) { + if (processResult.processed === 0) { warnings.push('None of the files listed in the coverage JSON were processed. The coverage report will be empty.'); } + const lineRate = processResult.totalLines > 0 ? processResult.coveredLines / processResult.totalLines : 0; + + const renderOptions = options?.maxAnnotations !== undefined ? { maxAnnotations: options.maxAnnotations } : undefined; + for (const [format, handler] of handlers.entries()) { const coverageObj = handler.finalize(); - const finalPath = await generateAndWriteReport(outputReportPath, coverageObj, format, formatAmount); + const finalPath = await generateAndWriteReport(outputReportPath, coverageObj, format, formatAmount, renderOptions); finalPaths.push(finalPath); } - return { finalPaths, warnings }; + if (options?.minCoverage !== undefined && lineRate * 100 < options.minCoverage) { + throw new Error( + `Coverage of ${(lineRate * 100).toFixed(2)}% is below the required minimum of ${options.minCoverage}%.`, + ); + } + + return { finalPaths, warnings, lineRate }; } function hasSourceContent( @@ -102,8 +122,20 @@ function createHandlers(formats: string[]): Map { +function countLines(lines: Record): LineTotals { + const totalLines = Object.keys(lines).length; + const coveredLines = Object.values(lines).filter((v) => v === 1).length; + return { totalLines, coveredLines }; +} + +async function processDeployCoverage( + data: DeployCoverageData, + context: CoverageProcessingContext, +): Promise { let processed = 0; + let totalLines = 0; + let coveredLines = 0; + await mapLimit(Object.keys(data), context.concurrencyLimit, async (fileName: string) => { const fileInfo = data[fileName]; const formattedName = fileName.replace(/no-map[\\/]+/, ''); @@ -118,16 +150,28 @@ async function processDeployCoverage(data: DeployCoverageData, context: Coverage const updatedLines = hasSourceContent(setCoveredResult) ? setCoveredResult.updatedLines : setCoveredResult; const sourceContent = hasSourceContent(setCoveredResult) ? setCoveredResult.sourceContent : undefined; fileInfo.s = updatedLines; + + const counts = countLines(updatedLines); + totalLines += counts.totalLines; + coveredLines += counts.coveredLines; + for (const handler of context.handlers.values()) { handler.processFile(path, formattedName, updatedLines, sourceContent); } processed++; }); - return processed; + + return { processed, totalLines, coveredLines }; } -async function processTestCoverage(data: TestCoverageData[], context: CoverageProcessingContext): Promise { +async function processTestCoverage( + data: TestCoverageData[], + context: CoverageProcessingContext, +): Promise { let processed = 0; + let totalLines = 0; + let coveredLines = 0; + await mapLimit(data, context.concurrencyLimit, async (entry: TestCoverageData) => { const formattedName = entry.name.replace(/no-map[\\/]+/, ''); const path = findFilePath(formattedName, context.filePathCache); @@ -137,11 +181,16 @@ async function processTestCoverage(data: TestCoverageData[], context: CoveragePr return; } + const counts = countLines(entry.lines); + totalLines += counts.totalLines; + coveredLines += counts.coveredLines; + const sourceContent = context.handlers.has('html') ? await readSourceFile(join(context.repoRoot, path)) : undefined; for (const handler of context.handlers.values()) { handler.processFile(path, formattedName, entry.lines, sourceContent); } processed++; }); - return processed; + + return { processed, totalLines, coveredLines }; } diff --git a/src/transformers/generators/generateGitHubActions.ts b/src/transformers/generators/generateGitHubActions.ts index 75c045f..a4fe571 100644 --- a/src/transformers/generators/generateGitHubActions.ts +++ b/src/transformers/generators/generateGitHubActions.ts @@ -1,6 +1,6 @@ import { GitHubActionsCoverageObject } from '../../utils/types.js'; -const MAX_ANNOTATIONS = 50; +export const DEFAULT_MAX_ANNOTATIONS = 50; function formatPercent(lineRate: number): string { return `${(lineRate * 100).toFixed(2)}%`; @@ -24,7 +24,10 @@ function escapeData(value: string): string { return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); } -export function generateGitHubActions(coverageObj: GitHubActionsCoverageObject): string { +export function generateGitHubActions( + coverageObj: GitHubActionsCoverageObject, + maxAnnotations = DEFAULT_MAX_ANNOTATIONS, +): string { const { summary, uncoveredLines } = coverageObj; const overall = formatPercent(summary.lineRate); @@ -34,7 +37,7 @@ export function generateGitHubActions(coverageObj: GitHubActionsCoverageObject): summary.fileCount } file${summary.fileCount === 1 ? '' : 's'})`; - const visible = uncoveredLines.slice(0, MAX_ANNOTATIONS); + const visible = uncoveredLines.slice(0, maxAnnotations); const truncated = uncoveredLines.length - visible.length; const annotations = visible.map( diff --git a/src/transformers/reportGenerator.ts b/src/transformers/reportGenerator.ts index b9e6c05..90dc756 100644 --- a/src/transformers/reportGenerator.ts +++ b/src/transformers/reportGenerator.ts @@ -16,6 +16,10 @@ import { generateHtml } from './generators/generateHtml.js'; import { generateMarkdown } from './generators/generateMarkdown.js'; import { generateGitHubActions } from './generators/generateGitHubActions.js'; +export type ReportRenderOptions = { + maxAnnotations?: number; +}; + function isXmlReportFormat(format: string): format is XmlReportFormat { return format in XML_HEADER_CONFIG; } @@ -29,11 +33,11 @@ function isXmlReportFormat(format: string): format is XmlReportFormat { * `generateReportContent` to a single decision point and avoids a long * chain of `format === ... && isXObject(...)` guards. */ -const STRING_FORMAT_RENDERERS: Record string> = { +const STRING_FORMAT_RENDERERS: Record string> = { lcovonly: (obj) => generateLcov(obj as LcovCoverageObject), html: (obj) => generateHtml(obj as HtmlCoverageObject), markdown: (obj) => generateMarkdown(obj as MarkdownCoverageObject), - 'github-actions': (obj) => generateGitHubActions(obj as GitHubActionsCoverageObject), + 'github-actions': (obj, opts) => generateGitHubActions(obj as GitHubActionsCoverageObject, opts?.maxAnnotations), json: (obj) => JSON.stringify(obj, null, 2), 'json-summary': (obj) => JSON.stringify(obj, null, 2), simplecov: (obj) => JSON.stringify(obj, null, 2), @@ -44,8 +48,9 @@ export async function generateAndWriteReport( coverageObj: AnyCoverageObject, format: string, formatAmount: number, + options?: ReportRenderOptions, ): Promise { - const content = generateReportContent(coverageObj, format); + const content = generateReportContent(coverageObj, format, options); const extension = HandlerRegistry.getExtension(format); const base = basename(outputPath, extname(outputPath)); // e.g., 'coverage' @@ -58,9 +63,9 @@ export async function generateAndWriteReport( return filePath; } -function generateReportContent(coverageObj: AnyCoverageObject, format: string): string { +function generateReportContent(coverageObj: AnyCoverageObject, format: string, options?: ReportRenderOptions): string { const renderer = STRING_FORMAT_RENDERERS[format]; - return renderer ? renderer(coverageObj) : generateXmlContent(coverageObj, format); + return renderer ? renderer(coverageObj, options) : generateXmlContent(coverageObj, format); } function generateXmlContent(coverageObj: AnyCoverageObject, format: string): string { diff --git a/test/commands/acc-transformer/transform.test.ts b/test/commands/acc-transformer/transform.test.ts index 6837dda..93305f7 100644 --- a/test/commands/acc-transformer/transform.test.ts +++ b/test/commands/acc-transformer/transform.test.ts @@ -53,7 +53,7 @@ describe('acc-transformer transform unit tests', () => { } catch (error) { if (error instanceof Error) { expect(error.message).toContain( - 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.' + '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.'); @@ -69,7 +69,7 @@ describe('acc-transformer transform unit tests', () => { deployCoverage, 'coverage.xml', ['sonar'], - ['packaged', 'force-app', samplesPackagePath] + ['packaged', 'force-app', samplesPackagePath], ); expect(result.warnings).toContain('The file name AccountTrigger was not found in any package directory.'); }); @@ -78,7 +78,7 @@ describe('acc-transformer transform unit tests', () => { testCoverage, 'coverage.xml', ['sonar'], - ['packaged', samplesPackagePath] + ['packaged', samplesPackagePath], ); expect(result.warnings).toContain('The file name AccountTrigger was not found in any package directory.'); }); @@ -88,6 +88,31 @@ describe('acc-transformer transform unit tests', () => { it('create a jacoco report using only 1 package directory', async () => { await transformCoverageReport(deployCoverage, 'coverage.xml', ['jacoco'], ['packaged', 'force-app']); }); + it('returns the overall line rate in the result', async () => { + const result = await transformCoverageReport(deployCoverage, 'coverage.xml', ['sonar'], [samplesPackagePath]); + expect(result.lineRate).toBeGreaterThanOrEqual(0); + expect(result.lineRate).toBeLessThanOrEqual(1); + }); + it('does not throw when coverage meets the minCoverage threshold', async () => { + await expect( + transformCoverageReport(deployCoverage, 'coverage.xml', ['sonar'], [samplesPackagePath], { minCoverage: 0 }), + ).resolves.toBeDefined(); + }); + it('throws when overall coverage is below the minCoverage threshold', async () => { + await expect( + transformCoverageReport(deployCoverage, 'coverage.xml', ['sonar'], [samplesPackagePath], { minCoverage: 100 }), + ).rejects.toThrow(/below the required minimum of 100%/); + }); + it('passes maxAnnotations through to the github-actions generator', async () => { + const result = await transformCoverageReport( + deployCoverage, + 'coverage.xml', + ['github-actions'], + [samplesPackagePath], + { maxAnnotations: 1 }, + ); + expect(result.finalPaths.length).toBeGreaterThan(0); + }); it('handles source file read failure gracefully when generating HTML from test coverage', async () => { shouldSimulateReadFailureForAccountTrigger = true; try { diff --git a/test/units/githubActions.test.ts b/test/units/githubActions.test.ts index 450f307..9faa3ca 100644 --- a/test/units/githubActions.test.ts +++ b/test/units/githubActions.test.ts @@ -82,6 +82,27 @@ describe('GitHubActionsCoverageHandler unit tests', () => { } }); + it('respects a custom maxAnnotations override passed via generateAndWriteReport options', async () => { + const handler = new GitHubActionsCoverageHandler(); + handler.processFile('force-app/main/default/classes/A.cls', 'A', { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 }); + const result = handler.finalize(); + + const tmpDir = await mkdtemp(join(tmpdir(), 'gha-override-')); + try { + const outPath = await generateAndWriteReport(join(tmpDir, 'coverage.txt'), result, 'github-actions', 1, { + maxAnnotations: 2, + }); + const content = await readFile(outPath, 'utf-8'); + const warningLines = content.split('\n').filter((l) => l.startsWith('::warning')); + const noticeLines = content.split('\n').filter((l) => l.startsWith('::notice')); + expect(warningLines).toHaveLength(2); + expect(noticeLines).toHaveLength(2); // summary + truncation + expect(noticeLines[1]).toContain('3 additional uncovered line'); + } finally { + await rm(tmpDir, { recursive: true }); + } + }); + it('caps annotations at 50 and emits a truncation notice for the remainder', async () => { const handler = new GitHubActionsCoverageHandler(); // 60 uncovered lines across one file