Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions messages/transformer.transform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 22 additions & 4 deletions src/commands/acc-transformer/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -39,6 +40,19 @@ export default class TransformerTransform extends SfCommand<TransformerTransform
required: false,
multiple: true,
}),
// eslint-disable-next-line sf-plugin/flag-min-max-default
'min-coverage': Flags.integer({
summary: messages.getMessage('flags.min-coverage.summary'),
required: false,
min: 0,
max: 100,
}),
'max-annotations': Flags.integer({
summary: messages.getMessage('flags.max-annotations.summary'),
required: false,
min: 1,
default: DEFAULT_MAX_ANNOTATIONS,
}),
};

public async run(): Promise<TransformerTransformResult> {
Expand All @@ -49,18 +63,22 @@ export default class TransformerTransform extends SfCommand<TransformerTransform
flags['coverage-json'],
flags['output-report'],
flags['format'] ?? ['sonar'],
flags['ignore-package-directory'] ?? []
flags['ignore-package-directory'] ?? [],
{
minCoverage: flags['min-coverage'],
maxAnnotations: flags['max-annotations'],
},
);
warnings.push(...result.warnings);
const finalPath = result.finalPaths;

this.log(`The coverage report has been written to: ${result.finalPaths.join(', ')}`);

if (warnings.length > 0) {
warnings.forEach((warning) => {
this.warn(warning);
});
}

this.log(`The coverage report has been written to: ${finalPath.join(', ')}`);
return { path: finalPath };
return { path: result.finalPaths };
}
}
73 changes: 61 additions & 12 deletions src/transformers/coverageTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,27 @@ import { generateAndWriteReport } from './reportGenerator.js';

type CoverageInput = DeployCoverageData | TestCoverageData[];

type LineTotals = { totalLines: number; coveredLines: number };
type ProcessResult = { processed: number } & LineTotals;

export type TransformOptions = {
minCoverage?: number;
maxAnnotations?: number;
};

export async function transformCoverageReport(
jsonFilePath: string,
outputReportPath: string,
formats: string[],
ignoreDirs: string[],
): Promise<{ finalPaths: string[]; warnings: string[] }> {
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);
Expand All @@ -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(
Expand Down Expand Up @@ -102,8 +122,20 @@ function createHandlers(formats: string[]): Map<string, ReturnType<typeof getCov
return handlers;
}

async function processDeployCoverage(data: DeployCoverageData, context: CoverageProcessingContext): Promise<number> {
function countLines(lines: Record<string, number>): 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<ProcessResult> {
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[\\/]+/, '');
Expand All @@ -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<number> {
async function processTestCoverage(
data: TestCoverageData[],
context: CoverageProcessingContext,
): Promise<ProcessResult> {
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);
Expand All @@ -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 };
}
9 changes: 6 additions & 3 deletions src/transformers/generators/generateGitHubActions.ts
Original file line number Diff line number Diff line change
@@ -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)}%`;
Expand All @@ -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);

Expand All @@ -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(
Expand Down
15 changes: 10 additions & 5 deletions src/transformers/reportGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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, (obj: AnyCoverageObject) => string> = {
const STRING_FORMAT_RENDERERS: Record<string, (obj: AnyCoverageObject, opts?: ReportRenderOptions) => 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),
Expand All @@ -44,8 +48,9 @@ export async function generateAndWriteReport(
coverageObj: AnyCoverageObject,
format: string,
formatAmount: number,
options?: ReportRenderOptions,
): Promise<string> {
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'
Expand All @@ -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 {
Expand Down
31 changes: 28 additions & 3 deletions test/commands/acc-transformer/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -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.');
});
Expand All @@ -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.');
});
Expand All @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions test/units/githubActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading