├── .eslintignore ├── .husky ├── pre-push ├── commit-msg └── pre-commit ├── .gitattributes ├── src ├── index.ts ├── utils │ ├── normalizePathToUnix.ts │ ├── getTotalLines.ts │ ├── getConcurrencyThreshold.ts │ ├── constants.ts │ ├── getPackageDirectories.ts │ ├── getRepoRoot.ts │ ├── setCoveredLines.ts │ ├── findFilePath.ts │ ├── setCoverageDataType.ts │ ├── buildFilePathCache.ts │ └── types.ts ├── handlers │ ├── getHandler.ts │ ├── sonar.ts │ ├── lcov.ts │ ├── istanbulJson.ts │ ├── clover.ts │ ├── BaseHandler.ts │ ├── simplecov.ts │ ├── cobertura.ts │ ├── HandlerRegistry.ts │ ├── jacoco.ts │ ├── jsonSummary.ts │ └── opencover.ts ├── commands │ └── acc-transformer │ │ └── transform.ts ├── hooks │ └── finally.ts └── transformers │ ├── reportGenerator.ts │ └── coverageTransformer.ts ├── .prettierignore ├── .prettierrc.json ├── bin ├── run.cmd ├── dev.cmd ├── run.js └── dev.js ├── commitlint.config.cjs ├── .lintstagedrc.cjs ├── inputs ├── invalid.json ├── test_coverage.json └── deploy_coverage.json ├── .eslintrc.cjs ├── test ├── tsconfig.json ├── utils │ ├── testSetup.ts │ ├── testCleanup.ts │ ├── normalizeCoverageReport.ts │ ├── testConstants.ts │ └── baselineCompare.ts ├── units │ ├── handler.test.ts │ ├── setCoveredLines.test.ts │ ├── repoRoot.test.ts │ ├── typeGuards.test.ts │ ├── jsonSummary.test.ts │ ├── handlerRegistry.test.ts │ ├── findFilePath.test.ts │ ├── buildFilePathCache.test.ts │ └── baseHandler.test.ts ├── .eslintrc.cjs └── commands │ └── acc-transformer │ ├── transform.nut.ts │ └── transform.test.ts ├── .sfdevrc.json ├── .editorconfig ├── defaults ├── salesforce-cli │ └── .apexcodecovtransformer.config.json └── sfdx-hardis │ └── .apexcodecovtransformer.config.json ├── tsconfig.json ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .github ├── workflows │ ├── test.yml │ ├── manual-deprecate-versions.yml │ ├── coverage.yml │ └── release.yml └── dependabot.yml ├── .gitignore ├── jest.config.js ├── baselines ├── json-summary_baseline.json ├── lcov_baseline.info ├── simplecov_baseline.json ├── sonar_baseline.xml ├── clover_baseline.xml ├── jacoco_baseline.xml ├── cobertura_baseline.xml └── opencover_baseline.xml ├── LICENSE.md ├── messages └── transformer.transform.md ├── CONTRIBUTING.md ├── package.json └── samples ├── classes ├── AccountHandler.cls └── AccountProfile.cls └── triggers └── AccountTrigger.trigger /.eslintignore: -------------------------------------------------------------------------------- 1 | *.cjs/ 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | yarn build -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | yarn commitlint --edit -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | baselines/json_baseline.json 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | "@salesforce/prettier-config" 2 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint && yarn pretty-quick --staged && yarn build -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.lintstagedrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,json,md}?(x)': () => 'npm run reformat', 3 | }; 4 | -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %* 4 | -------------------------------------------------------------------------------- /inputs/invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "no-map/AccountTrigger.trigger": { 3 | "path": "no-map/AccountTrigger.trigger" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/normalizePathToUnix.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export function normalizePathToUnix(path: string): string { 4 | return path.replace(/\\/g, '/'); 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint-config-salesforce-typescript', 'plugin:sf-plugin/recommended'], 3 | root: true, 4 | rules: { 5 | header: 'off', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-test-strict-esm", 3 | "include": ["./**/*.ts"], 4 | "compilerOptions": { 5 | "skipLibCheck": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.sfdevrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": { 3 | "testsPath": "test/**/*.test.ts" 4 | }, 5 | "wireit": { 6 | "test": { 7 | "dependencies": ["test:compile", "test:only", "lint"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /bin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // eslint-disable-next-line node/shebang 4 | async function main() { 5 | const { execute } = await import('@oclif/core'); 6 | await execute({ dir: import.meta.url }); 7 | } 8 | 9 | await main(); 10 | -------------------------------------------------------------------------------- /defaults/salesforce-cli/.apexcodecovtransformer.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "deployCoverageJsonPath": "coverage/coverage/coverage.json", 3 | "testCoverageJsonPath": "coverage/test-result-codecoverage.json", 4 | "outputReportPath": "coverage.xml", 5 | "format": "sonar" 6 | } 7 | -------------------------------------------------------------------------------- /defaults/sfdx-hardis/.apexcodecovtransformer.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "deployCoverageJsonPath": "hardis-report/apex-coverage-results.json", 3 | "testCoverageJsonPath": "hardis-report/apex-coverage-results.json", 4 | "outputReportPath": "coverage.xml", 5 | "format": "sonar" 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/getTotalLines.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { readFile } from 'node:fs/promises'; 4 | 5 | export async function getTotalLines(filePath: string): Promise { 6 | const fileContent = await readFile(filePath, 'utf8'); 7 | return fileContent.split(/\r\n|\r|\n/).length; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/getConcurrencyThreshold.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { availableParallelism } from 'node:os'; 4 | 5 | export function getConcurrencyThreshold(): number { 6 | const AVAILABLE_PARALLELISM: number = availableParallelism(); 7 | 8 | return Math.min(AVAILABLE_PARALLELISM, 6); 9 | } 10 | -------------------------------------------------------------------------------- /bin/dev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning 2 | // eslint-disable-next-line node/shebang 3 | async function main() { 4 | const { execute } = await import('@oclif/core'); 5 | await execute({ development: true, dir: import.meta.url }); 6 | } 7 | 8 | await main(); 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-strict-esm", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "sourceMap": true, 7 | "types": ["jest", "chai"] 8 | }, 9 | "include": ["./src/**/*.ts"], 10 | "exclude": ["node_modules"] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true 8 | }, 9 | "search.exclude": { 10 | "**/lib": true, 11 | "**/bin": true 12 | }, 13 | "editor.tabSize": 2, 14 | "editor.formatOnSave": true, 15 | "rewrap.wrappingColumn": 80 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import { HandlerRegistry } from '../handlers/HandlerRegistry.js'; 2 | 3 | // Import all handlers to ensure they're registered 4 | import '../handlers/getHandler.js'; 5 | 6 | /** 7 | * Get available coverage format options. 8 | * This dynamically retrieves all registered formats from the HandlerRegistry. 9 | */ 10 | export const formatOptions: string[] = HandlerRegistry.getAvailableFormats(); 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "problemMatcher": "$tsc", 4 | "tasks": [ 5 | { 6 | "label": "Compile tests", 7 | "group": { 8 | "kind": "build", 9 | "isDefault": true 10 | }, 11 | "command": "yarn", 12 | "type": "shell", 13 | "presentation": { 14 | "focus": false, 15 | "panel": "dedicated" 16 | }, 17 | "args": ["run", "pretest"], 18 | "isBackground": false 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - main 7 | paths-ignore: 8 | - '**.md' 9 | 10 | jobs: 11 | unit-tests: 12 | uses: salesforcecli/github-workflows/.github/workflows/unitTest.yml@main 13 | nuts: 14 | needs: unit-tests 15 | uses: salesforcecli/github-workflows/.github/workflows/nut.yml@main 16 | secrets: inherit 17 | strategy: 18 | matrix: 19 | os: [windows-latest] 20 | fail-fast: false 21 | with: 22 | os: ${{ matrix.os }} 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | versioning-strategy: 'increase' 8 | labels: 9 | - 'dependencies' 10 | open-pull-requests-limit: 5 11 | pull-request-branch-name: 12 | separator: '-' 13 | commit-message: 14 | # cause a release for non-dev-deps 15 | prefix: fix(deps) 16 | # no release for dev-deps 17 | prefix-development: chore(dev-deps) 18 | ignore: 19 | - dependency-name: '@salesforce/dev-scripts' 20 | - dependency-name: '*' 21 | update-types: ['version-update:semver-major'] 22 | -------------------------------------------------------------------------------- /test/utils/testSetup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | import { mkdir, copyFile, writeFile } from 'node:fs/promises'; 3 | 4 | import { sfdxConfigFile, baselineClassPath, baselineTriggerPath, configJsonString } from './testConstants.js'; 5 | 6 | export async function preTestSetup(): Promise { 7 | await mkdir('force-app/main/default/classes', { recursive: true }); 8 | await mkdir('packaged/triggers', { recursive: true }); 9 | await copyFile(baselineClassPath, 'force-app/main/default/classes/AccountProfile.cls'); 10 | await copyFile(baselineTriggerPath, 'packaged/triggers/AccountTrigger.trigger'); 11 | await writeFile(sfdxConfigFile, configJsonString); 12 | } 13 | -------------------------------------------------------------------------------- /test/units/handler.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | 'use strict'; 3 | import { describe, it, expect } from '@jest/globals'; 4 | 5 | import { getCoverageHandler } from '../../src/handlers/getHandler.js'; 6 | 7 | describe('coverage handler unit test', () => { 8 | it('confirms a failure with an invalid format.', async () => { 9 | try { 10 | getCoverageHandler('invalid'); 11 | throw new Error('Command did not fail as expected'); 12 | } catch (error) { 13 | if (error instanceof Error) { 14 | expect(error.message).toContain('Unsupported format: invalid'); 15 | } else { 16 | throw new Error('An unknown error type was thrown.'); 17 | } 18 | } 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /test/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '../.eslintrc.cjs', 3 | // Allow describe and it 4 | env: { mocha: true }, 5 | rules: { 6 | // Allow assert style expressions. i.e. expect(true).to.be.true 7 | 'no-unused-expressions': 'off', 8 | 9 | // It is common for tests to stub out method. 10 | 11 | // Return types are defined by the source code. Allows for quick overwrites. 12 | '@typescript-eslint/explicit-function-return-type': 'off', 13 | // Mocked out the methods that shouldn't do anything in the tests. 14 | '@typescript-eslint/no-empty-function': 'off', 15 | // Easily return a promise in a mocked method. 16 | '@typescript-eslint/require-await': 'off', 17 | header: 'off', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -- CLEAN 2 | tmp/ 3 | # use yarn by default, so ignore npm 4 | package-lock.json 5 | 6 | .sfdx/ 7 | 8 | # never checkin npm config 9 | .npmrc 10 | 11 | # debug logs 12 | npm-error.log 13 | yarn-error.log 14 | 15 | 16 | # compile source 17 | lib 18 | 19 | # test artifacts 20 | *xunit.xml 21 | *checkstyle.xml 22 | *unitcoverage 23 | .nyc_output 24 | coverage 25 | test_session* 26 | 27 | # generated docs 28 | docs 29 | 30 | # ignore sfdx-trust files 31 | *.tgz 32 | *.sig 33 | package.json.bak. 34 | 35 | # -- CLEAN ALL 36 | *.tsbuildinfo 37 | .eslintcache 38 | .wireit 39 | node_modules 40 | 41 | # -- 42 | # put files here you don't want cleaned with sf-clean 43 | 44 | # os specific files 45 | .DS_Store 46 | .idea 47 | 48 | oclif.manifest.json 49 | 50 | oclif.lock 51 | *.log 52 | stderr*.txt 53 | stdout*.txt 54 | -------------------------------------------------------------------------------- /test/units/setCoveredLines.test.ts: -------------------------------------------------------------------------------- 1 | import { setCoveredLines } from '../../src/utils/setCoveredLines.js'; 2 | 3 | // Mock getTotalLines to simulate a short file 4 | jest.mock('../../src/utils/getTotalLines.js', () => ({ 5 | getTotalLines: jest.fn(() => Promise.resolve(3)), // Pretend file has only 3 lines 6 | })); 7 | 8 | describe('setCoveredLines unit test', () => { 9 | it('renumbers out-of-range covered lines into available unused lines', async () => { 10 | const filePath = 'some/file.cls'; 11 | const repoRoot = '/repo'; 12 | 13 | // Line 5 is out of range since getTotalLines returns 3 14 | const inputLines = { 15 | '5': 1, 16 | }; 17 | 18 | const result = await setCoveredLines(filePath, repoRoot, inputLines); 19 | 20 | expect(result).toEqual({ 21 | '1': 1, // Line 5 is remapped to first available line (1) 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/units/repoRoot.test.ts: -------------------------------------------------------------------------------- 1 | import { access } from 'node:fs/promises'; 2 | import { getRepoRoot } from '../../src/utils/getRepoRoot.js'; 3 | 4 | jest.mock('node:fs/promises'); 5 | 6 | const accessMock = access as jest.Mock; 7 | 8 | describe('getRepoRoot recursion unit test', () => { 9 | it('recursively searches parent directories and eventually throws', async () => { 10 | // Start in a deeply nested directory 11 | const fakePath = '/a/b/c'; 12 | process.cwd = jest.fn(() => fakePath) as typeof process.cwd; 13 | 14 | // Set up access to fail for /a/b/c, /a/b, /a, / (4 levels) 15 | accessMock.mockImplementation((filePath: string) => { 16 | throw new Error(`File not found at ${filePath}`); 17 | }); 18 | 19 | await expect(getRepoRoot()).rejects.toThrow('sfdx-project.json not found in any parent directory.'); 20 | 21 | // Assert recursion happened 22 | expect(accessMock).toHaveBeenCalledTimes(4); // /a/b/c, /a/b, /a, / 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | export default { 5 | automock: false, 6 | clearMocks: true, 7 | coverageDirectory: 'coverage', 8 | coverageReporters: ['text', 'lcov'], 9 | coverageThreshold: { 10 | global: { 11 | branches: 90, 12 | functions: 90, 13 | lines: 95, 14 | statements: 95, 15 | }, 16 | }, 17 | extensionsToTreatAsEsm: ['.ts'], 18 | moduleNameMapper: { 19 | '(.+)\\.js': '$1', 20 | '^lodash-es$': 'lodash', 21 | }, 22 | testEnvironment: 'node', 23 | testMatch: ['**/test/**/*.test.ts'], 24 | transform: { 25 | '\\.[jt]sx?$': [ 26 | 'ts-jest', 27 | { 28 | tsconfig: './tsconfig.json', 29 | }, 30 | ], 31 | }, 32 | // An array of regexp pattern strings used to skip coverage collection 33 | coveragePathIgnorePatterns: ['/node_modules/', '/test/utils/', '/coverage/'], 34 | }; 35 | -------------------------------------------------------------------------------- /src/utils/getPackageDirectories.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { readFile } from 'node:fs/promises'; 4 | import { resolve } from 'node:path'; 5 | 6 | import { SfdxProject } from './types.js'; 7 | import { getRepoRoot } from './getRepoRoot.js'; 8 | 9 | export async function getPackageDirectories( 10 | ignoreDirectories: string[] 11 | ): Promise<{ repoRoot: string; packageDirectories: string[] }> { 12 | const { repoRoot, dxConfigFilePath } = (await getRepoRoot()) as { 13 | repoRoot: string; 14 | dxConfigFilePath: string; 15 | }; 16 | 17 | const sfdxProjectRaw: string = await readFile(dxConfigFilePath, 'utf-8'); 18 | const sfdxProject: SfdxProject = JSON.parse(sfdxProjectRaw) as SfdxProject; 19 | 20 | const packageDirectories = sfdxProject.packageDirectories 21 | .filter((directory) => !ignoreDirectories.includes(directory.path)) // Ignore exact folder names 22 | .map((directory) => resolve(repoRoot, directory.path)); 23 | 24 | return { repoRoot, packageDirectories }; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/getRepoRoot.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { access } from 'node:fs/promises'; 3 | import { join, dirname } from 'node:path'; 4 | 5 | async function findRepoRoot(dir: string): Promise<{ repoRoot: string; dxConfigFilePath: string }> { 6 | const filePath = join(dir, 'sfdx-project.json'); 7 | try { 8 | // Check if sfdx-project.json exists in the current directory 9 | await access(filePath); 10 | return { repoRoot: dir, dxConfigFilePath: filePath }; 11 | } catch { 12 | const parentDir = dirname(dir); 13 | if (dir === parentDir) { 14 | // Reached the root without finding the file, throw an error 15 | throw new Error('sfdx-project.json not found in any parent directory.'); 16 | } 17 | // Recursively search in the parent directory 18 | return findRepoRoot(parentDir); 19 | } 20 | } 21 | 22 | export async function getRepoRoot(): Promise<{ repoRoot: string | undefined; dxConfigFilePath: string | undefined }> { 23 | const currentDir = process.cwd(); 24 | return findRepoRoot(currentDir); 25 | } 26 | -------------------------------------------------------------------------------- /baselines/json-summary_baseline.json: -------------------------------------------------------------------------------- 1 | { 2 | "total": { 3 | "lines": { 4 | "total": 62, 5 | "covered": 54, 6 | "skipped": 0, 7 | "pct": 87.1 8 | }, 9 | "statements": { 10 | "total": 62, 11 | "covered": 54, 12 | "skipped": 0, 13 | "pct": 87.1 14 | } 15 | }, 16 | "files": { 17 | "force-app/main/default/classes/AccountProfile.cls": { 18 | "lines": { 19 | "total": 31, 20 | "covered": 27, 21 | "skipped": 0, 22 | "pct": 87.1 23 | }, 24 | "statements": { 25 | "total": 31, 26 | "covered": 27, 27 | "skipped": 0, 28 | "pct": 87.1 29 | } 30 | }, 31 | "packaged/triggers/AccountTrigger.trigger": { 32 | "lines": { 33 | "total": 31, 34 | "covered": 27, 35 | "skipped": 0, 36 | "pct": 87.1 37 | }, 38 | "statements": { 39 | "total": 31, 40 | "covered": 27, 41 | "skipped": 0, 42 | "pct": 87.1 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/manual-deprecate-versions.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deprecate versions 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version-expression: 8 | description: version number (semver format) or range to deprecate 9 | required: true 10 | type: string 11 | rationale: 12 | description: explain why this version is deprecated. No message content will un-deprecate the version 13 | type: string 14 | 15 | 16 | jobs: 17 | deprecate: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout sources 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | registry-url: 'https://registry.npmjs.org' 28 | 29 | - name: Change version 30 | run: npm deprecate apex-code-coverage-transformer@$"${{ github.event.inputs.version-expression }}" "${{ github.event.inputs.rationale }}" 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /baselines/lcov_baseline.info: -------------------------------------------------------------------------------- 1 | TN: 2 | SF:force-app/main/default/classes/AccountProfile.cls 3 | FNF:0 4 | FNH:0 5 | DA:52,0 6 | DA:53,0 7 | DA:54,1 8 | DA:55,1 9 | DA:56,1 10 | DA:57,1 11 | DA:58,1 12 | DA:59,0 13 | DA:60,0 14 | DA:61,1 15 | DA:62,1 16 | DA:63,1 17 | DA:64,1 18 | DA:65,1 19 | DA:66,1 20 | DA:67,1 21 | DA:68,1 22 | DA:69,1 23 | DA:70,1 24 | DA:71,1 25 | DA:72,1 26 | DA:73,1 27 | DA:74,1 28 | DA:75,1 29 | DA:76,1 30 | DA:77,1 31 | DA:78,1 32 | DA:79,1 33 | DA:80,1 34 | DA:81,1 35 | DA:82,1 36 | LF:31 37 | LH:27 38 | BRF:0 39 | BRH:0 40 | end_of_record 41 | TN: 42 | SF:packaged/triggers/AccountTrigger.trigger 43 | FNF:0 44 | FNH:0 45 | DA:52,0 46 | DA:53,0 47 | DA:54,1 48 | DA:55,1 49 | DA:56,1 50 | DA:57,1 51 | DA:58,1 52 | DA:59,0 53 | DA:60,0 54 | DA:61,1 55 | DA:62,1 56 | DA:63,1 57 | DA:64,1 58 | DA:65,1 59 | DA:66,1 60 | DA:67,1 61 | DA:68,1 62 | DA:69,1 63 | DA:70,1 64 | DA:71,1 65 | DA:72,1 66 | DA:73,1 67 | DA:74,1 68 | DA:75,1 69 | DA:76,1 70 | DA:77,1 71 | DA:78,1 72 | DA:79,1 73 | DA:80,1 74 | DA:81,1 75 | DA:82,1 76 | LF:31 77 | LH:27 78 | BRF:0 79 | BRH:0 80 | end_of_record -------------------------------------------------------------------------------- /test/utils/testCleanup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | import { rm } from 'node:fs/promises'; 3 | import { resolve } from 'node:path'; 4 | 5 | import { formatOptions } from '../../src/utils/constants.js'; 6 | import { getExtensionForFormat } from '../../src/transformers/reportGenerator.js'; 7 | import { sfdxConfigFile, inputJsons, defaultPath } from './testConstants.js'; 8 | 9 | export async function postTestCleanup(): Promise { 10 | await rm(sfdxConfigFile); 11 | await rm('force-app/main/default/classes/AccountProfile.cls'); 12 | await rm('packaged/triggers/AccountTrigger.trigger'); 13 | await rm('force-app', { recursive: true }); 14 | await rm('packaged', { recursive: true }); 15 | 16 | const pathsToRemove = formatOptions 17 | .flatMap((format) => 18 | inputJsons.map(({ label }) => { 19 | const reportExtension = getExtensionForFormat(format); 20 | return resolve(`${label}-${format}${reportExtension}`); 21 | }) 22 | ) 23 | .concat(defaultPath); 24 | 25 | for (const path of pathsToRemove) { 26 | await rm(path).catch(() => {}); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | on: 3 | workflow_call: 4 | push: 5 | branches-ignore: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | id-token: write # Required for OIDC 11 | 12 | jobs: 13 | coverage: 14 | name: Coverage 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest] 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4.1.1 23 | 24 | - name: Setup Node 25 | uses: actions/setup-node@v4.0.1 26 | with: 27 | node-version: 20 28 | cache: yarn 29 | registry-url: 'https://registry.npmjs.org' 30 | 31 | - name: Install Dependencies 32 | run: yarn install 33 | 34 | - name: Build 35 | run: yarn build 36 | 37 | - name: Test 38 | run: yarn test:only 39 | 40 | - name: Upload coverage 41 | if: runner.os == 'Linux' 42 | uses: qltysh/qlty-action/coverage@v1 43 | with: 44 | oidc: true 45 | files: coverage/lcov.info 46 | continue-on-error: true 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matthew Carvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/setCoveredLines.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { join } from 'node:path'; 4 | import { getTotalLines } from './getTotalLines.js'; 5 | 6 | export async function setCoveredLines( 7 | filePath: string, 8 | repoRoot: string, 9 | lines: Record 10 | ): Promise> { 11 | const totalLines = await getTotalLines(join(repoRoot, filePath)); 12 | const updatedLines: Record = {}; 13 | const usedLines = new Set(); 14 | 15 | const sortedLines = Object.entries(lines).sort(([lineA], [lineB]) => parseInt(lineA, 10) - parseInt(lineB, 10)); 16 | 17 | for (const [line, status] of sortedLines) { 18 | const lineNumber = parseInt(line, 10); 19 | 20 | if (status === 1 && lineNumber > totalLines) { 21 | for (let randomLine = 1; randomLine <= totalLines; randomLine++) { 22 | if (!usedLines.has(randomLine)) { 23 | updatedLines[randomLine.toString()] = status; 24 | usedLines.add(randomLine); 25 | break; 26 | } 27 | } 28 | } else { 29 | updatedLines[line] = status; 30 | usedLines.add(lineNumber); 31 | } 32 | } 33 | 34 | return updatedLines; 35 | } 36 | -------------------------------------------------------------------------------- /src/handlers/getHandler.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { CoverageHandler } from '../utils/types.js'; 4 | import { HandlerRegistry } from './HandlerRegistry.js'; 5 | 6 | // Import all handlers to trigger self-registration 7 | import './sonar.js'; 8 | import './cobertura.js'; 9 | import './clover.js'; 10 | import './lcov.js'; 11 | import './jacoco.js'; 12 | import './istanbulJson.js'; 13 | import './jsonSummary.js'; 14 | import './simplecov.js'; 15 | import './opencover.js'; 16 | 17 | /** 18 | * Get a coverage handler for the specified format. 19 | * 20 | * This function uses the HandlerRegistry to retrieve the appropriate handler. 21 | * All handlers are automatically registered when this module is imported. 22 | * 23 | * @param format - The coverage format identifier 24 | * @returns A new instance of the coverage handler 25 | * @throws Error if the format is not supported 26 | * 27 | * @example 28 | * const handler = getCoverageHandler('sonar'); 29 | * handler.processFile('path/to/file.cls', 'ClassName', { '1': 1, '2': 0 }); 30 | * const report = handler.finalize(); 31 | */ 32 | export function getCoverageHandler(format: string): CoverageHandler { 33 | return HandlerRegistry.get(format); 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/findFilePath.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Find file path using a pre-built cache (fast) or fallback to the old method. 5 | * 6 | * @param fileName - Name of the file to find 7 | * @param filePathCache - Optional pre-built cache of filename -> path mappings 8 | * @returns Normalized Unix-style relative path or undefined if not found 9 | */ 10 | export function findFilePath(fileName: string, filePathCache?: Map): string | undefined { 11 | if (filePathCache) { 12 | return findFilePathFromCache(fileName, filePathCache); 13 | } 14 | 15 | // Fallback to old behavior should never happen in practice, 16 | // but keeping for backwards compatibility 17 | return undefined; 18 | } 19 | 20 | function findFilePathFromCache(fileName: string, cache: Map): string | undefined { 21 | // Try exact match first 22 | const exactMatch = cache.get(fileName); 23 | if (exactMatch) { 24 | return exactMatch; 25 | } 26 | 27 | // Try with .cls extension 28 | const clsMatch = cache.get(`${fileName}.cls`); 29 | if (clsMatch) { 30 | return clsMatch; 31 | } 32 | 33 | // Try with .trigger extension 34 | const triggerMatch = cache.get(`${fileName}.trigger`); 35 | if (triggerMatch) { 36 | return triggerMatch; 37 | } 38 | 39 | return undefined; 40 | } 41 | -------------------------------------------------------------------------------- /messages/transformer.transform.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Transform Salesforce Apex code coverage JSONs created during deployments and test runs into other formats accepted by SonarQube, GitHub, GitLab, Azure, Bitbucket, etc. 4 | 5 | # description 6 | 7 | Transform Salesforce Apex code coverage JSONs created during deployments and test runs into other formats accepted by SonarQube, GitHub, GitLab, Azure, Bitbucket, etc. 8 | 9 | # examples 10 | 11 | - `sf acc-transformer transform -j "coverage.json" -r "coverage.xml" -f "sonar"` 12 | - `sf acc-transformer transform -j "coverage.json" -r "coverage.xml" -f "cobertura"` 13 | - `sf acc-transformer transform -j "coverage.json" -r "coverage.xml" -f "clover"` 14 | - `sf acc-transformer transform -j "coverage.json" -r "coverage.info" -f "lcovonly"` 15 | - `sf acc-transformer transform -j "coverage.json" -i "force-app"` 16 | 17 | # flags.coverage-json.summary 18 | 19 | Path to the code coverage JSON file created by the Salesforce CLI deploy or test command. 20 | 21 | # flags.output-report.summary 22 | 23 | Path to the code coverage file that will be created by this plugin. 24 | 25 | # flags.format.summary 26 | 27 | Output format for the coverage report. 28 | 29 | # flags.ignore-package-directory.summary 30 | 31 | Ignore a package directory when looking for matching files in the coverage report. 32 | -------------------------------------------------------------------------------- /test/units/typeGuards.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | import { checkCoverageDataType } from '../../src/utils/setCoverageDataType.js'; 3 | import { DeployCoverageData } from '../../src/utils/types.js'; 4 | 5 | describe('coverage type guard unit tests', () => { 6 | it('returns Unknown when a non-object is in the test coverage array', () => { 7 | const data = [123]; // Not an object 8 | 9 | const result = checkCoverageDataType(data as unknown as DeployCoverageData); 10 | expect(result).toStrictEqual('Unknown'); 11 | }); 12 | it('test where a statementMap has a non-object value.', async () => { 13 | const invalidDeployData = { 14 | 'someFile.js': { 15 | path: 'someFile.js', 16 | fnMap: {}, 17 | branchMap: {}, 18 | f: {}, 19 | b: {}, 20 | s: {}, 21 | statementMap: { 22 | someStatement: null, 23 | }, 24 | }, 25 | }; 26 | 27 | const result = checkCoverageDataType(invalidDeployData as unknown as DeployCoverageData); 28 | expect(result).toStrictEqual('Unknown'); 29 | }); 30 | it('returns Unknown when data is not an object', () => { 31 | const result = checkCoverageDataType(42 as unknown as DeployCoverageData); // 👈 non-object input 32 | expect(result).toStrictEqual('Unknown'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/utils/normalizeCoverageReport.ts: -------------------------------------------------------------------------------- 1 | export function normalizeCoverageReport(content: string, isJson = false): string { 2 | // For JSON formats, parse and re-stringify to normalize formatting 3 | if (isJson) { 4 | try { 5 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 6 | const parsed = JSON.parse(content); 7 | // Normalize SimpleCov timestamp 8 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 9 | if (parsed.timestamp !== undefined) { 10 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 11 | parsed.timestamp = 0; 12 | } 13 | return JSON.stringify(parsed, null, 2); 14 | } catch { 15 | // If parsing fails, fall through to string normalization 16 | } 17 | } 18 | 19 | return ( 20 | content 21 | // Normalize Cobertura timestamps: timestamp="123456789" 22 | .replace(/timestamp="[\d]+"/g, 'timestamp="NORMALIZED"') 23 | // Normalize Clover timestamps: generated="1234567890123" 24 | .replace(/generated="[\d]+"/g, 'generated="NORMALIZED"') 25 | // Normalize SimpleCov timestamps: "timestamp": 1234567890 26 | .replace(/"timestamp":\s*\d+/g, '"timestamp": 0') 27 | // Strip trailing whitespace from each line 28 | .split('\n') 29 | .map((line) => line.trimEnd()) 30 | .join('\n') 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | id-token: write # Required for OIDC 10 | 11 | jobs: 12 | test: 13 | uses: ./.github/workflows/coverage.yml 14 | secrets: inherit 15 | release: 16 | name: Release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Release Please 20 | id: release 21 | uses: googleapis/release-please-action@v4 22 | with: 23 | release-type: node 24 | 25 | - name: Checkout 26 | uses: actions/checkout@v4.1.1 27 | if: ${{ steps.release.outputs.release_created == 'true' }} 28 | 29 | - name: Setup Node 30 | uses: actions/setup-node@v4.0.1 31 | with: 32 | node-version: 20 33 | cache: yarn 34 | registry-url: 'https://registry.npmjs.org' 35 | if: ${{ steps.release.outputs.release_created == 'true' }} 36 | 37 | - name: Install Dependencies 38 | run: yarn install 39 | if: ${{ steps.release.outputs.release_created == 'true' }} 40 | 41 | - name: Build 42 | run: yarn build 43 | if: ${{ steps.release.outputs.release_created == 'true' }} 44 | 45 | - name: Publish to NPM 46 | run: npm publish --access public --tag latest 47 | env: 48 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | if: ${{ steps.release.outputs.release_created == 'true' }} 50 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach", 11 | "port": 9229, 12 | "skipFiles": ["/**"] 13 | }, 14 | { 15 | "name": "Run All Tests", 16 | "type": "node", 17 | "request": "launch", 18 | "program": "${workspaceFolder}/node_modules/mocha/bin/mocha", 19 | "args": ["--inspect", "--colors", "test/**/*.test.ts"], 20 | "env": { 21 | "NODE_ENV": "development", 22 | "SFDX_ENV": "development" 23 | }, 24 | "sourceMaps": true, 25 | "smartStep": true, 26 | "internalConsoleOptions": "openOnSessionStart", 27 | "preLaunchTask": "Compile tests" 28 | }, 29 | { 30 | "type": "node", 31 | "request": "launch", 32 | "name": "Run Current Test", 33 | "program": "${workspaceFolder}/node_modules/mocha/bin/mocha", 34 | "args": ["--inspect", "--colors", "${file}"], 35 | "env": { 36 | "NODE_ENV": "development", 37 | "SFDX_ENV": "development" 38 | }, 39 | "sourceMaps": true, 40 | "smartStep": true, 41 | "internalConsoleOptions": "openOnSessionStart", 42 | "preLaunchTask": "Compile tests" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /inputs/test_coverage.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "01p9X00000DKqDQQA1", 4 | "name": "AccountProfile", 5 | "totalLines": 31, 6 | "lines": { 7 | "52": 0, 8 | "53": 0, 9 | "54": 1, 10 | "55": 1, 11 | "56": 1, 12 | "57": 1, 13 | "58": 1, 14 | "59": 0, 15 | "60": 0, 16 | "61": 1, 17 | "62": 1, 18 | "63": 1, 19 | "64": 1, 20 | "65": 1, 21 | "66": 1, 22 | "67": 1, 23 | "68": 1, 24 | "69": 1, 25 | "70": 1, 26 | "71": 1, 27 | "72": 1, 28 | "73": 1, 29 | "74": 1, 30 | "75": 1, 31 | "76": 1, 32 | "77": 1, 33 | "78": 1, 34 | "79": 1, 35 | "80": 1, 36 | "81": 1, 37 | "82": 1 38 | }, 39 | "totalCovered": 27, 40 | "coveredPercent": 87 41 | }, 42 | { 43 | "id": "01p9X00000DKqCiQAL", 44 | "name": "AccountTrigger", 45 | "totalLines": 31, 46 | "lines": { 47 | "52": 0, 48 | "53": 0, 49 | "54": 1, 50 | "55": 1, 51 | "56": 1, 52 | "57": 1, 53 | "58": 1, 54 | "59": 0, 55 | "60": 0, 56 | "61": 1, 57 | "62": 1, 58 | "63": 1, 59 | "64": 1, 60 | "65": 1, 61 | "66": 1, 62 | "67": 1, 63 | "68": 1, 64 | "69": 1, 65 | "70": 1, 66 | "71": 1, 67 | "72": 1, 68 | "73": 1, 69 | "74": 1, 70 | "75": 1, 71 | "76": 1, 72 | "77": 1, 73 | "78": 1, 74 | "79": 1, 75 | "80": 1, 76 | "81": 1, 77 | "82": 1 78 | }, 79 | "totalCovered": 27, 80 | "coveredPercent": 87 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /test/utils/testConstants.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | 3 | export const baselineClassPath = resolve('samples/classes/AccountProfile.cls'); 4 | export const baselineTriggerPath = resolve('samples/triggers/AccountTrigger.trigger'); 5 | export const deployCoverage = resolve('inputs/deploy_coverage.json'); 6 | export const testCoverage = resolve('inputs/test_coverage.json'); 7 | export const invalidJson = resolve('inputs/invalid.json'); 8 | export const sonarBaselinePath = resolve('baselines/sonar_baseline.xml'); 9 | export const jacocoBaselinePath = resolve('baselines/jacoco_baseline.xml'); 10 | export const lcovBaselinePath = resolve('baselines/lcov_baseline.info'); 11 | export const coberturaBaselinePath = resolve('baselines/cobertura_baseline.xml'); 12 | export const cloverBaselinePath = resolve('baselines/clover_baseline.xml'); 13 | export const jsonBaselinePath = resolve('baselines/json_baseline.json'); 14 | export const jsonSummaryBaselinePath = resolve('baselines/json-summary_baseline.json'); 15 | export const simplecovBaselinePath = resolve('baselines/simplecov_baseline.json'); 16 | export const opencoverBaselinePath = resolve('baselines/opencover_baseline.xml'); 17 | export const defaultPath = resolve('coverage.xml'); 18 | export const sfdxConfigFile = resolve('sfdx-project.json'); 19 | 20 | const configFile = { 21 | packageDirectories: [{ path: 'force-app', default: true }, { path: 'packaged' }, { path: 'samples' }], 22 | namespace: '', 23 | sfdcLoginUrl: 'https://login.salesforce.com', 24 | sourceApiVersion: '58.0', 25 | }; 26 | export const configJsonString = JSON.stringify(configFile, null, 2); 27 | export const inputJsons = [ 28 | { label: 'deploy', path: deployCoverage }, 29 | { label: 'test', path: testCoverage }, 30 | ] as const; 31 | -------------------------------------------------------------------------------- /src/handlers/sonar.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { SonarCoverageObject, SonarClass } from '../utils/types.js'; 4 | import { BaseHandler } from './BaseHandler.js'; 5 | import { HandlerRegistry } from './HandlerRegistry.js'; 6 | 7 | /** 8 | * Handler for generating SonarQube Generic Coverage reports. 9 | * 10 | * This is the default format and is compatible with SonarQube and SonarCloud. 11 | * 12 | * @see https://docs.sonarqube.org/latest/analysis/generic-test/ 13 | */ 14 | export class SonarCoverageHandler extends BaseHandler { 15 | private readonly coverageObj: SonarCoverageObject; 16 | 17 | public constructor() { 18 | super(); 19 | this.coverageObj = { coverage: { '@version': '1', file: [] } }; 20 | } 21 | 22 | public processFile(filePath: string, _fileName: string, lines: Record): void { 23 | const fileObj: SonarClass = { 24 | '@path': filePath, 25 | lineToCover: [], 26 | }; 27 | for (const [lineNumberString, value] of Object.entries(lines)) { 28 | const covered = value === 1; 29 | fileObj.lineToCover.push({ 30 | '@lineNumber': Number(lineNumberString), 31 | '@covered': covered, 32 | }); 33 | } 34 | 35 | this.coverageObj.coverage.file.push(fileObj); 36 | } 37 | 38 | public finalize(): SonarCoverageObject { 39 | if (this.coverageObj.coverage?.file) { 40 | this.coverageObj.coverage.file = this.sortByPath(this.coverageObj.coverage.file); 41 | } 42 | return this.coverageObj; 43 | } 44 | } 45 | 46 | // Self-register this handler 47 | HandlerRegistry.register({ 48 | name: 'sonar', 49 | description: 'SonarQube Generic Coverage format', 50 | fileExtension: '.xml', 51 | handler: () => new SonarCoverageHandler(), 52 | compatibleWith: ['SonarQube', 'SonarCloud'], 53 | }); 54 | -------------------------------------------------------------------------------- /src/handlers/lcov.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { LcovCoverageObject, LcovFile } from '../utils/types.js'; 4 | import { BaseHandler } from './BaseHandler.js'; 5 | import { HandlerRegistry } from './HandlerRegistry.js'; 6 | 7 | /** 8 | * Handler for generating LCOV coverage reports. 9 | * 10 | * LCOV is a widely-used format for code coverage reporting, 11 | * particularly common in JavaScript/Node.js projects. 12 | * 13 | * Compatible with: 14 | * - Codecov 15 | * - Coveralls 16 | * - GitHub Actions 17 | * - LCOV analysis tools 18 | * 19 | * @see http://ltp.sourceforge.net/coverage/lcov.php 20 | */ 21 | export class LcovCoverageHandler extends BaseHandler { 22 | private readonly coverageObj: LcovCoverageObject; 23 | 24 | public constructor() { 25 | super(); 26 | this.coverageObj = { files: [] }; 27 | } 28 | 29 | public processFile(filePath: string, fileName: string, lines: Record): void { 30 | const { totalLines, coveredLines } = this.calculateCoverage(lines); 31 | 32 | const lcovFile: LcovFile = { 33 | sourceFile: filePath, 34 | lines: [], 35 | totalLines, 36 | coveredLines, 37 | }; 38 | 39 | for (const [lineNumber, isCovered] of Object.entries(lines)) { 40 | lcovFile.lines.push({ 41 | lineNumber: Number(lineNumber), 42 | hitCount: isCovered === 1 ? 1 : 0, 43 | }); 44 | } 45 | 46 | this.coverageObj.files.push(lcovFile); 47 | } 48 | 49 | public finalize(): LcovCoverageObject { 50 | if ('files' in this.coverageObj && Array.isArray(this.coverageObj.files)) { 51 | this.coverageObj.files.sort((a, b) => a.sourceFile.localeCompare(b.sourceFile)); 52 | } 53 | return this.coverageObj; 54 | } 55 | } 56 | 57 | // Self-register this handler 58 | HandlerRegistry.register({ 59 | name: 'lcovonly', 60 | description: 'LCOV format for JavaScript and C/C++ coverage', 61 | fileExtension: '.info', 62 | handler: () => new LcovCoverageHandler(), 63 | compatibleWith: ['Codecov', 'Coveralls', 'GitHub Actions'], 64 | }); 65 | -------------------------------------------------------------------------------- /test/units/jsonSummary.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | 'use strict'; 3 | import { describe, it, expect } from '@jest/globals'; 4 | 5 | import { JsonSummaryCoverageHandler } from '../../src/handlers/jsonSummary.js'; 6 | 7 | describe('JsonSummaryCoverageHandler unit tests', () => { 8 | it('should process file with coverage data', () => { 9 | const handler = new JsonSummaryCoverageHandler(); 10 | const lines = { 11 | '1': 1, 12 | '2': 0, 13 | '3': 1, 14 | }; 15 | 16 | handler.processFile('path/to/file.cls', 'FileName', lines); 17 | const result = handler.finalize(); 18 | 19 | expect(result.total.lines.total).toBe(3); 20 | expect(result.total.lines.covered).toBe(2); 21 | expect(result.total.lines.pct).toBe(66.67); 22 | expect(result.files['path/to/file.cls']).toBeDefined(); 23 | expect(result.files['path/to/file.cls'].lines.total).toBe(3); 24 | expect(result.files['path/to/file.cls'].lines.covered).toBe(2); 25 | }); 26 | 27 | it('should handle file with zero total lines', () => { 28 | const handler = new JsonSummaryCoverageHandler(); 29 | const lines = {}; // Empty lines object 30 | 31 | handler.processFile('path/to/empty.cls', 'EmptyFile', lines); 32 | const result = handler.finalize(); 33 | 34 | // When totalLines is 0, pct should be 0 (covers the else branch on line 48) 35 | expect(result.files['path/to/empty.cls'].lines.total).toBe(0); 36 | expect(result.files['path/to/empty.cls'].lines.covered).toBe(0); 37 | expect(result.files['path/to/empty.cls'].lines.pct).toBe(0); 38 | }); 39 | 40 | it('should calculate correct percentages', () => { 41 | const handler = new JsonSummaryCoverageHandler(); 42 | 43 | // File 1: 75% coverage 44 | handler.processFile('file1.cls', 'File1', { 45 | '1': 1, 46 | '2': 1, 47 | '3': 1, 48 | '4': 0, 49 | }); 50 | 51 | // File 2: 100% coverage 52 | handler.processFile('file2.cls', 'File2', { 53 | '1': 1, 54 | '2': 1, 55 | }); 56 | 57 | const result = handler.finalize(); 58 | 59 | // Total: 6 lines, 5 covered = 83.33% 60 | expect(result.total.lines.total).toBe(6); 61 | expect(result.total.lines.covered).toBe(5); 62 | expect(result.total.lines.pct).toBe(83.33); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/utils/baselineCompare.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | import { readFile } from 'node:fs/promises'; 3 | import { resolve } from 'node:path'; 4 | import { strictEqual } from 'node:assert'; 5 | 6 | import { formatOptions } from '../../src/utils/constants.js'; 7 | import { getExtensionForFormat } from '../../src/transformers/reportGenerator.js'; 8 | import { 9 | jacocoBaselinePath, 10 | lcovBaselinePath, 11 | sonarBaselinePath, 12 | cloverBaselinePath, 13 | coberturaBaselinePath, 14 | inputJsons, 15 | jsonBaselinePath, 16 | jsonSummaryBaselinePath, 17 | simplecovBaselinePath, 18 | opencoverBaselinePath, 19 | } from './testConstants.js'; 20 | import { normalizeCoverageReport } from './normalizeCoverageReport.js'; 21 | 22 | export async function compareToBaselines(): Promise { 23 | const baselineMap = { 24 | sonar: sonarBaselinePath, 25 | lcovonly: lcovBaselinePath, 26 | jacoco: jacocoBaselinePath, 27 | cobertura: coberturaBaselinePath, 28 | clover: cloverBaselinePath, 29 | json: jsonBaselinePath, 30 | 'json-summary': jsonSummaryBaselinePath, 31 | simplecov: simplecovBaselinePath, 32 | opencover: opencoverBaselinePath, 33 | } as const; 34 | 35 | const normalizationRequired = new Set(['cobertura', 'clover', 'json', 'json-summary', 'simplecov', 'opencover']); 36 | const jsonFormats = new Set(['json', 'json-summary', 'simplecov']); 37 | 38 | for (const format of formatOptions as Array) { 39 | for (const { label } of inputJsons) { 40 | const reportExtension = getExtensionForFormat(format); 41 | const outputPath = resolve(`${label}-${format}${reportExtension}`); 42 | const outputContent = await readFile(outputPath, 'utf-8'); 43 | const baselineContent = await readFile(baselineMap[format], 'utf-8'); 44 | 45 | if (normalizationRequired.has(format)) { 46 | const isJson = jsonFormats.has(format); 47 | strictEqual( 48 | normalizeCoverageReport(outputContent, isJson), 49 | normalizeCoverageReport(baselineContent, isJson), 50 | `Mismatch between ${outputPath} and ${baselineMap[format]}` 51 | ); 52 | } else { 53 | strictEqual(outputContent, baselineContent, `Mismatch between ${outputPath} and ${baselineMap[format]}`); 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/commands/acc-transformer/transform.nut.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { describe, it, expect } from '@jest/globals'; 4 | 5 | import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; 6 | import { formatOptions } from '../../../src/utils/constants.js'; 7 | import { inputJsons, invalidJson } from '../../utils/testConstants.js'; 8 | import { getExtensionForFormat } from '../../../src/transformers/reportGenerator.js'; 9 | import { compareToBaselines } from '../../utils/baselineCompare.js'; 10 | import { postTestCleanup } from '../../utils/testCleanup.js'; 11 | import { preTestSetup } from '../../utils/testSetup.js'; 12 | 13 | describe('acc-transformer transform NUTs', () => { 14 | let session: TestSession; 15 | const formatString = formatOptions.map((f) => `--format ${f}`).join(' '); 16 | 17 | beforeAll(async () => { 18 | session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); 19 | await preTestSetup(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await session?.clean(); 24 | await postTestCleanup(); 25 | }); 26 | 27 | inputJsons.forEach(({ label, path }) => { 28 | it(`transforms the ${label} command JSON file into all formats`, async () => { 29 | const command = `acc-transformer transform --coverage-json "${path}" --output-report "${label}.xml" ${formatString} -i "samples"`; 30 | const output = execCmd(command, { ensureExitCode: 0 }).shellOutput.stdout; 31 | 32 | const expectedOutput = 33 | 'The coverage report has been written to: ' + 34 | formatOptions 35 | .map((f) => { 36 | const ext = getExtensionForFormat(f); 37 | return `${label}-${f}${ext}`; 38 | }) 39 | .join(', '); 40 | 41 | expect(output.replace('\n', '')).toStrictEqual(expectedOutput); 42 | }); 43 | }); 44 | 45 | it('confirm the reports created are the same as the baselines.', async () => { 46 | await compareToBaselines(); 47 | }); 48 | 49 | it('confirms a failure on an invalid JSON file.', async () => { 50 | const command = `acc-transformer transform --coverage-json "${invalidJson}"`; 51 | const error = execCmd(command, { ensureExitCode: 1 }).shellOutput.stderr; 52 | 53 | expect(error.replace('\n', '')).toContain( 54 | 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.' 55 | ); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/handlers/istanbulJson.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { IstanbulCoverageMap, IstanbulCoverageFile, IstanbulCoverageObject, SourceRange } from '../utils/types.js'; 4 | import { BaseHandler } from './BaseHandler.js'; 5 | import { HandlerRegistry } from './HandlerRegistry.js'; 6 | 7 | /** 8 | * Handler for generating Istanbul/NYC JSON coverage reports. 9 | * 10 | * Istanbul is the most widely-used JavaScript code coverage tool. 11 | * This format is compatible with NYC, Codecov, and many other tools. 12 | * 13 | * Compatible with: 14 | * - Istanbul/NYC 15 | * - Codecov 16 | * - Coveralls 17 | * - Node.js coverage tools 18 | * 19 | * @see https://istanbul.js.org/ 20 | */ 21 | export class IstanbulCoverageHandler extends BaseHandler { 22 | private coverageMap: IstanbulCoverageMap = {}; 23 | 24 | public constructor() { 25 | super(); 26 | } 27 | 28 | public processFile(filePath: string, fileName: string, lines: Record): void { 29 | const statementMap: Record = {}; 30 | const s: Record = {}; 31 | const lineCoverage: Record = {}; 32 | 33 | for (const [lineNumber, hits] of Object.entries(lines)) { 34 | const line = Number(lineNumber); 35 | lineCoverage[lineNumber] = hits; 36 | statementMap[lineNumber] = { 37 | start: { line, column: 0 }, 38 | end: { line, column: 0 }, 39 | }; 40 | s[lineNumber] = hits; 41 | } 42 | 43 | const coverageFile: IstanbulCoverageFile = { 44 | path: filePath, 45 | statementMap, 46 | fnMap: {}, 47 | branchMap: {}, 48 | s, 49 | f: {}, 50 | b: {}, 51 | l: lineCoverage, 52 | }; 53 | 54 | this.coverageMap[filePath] = coverageFile; 55 | } 56 | 57 | public finalize(): IstanbulCoverageObject { 58 | // Sort coverage map by file path for deterministic output 59 | const sortedKeys = Object.keys(this.coverageMap).sort(); 60 | const sortedMap: IstanbulCoverageMap = {}; 61 | 62 | for (const key of sortedKeys) { 63 | sortedMap[key] = this.coverageMap[key]; 64 | } 65 | 66 | return sortedMap; 67 | } 68 | } 69 | 70 | // Self-register this handler 71 | HandlerRegistry.register({ 72 | name: 'json', 73 | description: 'Istanbul JSON format for Node.js and JavaScript tools', 74 | fileExtension: '.json', 75 | handler: () => new IstanbulCoverageHandler(), 76 | compatibleWith: ['Istanbul/NYC', 'Codecov', 'Coveralls', 'Node.js Tools'], 77 | }); 78 | -------------------------------------------------------------------------------- /src/commands/acc-transformer/transform.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; 4 | import { Messages } from '@salesforce/core'; 5 | import { TransformerTransformResult } from '../../utils/types.js'; 6 | import { transformCoverageReport } from '../../transformers/coverageTransformer.js'; 7 | import { formatOptions } from '../../utils/constants.js'; 8 | 9 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 10 | const messages = Messages.loadMessages('apex-code-coverage-transformer', 'transformer.transform'); 11 | 12 | export default class TransformerTransform extends SfCommand { 13 | public static override readonly summary = messages.getMessage('summary'); 14 | public static override readonly description = messages.getMessage('description'); 15 | public static override readonly examples = messages.getMessages('examples'); 16 | 17 | public static override readonly flags = { 18 | 'coverage-json': Flags.file({ 19 | summary: messages.getMessage('flags.coverage-json.summary'), 20 | char: 'j', 21 | required: true, 22 | }), 23 | 'output-report': Flags.file({ 24 | summary: messages.getMessage('flags.output-report.summary'), 25 | char: 'r', 26 | required: true, 27 | default: 'coverage.xml', 28 | }), 29 | format: Flags.string({ 30 | summary: messages.getMessage('flags.format.summary'), 31 | char: 'f', 32 | required: false, 33 | multiple: true, 34 | options: formatOptions, 35 | }), 36 | 'ignore-package-directory': Flags.directory({ 37 | summary: messages.getMessage('flags.ignore-package-directory.summary'), 38 | char: 'i', 39 | required: false, 40 | multiple: true, 41 | }), 42 | }; 43 | 44 | public async run(): Promise { 45 | const { flags } = await this.parse(TransformerTransform); 46 | const warnings: string[] = []; 47 | 48 | const result = await transformCoverageReport( 49 | flags['coverage-json'], 50 | flags['output-report'], 51 | flags['format'] ?? ['sonar'], 52 | flags['ignore-package-directory'] ?? [] 53 | ); 54 | warnings.push(...result.warnings); 55 | const finalPath = result.finalPaths; 56 | 57 | if (warnings.length > 0) { 58 | warnings.forEach((warning) => { 59 | this.warn(warning); 60 | }); 61 | } 62 | 63 | this.log(`The coverage report has been written to: ${finalPath.join(', ')}`); 64 | return { path: finalPath }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/setCoverageDataType.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { DeployCoverageData, TestCoverageData } from './types.js'; 4 | 5 | function isObject(val: unknown): val is Record { 6 | return typeof val === 'object' && val !== null; 7 | } 8 | 9 | function isValidPosition(pos: unknown): boolean { 10 | return isObject(pos) && typeof pos.line === 'number' && typeof pos.column === 'number'; 11 | } 12 | 13 | function isValidStatementMap(statementMap: unknown): boolean { 14 | if (!isObject(statementMap)) return false; 15 | 16 | return Object.values(statementMap).every((statement) => { 17 | if (!isObject(statement)) return false; 18 | const { start, end } = statement as { start: unknown; end: unknown }; 19 | return isValidPosition(start) && isValidPosition(end); 20 | }); 21 | } 22 | 23 | function isValidDeployItem(item: unknown): boolean { 24 | if (!isObject(item)) return false; 25 | 26 | const { path, fnMap, branchMap, f, b, s, statementMap } = item; 27 | 28 | const checks = [ 29 | typeof path === 'string', 30 | isObject(fnMap), 31 | isObject(branchMap), 32 | isObject(f), 33 | isObject(b), 34 | isObject(s), 35 | isValidStatementMap(statementMap), 36 | ]; 37 | 38 | return checks.every(Boolean); 39 | } 40 | 41 | function isDeployCoverageData(data: unknown): data is DeployCoverageData { 42 | if (!isObject(data)) return false; 43 | return Object.entries(data).every(([, item]) => isValidDeployItem(item)); 44 | } 45 | 46 | function isSingleTestCoverageData(data: unknown): data is TestCoverageData { 47 | if (!isObject(data)) return false; 48 | 49 | const { id, name, totalLines, lines, totalCovered, coveredPercent } = data; 50 | 51 | const checks = [ 52 | typeof id === 'string', 53 | typeof name === 'string', 54 | typeof totalLines === 'number', 55 | typeof totalCovered === 'number', 56 | typeof coveredPercent === 'number', 57 | isObject(lines), 58 | isObject(lines) && Object.values(lines).every((line) => typeof line === 'number'), 59 | ]; 60 | 61 | return checks.every(Boolean); 62 | } 63 | 64 | function isTestCoverageDataArray(data: unknown): data is TestCoverageData[] { 65 | return Array.isArray(data) && data.every(isSingleTestCoverageData); 66 | } 67 | 68 | export function checkCoverageDataType( 69 | data: DeployCoverageData | TestCoverageData[] 70 | ): 'DeployCoverageData' | 'TestCoverageData' | 'Unknown' { 71 | if (isDeployCoverageData(data)) return 'DeployCoverageData'; 72 | if (isTestCoverageDataArray(data)) return 'TestCoverageData'; 73 | return 'Unknown'; 74 | } 75 | -------------------------------------------------------------------------------- /src/utils/buildFilePathCache.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { readdir, stat } from 'node:fs/promises'; 4 | import { join, relative } from 'node:path'; 5 | import { normalizePathToUnix } from './normalizePathToUnix.js'; 6 | 7 | /** 8 | * Build a cache mapping filenames to their full paths. 9 | * This prevents recursive directory searches for every file in the coverage report. 10 | * 11 | * @param packageDirectories - Array of package directory paths to scan 12 | * @param repoRoot - Repository root path 13 | * @returns Map of filename (without path) to full relative path 14 | */ 15 | export async function buildFilePathCache(packageDirectories: string[], repoRoot: string): Promise> { 16 | const cache = new Map(); 17 | const extensions = ['cls', 'trigger']; 18 | 19 | await Promise.all( 20 | packageDirectories.map(async (directory) => { 21 | await scanDirectory(directory, repoRoot, extensions, cache); 22 | }) 23 | ); 24 | 25 | return cache; 26 | } 27 | 28 | async function scanDirectory( 29 | directory: string, 30 | repoRoot: string, 31 | extensions: string[], 32 | cache: Map 33 | ): Promise { 34 | let entries: string[]; 35 | 36 | try { 37 | entries = await readdir(directory); 38 | } catch { 39 | // Directory doesn't exist or not accessible, skip it 40 | return; 41 | } 42 | 43 | const subdirPromises: Array> = []; 44 | 45 | for (const entry of entries) { 46 | const fullPath = join(directory, entry); 47 | let stats; 48 | 49 | try { 50 | // eslint-disable-next-line no-await-in-loop 51 | stats = await stat(fullPath); 52 | } catch { 53 | // File not accessible, skip it 54 | continue; 55 | } 56 | 57 | if (stats.isDirectory()) { 58 | // Queue subdirectory scanning 59 | subdirPromises.push(scanDirectory(fullPath, repoRoot, extensions, cache)); 60 | } else { 61 | // Check if this is an Apex file 62 | const ext = entry.split('.').pop(); 63 | if (ext && extensions.includes(ext)) { 64 | const relativePath = normalizePathToUnix(relative(repoRoot, fullPath)); 65 | // Store with the full filename as key (e.g., "AccountHandler.cls") 66 | cache.set(entry, relativePath); 67 | // Also store without extension for lookups (e.g., "AccountHandler") 68 | const nameWithoutExt = entry.substring(0, entry.lastIndexOf('.')); 69 | if (!cache.has(nameWithoutExt)) { 70 | cache.set(nameWithoutExt, relativePath); 71 | } 72 | } 73 | } 74 | } 75 | 76 | // Process all subdirectories in parallel 77 | await Promise.all(subdirPromises); 78 | } 79 | -------------------------------------------------------------------------------- /baselines/simplecov_baseline.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage": { 3 | "force-app/main/default/classes/AccountProfile.cls": [ 4 | null, 5 | null, 6 | null, 7 | null, 8 | null, 9 | null, 10 | null, 11 | null, 12 | null, 13 | null, 14 | null, 15 | null, 16 | null, 17 | null, 18 | null, 19 | null, 20 | null, 21 | null, 22 | null, 23 | null, 24 | null, 25 | null, 26 | null, 27 | null, 28 | null, 29 | null, 30 | null, 31 | null, 32 | null, 33 | null, 34 | null, 35 | null, 36 | null, 37 | null, 38 | null, 39 | null, 40 | null, 41 | null, 42 | null, 43 | null, 44 | null, 45 | null, 46 | null, 47 | null, 48 | null, 49 | null, 50 | null, 51 | null, 52 | null, 53 | null, 54 | null, 55 | 0, 56 | 0, 57 | 1, 58 | 1, 59 | 1, 60 | 1, 61 | 1, 62 | 0, 63 | 0, 64 | 1, 65 | 1, 66 | 1, 67 | 1, 68 | 1, 69 | 1, 70 | 1, 71 | 1, 72 | 1, 73 | 1, 74 | 1, 75 | 1, 76 | 1, 77 | 1, 78 | 1, 79 | 1, 80 | 1, 81 | 1, 82 | 1, 83 | 1, 84 | 1, 85 | 1 86 | ], 87 | "packaged/triggers/AccountTrigger.trigger": [ 88 | null, 89 | null, 90 | null, 91 | null, 92 | null, 93 | null, 94 | null, 95 | null, 96 | null, 97 | null, 98 | null, 99 | null, 100 | null, 101 | null, 102 | null, 103 | null, 104 | null, 105 | null, 106 | null, 107 | null, 108 | null, 109 | null, 110 | null, 111 | null, 112 | null, 113 | null, 114 | null, 115 | null, 116 | null, 117 | null, 118 | null, 119 | null, 120 | null, 121 | null, 122 | null, 123 | null, 124 | null, 125 | null, 126 | null, 127 | null, 128 | null, 129 | null, 130 | null, 131 | null, 132 | null, 133 | null, 134 | null, 135 | null, 136 | null, 137 | null, 138 | null, 139 | 0, 140 | 0, 141 | 1, 142 | 1, 143 | 1, 144 | 1, 145 | 1, 146 | 0, 147 | 0, 148 | 1, 149 | 1, 150 | 1, 151 | 1, 152 | 1, 153 | 1, 154 | 1, 155 | 1, 156 | 1, 157 | 1, 158 | 1, 159 | 1, 160 | 1, 161 | 1, 162 | 1, 163 | 1, 164 | 1, 165 | 1, 166 | 1, 167 | 1, 168 | 1, 169 | 1 170 | ] 171 | }, 172 | "timestamp": 1761575054 173 | } 174 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome! If you would like to contribute, please fork the repository, make your changes, and submit a pull request. 4 | 5 | ## Requirements 6 | 7 | - Node >= 20.0.0 8 | - yarn 9 | 10 | ## Installation 11 | 12 | ### 1) Fork the repository 13 | 14 | ### 2) Install Dependencies 15 | 16 | This will install all the tools needed to contribute 17 | 18 | ```bash 19 | yarn 20 | ``` 21 | 22 | ### 3) Build application 23 | 24 | ```bash 25 | yarn build 26 | ``` 27 | 28 | Rebuild every time you made a change in the source and you need to test locally 29 | 30 | ## Testing 31 | 32 | When developing, run the provided unit tests for new additions. New additions must meet the jest code coverage requirements. 33 | 34 | ```bash 35 | # run unit tests 36 | yarn test:only 37 | ``` 38 | 39 | To run the non-unit test, ensure you re-build the application and then run: 40 | 41 | ```bash 42 | # run non-unit tests 43 | yarn test:nuts 44 | ``` 45 | 46 | ## Adding Coverage Formats 47 | 48 | To add new coverage formats to the transformer: 49 | 50 | 1. Add the format flag value to `formatOptions` in `src/utils/constants.ts`. 51 | 2. Add new coverage types to `src/utils/types.ts` including a `{format}CoverageObject` type. Add the new `{format}CoverageObject` type to the `CoverageHandler` type under `finalize`. 52 | 53 | ```typescript 54 | export type CoverageHandler = { 55 | processFile(filePath: string, fileName: string, lines: Record): void; 56 | finalize(): SonarCoverageObject | CoberturaCoverageObject | CloverCoverageObject | LcovCoverageObject; 57 | }; 58 | ``` 59 | 60 | 3. Create a new coverage handler class in `src/handlers` with a `processFile` and `finalize` function. 61 | 1. The `finalize` function should sort items in the coverage object before returning. 62 | 4. Add the new coverage handler class to `src/handlers/getHandler.ts`. 63 | 5. Add the new `{format}CoverageObject` type to `src/transformers/reportGenerator.ts` and add anything needed to create the final report for that format, including updating the report extension in the `getExtensionForFormat` function. 64 | 6. The unit and non-unit tests will automatically run the new coverage format after it's added to the `formatOptions` constant. You will need to run the unit test suite once to generate the baseline report for the new format. 65 | 1. Add the newly generated baseline to the `baselines` folder named `{format}_baseline.{ext}` 66 | 2. Create a new test constant with the baseline path in `test/utils/testConstants.ts` 67 | 3. Add the new baseline constant to the `baselineMap` in `test/utils/baselineCompare.ts` 68 | 3. If needed, update the `test/commands/acc-transformer/normalizeCoverageReport.ts` to remove timestamps if the new format report has timestamps, i.e. Cobertura and Clover. 69 | 4. Re-run the unit test and confirm all tests pass, including the baseline compare test. 70 | -------------------------------------------------------------------------------- /src/hooks/finally.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { existsSync } from 'node:fs'; 4 | import { readFile } from 'node:fs/promises'; 5 | import { resolve } from 'node:path'; 6 | import { Hook } from '@oclif/core'; 7 | 8 | import TransformerTransform from '../commands/acc-transformer/transform.js'; 9 | import { HookFile } from '../utils/types.js'; 10 | import { getRepoRoot } from '../utils/getRepoRoot.js'; 11 | 12 | export const hook: Hook<'finally'> = async function (options) { 13 | const commandId = options?.Command?.id ?? ''; 14 | let commandType: string; 15 | let coverageJson: string; 16 | if ( 17 | [ 18 | 'project:deploy:validate', 19 | 'project:deploy:start', 20 | 'project:deploy:report', 21 | 'project:deploy:resume', 22 | 'hardis:project:deploy:smart', 23 | ].includes(commandId) 24 | ) { 25 | commandType = 'deploy'; 26 | } else if (['apex:run:test', 'apex:get:test', 'hardis:org:test:apex'].includes(commandId)) { 27 | commandType = 'test'; 28 | } else { 29 | return; 30 | } 31 | let configFile: HookFile; 32 | const { repoRoot } = await getRepoRoot(); 33 | if (!repoRoot) { 34 | return; 35 | } 36 | const configPath = resolve(repoRoot, '.apexcodecovtransformer.config.json'); 37 | 38 | try { 39 | const jsonString: string = await readFile(configPath, 'utf-8'); 40 | configFile = JSON.parse(jsonString) as HookFile; 41 | } catch (error) { 42 | return; 43 | } 44 | 45 | const outputReport: string = configFile.outputReportPath || 'coverage.xml'; 46 | const coverageFormat: string = configFile.format || 'sonar'; 47 | const ignorePackageDirs: string = configFile.ignorePackageDirectories || ''; 48 | 49 | if (commandType === 'deploy') { 50 | coverageJson = configFile.deployCoverageJsonPath || '.'; 51 | } else { 52 | coverageJson = configFile.testCoverageJsonPath || '.'; 53 | } 54 | 55 | if (coverageJson.trim() === '.') { 56 | return; 57 | } 58 | 59 | const coverageJsonPath = resolve(coverageJson); 60 | const outputReportPath = resolve(outputReport); 61 | 62 | if (!existsSync(coverageJsonPath)) { 63 | return; 64 | } 65 | 66 | const commandArgs: string[] = []; 67 | commandArgs.push('--coverage-json'); 68 | commandArgs.push(coverageJsonPath); 69 | commandArgs.push('--output-report'); 70 | commandArgs.push(outputReportPath); 71 | if (coverageFormat.trim() !== '') { 72 | const formatArray: string[] = coverageFormat.split(','); 73 | for (const format of formatArray) { 74 | const sanitizedFormat = format.replace(/,/g, ''); 75 | commandArgs.push('--format'); 76 | commandArgs.push(sanitizedFormat); 77 | } 78 | } 79 | if (ignorePackageDirs.trim() !== '') { 80 | const ignorePackageDirArray: string[] = ignorePackageDirs.split(','); 81 | for (const dirs of ignorePackageDirArray) { 82 | const sanitizedDir = dirs.replace(/,/g, ''); 83 | commandArgs.push('--ignore-package-directory'); 84 | commandArgs.push(sanitizedDir); 85 | } 86 | } 87 | await TransformerTransform.run(commandArgs); 88 | }; 89 | -------------------------------------------------------------------------------- /test/commands/acc-transformer/transform.test.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { describe, it, expect } from '@jest/globals'; 3 | 4 | import { transformCoverageReport } from '../../../src/transformers/coverageTransformer.js'; 5 | import { formatOptions } from '../../../src/utils/constants.js'; 6 | import { inputJsons, invalidJson, deployCoverage, testCoverage } from '../../utils/testConstants.js'; 7 | import { compareToBaselines } from '../../utils/baselineCompare.js'; 8 | import { postTestCleanup } from '../../utils/testCleanup.js'; 9 | import { preTestSetup } from '../../utils/testSetup.js'; 10 | 11 | describe('acc-transformer transform unit tests', () => { 12 | beforeAll(async () => { 13 | await preTestSetup(); 14 | }); 15 | 16 | afterAll(async () => { 17 | await postTestCleanup(); 18 | }); 19 | 20 | inputJsons.forEach(({ label, path }) => { 21 | it(`transforms the ${label} command JSON file into all output formats`, async () => { 22 | await transformCoverageReport(path, `${label}.xml`, formatOptions, ['samples']); 23 | }); 24 | }); 25 | it('confirm the reports created are the same as the baselines.', async () => { 26 | await compareToBaselines(); 27 | }); 28 | it('confirms a failure on an invalid JSON file.', async () => { 29 | try { 30 | await transformCoverageReport(invalidJson, 'coverage.xml', ['sonar'], []); 31 | throw new Error('Command did not fail as expected'); 32 | } catch (error) { 33 | if (error instanceof Error) { 34 | expect(error.message).toContain( 35 | 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.' 36 | ); 37 | } else { 38 | throw new Error('An unknown error type was thrown.'); 39 | } 40 | } 41 | }); 42 | it('confirms a warning with a JSON file that does not exist.', async () => { 43 | const result = await transformCoverageReport('nonexistent.json', 'coverage.xml', ['sonar'], []); 44 | expect(result.warnings).toContain('Failed to read nonexistent.json. Confirm file exists.'); 45 | }); 46 | it('ignore a package directory and produce a warning on the deploy command report.', async () => { 47 | const result = await transformCoverageReport( 48 | deployCoverage, 49 | 'coverage.xml', 50 | ['sonar'], 51 | ['packaged', 'force-app', 'samples'] 52 | ); 53 | expect(result.warnings).toContain('The file name AccountTrigger was not found in any package directory.'); 54 | }); 55 | it('ignore a package directory and produce a warning on the test command report.', async () => { 56 | const result = await transformCoverageReport(testCoverage, 'coverage.xml', ['sonar'], ['packaged', 'samples']); 57 | expect(result.warnings).toContain('The file name AccountTrigger was not found in any package directory.'); 58 | }); 59 | it('create a cobertura report using only 1 package directory', async () => { 60 | await transformCoverageReport(deployCoverage, 'coverage.xml', ['cobertura'], ['packaged', 'force-app']); 61 | }); 62 | it('create a jacoco report using only 1 package directory', async () => { 63 | await transformCoverageReport(deployCoverage, 'coverage.xml', ['jacoco'], ['packaged', 'force-app']); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/handlers/clover.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { CloverCoverageObject, CloverFile } from '../utils/types.js'; 4 | import { BaseHandler } from './BaseHandler.js'; 5 | import { HandlerRegistry } from './HandlerRegistry.js'; 6 | 7 | /** 8 | * Handler for generating Clover XML coverage reports. 9 | * 10 | * Clover is a code coverage tool commonly used with Atlassian tools. 11 | * 12 | * Compatible with: 13 | * - Bamboo 14 | * - Bitbucket 15 | * - Jenkins 16 | * - Atlassian tools 17 | * 18 | * @see https://openclover.org/ 19 | */ 20 | export class CloverCoverageHandler extends BaseHandler { 21 | private readonly coverageObj: CloverCoverageObject; 22 | 23 | public constructor() { 24 | super(); 25 | this.coverageObj = { 26 | coverage: { 27 | '@generated': Date.now(), 28 | '@clover': '3.2.0', 29 | project: { 30 | '@timestamp': Date.now(), 31 | '@name': 'All files', 32 | metrics: { 33 | '@statements': 0, 34 | '@coveredstatements': 0, 35 | '@conditionals': 0, 36 | '@coveredconditionals': 0, 37 | '@methods': 0, 38 | '@coveredmethods': 0, 39 | '@elements': 0, 40 | '@coveredelements': 0, 41 | '@complexity': 0, 42 | '@loc': 0, 43 | '@ncloc': 0, 44 | '@packages': 1, 45 | '@files': 0, 46 | '@classes': 0, 47 | }, 48 | file: [], 49 | }, 50 | }, 51 | }; 52 | } 53 | 54 | public processFile(filePath: string, fileName: string, lines: Record): void { 55 | const { totalLines, coveredLines } = this.calculateCoverage(lines); 56 | 57 | const fileObj: CloverFile = { 58 | '@name': fileName, 59 | '@path': filePath, 60 | metrics: { 61 | '@statements': totalLines, 62 | '@coveredstatements': coveredLines, 63 | '@conditionals': 0, 64 | '@coveredconditionals': 0, 65 | '@methods': 0, 66 | '@coveredmethods': 0, 67 | }, 68 | line: [], 69 | }; 70 | for (const [lineNumber, isCovered] of Object.entries(lines)) { 71 | fileObj.line.push({ 72 | '@num': Number(lineNumber), 73 | '@count': isCovered === 1 ? 1 : 0, 74 | '@type': 'stmt', 75 | }); 76 | } 77 | this.coverageObj.coverage.project.file.push(fileObj); 78 | const projectMetrics = this.coverageObj.coverage.project.metrics; 79 | 80 | projectMetrics['@statements'] += totalLines; 81 | projectMetrics['@coveredstatements'] += coveredLines; 82 | projectMetrics['@elements'] += totalLines; 83 | projectMetrics['@coveredelements'] += coveredLines; 84 | projectMetrics['@files'] += 1; 85 | projectMetrics['@classes'] += 1; 86 | projectMetrics['@loc'] += totalLines; 87 | projectMetrics['@ncloc'] += totalLines; 88 | } 89 | 90 | public finalize(): CloverCoverageObject { 91 | if (this.coverageObj.coverage?.project?.file) { 92 | this.coverageObj.coverage.project.file = this.sortByPath(this.coverageObj.coverage.project.file); 93 | } 94 | return this.coverageObj; 95 | } 96 | } 97 | 98 | // Self-register this handler 99 | HandlerRegistry.register({ 100 | name: 'clover', 101 | description: 'Clover XML format for Atlassian tools', 102 | fileExtension: '.xml', 103 | handler: () => new CloverCoverageHandler(), 104 | compatibleWith: ['Bamboo', 'Bitbucket', 'Jenkins'], 105 | }); 106 | -------------------------------------------------------------------------------- /baselines/sonar_baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/handlers/BaseHandler.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { 4 | CoverageHandler, 5 | SonarCoverageObject, 6 | CoberturaCoverageObject, 7 | CloverCoverageObject, 8 | LcovCoverageObject, 9 | JaCoCoCoverageObject, 10 | IstanbulCoverageObject, 11 | JsonSummaryCoverageObject, 12 | SimpleCovCoverageObject, 13 | OpenCoverCoverageObject, 14 | } from '../utils/types.js'; 15 | 16 | /** 17 | * Abstract base class for coverage handlers providing common utilities. 18 | * Reduces code duplication across different format handlers. 19 | */ 20 | export abstract class BaseHandler implements CoverageHandler { 21 | /** 22 | * Calculate line coverage metrics from a lines record. 23 | * 24 | * @param lines - Record of line numbers to hit counts 25 | * @returns Coverage metrics including totals and rates 26 | */ 27 | // eslint-disable-next-line class-methods-use-this 28 | protected calculateCoverage(lines: Record): { 29 | totalLines: number; 30 | coveredLines: number; 31 | uncoveredLines: number; 32 | lineRate: number; 33 | } { 34 | const uncoveredLines = Object.values(lines).filter((hits) => hits === 0).length; 35 | const coveredLines = Object.values(lines).filter((hits) => hits > 0).length; 36 | const totalLines = uncoveredLines + coveredLines; 37 | const lineRate = totalLines > 0 ? coveredLines / totalLines : 0; 38 | 39 | return { totalLines, coveredLines, uncoveredLines, lineRate }; 40 | } 41 | 42 | /** 43 | * Extract line numbers by coverage status. 44 | * 45 | * @param lines - Record of line numbers to hit counts 46 | * @param covered - True to get covered lines, false for uncovered 47 | * @returns Sorted array of line numbers 48 | */ 49 | // eslint-disable-next-line class-methods-use-this 50 | protected extractLinesByStatus(lines: Record, covered: boolean): number[] { 51 | return Object.entries(lines) 52 | .filter(([, hits]) => (covered ? hits > 0 : hits === 0)) 53 | .map(([line]) => Number(line)) 54 | .sort((a, b) => a - b); 55 | } 56 | 57 | /** 58 | * Get covered and uncovered line numbers from a lines record. 59 | * 60 | * @param lines - Record of line numbers to hit counts 61 | * @returns Object with covered and uncovered line arrays 62 | */ 63 | protected getCoveredAndUncovered(lines: Record): { 64 | covered: number[]; 65 | uncovered: number[]; 66 | } { 67 | return { 68 | covered: this.extractLinesByStatus(lines, true), 69 | uncovered: this.extractLinesByStatus(lines, false), 70 | }; 71 | } 72 | 73 | /** 74 | * Sort array of objects by their path property. 75 | * Handles various path property names (@path, @filename, @name). 76 | * 77 | * @param items - Array of objects to sort 78 | * @returns Sorted array 79 | */ 80 | // eslint-disable-next-line class-methods-use-this 81 | protected sortByPath(items: T[]): T[] { 82 | return items.sort((a, b) => { 83 | const pathA = a['@path'] ?? a['@filename'] ?? a['@name'] ?? ''; 84 | const pathB = b['@path'] ?? b['@filename'] ?? b['@name'] ?? ''; 85 | return pathA.localeCompare(pathB); 86 | }); 87 | } 88 | 89 | public abstract processFile(filePath: string, fileName: string, lines: Record): void; 90 | 91 | public abstract finalize(): 92 | | SonarCoverageObject 93 | | CoberturaCoverageObject 94 | | CloverCoverageObject 95 | | LcovCoverageObject 96 | | JaCoCoCoverageObject 97 | | IstanbulCoverageObject 98 | | JsonSummaryCoverageObject 99 | | SimpleCovCoverageObject 100 | | OpenCoverCoverageObject; 101 | } 102 | -------------------------------------------------------------------------------- /src/handlers/simplecov.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { SimpleCovCoverageObject } from '../utils/types.js'; 4 | import { BaseHandler } from './BaseHandler.js'; 5 | import { HandlerRegistry } from './HandlerRegistry.js'; 6 | 7 | /** 8 | * Handler for generating SimpleCov JSON coverage reports. 9 | * 10 | * SimpleCov is a popular Ruby code coverage tool. This format is also 11 | * accepted by Codecov and other coverage aggregation tools. 12 | * 13 | * **Format Origin**: SimpleCov (Ruby coverage tool) 14 | * 15 | * @see https://github.com/simplecov-ruby/simplecov 16 | * @see https://github.com/vicentllongo/simplecov-json 17 | * @see https://docs.codecov.com/docs/codecov-uploader 18 | * 19 | * **Format Structure**: 20 | * The format uses an array of hit counts per line, with null for non-executable lines. 21 | * Array indices are 0-based (index 0 = line 1, index 1 = line 2, etc.) 22 | * 23 | * **Apex-Specific Adaptations**: 24 | * - Only lines present in Apex coverage data are tracked 25 | * - Lines not in coverage data are marked as `null` (non-executable) 26 | * - This works well with Apex since Salesforce only reports executable lines 27 | * 28 | * **Advantages for Apex**: 29 | * - Simple, compact format 30 | * - Direct mapping from Apex line coverage to SimpleCov format 31 | * - Well-supported by Codecov and similar platforms 32 | * 33 | * Compatible with: 34 | * - Codecov 35 | * - SimpleCov analyzers 36 | * - Ruby coverage tools 37 | * - Custom parsers 38 | * 39 | * @example 40 | * ```json 41 | * { 42 | * "coverage": { 43 | * "path/to/file.cls": [1, 1, 0, 1, null, 1] 44 | * }, 45 | * "timestamp": 1234567890 46 | * } 47 | * ``` 48 | */ 49 | export class SimpleCovCoverageHandler extends BaseHandler { 50 | private readonly coverageObj: SimpleCovCoverageObject; 51 | 52 | public constructor() { 53 | super(); 54 | this.coverageObj = { 55 | coverage: {}, 56 | timestamp: Math.floor(Date.now() / 1000), 57 | }; 58 | } 59 | 60 | public processFile(filePath: string, _fileName: string, lines: Record): void { 61 | // Find the maximum line number to determine array size 62 | const lineNumbers = Object.keys(lines).map(Number); 63 | const maxLine = Math.max(...lineNumbers); 64 | 65 | // Create array with nulls for non-executable lines 66 | // SimpleCov uses null to indicate lines that are not executable/trackable 67 | const lineArray: Array = new Array(maxLine).fill(null); 68 | 69 | // Fill in the coverage data 70 | // SimpleCov arrays are 0-indexed, but line numbers are 1-indexed 71 | // So line 1 goes into array index 0, line 2 into index 1, etc. 72 | for (const [lineNumber, hits] of Object.entries(lines)) { 73 | const lineIdx = Number(lineNumber) - 1; // Convert to 0-index 74 | lineArray[lineIdx] = hits; // Store hit count (0 = uncovered, >0 = covered) 75 | } 76 | 77 | this.coverageObj.coverage[filePath] = lineArray; 78 | } 79 | 80 | public finalize(): SimpleCovCoverageObject { 81 | // Sort coverage object by file path for deterministic output 82 | const sortedKeys = Object.keys(this.coverageObj.coverage).sort(); 83 | const sortedCoverage: Record> = {}; 84 | 85 | for (const key of sortedKeys) { 86 | sortedCoverage[key] = this.coverageObj.coverage[key]; 87 | } 88 | 89 | this.coverageObj.coverage = sortedCoverage; 90 | 91 | return this.coverageObj; 92 | } 93 | } 94 | 95 | // Self-register this handler 96 | HandlerRegistry.register({ 97 | name: 'simplecov', 98 | description: 'SimpleCov JSON format compatible with Ruby coverage tools', 99 | fileExtension: '.json', 100 | handler: () => new SimpleCovCoverageHandler(), 101 | compatibleWith: ['Codecov', 'SimpleCov', 'Ruby Tools'], 102 | }); 103 | -------------------------------------------------------------------------------- /baselines/clover_baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /baselines/jacoco_baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/transformers/reportGenerator.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from 'node:fs/promises'; 2 | import { extname, basename, dirname, join } from 'node:path'; 3 | import { create } from 'xmlbuilder2'; 4 | 5 | import { 6 | SonarCoverageObject, 7 | CoberturaCoverageObject, 8 | CloverCoverageObject, 9 | LcovCoverageObject, 10 | JaCoCoCoverageObject, 11 | IstanbulCoverageObject, 12 | JsonSummaryCoverageObject, 13 | SimpleCovCoverageObject, 14 | OpenCoverCoverageObject, 15 | } from '../utils/types.js'; 16 | import { HandlerRegistry } from '../handlers/HandlerRegistry.js'; 17 | 18 | export async function generateAndWriteReport( 19 | outputPath: string, 20 | coverageObj: 21 | | SonarCoverageObject 22 | | CoberturaCoverageObject 23 | | CloverCoverageObject 24 | | LcovCoverageObject 25 | | JaCoCoCoverageObject 26 | | IstanbulCoverageObject 27 | | JsonSummaryCoverageObject 28 | | SimpleCovCoverageObject 29 | | OpenCoverCoverageObject, 30 | format: string, 31 | formatAmount: number 32 | ): Promise { 33 | const content = generateReportContent(coverageObj, format); 34 | const extension = HandlerRegistry.getExtension(format); 35 | 36 | const base = basename(outputPath, extname(outputPath)); // e.g., 'coverage' 37 | const dir = dirname(outputPath); 38 | 39 | const suffix = formatAmount > 1 ? `-${format}` : ''; 40 | const filePath = join(dir, `${base}${suffix}${extension}`); 41 | 42 | await writeFile(filePath, content, 'utf-8'); 43 | return filePath; 44 | } 45 | 46 | function generateReportContent( 47 | coverageObj: 48 | | SonarCoverageObject 49 | | CoberturaCoverageObject 50 | | CloverCoverageObject 51 | | LcovCoverageObject 52 | | JaCoCoCoverageObject 53 | | IstanbulCoverageObject 54 | | JsonSummaryCoverageObject 55 | | SimpleCovCoverageObject 56 | | OpenCoverCoverageObject, 57 | format: string 58 | ): string { 59 | if (format === 'lcovonly' && isLcovCoverageObject(coverageObj)) { 60 | return generateLcov(coverageObj); 61 | } 62 | 63 | if (format === 'json' || format === 'json-summary' || format === 'simplecov') { 64 | return JSON.stringify(coverageObj, null, 2); 65 | } 66 | 67 | const isHeadless = ['cobertura', 'clover', 'jacoco', 'opencover'].includes(format); 68 | const xml = create(coverageObj).end({ prettyPrint: true, indent: ' ', headless: isHeadless }); 69 | 70 | return prependXmlHeader(xml, format); 71 | } 72 | 73 | function generateLcov(coverageObj: LcovCoverageObject): string { 74 | return coverageObj.files 75 | .map((file) => { 76 | const lineData = file.lines.map((line) => `DA:${line.lineNumber},${line.hitCount}`).join('\n'); 77 | return [ 78 | 'TN:', 79 | `SF:${file.sourceFile}`, 80 | 'FNF:0', 81 | 'FNH:0', 82 | lineData, 83 | `LF:${file.totalLines}`, 84 | `LH:${file.coveredLines}`, 85 | 'BRF:0', 86 | 'BRH:0', 87 | 'end_of_record', 88 | ].join('\n'); 89 | }) 90 | .join('\n'); 91 | } 92 | 93 | function prependXmlHeader(xml: string, format: string): string { 94 | switch (format) { 95 | case 'cobertura': 96 | return `\n\n${xml}`; 97 | case 'clover': 98 | return `\n${xml}`; 99 | case 'jacoco': 100 | return `\n\n${xml}`; 101 | case 'opencover': 102 | return `\n${xml}`; 103 | default: 104 | return xml; 105 | } 106 | } 107 | 108 | export function getExtensionForFormat(format: string): string { 109 | return HandlerRegistry.getExtension(format); 110 | } 111 | 112 | function isLcovCoverageObject(obj: unknown): obj is LcovCoverageObject { 113 | return typeof obj === 'object' && obj !== null && 'files' in obj; 114 | } 115 | -------------------------------------------------------------------------------- /src/handlers/cobertura.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { CoberturaCoverageObject, CoberturaPackage, CoberturaClass } from '../utils/types.js'; 4 | import { BaseHandler } from './BaseHandler.js'; 5 | import { HandlerRegistry } from './HandlerRegistry.js'; 6 | 7 | /** 8 | * Handler for generating Cobertura XML coverage reports. 9 | * 10 | * Cobertura format is widely supported by many CI/CD platforms 11 | * including Codecov, Azure DevOps, Jenkins, and GitLab. 12 | * 13 | * @see http://cobertura.github.io/cobertura/ 14 | */ 15 | export class CoberturaCoverageHandler extends BaseHandler { 16 | private readonly coverageObj: CoberturaCoverageObject; 17 | private packageMap: Map; 18 | 19 | public constructor() { 20 | super(); 21 | this.coverageObj = { 22 | coverage: { 23 | '@lines-valid': 0, 24 | '@lines-covered': 0, 25 | '@line-rate': 0, 26 | '@branches-valid': 0, 27 | '@branches-covered': 0, 28 | '@branch-rate': 1, 29 | '@timestamp': Date.now(), 30 | '@complexity': 0, 31 | '@version': '0.1', 32 | sources: { source: ['.'] }, 33 | packages: { package: [] }, 34 | }, 35 | }; 36 | this.packageMap = new Map(); 37 | } 38 | 39 | public processFile(filePath: string, fileName: string, lines: Record): void { 40 | const packageName = filePath.split('/')[0]; // Extract root directory as package name 41 | 42 | if (!this.packageMap.has(packageName)) { 43 | this.packageMap.set(packageName, { 44 | '@name': packageName, 45 | '@line-rate': 0, 46 | '@branch-rate': 1, 47 | classes: { class: [] }, 48 | }); 49 | } 50 | 51 | const packageObj = this.packageMap.get(packageName)!; 52 | const { totalLines, coveredLines } = this.calculateCoverage(lines); 53 | 54 | const classObj: CoberturaClass = { 55 | '@name': fileName, 56 | '@filename': filePath, 57 | '@line-rate': 0, 58 | '@branch-rate': 1, 59 | methods: {}, 60 | lines: { line: [] }, 61 | }; 62 | 63 | for (const [lineNumber, isCovered] of Object.entries(lines)) { 64 | classObj.lines.line.push({ 65 | '@number': Number(lineNumber), 66 | '@hits': isCovered === 1 ? 1 : 0, 67 | '@branch': 'false', 68 | }); 69 | } 70 | 71 | if (totalLines > 0) { 72 | classObj['@line-rate'] = parseFloat((coveredLines / totalLines).toFixed(4)); 73 | } 74 | 75 | this.coverageObj.coverage['@lines-valid'] += totalLines; 76 | this.coverageObj.coverage['@lines-covered'] += coveredLines; 77 | 78 | packageObj.classes.class.push(classObj); 79 | this.packageMap.set(packageName, packageObj); 80 | } 81 | 82 | public finalize(): CoberturaCoverageObject { 83 | this.coverageObj.coverage.packages.package = Array.from(this.packageMap.values()); 84 | 85 | for (const pkg of this.coverageObj.coverage.packages.package) { 86 | const totalLines = pkg.classes.class.reduce((sum, cls) => sum + cls['@line-rate'] * cls.lines.line.length, 0); 87 | const totalClasses = pkg.classes.class.reduce((sum, cls) => sum + cls.lines.line.length, 0); 88 | 89 | pkg['@line-rate'] = parseFloat((totalLines / totalClasses).toFixed(4)); 90 | } 91 | 92 | this.coverageObj.coverage['@line-rate'] = parseFloat( 93 | (this.coverageObj.coverage['@lines-covered'] / this.coverageObj.coverage['@lines-valid']).toFixed(4) 94 | ); 95 | 96 | this.coverageObj.coverage.packages.package.sort((a, b) => a['@name'].localeCompare(b['@name'])); 97 | for (const pkg of this.coverageObj.coverage.packages.package) { 98 | if (pkg.classes?.class) { 99 | pkg.classes.class.sort((a, b) => a['@filename'].localeCompare(b['@filename'])); 100 | } 101 | } 102 | 103 | return this.coverageObj; 104 | } 105 | } 106 | 107 | // Self-register this handler 108 | HandlerRegistry.register({ 109 | name: 'cobertura', 110 | description: 'Cobertura XML format for wide CI/CD compatibility', 111 | fileExtension: '.xml', 112 | handler: () => new CoberturaCoverageHandler(), 113 | compatibleWith: ['Codecov', 'Azure DevOps', 'Jenkins', 'GitLab', 'GitHub Actions'], 114 | }); 115 | -------------------------------------------------------------------------------- /src/handlers/HandlerRegistry.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { CoverageHandler } from '../utils/types.js'; 4 | 5 | /** 6 | * Registration information for a coverage format handler. 7 | */ 8 | export type HandlerRegistration = { 9 | /** Format identifier (e.g., 'sonar', 'cobertura') */ 10 | name: string; 11 | /** Human-readable description of the format */ 12 | description: string; 13 | /** File extension for this format (e.g., '.xml', '.json', '.info') */ 14 | fileExtension: string; 15 | /** Factory function to create a new handler instance */ 16 | handler: () => CoverageHandler; 17 | /** List of platforms/tools compatible with this format */ 18 | compatibleWith?: string[]; 19 | }; 20 | 21 | /** 22 | * Registry for coverage format handlers. 23 | * Provides a centralized system for registering and retrieving format handlers. 24 | * 25 | * @example 26 | * ```typescript 27 | * // Register a handler 28 | * HandlerRegistry.register({ 29 | * name: 'myformat', 30 | * description: 'My custom format', 31 | * fileExtension: '.xml', 32 | * handler: () => new MyFormatHandler(), 33 | * }); 34 | * 35 | * // Retrieve a handler 36 | * const handler = HandlerRegistry.get('myformat'); 37 | * ``` 38 | */ 39 | export class HandlerRegistry { 40 | private static handlers = new Map(); 41 | 42 | /** 43 | * Register a new format handler. 44 | * 45 | * @param registration - Handler registration information 46 | * @throws Error if a handler with the same name is already registered 47 | */ 48 | public static register(registration: HandlerRegistration): void { 49 | if (this.handlers.has(registration.name)) { 50 | throw new Error(`Handler for format '${registration.name}' is already registered`); 51 | } 52 | this.handlers.set(registration.name, registration); 53 | } 54 | 55 | /** 56 | * Get a handler instance for the specified format. 57 | * 58 | * @param format - Format identifier 59 | * @returns New handler instance 60 | * @throws Error if format is not supported 61 | */ 62 | public static get(format: string): CoverageHandler { 63 | const registration = this.handlers.get(format); 64 | if (!registration) { 65 | const available = this.getAvailableFormats().join(', '); 66 | throw new Error(`Unsupported format: ${format}. Available formats: ${available}`); 67 | } 68 | return registration.handler(); 69 | } 70 | 71 | /** 72 | * Get list of all registered format names. 73 | * 74 | * @returns Array of format identifiers 75 | */ 76 | public static getAvailableFormats(): string[] { 77 | return Array.from(this.handlers.keys()).sort(); 78 | } 79 | 80 | /** 81 | * Get file extension for a format. 82 | * 83 | * @param format - Format identifier 84 | * @returns File extension including the dot (e.g., '.xml') 85 | */ 86 | public static getExtension(format: string): string { 87 | const registration = this.handlers.get(format); 88 | return registration?.fileExtension ?? '.xml'; 89 | } 90 | 91 | /** 92 | * Get description for a format. 93 | * 94 | * @param format - Format identifier 95 | * @returns Human-readable description 96 | */ 97 | public static getDescription(format: string): string { 98 | const registration = this.handlers.get(format); 99 | return registration?.description ?? ''; 100 | } 101 | 102 | /** 103 | * Get compatible platforms for a format. 104 | * 105 | * @param format - Format identifier 106 | * @returns Array of compatible platform names 107 | */ 108 | public static getCompatiblePlatforms(format: string): string[] { 109 | const registration = this.handlers.get(format); 110 | return registration?.compatibleWith ?? []; 111 | } 112 | 113 | /** 114 | * Check if a format is registered. 115 | * 116 | * @param format - Format identifier 117 | * @returns True if format is registered 118 | */ 119 | public static has(format: string): boolean { 120 | return this.handlers.has(format); 121 | } 122 | 123 | /** 124 | * Clear all registered handlers (primarily for testing). 125 | */ 126 | public static clear(): void { 127 | this.handlers.clear(); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/handlers/jacoco.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { JaCoCoCoverageObject, JaCoCoPackage, JaCoCoSourceFile, JaCoCoLine } from '../utils/types.js'; 4 | import { BaseHandler } from './BaseHandler.js'; 5 | import { HandlerRegistry } from './HandlerRegistry.js'; 6 | 7 | /** 8 | * Handler for generating JaCoCo XML coverage reports. 9 | * 10 | * JaCoCo is the standard code coverage library for Java projects. 11 | * The format is also accepted by Codecov and other coverage tools. 12 | * 13 | * Compatible with: 14 | * - Codecov 15 | * - Jenkins 16 | * - Maven 17 | * - Gradle 18 | * - IntelliJ IDEA 19 | * 20 | * @see https://www.jacoco.org/ 21 | */ 22 | export class JaCoCoCoverageHandler extends BaseHandler { 23 | private readonly coverageObj: JaCoCoCoverageObject; 24 | private packageMap: Record; 25 | 26 | public constructor() { 27 | super(); 28 | this.coverageObj = { 29 | report: { 30 | '@name': 'JaCoCo', 31 | package: [], 32 | counter: [], 33 | }, 34 | }; 35 | this.packageMap = {}; // Stores packages by directory 36 | } 37 | 38 | public processFile(filePath: string, fileName: string, lines: Record): void { 39 | const pathParts = filePath.split('/'); 40 | const fileNamewithExt = pathParts.pop()!; 41 | const packageName = pathParts.join('/'); 42 | 43 | const packageObj = this.getOrCreatePackage(packageName); 44 | 45 | // Ensure source file only contains the filename, not the full path 46 | const sourceFileObj: JaCoCoSourceFile = { 47 | '@name': fileNamewithExt, 48 | line: [], 49 | counter: [], 50 | }; 51 | 52 | let coveredLines = 0; 53 | let totalLines = 0; 54 | 55 | for (const [lineNumber, isCovered] of Object.entries(lines)) { 56 | totalLines++; 57 | if (isCovered === 1) coveredLines++; 58 | 59 | const lineObj: JaCoCoLine = { 60 | '@nr': Number(lineNumber), 61 | '@mi': isCovered === 0 ? 1 : 0, 62 | '@ci': isCovered === 1 ? 1 : 0, 63 | '@mb': 0, 64 | '@cb': 0, 65 | }; 66 | sourceFileObj.line.push(lineObj); 67 | } 68 | 69 | // Add line coverage counter for the source file 70 | sourceFileObj.counter.push({ 71 | '@type': 'LINE', 72 | '@missed': totalLines - coveredLines, 73 | '@covered': coveredLines, 74 | }); 75 | 76 | packageObj.sourcefile.push(sourceFileObj); 77 | } 78 | 79 | public finalize(): JaCoCoCoverageObject { 80 | let overallCovered = 0; 81 | let overallMissed = 0; 82 | 83 | // Sort packages by name for consistent output 84 | const sortedPackages = Object.keys(this.packageMap).sort(); 85 | 86 | for (const packageName of sortedPackages) { 87 | const packageObj = this.packageMap[packageName]; 88 | packageObj.sourcefile.sort((a, b) => a['@name'].localeCompare(b['@name'])); 89 | 90 | let packageCovered = 0; 91 | let packageMissed = 0; 92 | 93 | for (const sf of packageObj.sourcefile) { 94 | packageCovered += sf.counter[0]['@covered']; 95 | packageMissed += sf.counter[0]['@missed']; 96 | } 97 | 98 | packageObj.counter.push({ 99 | '@type': 'LINE', 100 | '@missed': packageMissed, 101 | '@covered': packageCovered, 102 | }); 103 | 104 | overallCovered += packageCovered; 105 | overallMissed += packageMissed; 106 | } 107 | 108 | // Rebuild the package array in sorted order 109 | this.coverageObj.report.package = sortedPackages.map((name) => this.packageMap[name]); 110 | 111 | this.coverageObj.report.counter.push({ 112 | '@type': 'LINE', 113 | '@missed': overallMissed, 114 | '@covered': overallCovered, 115 | }); 116 | 117 | return this.coverageObj; 118 | } 119 | 120 | private getOrCreatePackage(packageName: string): JaCoCoPackage { 121 | if (!this.packageMap[packageName]) { 122 | this.packageMap[packageName] = { 123 | '@name': packageName, 124 | sourcefile: [], 125 | counter: [], 126 | }; 127 | this.coverageObj.report.package.push(this.packageMap[packageName]); 128 | } 129 | return this.packageMap[packageName]; 130 | } 131 | } 132 | 133 | // Self-register this handler 134 | HandlerRegistry.register({ 135 | name: 'jacoco', 136 | description: 'JaCoCo XML format for Java projects', 137 | fileExtension: '.xml', 138 | handler: () => new JaCoCoCoverageHandler(), 139 | compatibleWith: ['Codecov', 'Jenkins', 'Maven', 'Gradle'], 140 | }); 141 | -------------------------------------------------------------------------------- /src/handlers/jsonSummary.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { JsonSummaryCoverageObject, JsonSummaryFileCoverage } from '../utils/types.js'; 4 | import { BaseHandler } from './BaseHandler.js'; 5 | import { HandlerRegistry } from './HandlerRegistry.js'; 6 | 7 | /** 8 | * Handler for generating JSON Summary coverage reports. 9 | * 10 | * This format provides a concise summary of coverage statistics, 11 | * ideal for badges, PR comments, and quick analysis. 12 | * 13 | * **Format Origin**: Istanbul/NYC coverage tools 14 | * 15 | * @see https://istanbul.js.org/ 16 | * @see https://github.com/istanbuljs/nyc 17 | * 18 | * **Apex-Specific Adaptations**: 19 | * - Salesforce Apex only provides line-level coverage data 20 | * - `statements`, `functions`, and `branches` metrics mirror line coverage 21 | * - In native JavaScript environments, these would be distinct metrics 22 | * - `skipped` is always 0 (Apex doesn't report skipped lines) 23 | * 24 | * **Limitations**: 25 | * - No branch coverage (if/else paths) - Apex doesn't provide this data 26 | * - No function/method coverage separate from lines 27 | * - No statement coverage distinct from line coverage 28 | * 29 | * Compatible with: 30 | * - GitHub Actions (for badges and PR comments) 31 | * - GitLab CI (for MR comments) 32 | * - Custom reporting dashboards 33 | * 34 | * @example 35 | * ```json 36 | * { 37 | * "total": { 38 | * "lines": { "total": 100, "covered": 75, "skipped": 0, "pct": 75 } 39 | * }, 40 | * "files": { 41 | * "path/to/file.cls": { 42 | * "lines": { "total": 50, "covered": 40, "skipped": 0, "pct": 80 } 43 | * } 44 | * } 45 | * } 46 | * ``` 47 | */ 48 | export class JsonSummaryCoverageHandler extends BaseHandler { 49 | private readonly coverageObj: JsonSummaryCoverageObject; 50 | 51 | public constructor() { 52 | super(); 53 | this.coverageObj = { 54 | total: { 55 | lines: { total: 0, covered: 0, skipped: 0, pct: 0 }, 56 | statements: { total: 0, covered: 0, skipped: 0, pct: 0 }, 57 | }, 58 | files: {}, 59 | }; 60 | } 61 | 62 | public processFile(filePath: string, _fileName: string, lines: Record): void { 63 | const { totalLines, coveredLines } = this.calculateCoverage(lines); 64 | const pct = totalLines > 0 ? Number(((coveredLines / totalLines) * 100).toFixed(2)) : 0; 65 | 66 | // NOTE: Apex only provides line coverage, so we use the same values for statements 67 | // In JavaScript/TypeScript environments, statements would be a distinct metric 68 | const fileCoverage: JsonSummaryFileCoverage = { 69 | lines: { 70 | total: totalLines, 71 | covered: coveredLines, 72 | skipped: 0, // Apex doesn't report skipped lines 73 | pct, 74 | }, 75 | statements: { 76 | total: totalLines, // Using line count as statement count (Apex limitation) 77 | covered: coveredLines, // Using covered lines as covered statements 78 | skipped: 0, 79 | pct, 80 | }, 81 | }; 82 | 83 | this.coverageObj.files[filePath] = fileCoverage; 84 | 85 | // Update totals 86 | this.coverageObj.total.lines.total += totalLines; 87 | this.coverageObj.total.lines.covered += coveredLines; 88 | this.coverageObj.total.statements.total += totalLines; 89 | this.coverageObj.total.statements.covered += coveredLines; 90 | } 91 | 92 | public finalize(): JsonSummaryCoverageObject { 93 | // Calculate total percentages 94 | const totalLines = this.coverageObj.total.lines.total; 95 | const totalCovered = this.coverageObj.total.lines.covered; 96 | 97 | if (totalLines > 0) { 98 | const pct = Number(((totalCovered / totalLines) * 100).toFixed(2)); 99 | this.coverageObj.total.lines.pct = pct; 100 | this.coverageObj.total.statements.pct = pct; 101 | } 102 | 103 | // Sort files object by path for deterministic output 104 | const sortedKeys = Object.keys(this.coverageObj.files).sort(); 105 | const sortedFiles: Record = {}; 106 | 107 | for (const key of sortedKeys) { 108 | sortedFiles[key] = this.coverageObj.files[key]; 109 | } 110 | 111 | this.coverageObj.files = sortedFiles; 112 | 113 | return this.coverageObj; 114 | } 115 | } 116 | 117 | // Self-register this handler 118 | HandlerRegistry.register({ 119 | name: 'json-summary', 120 | description: 'JSON Summary format for badges and quick analysis', 121 | fileExtension: '.json', 122 | handler: () => new JsonSummaryCoverageHandler(), 123 | compatibleWith: ['GitHub Actions', 'GitLab CI', 'Custom Dashboards'], 124 | }); 125 | -------------------------------------------------------------------------------- /baselines/cobertura_baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | . 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /test/units/handlerRegistry.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | 'use strict'; 3 | import { describe, it, expect } from '@jest/globals'; 4 | 5 | import { HandlerRegistry } from '../../src/handlers/HandlerRegistry.js'; 6 | import { SonarCoverageHandler } from '../../src/handlers/sonar.js'; 7 | // Import all handlers to ensure they are registered 8 | import '../../src/handlers/cobertura.js'; 9 | import '../../src/handlers/clover.js'; 10 | import '../../src/handlers/lcov.js'; 11 | import '../../src/handlers/jacoco.js'; 12 | import '../../src/handlers/istanbulJson.js'; 13 | import '../../src/handlers/jsonSummary.js'; 14 | import '../../src/handlers/simplecov.js'; 15 | import '../../src/handlers/opencover.js'; 16 | 17 | describe('HandlerRegistry unit tests', () => { 18 | it('should retrieve an existing handler', () => { 19 | const handler = HandlerRegistry.get('sonar'); 20 | expect(handler).toBeInstanceOf(SonarCoverageHandler); 21 | }); 22 | 23 | it('should throw error for unsupported format', () => { 24 | expect(() => { 25 | HandlerRegistry.get('unsupported-format'); 26 | }).toThrow('Unsupported format: unsupported-format'); 27 | }); 28 | 29 | it('should return list of available formats', () => { 30 | const formats = HandlerRegistry.getAvailableFormats(); 31 | expect(formats).toContain('sonar'); 32 | expect(formats).toContain('cobertura'); 33 | expect(formats).toContain('jacoco'); 34 | expect(formats).toContain('json-summary'); 35 | expect(formats).toContain('simplecov'); 36 | expect(formats).toContain('opencover'); 37 | expect(formats.length).toBeGreaterThanOrEqual(9); 38 | }); 39 | 40 | it('should return correct file extension for format', () => { 41 | expect(HandlerRegistry.getExtension('sonar')).toBe('.xml'); 42 | expect(HandlerRegistry.getExtension('json')).toBe('.json'); 43 | expect(HandlerRegistry.getExtension('lcovonly')).toBe('.info'); 44 | expect(HandlerRegistry.getExtension('json-summary')).toBe('.json'); 45 | expect(HandlerRegistry.getExtension('simplecov')).toBe('.json'); 46 | expect(HandlerRegistry.getExtension('opencover')).toBe('.xml'); 47 | }); 48 | 49 | it('should return default extension for unknown format', () => { 50 | // Test the fallback when format is not registered 51 | expect(HandlerRegistry.getExtension('unknown-format')).toBe('.xml'); 52 | }); 53 | 54 | it('should return description for format', () => { 55 | const description = HandlerRegistry.getDescription('sonar'); 56 | expect(description).toContain('SonarQube'); 57 | }); 58 | 59 | it('should return empty string for unknown format description', () => { 60 | // Test the fallback when format is not registered 61 | expect(HandlerRegistry.getDescription('unknown-format')).toBe(''); 62 | }); 63 | 64 | it('should return compatible platforms for format', () => { 65 | const platforms = HandlerRegistry.getCompatiblePlatforms('cobertura'); 66 | expect(platforms).toContain('Codecov'); 67 | expect(platforms.length).toBeGreaterThan(0); 68 | }); 69 | 70 | it('should return empty array for unknown format platforms', () => { 71 | // Test the fallback when format is not registered 72 | const platforms = HandlerRegistry.getCompatiblePlatforms('unknown-format'); 73 | expect(platforms).toEqual([]); 74 | }); 75 | 76 | it('should check if format exists', () => { 77 | expect(HandlerRegistry.has('sonar')).toBe(true); 78 | expect(HandlerRegistry.has('nonexistent')).toBe(false); 79 | }); 80 | 81 | it('should throw error when registering duplicate format', () => { 82 | // Try to register a handler with the same name 83 | expect(() => { 84 | HandlerRegistry.register({ 85 | name: 'sonar', // Already registered 86 | description: 'Test duplicate', 87 | fileExtension: '.xml', 88 | handler: () => new SonarCoverageHandler(), 89 | }); 90 | }).toThrow("Handler for format 'sonar' is already registered"); 91 | }); 92 | 93 | it('should clear all handlers', () => { 94 | // Store current formats count 95 | const formatsBefore = HandlerRegistry.getAvailableFormats().length; 96 | expect(formatsBefore).toBeGreaterThan(0); 97 | 98 | // Clear all handlers 99 | HandlerRegistry.clear(); 100 | 101 | // Verify handlers are cleared 102 | const formatsAfter = HandlerRegistry.getAvailableFormats(); 103 | expect(formatsAfter.length).toBe(0); 104 | 105 | // Re-import handlers to restore them for other tests 106 | // This is important so other tests don't fail 107 | import('../../src/handlers/sonar.js'); 108 | import('../../src/handlers/cobertura.js'); 109 | import('../../src/handlers/clover.js'); 110 | import('../../src/handlers/lcov.js'); 111 | import('../../src/handlers/jacoco.js'); 112 | import('../../src/handlers/istanbulJson.js'); 113 | import('../../src/handlers/jsonSummary.js'); 114 | import('../../src/handlers/simplecov.js'); 115 | import('../../src/handlers/opencover.js'); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/transformers/coverageTransformer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | import { readFile } from 'node:fs/promises'; 3 | import { mapLimit } from 'async'; 4 | 5 | import { getCoverageHandler } from '../handlers/getHandler.js'; 6 | import { DeployCoverageData, TestCoverageData, CoverageProcessingContext } from '../utils/types.js'; 7 | import { getPackageDirectories } from '../utils/getPackageDirectories.js'; 8 | import { findFilePath } from '../utils/findFilePath.js'; 9 | import { buildFilePathCache } from '../utils/buildFilePathCache.js'; 10 | import { setCoveredLines } from '../utils/setCoveredLines.js'; 11 | import { getConcurrencyThreshold } from '../utils/getConcurrencyThreshold.js'; 12 | import { checkCoverageDataType } from '../utils/setCoverageDataType.js'; 13 | import { generateAndWriteReport } from './reportGenerator.js'; 14 | 15 | type CoverageInput = DeployCoverageData | TestCoverageData[]; 16 | 17 | export async function transformCoverageReport( 18 | jsonFilePath: string, 19 | outputReportPath: string, 20 | formats: string[], 21 | ignoreDirs: string[] 22 | ): Promise<{ finalPaths: string[]; warnings: string[] }> { 23 | const warnings: string[] = []; 24 | const finalPaths: string[] = []; 25 | const formatAmount: number = formats.length; 26 | let filesProcessed = 0; 27 | 28 | const jsonData = await tryReadJson(jsonFilePath, warnings); 29 | if (!jsonData) return { finalPaths: [outputReportPath], warnings }; 30 | 31 | const parsedData = JSON.parse(jsonData) as CoverageInput; 32 | const { repoRoot, packageDirectories } = await getPackageDirectories(ignoreDirs); 33 | const handlers = createHandlers(formats); 34 | const commandType = checkCoverageDataType(parsedData); 35 | const concurrencyLimit = getConcurrencyThreshold(); 36 | 37 | // Build file path cache upfront to avoid O(n*m) directory traversals 38 | const filePathCache = await buildFilePathCache(packageDirectories, repoRoot); 39 | 40 | const context: CoverageProcessingContext = { 41 | handlers, 42 | packageDirs: packageDirectories, 43 | repoRoot, 44 | concurrencyLimit, 45 | warnings, 46 | filePathCache, 47 | }; 48 | 49 | if (commandType === 'DeployCoverageData') { 50 | filesProcessed = await processDeployCoverage(parsedData as DeployCoverageData, context); 51 | } else if (commandType === 'TestCoverageData') { 52 | filesProcessed = await processTestCoverage(parsedData as TestCoverageData[], context); 53 | } else { 54 | throw new Error( 55 | 'The provided JSON does not match a known coverage data format from the Salesforce deploy or test command.' 56 | ); 57 | } 58 | 59 | if (filesProcessed === 0) { 60 | warnings.push('None of the files listed in the coverage JSON were processed. The coverage report will be empty.'); 61 | } 62 | 63 | for (const [format, handler] of handlers.entries()) { 64 | const coverageObj = handler.finalize(); 65 | const finalPath = await generateAndWriteReport(outputReportPath, coverageObj, format, formatAmount); 66 | finalPaths.push(finalPath); 67 | } 68 | 69 | return { finalPaths, warnings }; 70 | } 71 | 72 | async function tryReadJson(path: string, warnings: string[]): Promise { 73 | try { 74 | return await readFile(path, 'utf-8'); 75 | } catch { 76 | warnings.push(`Failed to read ${path}. Confirm file exists.`); 77 | return null; 78 | } 79 | } 80 | 81 | function createHandlers(formats: string[]): Map> { 82 | const handlers = new Map>(); 83 | for (const format of formats) { 84 | handlers.set(format, getCoverageHandler(format)); 85 | } 86 | return handlers; 87 | } 88 | 89 | async function processDeployCoverage(data: DeployCoverageData, context: CoverageProcessingContext): Promise { 90 | let processed = 0; 91 | await mapLimit(Object.keys(data), context.concurrencyLimit, async (fileName: string) => { 92 | const fileInfo = data[fileName]; 93 | const formattedName = fileName.replace(/no-map[\\/]+/, ''); 94 | const path = findFilePath(formattedName, context.filePathCache); 95 | 96 | if (!path) { 97 | context.warnings.push(`The file name ${formattedName} was not found in any package directory.`); 98 | return; 99 | } 100 | 101 | fileInfo.s = await setCoveredLines(path, context.repoRoot, fileInfo.s); 102 | for (const handler of context.handlers.values()) { 103 | handler.processFile(path, formattedName, fileInfo.s); 104 | } 105 | processed++; 106 | }); 107 | return processed; 108 | } 109 | 110 | async function processTestCoverage(data: TestCoverageData[], context: CoverageProcessingContext): Promise { 111 | let processed = 0; 112 | // eslint-disable-next-line @typescript-eslint/require-await 113 | await mapLimit(data, context.concurrencyLimit, async (entry: TestCoverageData) => { 114 | const formattedName = entry.name.replace(/no-map[\\/]+/, ''); 115 | const path = findFilePath(formattedName, context.filePathCache); 116 | 117 | if (!path) { 118 | context.warnings.push(`The file name ${formattedName} was not found in any package directory.`); 119 | return; 120 | } 121 | 122 | for (const handler of context.handlers.values()) { 123 | handler.processFile(path, formattedName, entry.lines); 124 | } 125 | processed++; 126 | }); 127 | return processed; 128 | } 129 | -------------------------------------------------------------------------------- /baselines/opencover_baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /test/units/findFilePath.test.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { describe, it, expect } from '@jest/globals'; 4 | import { findFilePath } from '../../src/utils/findFilePath.js'; 5 | 6 | describe('findFilePath', () => { 7 | describe('with cache', () => { 8 | it('should find file with exact match', () => { 9 | const cache = new Map([ 10 | ['AccountHandler.cls', 'force-app/main/default/classes/AccountHandler.cls'], 11 | ['AccountHandler', 'force-app/main/default/classes/AccountHandler.cls'], 12 | ]); 13 | 14 | const result = findFilePath('AccountHandler.cls', cache); 15 | expect(result).toBe('force-app/main/default/classes/AccountHandler.cls'); 16 | }); 17 | 18 | it('should find file by name without extension', () => { 19 | const cache = new Map([ 20 | ['AccountHandler.cls', 'force-app/main/default/classes/AccountHandler.cls'], 21 | ['AccountHandler', 'force-app/main/default/classes/AccountHandler.cls'], 22 | ]); 23 | 24 | const result = findFilePath('AccountHandler', cache); 25 | expect(result).toBe('force-app/main/default/classes/AccountHandler.cls'); 26 | }); 27 | 28 | it('should find .cls file when searching by name without extension', () => { 29 | const cache = new Map([ 30 | ['ContactHandler.cls', 'force-app/main/default/classes/ContactHandler.cls'], 31 | ['ContactHandler', 'force-app/main/default/classes/ContactHandler.cls'], 32 | ]); 33 | 34 | const result = findFilePath('ContactHandler', cache); 35 | expect(result).toBe('force-app/main/default/classes/ContactHandler.cls'); 36 | }); 37 | 38 | it('should find .trigger file when searching by name without extension', () => { 39 | const cache = new Map([ 40 | ['AccountTrigger.trigger', 'packaged/triggers/AccountTrigger.trigger'], 41 | ['AccountTrigger', 'packaged/triggers/AccountTrigger.trigger'], 42 | ]); 43 | 44 | const result = findFilePath('AccountTrigger', cache); 45 | expect(result).toBe('packaged/triggers/AccountTrigger.trigger'); 46 | }); 47 | 48 | it('should return undefined when file not found', () => { 49 | const cache = new Map([ 50 | ['AccountHandler.cls', 'force-app/main/default/classes/AccountHandler.cls'], 51 | ]); 52 | 53 | const result = findFilePath('NonExistentClass', cache); 54 | expect(result).toBeUndefined(); 55 | }); 56 | 57 | it('should return undefined when searching for non-Apex file', () => { 58 | const cache = new Map([ 59 | ['AccountHandler.cls', 'force-app/main/default/classes/AccountHandler.cls'], 60 | ]); 61 | 62 | const result = findFilePath('README.md', cache); 63 | expect(result).toBeUndefined(); 64 | }); 65 | 66 | it('should prioritize exact match over extension matches', () => { 67 | const cache = new Map([ 68 | ['TestFile', 'exact/match/TestFile'], 69 | ['TestFile.cls', 'with/cls/TestFile.cls'], 70 | ]); 71 | 72 | const result = findFilePath('TestFile', cache); 73 | expect(result).toBe('exact/match/TestFile'); 74 | }); 75 | 76 | it('should try .cls extension when exact match not found', () => { 77 | const cache = new Map([['TestClass.cls', 'force-app/classes/TestClass.cls']]); 78 | 79 | const result = findFilePath('TestClass', cache); 80 | expect(result).toBe('force-app/classes/TestClass.cls'); 81 | }); 82 | 83 | it('should try .trigger extension when exact and .cls not found', () => { 84 | const cache = new Map([['TestTrigger.trigger', 'force-app/triggers/TestTrigger.trigger']]); 85 | 86 | const result = findFilePath('TestTrigger', cache); 87 | expect(result).toBe('force-app/triggers/TestTrigger.trigger'); 88 | }); 89 | 90 | it('should return undefined when all lookup attempts fail', () => { 91 | const cache = new Map([['SomeClass.cls', 'force-app/classes/SomeClass.cls']]); 92 | 93 | const result = findFilePath('OtherClass', cache); 94 | expect(result).toBeUndefined(); 95 | }); 96 | }); 97 | 98 | describe('without cache', () => { 99 | it('should return undefined when cache is not provided', () => { 100 | const result = findFilePath('AccountHandler'); 101 | expect(result).toBeUndefined(); 102 | }); 103 | 104 | it('should return undefined for any filename when cache is undefined', () => { 105 | const result = findFilePath('TestClass', undefined); 106 | expect(result).toBeUndefined(); 107 | }); 108 | }); 109 | 110 | describe('edge cases', () => { 111 | it('should handle empty cache', () => { 112 | const cache = new Map(); 113 | const result = findFilePath('AccountHandler', cache); 114 | expect(result).toBeUndefined(); 115 | }); 116 | 117 | it('should handle special characters in filename', () => { 118 | const cache = new Map([ 119 | ['Test$Class.cls', 'force-app/classes/Test$Class.cls'], 120 | ['Test$Class', 'force-app/classes/Test$Class.cls'], 121 | ]); 122 | 123 | const result = findFilePath('Test$Class', cache); 124 | expect(result).toBe('force-app/classes/Test$Class.cls'); 125 | }); 126 | 127 | it('should handle filenames with numbers', () => { 128 | const cache = new Map([ 129 | ['Account2Handler.cls', 'force-app/classes/Account2Handler.cls'], 130 | ['Account2Handler', 'force-app/classes/Account2Handler.cls'], 131 | ]); 132 | 133 | const result = findFilePath('Account2Handler', cache); 134 | expect(result).toBe('force-app/classes/Account2Handler.cls'); 135 | }); 136 | 137 | it('should be case-sensitive', () => { 138 | const cache = new Map([ 139 | ['AccountHandler.cls', 'force-app/classes/AccountHandler.cls'], 140 | ['AccountHandler', 'force-app/classes/AccountHandler.cls'], 141 | ]); 142 | 143 | const result = findFilePath('accounthandler', cache); 144 | expect(result).toBeUndefined(); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apex-code-coverage-transformer", 3 | "description": "Transform Salesforce Apex code coverage JSONs into other formats accepted by SonarQube, GitHub, GitLab, Azure, Bitbucket, etc.", 4 | "version": "2.14.2", 5 | "dependencies": { 6 | "@oclif/core": "^4.8.0", 7 | "@salesforce/core": "^8.23.4", 8 | "@salesforce/sf-plugins-core": "^12.2.6", 9 | "async": "^3.2.6", 10 | "xmlbuilder2": "^3.1.1" 11 | }, 12 | "devDependencies": { 13 | "@commitlint/cli": "^19.8.1", 14 | "@commitlint/config-conventional": "^19.8.1", 15 | "@oclif/plugin-command-snapshot": "^5.2.40", 16 | "@salesforce/cli-plugins-testkit": "^5.3.39", 17 | "@salesforce/dev-scripts": "^10.2.11", 18 | "@types/async": "^3.2.24", 19 | "@types/jest": "^29.5.14", 20 | "@types/node": "18", 21 | "eslint-plugin-sf-plugin": "^1.20.26", 22 | "husky": "^9.1.7", 23 | "jest": "^29.7.0", 24 | "oclif": "^4.22.52", 25 | "shx": "0.4.0", 26 | "ts-jest": "^29.4.0", 27 | "ts-jest-mock-import-meta": "^1.3.0", 28 | "ts-node": "^10.9.2", 29 | "typescript": "^5.8.3", 30 | "wireit": "^0.14.12" 31 | }, 32 | "engines": { 33 | "node": ">=20.0.0" 34 | }, 35 | "files": [ 36 | "/lib", 37 | "/messages", 38 | "/oclif.manifest.json", 39 | "/oclif.lock", 40 | "/CHANGELOG.md" 41 | ], 42 | "keywords": [ 43 | "force", 44 | "salesforce", 45 | "salesforcedx", 46 | "sf", 47 | "sf-plugin", 48 | "sfdx", 49 | "sfdx-plugin", 50 | "xml", 51 | "json", 52 | "sonarqube", 53 | "apex", 54 | "coverage", 55 | "git", 56 | "cobertura", 57 | "clover", 58 | "converter", 59 | "transformer", 60 | "code", 61 | "quality", 62 | "validation", 63 | "deployment", 64 | "gitlab", 65 | "github", 66 | "azure", 67 | "bitbucket", 68 | "jacoco", 69 | "lcov", 70 | "json2xml", 71 | "istanbul" 72 | ], 73 | "license": "MIT", 74 | "oclif": { 75 | "commands": "./lib/commands", 76 | "bin": "sf", 77 | "topicSeparator": " ", 78 | "topics": { 79 | "acc-transformer": { 80 | "description": "description for acc-transformer" 81 | } 82 | }, 83 | "hooks": { 84 | "finally": "./lib/hooks/finally" 85 | }, 86 | "devPlugins": [ 87 | "@oclif/plugin-help" 88 | ], 89 | "flexibleTaxonomy": true 90 | }, 91 | "scripts": { 92 | "command-docs": "oclif readme", 93 | "build": "tsc -b", 94 | "clean": "sf-clean", 95 | "clean-all": "sf-clean all", 96 | "clean:lib": "shx rm -rf lib && shx rm -rf coverage && shx rm -rf .nyc_output && shx rm -f oclif.manifest.json oclif.lock", 97 | "compile": "wireit", 98 | "docs": "sf-docs", 99 | "format": "sf-format", 100 | "lint": "wireit", 101 | "postpack": "shx rm -f oclif.manifest.json oclif.lock", 102 | "prepack": "sf-prepack", 103 | "prepare": "husky install", 104 | "test": "wireit", 105 | "test:nuts": "oclif manifest && jest --testMatch \"**/*.nut.ts\"", 106 | "test:only": "wireit", 107 | "version": "oclif readme" 108 | }, 109 | "publishConfig": { 110 | "access": "public" 111 | }, 112 | "wireit": { 113 | "build": { 114 | "dependencies": [ 115 | "compile", 116 | "lint" 117 | ] 118 | }, 119 | "compile": { 120 | "command": "tsc -p . --pretty --incremental", 121 | "files": [ 122 | "src/**/*.ts", 123 | "**/tsconfig.json", 124 | "messages/**" 125 | ], 126 | "output": [ 127 | "lib/**", 128 | "*.tsbuildinfo" 129 | ], 130 | "clean": "if-file-deleted" 131 | }, 132 | "format": { 133 | "command": "prettier --write \"+(src|test|schemas)/**/*.+(ts|js|json)|command-snapshot.json\"", 134 | "files": [ 135 | "src/**/*.ts", 136 | "test/**/*.ts", 137 | "schemas/**/*.json", 138 | "command-snapshot.json", 139 | ".prettier*" 140 | ], 141 | "output": [] 142 | }, 143 | "lint": { 144 | "command": "eslint src test --color --cache --cache-location .eslintcache", 145 | "files": [ 146 | "src/**/*.ts", 147 | "test/**/*.ts", 148 | "messages/**", 149 | "**/.eslint*", 150 | "**/tsconfig.json" 151 | ], 152 | "output": [] 153 | }, 154 | "test:compile": { 155 | "command": "tsc -p \"./test\" --pretty", 156 | "files": [ 157 | "test/**/*.ts", 158 | "**/tsconfig.json" 159 | ], 160 | "output": [] 161 | }, 162 | "test": { 163 | "dependencies": [ 164 | "test:compile", 165 | "test:only", 166 | "lint" 167 | ] 168 | }, 169 | "test:only": { 170 | "command": "jest --coverage", 171 | "env": { 172 | "FORCE_COLOR": "2" 173 | }, 174 | "files": [ 175 | "test/**/*.ts", 176 | "src/**/*.ts", 177 | "**/tsconfig.json", 178 | ".mocha*", 179 | "!*.nut.ts", 180 | ".nycrc" 181 | ], 182 | "output": [] 183 | }, 184 | "test:command-reference": { 185 | "command": "\"./bin/dev\" commandreference:generate --erroronwarnings", 186 | "files": [ 187 | "src/**/*.ts", 188 | "messages/**", 189 | "package.json" 190 | ], 191 | "output": [ 192 | "tmp/root" 193 | ] 194 | }, 195 | "test:deprecation-policy": { 196 | "command": "\"./bin/dev\" snapshot:compare", 197 | "files": [ 198 | "src/**/*.ts" 199 | ], 200 | "output": [], 201 | "dependencies": [ 202 | "compile" 203 | ] 204 | }, 205 | "test:json-schema": { 206 | "command": "\"./bin/dev\" schema:compare", 207 | "files": [ 208 | "src/**/*.ts", 209 | "schemas" 210 | ], 211 | "output": [] 212 | } 213 | }, 214 | "exports": "./lib/index.js", 215 | "type": "module", 216 | "author": "Matt Carvin", 217 | "repository": { 218 | "type": "git", 219 | "url": "git+https://github.com/mcarvin8/apex-code-coverage-transformer.git" 220 | }, 221 | "bugs": { 222 | "url": "https://github.com/mcarvin8/apex-code-coverage-transformer/issues" 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /test/units/buildFilePathCache.test.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { mkdir, writeFile, rm } from 'node:fs/promises'; 4 | import { join } from 'node:path'; 5 | import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 6 | import { buildFilePathCache } from '../../src/utils/buildFilePathCache.js'; 7 | 8 | describe('buildFilePathCache', () => { 9 | const testDir = join(process.cwd(), 'test-cache-temp'); 10 | const packageDir1 = join(testDir, 'force-app', 'main', 'default', 'classes'); 11 | const packageDir2 = join(testDir, 'packaged', 'triggers'); 12 | const repoRoot = testDir; 13 | 14 | beforeEach(async () => { 15 | // Create test directory structure 16 | await mkdir(packageDir1, { recursive: true }); 17 | await mkdir(packageDir2, { recursive: true }); 18 | }); 19 | 20 | afterEach(async () => { 21 | // Clean up test directory 22 | await rm(testDir, { recursive: true, force: true }); 23 | }); 24 | 25 | it('should build cache for .cls files', async () => { 26 | // Create test files 27 | await writeFile(join(packageDir1, 'AccountHandler.cls'), 'public class AccountHandler {}'); 28 | await writeFile(join(packageDir1, 'ContactHandler.cls'), 'public class ContactHandler {}'); 29 | 30 | const cache = await buildFilePathCache([join(testDir, 'force-app')], repoRoot); 31 | 32 | expect(cache.has('AccountHandler.cls')).toBe(true); 33 | expect(cache.has('AccountHandler')).toBe(true); 34 | expect(cache.has('ContactHandler.cls')).toBe(true); 35 | expect(cache.has('ContactHandler')).toBe(true); 36 | expect(cache.get('AccountHandler.cls')).toContain('force-app/main/default/classes/AccountHandler.cls'); 37 | }); 38 | 39 | it('should build cache for .trigger files', async () => { 40 | // Create test files 41 | await writeFile(join(packageDir2, 'AccountTrigger.trigger'), 'trigger AccountTrigger on Account {}'); 42 | 43 | const cache = await buildFilePathCache([join(testDir, 'packaged')], repoRoot); 44 | 45 | expect(cache.has('AccountTrigger.trigger')).toBe(true); 46 | expect(cache.has('AccountTrigger')).toBe(true); 47 | expect(cache.get('AccountTrigger.trigger')).toContain('packaged/triggers/AccountTrigger.trigger'); 48 | }); 49 | 50 | it('should handle multiple package directories', async () => { 51 | await writeFile(join(packageDir1, 'Class1.cls'), 'public class Class1 {}'); 52 | await writeFile(join(packageDir2, 'Trigger1.trigger'), 'trigger Trigger1 on Account {}'); 53 | 54 | const cache = await buildFilePathCache([join(testDir, 'force-app'), join(testDir, 'packaged')], repoRoot); 55 | 56 | expect(cache.has('Class1.cls')).toBe(true); 57 | expect(cache.has('Trigger1.trigger')).toBe(true); 58 | }); 59 | 60 | it('should handle non-existent directories gracefully', async () => { 61 | const nonExistentDir = join(testDir, 'non-existent'); 62 | 63 | // Should not throw error 64 | const cache = await buildFilePathCache([nonExistentDir], repoRoot); 65 | 66 | expect(cache.size).toBe(0); 67 | }); 68 | 69 | it('should handle nested directory structure', async () => { 70 | const nestedDir = join(packageDir1, 'nested', 'deep'); 71 | await mkdir(nestedDir, { recursive: true }); 72 | await writeFile(join(nestedDir, 'DeepClass.cls'), 'public class DeepClass {}'); 73 | 74 | const cache = await buildFilePathCache([join(testDir, 'force-app')], repoRoot); 75 | 76 | expect(cache.has('DeepClass.cls')).toBe(true); 77 | expect(cache.has('DeepClass')).toBe(true); 78 | }); 79 | 80 | it('should ignore non-Apex files', async () => { 81 | await writeFile(join(packageDir1, 'AccountHandler.cls'), 'public class AccountHandler {}'); 82 | await writeFile(join(packageDir1, 'README.md'), '# Readme'); 83 | await writeFile(join(packageDir1, 'config.xml'), ''); 84 | 85 | const cache = await buildFilePathCache([join(testDir, 'force-app')], repoRoot); 86 | 87 | expect(cache.has('AccountHandler.cls')).toBe(true); 88 | expect(cache.has('README.md')).toBe(false); 89 | expect(cache.has('config.xml')).toBe(false); 90 | }); 91 | 92 | it('should not overwrite existing entries with same name without extension', async () => { 93 | // This tests that if AccountHandler.cls is found first, it stores under both 94 | // 'AccountHandler.cls' and 'AccountHandler', and won't be overwritten 95 | await writeFile(join(packageDir1, 'AccountHandler.cls'), 'public class AccountHandler {}'); 96 | 97 | const cache = await buildFilePathCache([join(testDir, 'force-app')], repoRoot); 98 | 99 | expect(cache.has('AccountHandler')).toBe(true); 100 | expect(cache.has('AccountHandler.cls')).toBe(true); 101 | // Both should point to the same file 102 | expect(cache.get('AccountHandler')).toBe(cache.get('AccountHandler.cls')); 103 | }); 104 | 105 | it('should handle files that cannot be stat-ed gracefully', async () => { 106 | // Create a valid file first 107 | await writeFile(join(packageDir1, 'ValidClass.cls'), 'public class ValidClass {}'); 108 | 109 | // Create a broken symlink (points to non-existent target) 110 | // This will be in the directory listing but stat will fail 111 | try { 112 | const { symlink } = await import('node:fs/promises'); 113 | const brokenLink = join(packageDir1, 'BrokenLink.cls'); 114 | const nonExistentTarget = join(packageDir1, 'non-existent-target.cls'); 115 | 116 | // Create symlink to non-existent file 117 | await symlink(nonExistentTarget, brokenLink).catch(() => { 118 | // If symlink fails (e.g., insufficient permissions on Windows), 119 | // skip this part of the test but continue 120 | }); 121 | } catch { 122 | // Symlink might not be available, that's okay 123 | } 124 | 125 | // Build cache - should not crash even if a file becomes inaccessible 126 | const cache = await buildFilePathCache([join(testDir, 'force-app')], repoRoot); 127 | 128 | expect(cache.has('ValidClass.cls')).toBe(true); 129 | // BrokenLink should not be in cache since stat failed 130 | expect(cache.has('BrokenLink.cls')).toBe(false); 131 | }); 132 | 133 | it('should normalize paths to Unix format', async () => { 134 | await writeFile(join(packageDir1, 'TestClass.cls'), 'public class TestClass {}'); 135 | 136 | const cache = await buildFilePathCache([join(testDir, 'force-app')], repoRoot); 137 | 138 | const path = cache.get('TestClass.cls'); 139 | expect(path).toBeDefined(); 140 | // Unix paths use forward slashes 141 | expect(path).not.toContain('\\'); 142 | expect(path).toContain('/'); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/handlers/opencover.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { 4 | OpenCoverCoverageObject, 5 | OpenCoverModule, 6 | OpenCoverFile, 7 | OpenCoverClass, 8 | OpenCoverMethod, 9 | OpenCoverSequencePoint, 10 | } from '../utils/types.js'; 11 | import { BaseHandler } from './BaseHandler.js'; 12 | import { HandlerRegistry } from './HandlerRegistry.js'; 13 | 14 | /** 15 | * Handler for generating OpenCover XML coverage reports. 16 | * 17 | * OpenCover is a code coverage tool for .NET, but its XML format 18 | * is also accepted by Azure DevOps, Visual Studio, and other tools. 19 | * 20 | * **Format Origin**: OpenCover (.NET coverage tool) 21 | * 22 | * @see https://github.com/OpenCover/opencover 23 | * @see https://github.com/OpenCover/opencover/wiki/Reports 24 | * 25 | * **Apex-Specific Adaptations**: 26 | * - Salesforce Apex only provides line-level coverage data 27 | * - Each Apex class is represented as an OpenCover "Module" 28 | * - Line coverage is mapped to "SequencePoints" (executable code locations) 29 | * - Branch coverage is always 0 (Apex doesn't provide branch/decision coverage) 30 | * - Column information (`@sc`, `@ec`) defaults to 0 (not available in Apex) 31 | * 32 | * **Limitations**: 33 | * - No branch/decision coverage - OpenCover supports this, Apex does not 34 | * - No method-level coverage granularity - treating entire class as one method 35 | * - No cyclomatic complexity metrics 36 | * - No column-level positioning data 37 | * 38 | * **Structure Mapping**: 39 | * - Apex Class → OpenCover Module/Class 40 | * - Apex Class → OpenCover Method (single method per class) 41 | * - Apex Lines → OpenCover SequencePoints 42 | * 43 | * Compatible with: 44 | * - Azure DevOps 45 | * - Visual Studio 46 | * - Codecov 47 | * - JetBrains tools (ReSharper, Rider) 48 | * 49 | * @example 50 | * ```xml 51 | * 52 | * 53 | * 54 | * 55 | * 56 | * 57 | * 58 | * 59 | * 60 | * 61 | * 62 | * 63 | * 64 | * 65 | * 66 | * 67 | * 68 | * 69 | * 70 | * 71 | * 72 | * ``` 73 | */ 74 | export class OpenCoverCoverageHandler extends BaseHandler { 75 | private readonly coverageObj: OpenCoverCoverageObject; 76 | private readonly module: OpenCoverModule; 77 | private fileIdCounter = 1; 78 | private filePathToId: Map = new Map(); 79 | 80 | public constructor() { 81 | super(); 82 | this.module = { 83 | '@hash': 'apex-module', 84 | Files: { File: [] }, 85 | Classes: { Class: [] }, 86 | }; 87 | this.coverageObj = { 88 | CoverageSession: { 89 | Summary: { 90 | '@numSequencePoints': 0, 91 | '@visitedSequencePoints': 0, 92 | '@numBranchPoints': 0, 93 | '@visitedBranchPoints': 0, 94 | '@sequenceCoverage': 0, 95 | '@branchCoverage': 0, 96 | }, 97 | Modules: { 98 | Module: [this.module], 99 | }, 100 | }, 101 | }; 102 | } 103 | 104 | public processFile(filePath: string, fileName: string, lines: Record): void { 105 | // Register file if not already registered 106 | if (!this.filePathToId.has(filePath)) { 107 | const fileId = this.fileIdCounter++; 108 | this.filePathToId.set(filePath, fileId); 109 | 110 | const fileObj: OpenCoverFile = { 111 | '@uid': fileId, 112 | '@fullPath': filePath, 113 | }; 114 | this.module.Files.File.push(fileObj); 115 | } 116 | 117 | const { totalLines, coveredLines } = this.calculateCoverage(lines); 118 | 119 | // Create sequence points for each line 120 | // In OpenCover, a SequencePoint represents an executable statement location 121 | // We map each Apex line to a SequencePoint 122 | const sequencePoints: OpenCoverSequencePoint[] = []; 123 | for (const [lineNumber, hits] of Object.entries(lines)) { 124 | sequencePoints.push({ 125 | '@vc': hits, // visit count (number of times this line was executed) 126 | '@sl': Number(lineNumber), // start line 127 | '@sc': 0, // start column (not available in Apex coverage data) 128 | '@el': Number(lineNumber), // end line (same as start for line-level coverage) 129 | '@ec': 0, // end column (not available in Apex coverage data) 130 | }); 131 | } 132 | 133 | // Create a method for this file 134 | // NOTE: Apex classes are treated as a single method since we don't have 135 | // method-level coverage granularity from Salesforce 136 | const method: OpenCoverMethod = { 137 | '@name': fileName, 138 | '@isConstructor': false, 139 | '@isStatic': false, 140 | SequencePoints: { 141 | SequencePoint: sequencePoints, 142 | }, 143 | }; 144 | 145 | // Create a class for this file 146 | const classObj: OpenCoverClass = { 147 | '@fullName': fileName, 148 | Methods: { 149 | Method: [method], 150 | }, 151 | }; 152 | 153 | this.module.Classes.Class.push(classObj); 154 | 155 | // Update summary statistics 156 | this.coverageObj.CoverageSession.Summary['@numSequencePoints'] += totalLines; 157 | this.coverageObj.CoverageSession.Summary['@visitedSequencePoints'] += coveredLines; 158 | } 159 | 160 | public finalize(): OpenCoverCoverageObject { 161 | const summary = this.coverageObj.CoverageSession.Summary; 162 | 163 | // Calculate sequence coverage percentage 164 | if (summary['@numSequencePoints'] > 0) { 165 | const coverage = (summary['@visitedSequencePoints'] / summary['@numSequencePoints']) * 100; 166 | summary['@sequenceCoverage'] = Number(coverage.toFixed(2)); 167 | } 168 | 169 | // Branch coverage is always 0 for Apex (no branch/decision coverage data available) 170 | // In .NET environments, this would track if/else branches, switch cases, etc. 171 | summary['@branchCoverage'] = 0; 172 | 173 | // Sort classes by name for consistent output 174 | this.module.Classes.Class.sort((a, b) => a['@fullName'].localeCompare(b['@fullName'])); 175 | 176 | // Sort files by path and reassign UIDs sequentially for deterministic output 177 | this.module.Files.File.sort((a, b) => a['@fullPath'].localeCompare(b['@fullPath'])); 178 | 179 | // Reassign UIDs based on sorted order 180 | for (let i = 0; i < this.module.Files.File.length; i++) { 181 | this.module.Files.File[i]['@uid'] = i + 1; 182 | } 183 | 184 | return this.coverageObj; 185 | } 186 | } 187 | 188 | // Self-register this handler 189 | HandlerRegistry.register({ 190 | name: 'opencover', 191 | description: 'OpenCover XML format for .NET and Azure DevOps', 192 | fileExtension: '.xml', 193 | handler: () => new OpenCoverCoverageHandler(), 194 | compatibleWith: ['Azure DevOps', 'Visual Studio', 'Codecov', 'JetBrains Tools'], 195 | }); 196 | -------------------------------------------------------------------------------- /test/units/baseHandler.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | 'use strict'; 3 | import { describe, it, expect } from '@jest/globals'; 4 | 5 | import { BaseHandler } from '../../src/handlers/BaseHandler.js'; 6 | import type { 7 | SonarCoverageObject, 8 | CoberturaCoverageObject, 9 | CloverCoverageObject, 10 | LcovCoverageObject, 11 | JaCoCoCoverageObject, 12 | IstanbulCoverageObject, 13 | JsonSummaryCoverageObject, 14 | SimpleCovCoverageObject, 15 | OpenCoverCoverageObject, 16 | } from '../../src/utils/types.js'; 17 | 18 | // Create a concrete implementation for testing 19 | class TestHandler extends BaseHandler { 20 | private data: string[] = []; 21 | 22 | public processFile(filePath: string, fileName: string, lines: Record): void { 23 | this.data.push(`${filePath}:${fileName}`); 24 | // Use protected methods for testing 25 | const coverage = this.calculateCoverage(lines); 26 | const byStatus = this.getCoveredAndUncovered(lines); 27 | this.data.push(`total:${coverage.totalLines},covered:${coverage.coveredLines}`); 28 | this.data.push(`covered-lines:${byStatus.covered.join(',')}`); 29 | } 30 | 31 | public finalize(): 32 | | SonarCoverageObject 33 | | CoberturaCoverageObject 34 | | CloverCoverageObject 35 | | LcovCoverageObject 36 | | JaCoCoCoverageObject 37 | | IstanbulCoverageObject 38 | | JsonSummaryCoverageObject 39 | | SimpleCovCoverageObject 40 | | OpenCoverCoverageObject { 41 | return { data: this.data } as unknown as 42 | | SonarCoverageObject 43 | | CoberturaCoverageObject 44 | | CloverCoverageObject 45 | | LcovCoverageObject 46 | | JaCoCoCoverageObject 47 | | IstanbulCoverageObject 48 | | JsonSummaryCoverageObject 49 | | SimpleCovCoverageObject 50 | | OpenCoverCoverageObject; 51 | } 52 | 53 | // Expose protected methods for testing 54 | public testCalculateCoverage(lines: Record) { 55 | return this.calculateCoverage(lines); 56 | } 57 | 58 | public testExtractLinesByStatus(lines: Record, covered: boolean) { 59 | return this.extractLinesByStatus(lines, covered); 60 | } 61 | 62 | public testGetCoveredAndUncovered(lines: Record) { 63 | return this.getCoveredAndUncovered(lines); 64 | } 65 | 66 | public testSortByPath(items: T[]): T[] { 67 | return this.sortByPath(items); 68 | } 69 | } 70 | 71 | describe('BaseHandler unit tests', () => { 72 | it('should calculate coverage correctly', () => { 73 | const handler = new TestHandler(); 74 | const lines = { 75 | '1': 1, 76 | '2': 1, 77 | '3': 0, 78 | '4': 1, 79 | '5': 0, 80 | }; 81 | 82 | const result = handler.testCalculateCoverage(lines); 83 | expect(result.totalLines).toBe(5); 84 | expect(result.coveredLines).toBe(3); 85 | expect(result.uncoveredLines).toBe(2); 86 | expect(result.lineRate).toBe(0.6); // 3/5 87 | }); 88 | 89 | it('should calculate coverage for all covered lines', () => { 90 | const handler = new TestHandler(); 91 | const lines = { 92 | '1': 1, 93 | '2': 1, 94 | '3': 1, 95 | }; 96 | 97 | const result = handler.testCalculateCoverage(lines); 98 | expect(result.totalLines).toBe(3); 99 | expect(result.coveredLines).toBe(3); 100 | expect(result.uncoveredLines).toBe(0); 101 | expect(result.lineRate).toBe(1.0); 102 | }); 103 | 104 | it('should calculate coverage for no covered lines', () => { 105 | const handler = new TestHandler(); 106 | const lines = { 107 | '1': 0, 108 | '2': 0, 109 | }; 110 | 111 | const result = handler.testCalculateCoverage(lines); 112 | expect(result.totalLines).toBe(2); 113 | expect(result.coveredLines).toBe(0); 114 | expect(result.uncoveredLines).toBe(2); 115 | expect(result.lineRate).toBe(0); 116 | }); 117 | 118 | it('should extract covered lines correctly', () => { 119 | const handler = new TestHandler(); 120 | const lines = { 121 | '5': 1, 122 | '2': 0, 123 | '8': 1, 124 | '1': 1, 125 | '3': 0, 126 | }; 127 | 128 | const covered = handler.testExtractLinesByStatus(lines, true); 129 | expect(covered).toEqual([1, 5, 8]); // Sorted 130 | }); 131 | 132 | it('should extract uncovered lines correctly', () => { 133 | const handler = new TestHandler(); 134 | const lines = { 135 | '5': 1, 136 | '2': 0, 137 | '8': 1, 138 | '1': 1, 139 | '3': 0, 140 | }; 141 | 142 | const uncovered = handler.testExtractLinesByStatus(lines, false); 143 | expect(uncovered).toEqual([2, 3]); // Sorted 144 | }); 145 | 146 | it('should get both covered and uncovered lines', () => { 147 | const handler = new TestHandler(); 148 | const lines = { 149 | '1': 1, 150 | '2': 0, 151 | '3': 1, 152 | '4': 0, 153 | }; 154 | 155 | const result = handler.testGetCoveredAndUncovered(lines); 156 | expect(result.covered).toEqual([1, 3]); 157 | expect(result.uncovered).toEqual([2, 4]); 158 | }); 159 | 160 | it('should sort items by @path', () => { 161 | const handler = new TestHandler(); 162 | const items = [{ '@path': 'zzz/file.cls' }, { '@path': 'aaa/file.cls' }, { '@path': 'mmm/file.cls' }]; 163 | 164 | const sorted = handler.testSortByPath(items); 165 | expect(sorted[0]['@path']).toBe('aaa/file.cls'); 166 | expect(sorted[1]['@path']).toBe('mmm/file.cls'); 167 | expect(sorted[2]['@path']).toBe('zzz/file.cls'); 168 | }); 169 | 170 | it('should sort items by @filename when @path is missing', () => { 171 | const handler = new TestHandler(); 172 | const items = [{ '@filename': 'zzz.cls' }, { '@filename': 'aaa.cls' }, { '@filename': 'mmm.cls' }]; 173 | 174 | const sorted = handler.testSortByPath(items); 175 | expect(sorted[0]['@filename']).toBe('aaa.cls'); 176 | expect(sorted[1]['@filename']).toBe('mmm.cls'); 177 | expect(sorted[2]['@filename']).toBe('zzz.cls'); 178 | }); 179 | 180 | it('should sort items by @name when @path and @filename are missing', () => { 181 | const handler = new TestHandler(); 182 | const items = [{ '@name': 'zzz' }, { '@name': 'aaa' }, { '@name': 'mmm' }]; 183 | 184 | const sorted = handler.testSortByPath(items); 185 | expect(sorted[0]['@name']).toBe('aaa'); 186 | expect(sorted[1]['@name']).toBe('mmm'); 187 | expect(sorted[2]['@name']).toBe('zzz'); 188 | }); 189 | 190 | it('should sort items with missing path properties using empty string', () => { 191 | const handler = new TestHandler(); 192 | type ItemType = { [key: string]: unknown; '@path'?: string; '@filename'?: string; '@name'?: string }; 193 | const items: ItemType[] = [{ other: 'value' }, { '@name': 'aaa' }, { other2: 'value2' }]; 194 | 195 | const sorted = handler.testSortByPath(items); 196 | // Items without any path property get empty string, so they sort first 197 | // Then the one with '@name': 'aaa' comes after 198 | expect(sorted.length).toBe(3); 199 | }); 200 | 201 | it('should handle empty lines in calculateCoverage', () => { 202 | const handler = new TestHandler(); 203 | const lines = {}; 204 | 205 | const result = handler.testCalculateCoverage(lines); 206 | expect(result.totalLines).toBe(0); 207 | expect(result.coveredLines).toBe(0); 208 | expect(result.uncoveredLines).toBe(0); 209 | expect(result.lineRate).toBe(0); 210 | }); 211 | 212 | it('should process file using base handler methods', () => { 213 | const handler = new TestHandler(); 214 | const lines = { 215 | '1': 1, 216 | '2': 0, 217 | '3': 1, 218 | }; 219 | 220 | handler.processFile('path/to/file.cls', 'FileName', lines); 221 | const result = handler.finalize(); 222 | 223 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access 224 | expect((result as any).data).toContain('path/to/file.cls:FileName'); 225 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access 226 | expect((result as any).data).toContain('total:3,covered:2'); 227 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access 228 | expect((result as any).data).toContain('covered-lines:1,3'); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { getCoverageHandler } from '../handlers/getHandler.js'; 3 | 4 | export type TransformerTransformResult = { 5 | path: string[]; 6 | }; 7 | 8 | export type DeployCoverageData = { 9 | [className: string]: { 10 | fnMap: Record; 11 | branchMap: Record; 12 | path: string; 13 | f: Record; 14 | b: Record; 15 | s: Record; 16 | statementMap: Record< 17 | string, 18 | { 19 | start: { line: number; column: number }; 20 | end: { line: number; column: number }; 21 | } 22 | >; 23 | }; 24 | }; 25 | 26 | export type TestCoverageData = { 27 | id: string; 28 | name: string; 29 | totalLines: number; 30 | lines: Record; 31 | totalCovered: number; 32 | coveredPercent: number; 33 | }; 34 | 35 | export type SfdxProject = { 36 | packageDirectories: Array<{ path: string }>; 37 | }; 38 | 39 | export type CoverageProcessingContext = { 40 | handlers: Map>; 41 | packageDirs: string[]; 42 | repoRoot: string; 43 | concurrencyLimit: number; 44 | warnings: string[]; 45 | filePathCache: Map; 46 | }; 47 | 48 | type SonarLine = { 49 | '@lineNumber': number; 50 | '@covered': boolean; 51 | }; 52 | 53 | export type SonarClass = { 54 | '@path': string; 55 | lineToCover: SonarLine[]; 56 | }; 57 | 58 | export type SonarCoverageObject = { 59 | coverage: { 60 | file: SonarClass[]; 61 | '@version': string; 62 | }; 63 | }; 64 | 65 | export type HookFile = { 66 | deployCoverageJsonPath: string; 67 | testCoverageJsonPath: string; 68 | outputReportPath: string; 69 | format: string; 70 | ignorePackageDirectories: string; 71 | }; 72 | 73 | export type CoberturaLine = { 74 | '@number': number; 75 | '@hits': number; 76 | '@branch': string; 77 | }; 78 | 79 | export type CoberturaClass = { 80 | '@name': string; 81 | '@filename': string; 82 | '@line-rate': number; 83 | '@branch-rate': number; 84 | methods: Record; 85 | lines: { 86 | line: CoberturaLine[]; 87 | }; 88 | }; 89 | 90 | export type CoberturaPackage = { 91 | '@name': string; 92 | '@line-rate': number; 93 | '@branch-rate': number; 94 | classes: { 95 | class: CoberturaClass[]; 96 | }; 97 | }; 98 | 99 | export type CoberturaCoverageObject = { 100 | coverage: { 101 | '@lines-valid': number; 102 | '@lines-covered': number; 103 | '@line-rate': number; 104 | '@branches-valid': number; 105 | '@branches-covered': number; 106 | '@branch-rate': number | string; 107 | '@timestamp': number; 108 | '@complexity': number; 109 | '@version': string; 110 | sources: { 111 | source: string[]; 112 | }; 113 | packages: { 114 | package: CoberturaPackage[]; 115 | }; 116 | }; 117 | }; 118 | 119 | export type CloverLine = { 120 | '@num': number; 121 | '@count': number; 122 | '@type': string; 123 | }; 124 | 125 | export type CloverFile = { 126 | '@name': string; 127 | '@path': string; 128 | metrics: { 129 | '@statements': number; 130 | '@coveredstatements': number; 131 | '@conditionals': number; 132 | '@coveredconditionals': number; 133 | '@methods': number; 134 | '@coveredmethods': number; 135 | }; 136 | line: CloverLine[]; 137 | }; 138 | 139 | type CloverProjectMetrics = { 140 | '@statements': number; 141 | '@coveredstatements': number; 142 | '@conditionals': number; 143 | '@coveredconditionals': number; 144 | '@methods': number; 145 | '@coveredmethods': number; 146 | '@elements': number; 147 | '@coveredelements': number; 148 | '@complexity': number; 149 | '@loc': number; 150 | '@ncloc': number; 151 | '@packages': number; 152 | '@files': number; 153 | '@classes': number; 154 | }; 155 | 156 | type CloverProject = { 157 | '@timestamp': number; 158 | '@name': string; 159 | metrics: CloverProjectMetrics; 160 | file: CloverFile[]; 161 | }; 162 | 163 | export type CloverCoverageObject = { 164 | coverage: { 165 | '@generated': number; 166 | '@clover': string; 167 | project: CloverProject; 168 | }; 169 | }; 170 | 171 | export type CoverageHandler = { 172 | processFile(filePath: string, fileName: string, lines: Record): void; 173 | finalize(): 174 | | SonarCoverageObject 175 | | CoberturaCoverageObject 176 | | CloverCoverageObject 177 | | LcovCoverageObject 178 | | JaCoCoCoverageObject 179 | | IstanbulCoverageObject 180 | | JsonSummaryCoverageObject 181 | | SimpleCovCoverageObject 182 | | OpenCoverCoverageObject; 183 | }; 184 | 185 | type LcovLine = { 186 | lineNumber: number; 187 | hitCount: number; 188 | }; 189 | 190 | export type LcovFile = { 191 | sourceFile: string; 192 | lines: LcovLine[]; 193 | totalLines: number; 194 | coveredLines: number; 195 | }; 196 | 197 | export type LcovCoverageObject = { 198 | files: LcovFile[]; 199 | }; 200 | 201 | export type JaCoCoCoverageObject = { 202 | report: { 203 | '@name': string; 204 | package: JaCoCoPackage[]; 205 | counter: JaCoCoCounter[]; 206 | }; 207 | }; 208 | 209 | export type JaCoCoPackage = { 210 | '@name': string; 211 | sourcefile: JaCoCoSourceFile[]; 212 | counter: JaCoCoCounter[]; 213 | }; 214 | 215 | export type JaCoCoSourceFile = { 216 | '@name': string; 217 | line: JaCoCoLine[]; 218 | counter: JaCoCoCounter[]; 219 | }; 220 | 221 | export type JaCoCoLine = { 222 | '@nr': number; // Line number 223 | '@mi': number; // Missed (0 = not covered, 1 = covered) 224 | '@ci': number; // Covered (1 = covered, 0 = missed) 225 | '@mb'?: number; // Missed Branch (optional, can be adjusted if needed) 226 | '@cb'?: number; // Covered Branch (optional, can be adjusted if needed) 227 | }; 228 | 229 | export type JaCoCoCounter = { 230 | '@type': 'INSTRUCTION' | 'BRANCH' | 'LINE' | 'METHOD' | 'CLASS' | 'PACKAGE'; 231 | '@missed': number; 232 | '@covered': number; 233 | }; 234 | 235 | export type IstanbulCoverageMap = { 236 | [filePath: string]: IstanbulCoverageFile; 237 | }; 238 | 239 | export type IstanbulCoverageFile = { 240 | path: string; 241 | statementMap: Record; 242 | fnMap: Record; 243 | branchMap: Record; 244 | s: Record; // statement hits 245 | f: Record; // function hits 246 | b: Record; // branch hits 247 | l: Record; // line hits 248 | }; 249 | 250 | export type SourcePosition = { 251 | line: number; 252 | column: number; 253 | }; 254 | 255 | export type SourceRange = { 256 | start: SourcePosition; 257 | end: SourcePosition; 258 | }; 259 | 260 | export type FunctionMapping = { 261 | name: string; 262 | decl: SourceRange; 263 | loc: SourceRange; 264 | line: number; 265 | }; 266 | 267 | export type BranchMapping = { 268 | loc: SourceRange; 269 | type: string; 270 | locations: SourceRange[]; 271 | line: number; 272 | }; 273 | 274 | export type IstanbulCoverageObject = IstanbulCoverageMap; // alias for clarity 275 | 276 | // JSON Summary format types 277 | export type JsonSummaryFileCoverage = { 278 | lines: { 279 | total: number; 280 | covered: number; 281 | skipped: number; 282 | pct: number; 283 | }; 284 | statements: { 285 | total: number; 286 | covered: number; 287 | skipped: number; 288 | pct: number; 289 | }; 290 | }; 291 | 292 | export type JsonSummaryCoverageObject = { 293 | total: JsonSummaryFileCoverage; 294 | files: { 295 | [filePath: string]: JsonSummaryFileCoverage; 296 | }; 297 | }; 298 | 299 | // SimpleCov JSON format types 300 | export type SimpleCovCoverageObject = { 301 | coverage: { 302 | [filePath: string]: Array; 303 | }; 304 | timestamp: number; 305 | }; 306 | 307 | // OpenCover XML format types 308 | export type OpenCoverSequencePoint = { 309 | '@vc': number; // visit count 310 | '@sl': number; // start line 311 | '@sc'?: number; // start column 312 | '@el'?: number; // end line 313 | '@ec'?: number; // end column 314 | }; 315 | 316 | export type OpenCoverMethod = { 317 | '@name': string; 318 | '@isConstructor'?: boolean; 319 | '@isStatic'?: boolean; 320 | '@isGetter'?: boolean; 321 | '@isSetter'?: boolean; 322 | SequencePoints: { 323 | SequencePoint: OpenCoverSequencePoint[]; 324 | }; 325 | }; 326 | 327 | export type OpenCoverClass = { 328 | '@fullName': string; 329 | Methods: { 330 | Method: OpenCoverMethod[]; 331 | }; 332 | }; 333 | 334 | export type OpenCoverFile = { 335 | '@uid': number; 336 | '@fullPath': string; 337 | }; 338 | 339 | export type OpenCoverModule = { 340 | '@hash': string; 341 | Files: { 342 | File: OpenCoverFile[]; 343 | }; 344 | Classes: { 345 | Class: OpenCoverClass[]; 346 | }; 347 | }; 348 | 349 | export type OpenCoverCoverageObject = { 350 | CoverageSession: { 351 | Summary: { 352 | '@numSequencePoints': number; 353 | '@visitedSequencePoints': number; 354 | '@numBranchPoints': number; 355 | '@visitedBranchPoints': number; 356 | '@sequenceCoverage': number; 357 | '@branchCoverage': number; 358 | }; 359 | Modules: { 360 | Module: OpenCoverModule[]; 361 | }; 362 | }; 363 | }; 364 | -------------------------------------------------------------------------------- /samples/classes/AccountHandler.cls: -------------------------------------------------------------------------------- 1 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 2 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 3 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 4 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 5 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 6 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 7 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 8 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 9 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 10 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 11 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 12 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 13 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 14 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 15 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 16 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 17 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 18 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 19 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 20 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 21 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 22 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 23 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 24 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 25 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 26 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 27 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 28 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 29 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 30 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 31 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 32 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 33 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 34 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 35 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 36 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 37 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 38 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 39 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 40 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 41 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 42 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 43 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 44 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 45 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 46 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 47 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 48 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 49 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 50 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 51 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 52 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 53 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 54 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 55 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 56 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 57 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 58 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 59 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 60 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 61 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 62 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 63 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 64 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 65 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 66 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 67 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 68 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 69 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 70 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 71 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 72 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 73 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 74 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 75 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 76 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 77 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 78 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 79 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 80 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 81 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 82 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 83 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 84 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 85 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 86 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 87 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 88 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 89 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 90 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 91 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 92 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 93 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 94 | -------------------------------------------------------------------------------- /samples/classes/AccountProfile.cls: -------------------------------------------------------------------------------- 1 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 2 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 3 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 4 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 5 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 6 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 7 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 8 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 9 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 10 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 11 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 12 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 13 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 14 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 15 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 16 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 17 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 18 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 19 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 20 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 21 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 22 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 23 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 24 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 25 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 26 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 27 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 28 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 29 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 30 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 31 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 32 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 33 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 34 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 35 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 36 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 37 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 38 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 39 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 40 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 41 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 42 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 43 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 44 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 45 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 46 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 47 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 48 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 49 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 50 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 51 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 52 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 53 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 54 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 55 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 56 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 57 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 58 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 59 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 60 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 61 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 62 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 63 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 64 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 65 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 66 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 67 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 68 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 69 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 70 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 71 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 72 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 73 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 74 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 75 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 76 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 77 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 78 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 79 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 80 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 81 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 82 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 83 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 84 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 85 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 86 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 87 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 88 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 89 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 90 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 91 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 92 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 93 | // This is a sample Apex class that needs to have as many lines as the coverage reports under test have 94 | -------------------------------------------------------------------------------- /inputs/deploy_coverage.json: -------------------------------------------------------------------------------- 1 | { 2 | "no-map/AccountTrigger": { 3 | "fnMap": {}, 4 | "branchMap": {}, 5 | "path": "no-map/AccountTrigger", 6 | "f": {}, 7 | "b": {}, 8 | "s": { 9 | "52": 0, 10 | "53": 0, 11 | "54": 1, 12 | "55": 1, 13 | "56": 1, 14 | "57": 1, 15 | "58": 1, 16 | "59": 0, 17 | "60": 0, 18 | "61": 1, 19 | "62": 1, 20 | "63": 1, 21 | "64": 1, 22 | "65": 1, 23 | "66": 1, 24 | "67": 1, 25 | "68": 1, 26 | "69": 1, 27 | "70": 1, 28 | "71": 1, 29 | "72": 1, 30 | "73": 1, 31 | "74": 1, 32 | "75": 1, 33 | "76": 1, 34 | "77": 1, 35 | "78": 1, 36 | "79": 1, 37 | "80": 1, 38 | "81": 1, 39 | "82": 1 40 | }, 41 | "statementMap": { 42 | "52": { "start": { "line": 52, "column": 0 }, "end": { "line": 52, "column": 0 } }, 43 | "53": { "start": { "line": 53, "column": 0 }, "end": { "line": 53, "column": 0 } }, 44 | "54": { "start": { "line": 54, "column": 0 }, "end": { "line": 54, "column": 0 } }, 45 | "55": { "start": { "line": 55, "column": 0 }, "end": { "line": 55, "column": 0 } }, 46 | "56": { "start": { "line": 56, "column": 0 }, "end": { "line": 56, "column": 0 } }, 47 | "57": { "start": { "line": 57, "column": 0 }, "end": { "line": 57, "column": 0 } }, 48 | "58": { "start": { "line": 58, "column": 0 }, "end": { "line": 58, "column": 0 } }, 49 | "59": { "start": { "line": 59, "column": 0 }, "end": { "line": 59, "column": 0 } }, 50 | "60": { "start": { "line": 60, "column": 0 }, "end": { "line": 60, "column": 0 } }, 51 | "61": { "start": { "line": 61, "column": 0 }, "end": { "line": 61, "column": 0 } }, 52 | "62": { "start": { "line": 62, "column": 0 }, "end": { "line": 62, "column": 0 } }, 53 | "63": { "start": { "line": 63, "column": 0 }, "end": { "line": 63, "column": 0 } }, 54 | "64": { "start": { "line": 64, "column": 0 }, "end": { "line": 64, "column": 0 } }, 55 | "65": { "start": { "line": 65, "column": 0 }, "end": { "line": 65, "column": 0 } }, 56 | "66": { "start": { "line": 66, "column": 0 }, "end": { "line": 66, "column": 0 } }, 57 | "67": { "start": { "line": 67, "column": 0 }, "end": { "line": 67, "column": 0 } }, 58 | "68": { "start": { "line": 68, "column": 0 }, "end": { "line": 68, "column": 0 } }, 59 | "69": { "start": { "line": 69, "column": 0 }, "end": { "line": 69, "column": 0 } }, 60 | "70": { "start": { "line": 70, "column": 0 }, "end": { "line": 70, "column": 0 } }, 61 | "71": { "start": { "line": 71, "column": 0 }, "end": { "line": 71, "column": 0 } }, 62 | "72": { "start": { "line": 72, "column": 0 }, "end": { "line": 72, "column": 0 } }, 63 | "73": { "start": { "line": 73, "column": 0 }, "end": { "line": 73, "column": 0 } }, 64 | "74": { "start": { "line": 74, "column": 0 }, "end": { "line": 74, "column": 0 } }, 65 | "75": { "start": { "line": 75, "column": 0 }, "end": { "line": 75, "column": 0 } }, 66 | "76": { "start": { "line": 76, "column": 0 }, "end": { "line": 76, "column": 0 } }, 67 | "77": { "start": { "line": 77, "column": 0 }, "end": { "line": 77, "column": 0 } }, 68 | "78": { "start": { "line": 78, "column": 0 }, "end": { "line": 78, "column": 0 } }, 69 | "79": { "start": { "line": 79, "column": 0 }, "end": { "line": 79, "column": 0 } }, 70 | "80": { "start": { "line": 80, "column": 0 }, "end": { "line": 80, "column": 0 } }, 71 | "81": { "start": { "line": 81, "column": 0 }, "end": { "line": 81, "column": 0 } }, 72 | "82": { "start": { "line": 82, "column": 0 }, "end": { "line": 82, "column": 0 } } 73 | } 74 | }, 75 | "no-map/AccountProfile": { 76 | "fnMap": {}, 77 | "branchMap": {}, 78 | "path": "no-map/AccountProfile", 79 | "f": {}, 80 | "b": {}, 81 | "s": { 82 | "52": 0, 83 | "53": 0, 84 | "54": 1, 85 | "55": 1, 86 | "56": 1, 87 | "57": 1, 88 | "58": 1, 89 | "59": 0, 90 | "60": 0, 91 | "61": 1, 92 | "62": 1, 93 | "63": 1, 94 | "64": 1, 95 | "65": 1, 96 | "66": 1, 97 | "67": 1, 98 | "68": 1, 99 | "69": 1, 100 | "70": 1, 101 | "71": 1, 102 | "72": 1, 103 | "73": 1, 104 | "74": 1, 105 | "75": 1, 106 | "76": 1, 107 | "77": 1, 108 | "78": 1, 109 | "79": 1, 110 | "80": 1, 111 | "81": 1, 112 | "82": 1 113 | }, 114 | "statementMap": { 115 | "52": { "start": { "line": 52, "column": 0 }, "end": { "line": 52, "column": 0 } }, 116 | "53": { "start": { "line": 53, "column": 0 }, "end": { "line": 53, "column": 0 } }, 117 | "54": { "start": { "line": 54, "column": 0 }, "end": { "line": 54, "column": 0 } }, 118 | "55": { "start": { "line": 55, "column": 0 }, "end": { "line": 55, "column": 0 } }, 119 | "56": { "start": { "line": 56, "column": 0 }, "end": { "line": 56, "column": 0 } }, 120 | "57": { "start": { "line": 57, "column": 0 }, "end": { "line": 57, "column": 0 } }, 121 | "58": { "start": { "line": 58, "column": 0 }, "end": { "line": 58, "column": 0 } }, 122 | "59": { "start": { "line": 59, "column": 0 }, "end": { "line": 59, "column": 0 } }, 123 | "60": { "start": { "line": 60, "column": 0 }, "end": { "line": 60, "column": 0 } }, 124 | "61": { "start": { "line": 61, "column": 0 }, "end": { "line": 61, "column": 0 } }, 125 | "62": { "start": { "line": 62, "column": 0 }, "end": { "line": 62, "column": 0 } }, 126 | "63": { "start": { "line": 63, "column": 0 }, "end": { "line": 63, "column": 0 } }, 127 | "64": { "start": { "line": 64, "column": 0 }, "end": { "line": 64, "column": 0 } }, 128 | "65": { "start": { "line": 65, "column": 0 }, "end": { "line": 65, "column": 0 } }, 129 | "66": { "start": { "line": 66, "column": 0 }, "end": { "line": 66, "column": 0 } }, 130 | "67": { "start": { "line": 67, "column": 0 }, "end": { "line": 67, "column": 0 } }, 131 | "68": { "start": { "line": 68, "column": 0 }, "end": { "line": 68, "column": 0 } }, 132 | "69": { "start": { "line": 69, "column": 0 }, "end": { "line": 69, "column": 0 } }, 133 | "70": { "start": { "line": 70, "column": 0 }, "end": { "line": 70, "column": 0 } }, 134 | "71": { "start": { "line": 71, "column": 0 }, "end": { "line": 71, "column": 0 } }, 135 | "72": { "start": { "line": 72, "column": 0 }, "end": { "line": 72, "column": 0 } }, 136 | "73": { "start": { "line": 73, "column": 0 }, "end": { "line": 73, "column": 0 } }, 137 | "74": { "start": { "line": 74, "column": 0 }, "end": { "line": 74, "column": 0 } }, 138 | "75": { "start": { "line": 75, "column": 0 }, "end": { "line": 75, "column": 0 } }, 139 | "76": { "start": { "line": 76, "column": 0 }, "end": { "line": 76, "column": 0 } }, 140 | "77": { "start": { "line": 77, "column": 0 }, "end": { "line": 77, "column": 0 } }, 141 | "78": { "start": { "line": 78, "column": 0 }, "end": { "line": 78, "column": 0 } }, 142 | "79": { "start": { "line": 79, "column": 0 }, "end": { "line": 79, "column": 0 } }, 143 | "80": { "start": { "line": 80, "column": 0 }, "end": { "line": 80, "column": 0 } }, 144 | "81": { "start": { "line": 81, "column": 0 }, "end": { "line": 81, "column": 0 } }, 145 | "82": { "start": { "line": 82, "column": 0 }, "end": { "line": 82, "column": 0 } } 146 | } 147 | }, 148 | "no-map/AccountHandler": { 149 | "fnMap": {}, 150 | "branchMap": {}, 151 | "path": "no-map/AccountHandler", 152 | "f": {}, 153 | "b": {}, 154 | "s": { 155 | "52": 0, 156 | "53": 0, 157 | "54": 1, 158 | "55": 1, 159 | "56": 1, 160 | "57": 1, 161 | "58": 1, 162 | "59": 0, 163 | "60": 0, 164 | "61": 1, 165 | "62": 1, 166 | "63": 1, 167 | "64": 1, 168 | "65": 1, 169 | "66": 1, 170 | "67": 1, 171 | "68": 1, 172 | "69": 1, 173 | "70": 1, 174 | "71": 1, 175 | "72": 1, 176 | "73": 1, 177 | "74": 1, 178 | "75": 1, 179 | "76": 1, 180 | "77": 1, 181 | "78": 1, 182 | "79": 1, 183 | "80": 1, 184 | "81": 1, 185 | "82": 1 186 | }, 187 | "statementMap": { 188 | "52": { "start": { "line": 52, "column": 0 }, "end": { "line": 52, "column": 0 } }, 189 | "53": { "start": { "line": 53, "column": 0 }, "end": { "line": 53, "column": 0 } }, 190 | "54": { "start": { "line": 54, "column": 0 }, "end": { "line": 54, "column": 0 } }, 191 | "55": { "start": { "line": 55, "column": 0 }, "end": { "line": 55, "column": 0 } }, 192 | "56": { "start": { "line": 56, "column": 0 }, "end": { "line": 56, "column": 0 } }, 193 | "57": { "start": { "line": 57, "column": 0 }, "end": { "line": 57, "column": 0 } }, 194 | "58": { "start": { "line": 58, "column": 0 }, "end": { "line": 58, "column": 0 } }, 195 | "59": { "start": { "line": 59, "column": 0 }, "end": { "line": 59, "column": 0 } }, 196 | "60": { "start": { "line": 60, "column": 0 }, "end": { "line": 60, "column": 0 } }, 197 | "61": { "start": { "line": 61, "column": 0 }, "end": { "line": 61, "column": 0 } }, 198 | "62": { "start": { "line": 62, "column": 0 }, "end": { "line": 62, "column": 0 } }, 199 | "63": { "start": { "line": 63, "column": 0 }, "end": { "line": 63, "column": 0 } }, 200 | "64": { "start": { "line": 64, "column": 0 }, "end": { "line": 64, "column": 0 } }, 201 | "65": { "start": { "line": 65, "column": 0 }, "end": { "line": 65, "column": 0 } }, 202 | "66": { "start": { "line": 66, "column": 0 }, "end": { "line": 66, "column": 0 } }, 203 | "67": { "start": { "line": 67, "column": 0 }, "end": { "line": 67, "column": 0 } }, 204 | "68": { "start": { "line": 68, "column": 0 }, "end": { "line": 68, "column": 0 } }, 205 | "69": { "start": { "line": 69, "column": 0 }, "end": { "line": 69, "column": 0 } }, 206 | "70": { "start": { "line": 70, "column": 0 }, "end": { "line": 70, "column": 0 } }, 207 | "71": { "start": { "line": 71, "column": 0 }, "end": { "line": 71, "column": 0 } }, 208 | "72": { "start": { "line": 72, "column": 0 }, "end": { "line": 72, "column": 0 } }, 209 | "73": { "start": { "line": 73, "column": 0 }, "end": { "line": 73, "column": 0 } }, 210 | "74": { "start": { "line": 74, "column": 0 }, "end": { "line": 74, "column": 0 } }, 211 | "75": { "start": { "line": 75, "column": 0 }, "end": { "line": 75, "column": 0 } }, 212 | "76": { "start": { "line": 76, "column": 0 }, "end": { "line": 76, "column": 0 } }, 213 | "77": { "start": { "line": 77, "column": 0 }, "end": { "line": 77, "column": 0 } }, 214 | "78": { "start": { "line": 78, "column": 0 }, "end": { "line": 78, "column": 0 } }, 215 | "79": { "start": { "line": 79, "column": 0 }, "end": { "line": 79, "column": 0 } }, 216 | "80": { "start": { "line": 80, "column": 0 }, "end": { "line": 80, "column": 0 } }, 217 | "81": { "start": { "line": 81, "column": 0 }, "end": { "line": 81, "column": 0 } }, 218 | "82": { "start": { "line": 82, "column": 0 }, "end": { "line": 82, "column": 0 } } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /samples/triggers/AccountTrigger.trigger: -------------------------------------------------------------------------------- 1 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 2 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 3 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 4 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 5 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 6 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 7 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 8 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 9 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 10 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 11 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 12 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 13 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 14 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 15 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 16 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 17 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 18 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 19 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 20 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 21 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 22 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 23 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 24 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 25 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 26 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 27 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 28 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 29 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 30 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 31 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 32 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 33 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 34 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 35 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 36 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 37 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 38 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 39 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 40 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 41 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 42 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 43 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 44 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 45 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 46 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 47 | 48 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 49 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 50 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 51 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 52 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 53 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 54 | 55 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have// This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 56 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 57 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 58 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 59 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 60 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 61 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 62 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 63 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 64 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 65 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 66 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 67 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 68 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 69 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 70 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 71 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 72 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 73 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 74 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 75 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 76 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 77 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 78 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 79 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 80 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 81 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 82 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 83 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 84 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 85 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 86 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have// This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 87 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 88 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 89 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 90 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 91 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 92 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 93 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 94 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 95 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have// This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 96 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 97 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 98 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 99 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 100 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 101 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 102 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 103 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 104 | // This is a sample Apex trigger that needs to have as many lines as the coverage reports under test have 105 | --------------------------------------------------------------------------------