diff --git a/.github/labeler.yml b/.github/labeler.yml index 8a57ba736..48882a216 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -4,6 +4,7 @@ 🧩 nx-plugin: packages/nx-plugin/** 🧩 eslint-plugin: packages/plugin-eslint/** 🧩 lighthouse-plugin: packages/plugin-lighthouse/** +🧩 coverage-plugin: packages/plugin-coverage/** 🧩 utils: packages/utils/** 🔬 testing: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46d55c9db..8787fe214 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,8 @@ jobs: cache: npm - name: Install dependencies run: npm ci + - name: Collect unit test coverage + run: npx nx run-many -t unit-test --coverage - name: Collect Code PushUp report run: npx nx run-collect - name: Upload Code PushUp report to portal diff --git a/code-pushup.config.ts b/code-pushup.config.ts index a74cbcfa2..642e2bb5e 100644 --- a/code-pushup.config.ts +++ b/code-pushup.config.ts @@ -7,6 +7,7 @@ import { packageJsonPerformanceGroupRef, packageJsonPlugin, } from './dist/examples/plugins'; +import coveragePlugin from './dist/packages/plugin-coverage'; import eslintPlugin, { eslintConfigFromNxProjects, } from './dist/packages/plugin-eslint'; @@ -44,7 +45,34 @@ const config: CoreConfig = { plugins: [ await eslintPlugin(await eslintConfigFromNxProjects()), - + coveragePlugin({ + reports: [ + { + resultsPath: 'coverage/cli/unit-tests/lcov.info', + pathToProject: 'packages/cli', + }, + { + resultsPath: 'coverage/core/unit-tests/lcov.info', + pathToProject: 'packages/core', + }, + { + resultsPath: 'coverage/models/unit-tests/lcov.info', + pathToProject: 'packages/models', + }, + { + resultsPath: 'coverage/utils/unit-tests/lcov.info', + pathToProject: 'packages/utils', + }, + { + resultsPath: 'coverage/plugin-eslint/unit-tests/lcov.info', + pathToProject: 'packages/plugin-eslint', + }, + { + resultsPath: 'coverage/plugin-coverage/unit-tests/lcov.info', + pathToProject: 'packages/plugin-coverage', + }, + ], + }), fileSizePlugin({ directory: './dist/examples/react-todos-app', pattern: /\.js$/, @@ -71,6 +99,30 @@ const config: CoreConfig = { { type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 }, ], }, + { + slug: 'code-coverage', + title: 'Code coverage', + refs: [ + { + type: 'audit', + plugin: 'coverage', + slug: 'function-coverage', + weight: 1, + }, + { + type: 'audit', + plugin: 'coverage', + slug: 'branch-coverage', + weight: 1, + }, + { + type: 'audit', + plugin: 'coverage', + slug: 'line-coverage', + weight: 1, + }, + ], + }, { slug: 'custom-checks', title: 'Custom checks', diff --git a/e2e/cli-e2e/.eslintrc.json b/e2e/cli-e2e/.eslintrc.json index 76bdb737c..1e0f5f7ec 100644 --- a/e2e/cli-e2e/.eslintrc.json +++ b/e2e/cli-e2e/.eslintrc.json @@ -1,6 +1,6 @@ { "extends": ["../../.eslintrc.json"], - "ignorePatterns": ["!**/*", "code-pushup.config.ts"], + "ignorePatterns": ["!**/*", "code-pushup.config*.ts"], "overrides": [ { "files": ["*.ts", "*.tsx"], diff --git a/e2e/cli-e2e/mocks/fixtures/code-pushup.config.coverage.ts b/e2e/cli-e2e/mocks/fixtures/code-pushup.config.coverage.ts new file mode 100644 index 000000000..99cf2f19c --- /dev/null +++ b/e2e/cli-e2e/mocks/fixtures/code-pushup.config.coverage.ts @@ -0,0 +1,49 @@ +import { join } from 'node:path'; +import coveragePlugin from '@code-pushup/coverage-plugin'; +import { CoreConfig } from '@code-pushup/models'; + +export default { + upload: { + organization: 'code-pushup', + project: 'cli-ts', + apiKey: 'e2e-api-key', + server: 'https://e2e.com/api', + }, + categories: [ + { + slug: 'code-coverage', + title: 'Code coverage', + refs: [ + { + type: 'audit', + plugin: 'coverage', + slug: 'function-coverage', + weight: 1, + }, + { + type: 'audit', + plugin: 'coverage', + slug: 'branch-coverage', + weight: 1, + }, + { + type: 'audit', + plugin: 'coverage', + slug: 'line-coverage', + weight: 1, + }, + ], + }, + ], + plugins: [ + coveragePlugin({ + coverageTypes: ['branch', 'function', 'line'], + reports: [ + { + resultsPath: join('e2e', 'cli-e2e', 'mocks', 'fixtures', 'lcov.info'), + pathToProject: join('packages', 'cli'), + }, + ], + }), + ], +} satisfies CoreConfig; diff --git a/e2e/cli-e2e/mocks/fixtures/lcov.info b/e2e/cli-e2e/mocks/fixtures/lcov.info new file mode 100644 index 000000000..474ad74e7 --- /dev/null +++ b/e2e/cli-e2e/mocks/fixtures/lcov.info @@ -0,0 +1,78 @@ +TN: +SF:src\lib\partly-covered\utils.ts +FN:2,formatReportScore +FN:6,calcDuration +FNF:2 +FNH:1 +FNDA:0,formatReportScore +FNDA:6,calcDuration +DA:1,1 +DA:2,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,1 +LF:10 +LH:7 +BRDA:1,0,0,6 +BRDA:1,1,0,5 +BRDA:2,4,0,1 +BRDA:4,5,0,17 +BRDA:5,6,0,4 +BRDA:6,7,0,13 +BRDA:6,10,1,0 +BRDA:7,11,0,3 +BRDA:10,12,0,12 +BRDA:10,13,1,0 +BRF:10 +BRH:8 +end_of_record +SF:src\lib\not-covered\sorting.ts +FN:1,sortReport +FNF:1 +FNH:0 +FNDA:0,sortReport +DA:1,0 +DA:2,0 +DA:3,0 +DA:4,0 +DA:5,0 +LF:5 +LH:0 +BRDA:7,1,0,0 +BRDA:7,2,1,0 +BRF:2 +BRH:0 +end_of_record +TN: +SF:src\lib\fully-covered\scoring.ts +FN:2,scoreReport +FN:8,calculateScore +FNF:2 +FNH:2 +FNDA:3,scoreReport +FNDA:5,calculateScore +DA:1,1 +DA:2,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +LF:10 +LH:10 +BRDA:1,0,0,5 +BRDA:2,1,0,1 +BRDA:2,2,1,4 +BRDA:2,3,2,3 +BRDA:6,4,0,4 +BRF:5 +BRH:5 +end_of_record diff --git a/e2e/cli-e2e/project.json b/e2e/cli-e2e/project.json index dfb35b70f..d565cecb1 100644 --- a/e2e/cli-e2e/project.json +++ b/e2e/cli-e2e/project.json @@ -24,6 +24,7 @@ "cli", "plugin-eslint", "plugin-lighthouse", + "plugin-coverage", "react-todos-app" ], "tags": ["scope:core", "scope:plugin", "type:e2e"] diff --git a/e2e/cli-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/cli-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index 549620055..5a1c04e01 100644 --- a/e2e/cli-e2e/tests/__snapshots__/collect.e2e.test.ts.snap +++ b/e2e/cli-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -1,5 +1,167 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`CLI collect > should run Code coverage plugin and create report.json 1`] = ` +{ + "categories": [ + { + "refs": [ + { + "plugin": "coverage", + "slug": "function-coverage", + "type": "audit", + "weight": 1, + }, + { + "plugin": "coverage", + "slug": "branch-coverage", + "type": "audit", + "weight": 1, + }, + { + "plugin": "coverage", + "slug": "line-coverage", + "type": "audit", + "weight": 1, + }, + ], + "slug": "code-coverage", + "title": "Code coverage", + }, + ], + "packageName": "@code-pushup/core", + "plugins": [ + { + "audits": [ + { + "description": "Branch coverage percentage on the project.", + "details": { + "issues": [ + { + "message": "2nd branch is not taken in any test case.", + "severity": "error", + "source": { + "file": "packages/cli/src/lib/partly-covered/utils.ts", + "position": { + "startLine": 6, + }, + }, + }, + { + "message": "2nd branch is not taken in any test case.", + "severity": "error", + "source": { + "file": "packages/cli/src/lib/partly-covered/utils.ts", + "position": { + "startLine": 10, + }, + }, + }, + { + "message": "1st branch is not taken in any test case.", + "severity": "error", + "source": { + "file": "packages/cli/src/lib/not-covered/sorting.ts", + "position": { + "startLine": 7, + }, + }, + }, + { + "message": "2nd branch is not taken in any test case.", + "severity": "error", + "source": { + "file": "packages/cli/src/lib/not-covered/sorting.ts", + "position": { + "startLine": 7, + }, + }, + }, + ], + }, + "displayValue": "76 %", + "score": 0.7647, + "slug": "branch-coverage", + "title": "Branch coverage", + "value": 76, + }, + { + "description": "Function coverage percentage on the project.", + "details": { + "issues": [ + { + "message": "Function formatReportScore is not called in any test case.", + "severity": "error", + "source": { + "file": "packages/cli/src/lib/partly-covered/utils.ts", + "position": { + "startLine": 2, + }, + }, + }, + { + "message": "Function sortReport is not called in any test case.", + "severity": "error", + "source": { + "file": "packages/cli/src/lib/not-covered/sorting.ts", + "position": { + "startLine": 1, + }, + }, + }, + ], + }, + "displayValue": "60 %", + "score": 0.6, + "slug": "function-coverage", + "title": "Function coverage", + "value": 60, + }, + { + "description": "Line coverage percentage on the project.", + "details": { + "issues": [ + { + "message": "Lines 7-9 are not covered in any test case.", + "severity": "warning", + "source": { + "file": "packages/cli/src/lib/partly-covered/utils.ts", + "position": { + "endLine": 9, + "startLine": 7, + }, + }, + }, + { + "message": "Lines 1-5 are not covered in any test case.", + "severity": "warning", + "source": { + "file": "packages/cli/src/lib/not-covered/sorting.ts", + "position": { + "endLine": 5, + "startLine": 1, + }, + }, + }, + ], + }, + "displayValue": "68 %", + "score": 0.68, + "slug": "line-coverage", + "title": "Line coverage", + "value": 68, + }, + ], + "description": "Official Code PushUp code coverage plugin.", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/coverage-plugin/", + "icon": "folder-coverage-open", + "packageName": "@code-pushup/coverage-plugin", + "slug": "coverage", + "title": "Code coverage", + }, + ], +} +`; + exports[`CLI collect > should run ESLint plugin and create report.json 1`] = ` { "categories": [ diff --git a/e2e/cli-e2e/tests/collect.e2e.test.ts b/e2e/cli-e2e/tests/collect.e2e.test.ts index afa128bdc..2453f2011 100644 --- a/e2e/cli-e2e/tests/collect.e2e.test.ts +++ b/e2e/cli-e2e/tests/collect.e2e.test.ts @@ -1,4 +1,7 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { PluginReport, Report, reportSchema } from '@code-pushup/models'; +import { cleanTestFolder } from '@code-pushup/testing-utils'; import { executeProcess, readJsonFile, readTextFile } from '@code-pushup/utils'; describe('CLI collect', () => { @@ -20,6 +23,10 @@ describe('CLI collect', () => { plugins: report.plugins.map(omitVariableData) as PluginReport[], }); + beforeEach(async () => { + await cleanTestFolder('tmp/e2e'); + }); + it('should run ESLint plugin and create report.json', async () => { const { code, stderr } = await executeProcess({ command: 'code-pushup', @@ -36,6 +43,42 @@ describe('CLI collect', () => { expect(omitVariableReportData(report as Report)).toMatchSnapshot(); }); + it('should run Code coverage plugin and create report.json', async () => { + /** + * The stats passed in the fixture are as follows + * 3 files: one partially covered, one with no coverage, one with full coverage + * Functions: 2 + 1 + 2 found | 1 + 0 + 2 covered (60% coverage) + * Branches: 10 + 2 + 5 found | 8 + 0 + 5 covered (76% coverage) + * Lines: 10 + 5 + 10 found | 7 + 0 + 10 covered (68% coverage) + */ + + const configPath = join( + fileURLToPath(dirname(import.meta.url)), + '..', + 'mocks', + 'fixtures', + 'code-pushup.config.coverage.ts', + ); + + const { code, stderr } = await executeProcess({ + command: 'code-pushup', + args: [ + 'collect', + '--no-progress', + `--config=${configPath}`, + `--persist.outputDir=tmp/e2e`, + ], + }); + + expect(code).toBe(0); + expect(stderr).toBe(''); + + const report = await readJsonFile(join('tmp', 'e2e', 'report.json')); + + expect(() => reportSchema.parse(report)).not.toThrow(); + expect(omitVariableReportData(report as Report)).toMatchSnapshot(); + }); + it('should create report.md', async () => { const { code, stderr } = await executeProcess({ command: 'code-pushup', diff --git a/global-setup.e2e.ts b/global-setup.e2e.ts index 61c5e2737..2d660242a 100644 --- a/global-setup.e2e.ts +++ b/global-setup.e2e.ts @@ -1,6 +1,6 @@ import { execSync } from 'child_process'; import { setup as globalSetup } from './global-setup'; -import { teardownTestFolder } from './testing-utils/src'; +import { setupTestFolder, teardownTestFolder } from './testing-utils/src'; import startLocalRegistry from './tools/scripts/start-local-registry'; import stopLocalRegistry from './tools/scripts/stop-local-registry'; @@ -9,11 +9,14 @@ export async function setup() { await startLocalRegistry(); execSync('npm install -D @code-pushup/cli@e2e'); execSync('npm install -D @code-pushup/eslint-plugin@e2e'); + execSync('npm install -D @code-pushup/coverage-plugin@e2e'); + await setupTestFolder('tmp/e2e'); } export async function teardown() { stopLocalRegistry(); execSync('npm uninstall @code-pushup/cli'); execSync('npm uninstall @code-pushup/eslint-plugin'); + execSync('npm uninstall @code-pushup/coverage-plugin@e2e'); await teardownTestFolder('tmp'); } diff --git a/package-lock.json b/package-lock.json index a403c72bc..8cb492f00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@code-pushup/cli-source", - "version": "0.11.2", + "version": "0.12.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@code-pushup/cli-source", - "version": "0.11.2", + "version": "0.12.2", "license": "MIT", "dependencies": { "@code-pushup/portal-client": "^0.4.1", @@ -16,6 +16,7 @@ "chalk": "^5.3.0", "cli-table3": "^0.6.3", "multi-progress-bars": "^5.0.3", + "parse-lcov": "^1.0.4", "simple-git": "^3.20.0", "yargs": "^17.7.2", "zod": "^3.22.4" @@ -18126,6 +18127,11 @@ "dev": true, "license": "MIT" }, + "node_modules/parse-lcov": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/parse-lcov/-/parse-lcov-1.0.4.tgz", + "integrity": "sha512-jE72M66VFyZJ0KYKnmaV70U/Y6FZyPoBCtJ6we5rDIVpWFR/GEkdCSLJn/R3UHJWZ3e3Qf3jAm2AUrmkaso+wA==" + }, "node_modules/parse-passwd": { "version": "1.0.0", "dev": true, diff --git a/package.json b/package.json index db02d4fca..2cbfbfd6f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "chalk": "^5.3.0", "cli-table3": "^0.6.3", "multi-progress-bars": "^5.0.3", + "parse-lcov": "^1.0.4", "simple-git": "^3.20.0", "yargs": "^17.7.2", "zod": "^3.22.4" diff --git a/packages/cli/README.md b/packages/cli/README.md index 58457f6be..444eae6bc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -46,7 +46,7 @@ _If you're looking for programmatic usage, then refer to the underlying [@code-p }; ``` -3. Add plugins as per your project needs (e.g. [@code-pushup/eslint-plugin](../plugin-eslint/README.md)). +3. Add plugins as per your project needs (e.g. [@code-pushup/eslint-plugin](../plugin-eslint/README.md) or [@code-pushup/coverage-plugin](../plugin-coverage/README.md)). ```sh npm install --save-dev @code-pushup/eslint-plugin diff --git a/packages/cli/vite.config.unit.ts b/packages/cli/vite.config.unit.ts index 593cf03fc..cd12cf9e2 100644 --- a/packages/cli/vite.config.unit.ts +++ b/packages/cli/vite.config.unit.ts @@ -10,6 +10,9 @@ export default defineConfig({ cache: { dir: '../../node_modules/.vitest', }, + coverage: { + reporter: ['lcov'], + }, environment: 'node', include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['global-setup.ts'], diff --git a/packages/core/vite.config.unit.ts b/packages/core/vite.config.unit.ts index 21f9a8060..782ca7434 100644 --- a/packages/core/vite.config.unit.ts +++ b/packages/core/vite.config.unit.ts @@ -10,6 +10,9 @@ export default defineConfig({ cache: { dir: '../../node_modules/.vitest', }, + coverage: { + reporter: ['lcov'], + }, environment: 'node', include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['global-setup.ts'], diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 6589d24ef..33da5d9c5 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -19,26 +19,27 @@ export { } from './lib/core-config'; export { Group, GroupRef, groupSchema } from './lib/group'; export { - MAX_DESCRIPTION_LENGTH, - MAX_ISSUE_MESSAGE_LENGTH, - MAX_SLUG_LENGTH, - MAX_TITLE_LENGTH, -} from './lib/implementation/limits'; + CONFIG_FILE_NAME, + SUPPORTED_CONFIG_FILE_FORMATS, +} from './lib/implementation/configuration'; export { PERSIST_FILENAME, - PERSIST_OUTPUT_DIR, PERSIST_FORMAT, + PERSIST_OUTPUT_DIR, } from './lib/implementation/constants'; export { - CONFIG_FILE_NAME, - SUPPORTED_CONFIG_FILE_FORMATS, -} from './lib/implementation/configuration'; + MAX_DESCRIPTION_LENGTH, + MAX_ISSUE_MESSAGE_LENGTH, + MAX_SLUG_LENGTH, + MAX_TITLE_LENGTH, +} from './lib/implementation/limits'; export { fileNameSchema, filePathSchema, materialIconSchema, urlSchema, } from './lib/implementation/schemas'; +export { exists } from './lib/implementation/utils'; export { Format, PersistConfig, diff --git a/packages/models/vite.config.unit.ts b/packages/models/vite.config.unit.ts index 1f8ed984b..699d83cc8 100644 --- a/packages/models/vite.config.unit.ts +++ b/packages/models/vite.config.unit.ts @@ -10,6 +10,9 @@ export default defineConfig({ cache: { dir: '../../node_modules/.vitest', }, + coverage: { + reporter: ['lcov'], + }, environment: 'node', include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['global-setup.ts'], diff --git a/packages/plugin-coverage/.eslintrc.json b/packages/plugin-coverage/.eslintrc.json new file mode 100644 index 000000000..a8f1c89f5 --- /dev/null +++ b/packages/plugin-coverage/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parserOptions": { + "project": ["packages/plugin-coverage/tsconfig.*?.json"] + } + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": ["error"] + } + } + ] +} diff --git a/packages/plugin-coverage/README.md b/packages/plugin-coverage/README.md new file mode 100644 index 000000000..10ad1f810 --- /dev/null +++ b/packages/plugin-coverage/README.md @@ -0,0 +1,159 @@ +# @code-pushup/coverage-plugin + +🧪 **Code PushUp plugin for tracking code coverage.** ☂️ + +This plugin allows you to measure and track code coverage on your project. + +Measured coverage types are mapped to Code PushUp audits in the following way + +- The value is in range 0-100 and represents the code coverage for all passed results (_covered / total_) +- the score is value converted to 0-1 range +- missing coverage is mapped to issues in the audit details (uncalled functions, uncovered branches or lines) + +## Getting started + +1. If you haven't already, install [@code-pushup/cli](../cli/README.md) and create a configuration file. + +2. Prepare either existing code coverage result files or a command for a coverage tool of your choice that will generate the results. Set lcov as the reporter to the configuration (example for Jest [here](https://jestjs.io/docs/configuration#coveragereporters-arraystring--string-options)). + +3. Add this plugin to the `plugins` array in your Code PushUp CLI config file (e.g. `code-pushup.config.js`). + + Pass paths to the code coverage results in LCOV format and optionally define your code coverage tool to be run first. + All coverage types are measured by default. If you wish to focus on a subset of offered types of coverage, define them in `coverageTypes`. + + 📌 Please note that when you define the tool command, you still need to define the paths to all relevant coverage results. + + The configuration will look similarly to the following: + + ```js + import coveragePlugin from '@code-pushup/coverage-plugin'; + + export default { + // ... + plugins: [ + // ... + await coveragePlugin({ + reports: [{ resultsPath: 'coverage/lcov.info' }], + coverageToolCommand: { + command: 'npx', + args: ['jest', '--coverage', '--coverageReporters=lcov'], + }, + }), + ], + }; + ``` + +4. (Optional) Reference audits which you wish to include in custom categories (use `npx code-pushup print-config` to list audits and groups). + + 💡 Assign weights based on what influence each coverage type should have on the overall category score (assign weight 0 to only include as extra info, without influencing category score). + + ```js + export default { + // ... + categories: [ + { + slug: 'code-coverage', + title: 'Code coverage', + refs: [ + { + type: 'audit', + plugin: 'coverage', + slug: 'function-coverage', + weight: 2, + }, + { + type: 'audit', + plugin: 'coverage', + slug: 'branch-coverage', + weight: 1, + }, + { + type: 'audit', + plugin: 'coverage', + slug: 'line-coverage', + weight: 1, + }, + // ... + ], + }, + // ... + ], + }; + ``` + +5. Run the CLI with `npx code-pushup collect` and view or upload report (refer to [CLI docs](../cli/README.md)). + +## About code coverage + +Code coverage is a metric that indicates what percentage of source code is executed by unit tests. It can give insights into test effectiveness and uncover parts of source code that would otherwise go untested. + +> [!IMPORTANT] +> Please note that code coverage is not the same as test coverage. Test coverage measures the amount of acceptance criteria covered by tests and is hard to formally verify. This means that code coverage cannot guarantee that the designed software caters to the business requirements. + +If you want to know more code coverage and how each type of coverage is measured, go to [Software Testing Help](https://www.softwaretestinghelp.com/code-coverage-tutorial/). + +### LCOV format + +The LCOV format was originally used by [GCOV](https://gcc.gnu.org/onlinedocs/gcc/gcov/introduction-to-gcov.html) tool for coverage results in C/C++ projects. +It recognises the following entities: + +- TN [test name] +- SF [source file] +- FN [line number] [function name] +- FNF [number of functions found] +- FNH [number of functions hit] +- FNDA [number of hits] [function name] +- BRDA [line number] [block number] [branch name] [number of hits] +- BRF [number of branches found] +- BRH [number of branches taken] +- DA [line number] [number of hits] +- LF [lines found] +- LH [lines hit] + +[Here](https://github.com/linux-test-project/lcov/issues/113#issuecomment-762335134) is the source of the information above. + +> [!NOTE] +> Branch name is usually a number indexed from 0, indicating either truthy/falsy condition or loop conditions. + +## Plugin architecture + +### Plugin configuration specification + +The plugin accepts the following parameters: + +- `coverageTypes`: An array of types of coverage that you wish to track. Supported values: `function`, `branch`, `line`. Defaults to all available types. +- `reports`: Array of information about files with code coverage results - paths to results, path to project root the results belong to. LCOV format is supported for now. +- (optional) `coverageToolCommand`: If you wish to run your coverage tool to generate the results first, you may define it here. +- (optional) `perfectScoreThreshold`: If your coverage goal is not 100%, you may define it here in range 0-1. Any score above the defined threshold will be given the perfect score. The value will stay unaffected. + +### Audit output + +An audit is an aggregation of all results for one coverage type passed to the plugin. + +For functions and branches, an issue points to a single instance of a branch or function not covered in any test and counts as an error. In line coverage, one issue groups any amount of consecutive lines together to reduce the total amount of issues and counts as a warning. + +For instance, the following can be an audit output for line coverage. + +```json +{ + "slug": "line-coverage", + "displayValue": "95 %", + "score": 0.95, + "value": 95, + "details": { + "issues": [ + { + "message": "Lines 7-9 are not covered in any test case.", + "severity": "warning", + "source": { + "file": "packages/cli/src/lib/utils.ts", + "position": { + "startLine": 7, + "endLine": 9 + } + } + } + ] + } +} +``` diff --git a/packages/plugin-coverage/mocks/single-record-lcov.info b/packages/plugin-coverage/mocks/single-record-lcov.info new file mode 100644 index 000000000..89dbf0777 --- /dev/null +++ b/packages/plugin-coverage/mocks/single-record-lcov.info @@ -0,0 +1,33 @@ +TN: +SF:src\lib\utils.ts +FN:2,formatReportScore +FN:6,calcDuration +FNF:2 +FNH:2 +FNDA:1,formatReportScore +FNDA:6,calcDuration +DA:1,1 +DA:2,1 +DA:3,1 +DA:4,1 +DA:5,1 +DA:6,1 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,1 +LF:10 +LH:7 +BRDA:1,0,0,6 +BRDA:1,1,0,5 +BRDA:2,4,0,1 +BRDA:4,5,0,17 +BRDA:5,6,0,4 +BRDA:6,7,0,13 +BRDA:6,10,0,0 +BRDA:7,11,0,3 +BRDA:10,12,0,12 +BRDA:10,13,0,0 +BRF:10 +BRH:8 +end_of_record diff --git a/packages/plugin-coverage/package.json b/packages/plugin-coverage/package.json new file mode 100644 index 000000000..96a53027b --- /dev/null +++ b/packages/plugin-coverage/package.json @@ -0,0 +1,10 @@ +{ + "name": "@code-pushup/coverage-plugin", + "version": "0.8.25", + "dependencies": { + "@code-pushup/models": "*", + "@code-pushup/utils": "*", + "parse-lcov": "^1.0.4", + "zod": "^3.22.4" + } +} diff --git a/packages/plugin-coverage/project.json b/packages/plugin-coverage/project.json new file mode 100644 index 000000000..2b325a66f --- /dev/null +++ b/packages/plugin-coverage/project.json @@ -0,0 +1,55 @@ +{ + "name": "plugin-coverage", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/plugin-coverage/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/plugin-coverage", + "main": "packages/plugin-coverage/src/index.ts", + "tsConfig": "packages/plugin-coverage/tsconfig.lib.json", + "assets": ["packages/plugin-coverage/*.md"], + "esbuildConfig": "esbuild.config.js" + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "packages/plugin-coverage/**/*.ts", + "packages/plugin-coverage/package.json" + ] + } + }, + "unit-test": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/coverage/packages/plugin-coverage"], + "options": { + "config": "packages/plugin-coverage/vite.config.unit.ts", + "reportsDirectory": "../../coverage/plugin-coverage/unit-tests" + } + }, + "integration-test": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/coverage/packages/plugin-coverage"], + "options": { + "config": "packages/plugin-coverage/vite.config.integration.ts", + "reportsDirectory": "../../coverage/plugin-coverage/integration-tests" + } + }, + "deploy": { + "options": { + "distFolderPath": "dist/packages/plugin-coverage" + } + }, + "publish": { + "command": "node tools/scripts/publish.mjs plugin-coverage {args.ver} {args.tag}", + "dependsOn": ["build"] + } + }, + "tags": ["scope:plugin", "type:feature"] +} diff --git a/packages/plugin-coverage/src/index.ts b/packages/plugin-coverage/src/index.ts new file mode 100644 index 000000000..89fb4def3 --- /dev/null +++ b/packages/plugin-coverage/src/index.ts @@ -0,0 +1,4 @@ +import { coveragePlugin } from './lib/coverage-plugin'; + +export default coveragePlugin; +export type { CoveragePluginConfig } from './lib/config'; diff --git a/packages/plugin-coverage/src/lib/config.ts b/packages/plugin-coverage/src/lib/config.ts new file mode 100644 index 000000000..bd7a6f4bf --- /dev/null +++ b/packages/plugin-coverage/src/lib/config.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +export const coverageTypeSchema = z.enum(['function', 'branch', 'line']); +export type CoverageType = z.infer; + +export const coverageReportSchema = z.object({ + resultsPath: z.string().includes('lcov'), + pathToProject: z + .string({ + description: + 'Path from workspace root to project root. Necessary for LCOV reports.', + }) + .optional(), +}); +export type CoverageReport = z.infer; + +export const coveragePluginConfigSchema = z.object({ + coverageToolCommand: z + .object({ + command: z + .string({ description: 'Command to run coverage tool.' }) + .min(1), + args: z + .array(z.string(), { + description: 'Arguments to be passed to the coverage tool.', + }) + .optional(), + }) + .optional(), + coverageTypes: z + .array(coverageTypeSchema, { + description: 'Coverage types measured. Defaults to all available types.', + }) + .min(1) + .default(['function', 'branch', 'line']), + reports: z + .array(coverageReportSchema, { + description: + 'Path to all code coverage report files. Only LCOV format is supported for now.', + }) + .min(1), + perfectScoreThreshold: z + .number({ + description: + 'Score will be 1 (perfect) for this coverage and above. Score range is 0 - 1.', + }) + .gt(0) + .max(1) + .optional(), +}); +export type CoveragePluginConfig = z.input; diff --git a/packages/plugin-coverage/src/lib/config.unit.test.ts b/packages/plugin-coverage/src/lib/config.unit.test.ts new file mode 100644 index 000000000..48140248c --- /dev/null +++ b/packages/plugin-coverage/src/lib/config.unit.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + CoveragePluginConfig, + CoverageType, + coveragePluginConfigSchema, +} from './config'; + +describe('coveragePluginConfigSchema', () => { + it('accepts a code coverage configuration with all entities', () => { + expect(() => + coveragePluginConfigSchema.parse({ + coverageTypes: ['branch', 'function'], + reports: [ + { + resultsPath: 'coverage/cli/lcov.info', + pathToProject: 'packages/cli', + }, + ], + coverageToolCommand: { + command: 'npx nx run-many', + args: ['-t', 'test', '--coverage'], + }, + perfectScoreThreshold: 0.85, + } satisfies CoveragePluginConfig), + ).not.toThrow(); + }); + + it('accepts a minimal code coverage configuration', () => { + expect(() => + coveragePluginConfigSchema.parse({ + reports: [{ resultsPath: 'coverage/cli/lcov.info' }], + } satisfies CoveragePluginConfig), + ).not.toThrow(); + }); + + it('replaces undefined coverage with all available types', () => { + const config = { + reports: [{ resultsPath: 'coverage/cli/lcov.info' }], + } satisfies CoveragePluginConfig; + expect(() => coveragePluginConfigSchema.parse(config)).not.toThrow(); + + const { coverageTypes } = coveragePluginConfigSchema.parse(config); + expect(coverageTypes).toEqual([ + 'function', + 'branch', + 'line', + ] satisfies CoverageType[]); + }); + + it('throws for empty coverage type array', () => { + expect(() => + coveragePluginConfigSchema.parse({ + coverageTypes: [], + reports: [{ resultsPath: 'coverage/cli/lcov.info' }], + } satisfies CoveragePluginConfig), + ).toThrow('too_small'); + }); + + it('throws for no report', () => { + expect(() => + coveragePluginConfigSchema.parse({ + coverageTypes: ['branch'], + reports: [], + } satisfies CoveragePluginConfig), + ).toThrow('too_small'); + }); + + it('throws for unsupported report format', () => { + expect(() => + coveragePluginConfigSchema.parse({ + coverageTypes: ['line'], + reports: [{ resultsPath: 'coverage/cli/coverage-final.json' }], + } satisfies CoveragePluginConfig), + ).toThrow(/Invalid input: must include.+lcov/); + }); + + it('throws for missing command', () => { + expect(() => + coveragePluginConfigSchema.parse({ + coverageTypes: ['line'], + reports: ['coverage/cli/lcov.info'], + coverageToolCommand: { + args: ['npx', 'nx', 'run-many', '-t', 'test', '--coverage'], + }, + }), + ).toThrow('invalid_type'); + }); + + it('throws for invalid score threshold', () => { + expect(() => + coveragePluginConfigSchema.parse({ + coverageTypes: ['line'], + reports: [{ resultsPath: 'coverage/cli/lcov.info' }], + perfectScoreThreshold: 1.1, + } satisfies CoveragePluginConfig), + ).toThrow('too_big'); + }); +}); diff --git a/packages/plugin-coverage/src/lib/coverage-plugin.integration.test.ts b/packages/plugin-coverage/src/lib/coverage-plugin.integration.test.ts new file mode 100644 index 000000000..78c36b436 --- /dev/null +++ b/packages/plugin-coverage/src/lib/coverage-plugin.integration.test.ts @@ -0,0 +1,85 @@ +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { CoveragePluginConfig } from './config'; +import { coveragePlugin } from './coverage-plugin'; + +describe('coveragePlugin', () => { + const LCOV_PATH = join( + 'packages', + 'plugin-coverage', + 'mocks', + 'single-record-lcov.info', + ); + + it('should initialise a Code coverage plugin', () => { + expect( + coveragePlugin({ + coverageTypes: ['function'], + reports: [{ resultsPath: LCOV_PATH }], + }), + ).toStrictEqual( + expect.objectContaining({ + slug: 'coverage', + title: 'Code coverage', + audits: expect.any(Array), + }), + ); + }); + + it('should generate audits from coverage types', () => { + expect( + coveragePlugin({ + coverageTypes: ['function', 'branch'], + reports: [{ resultsPath: LCOV_PATH }], + }), + ).toStrictEqual( + expect.objectContaining({ + audits: [ + { + slug: 'function-coverage', + title: 'Function coverage', + description: expect.stringContaining('Function coverage'), + }, + expect.objectContaining({ slug: 'branch-coverage' }), + ], + }), + ); + }); + + it('should assign RunnerConfig when a command is passed', () => { + expect( + coveragePlugin({ + coverageTypes: ['line'], + reports: [{ resultsPath: LCOV_PATH }], + coverageToolCommand: { + command: 'npm run-many', + args: ['-t', 'test', '--coverage'], + }, + } satisfies CoveragePluginConfig), + ).toStrictEqual( + expect.objectContaining({ + slug: 'coverage', + runner: { + command: 'npm run-many', + args: ['-t', 'test', '--coverage'], + outputFile: expect.stringContaining('runner-output.json'), + outputTransform: expect.any(Function), + }, + }), + ); + }); + + it('should assign a RunnerFunction when only reports are passed', () => { + expect( + coveragePlugin({ + coverageTypes: ['line'], + reports: [{ resultsPath: LCOV_PATH }], + } satisfies CoveragePluginConfig), + ).toStrictEqual( + expect.objectContaining({ + slug: 'coverage', + runner: expect.any(Function), + }), + ); + }); +}); diff --git a/packages/plugin-coverage/src/lib/coverage-plugin.ts b/packages/plugin-coverage/src/lib/coverage-plugin.ts new file mode 100644 index 000000000..989fe5687 --- /dev/null +++ b/packages/plugin-coverage/src/lib/coverage-plugin.ts @@ -0,0 +1,80 @@ +import { join } from 'node:path'; +import type { + Audit, + PluginConfig, + RunnerConfig, + RunnerFunction, +} from '@code-pushup/models'; +import { capitalize, pluginWorkDir } from '@code-pushup/utils'; +import { name, version } from '../../package.json'; +import { CoveragePluginConfig, coveragePluginConfigSchema } from './config'; +import { lcovResultsToAuditOutputs } from './runner/lcov/runner'; +import { applyMaxScoreAboveThreshold } from './utils'; + +export const RUNNER_OUTPUT_PATH = join( + pluginWorkDir('coverage'), + 'runner-output.json', +); + +/** + * Instantiates Code PushUp code coverage plugin for core config. + * + * @example + * import coveragePlugin from '@code-pushup/coverage-plugin' + * + * export default { + * // ... core config ... + * plugins: [ + * // ... other plugins ... + * await coveragePlugin({ + * reports: [{ resultsPath: 'coverage/cli/lcov.info', pathToProject: 'packages/cli' }] + * }) + * ] + * } + * + * @returns Plugin configuration. + */ +export function coveragePlugin(config: CoveragePluginConfig): PluginConfig { + const { reports, perfectScoreThreshold, coverageTypes, coverageToolCommand } = + coveragePluginConfigSchema.parse(config); + + const audits = coverageTypes.map( + (type): Audit => ({ + slug: `${type}-coverage`, + title: `${capitalize(type)} coverage`, + description: `${capitalize(type)} coverage percentage on the project.`, + }), + ); + + const getAuditOutputs = async () => + perfectScoreThreshold + ? applyMaxScoreAboveThreshold( + await lcovResultsToAuditOutputs(reports, coverageTypes), + perfectScoreThreshold, + ) + : await lcovResultsToAuditOutputs(reports, coverageTypes); + + // if coverage results are provided, only convert them to AuditOutputs + // if not, run coverage command and then run result conversion + const runner: RunnerConfig | RunnerFunction = + coverageToolCommand == null + ? getAuditOutputs + : { + command: coverageToolCommand.command, + args: coverageToolCommand.args, + outputFile: RUNNER_OUTPUT_PATH, + outputTransform: getAuditOutputs, + }; + + return { + slug: 'coverage', + title: 'Code coverage', + icon: 'folder-coverage-open', + description: 'Official Code PushUp code coverage plugin.', + docsUrl: 'https://www.npmjs.com/package/@code-pushup/coverage-plugin/', + packageName: name, + version, + audits, + runner, + }; +} diff --git a/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/runner.integration.test.ts.snap b/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/runner.integration.test.ts.snap new file mode 100644 index 000000000..d9d95ca0f --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/runner.integration.test.ts.snap @@ -0,0 +1,63 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`lcovResultsToAuditOutputs > should correctly convert lcov results to AuditOutputs 1`] = ` +[ + { + "details": { + "issues": [ + { + "message": "1st branch is not taken in any test case.", + "severity": "error", + "source": { + "file": "packages/cli/src/lib/utils.ts", + "position": { + "startLine": 6, + }, + }, + }, + { + "message": "1st branch is not taken in any test case.", + "severity": "error", + "source": { + "file": "packages/cli/src/lib/utils.ts", + "position": { + "startLine": 10, + }, + }, + }, + ], + }, + "displayValue": "80 %", + "score": 0.8, + "slug": "branch-coverage", + "value": 80, + }, + { + "displayValue": "100 %", + "score": 1, + "slug": "function-coverage", + "value": 100, + }, + { + "details": { + "issues": [ + { + "message": "Lines 7-9 are not covered in any test case.", + "severity": "warning", + "source": { + "file": "packages/cli/src/lib/utils.ts", + "position": { + "endLine": 9, + "startLine": 7, + }, + }, + }, + ], + }, + "displayValue": "70 %", + "score": 0.7, + "slug": "line-coverage", + "value": 70, + }, +] +`; diff --git a/packages/plugin-coverage/src/lib/runner/lcov/parse-lcov.ts b/packages/plugin-coverage/src/lib/runner/lcov/parse-lcov.ts new file mode 100644 index 000000000..f8f316dfe --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/parse-lcov.ts @@ -0,0 +1,11 @@ +import parseLcovExport from 'parse-lcov'; + +type ParseLcovFn = typeof parseLcovExport; + +// the parse-lcov export is inconsistent (sometimes it's .default, sometimes it's .default.default) +const godKnows = parseLcovExport as unknown as + | ParseLcovFn + | { default: ParseLcovFn }; + +export const parseLcov: ParseLcovFn = + 'default' in godKnows ? godKnows.default : godKnows; diff --git a/packages/plugin-coverage/src/lib/runner/lcov/runner.integration.test.ts b/packages/plugin-coverage/src/lib/runner/lcov/runner.integration.test.ts new file mode 100644 index 000000000..626867bfa --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/runner.integration.test.ts @@ -0,0 +1,33 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'vitest'; +import { lcovResultsToAuditOutputs } from './runner'; + +describe('lcovResultsToAuditOutputs', () => { + it('should correctly convert lcov results to AuditOutputs', async () => { + /** + * The stats passed in the fixture are as follows + * Functions: 2 found, 2 covered (100% coverage) + * Branches: 10 found, 8 covered (80% coverage) - last value of BRDA + * Lines: 10 found, 7 covered (70% coverage) - merged into one issue with line range + */ + const results = await lcovResultsToAuditOutputs( + [ + { + resultsPath: join( + fileURLToPath(dirname(import.meta.url)), + '..', + '..', + '..', + '..', + 'mocks', + 'single-record-lcov.info', + ), + pathToProject: join('packages', 'cli'), + }, + ], + ['branch', 'function', 'line'], + ); + expect(results).toMatchSnapshot(); + }); +}); diff --git a/packages/plugin-coverage/src/lib/runner/lcov/runner.ts b/packages/plugin-coverage/src/lib/runner/lcov/runner.ts new file mode 100644 index 000000000..1888c265b --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/runner.ts @@ -0,0 +1,112 @@ +import { join } from 'node:path'; +import type { LCOVRecord } from 'parse-lcov'; +import { AuditOutputs } from '@code-pushup/models'; +import { exists, readTextFile, toUnixNewlines } from '@code-pushup/utils'; +import { CoverageReport, CoverageType } from '../../config'; +import { parseLcov } from './parse-lcov'; +import { + lcovCoverageToAuditOutput, + recordToStatFunctionMapper, +} from './transform'; +import { LCOVStat, LCOVStats } from './types'; + +// Note: condition or statement coverage is not supported in LCOV +// https://stackoverflow.com/questions/48260434/is-it-possible-to-check-condition-coverage-with-gcov + +/** + * + * @param reports report files + * @param coverageTypes types of coverage to be considered + * @returns Audit outputs with complete coverage data. + */ +export async function lcovResultsToAuditOutputs( + reports: CoverageReport[], + coverageTypes: CoverageType[], +): Promise { + const parsedReports = await Promise.all( + reports.map(async report => { + const reportContent = await readTextFile(report.resultsPath); + const parsedRecords = parseLcov(toUnixNewlines(reportContent)); + return parsedRecords.map(record => ({ + ...record, + file: + report.pathToProject == null + ? record.file + : join(report.pathToProject, record.file), + })); + }), + ); + if (parsedReports.length !== reports.length) { + throw new Error('Some provided LCOV reports were not valid.'); + } + + const flatReports = parsedReports.flat(); + + if (flatReports.length === 0) { + throw new Error('All provided reports are empty.'); + } + + // Accumulate code coverage from all coverage result files + const totalCoverageStats = getTotalCoverageFromLcovReports( + flatReports, + coverageTypes, + ); + + return coverageTypes + .map(coverageType => { + const stats = totalCoverageStats[coverageType]; + if (!stats) { + return null; + } + return lcovCoverageToAuditOutput(stats, coverageType); + }) + .filter(exists); +} + +/** + * + * @param records This function aggregates coverage stats from all coverage files + * @param coverageTypes Types of coverage to be gathered + * @returns Complete coverage stats for all defined types of coverage. + */ +function getTotalCoverageFromLcovReports( + records: LCOVRecord[], + coverageTypes: CoverageType[], +): LCOVStats { + return records.reduce( + (acc, report) => + Object.fromEntries([ + ...Object.entries(acc), + ...( + Object.entries( + getCoverageStatsFromLcovRecord(report, coverageTypes), + ) as [CoverageType, LCOVStat][] + ).map(([type, stats]): [CoverageType, LCOVStat] => [ + type, + { + totalFound: (acc[type]?.totalFound ?? 0) + stats.totalFound, + totalHit: (acc[type]?.totalHit ?? 0) + stats.totalHit, + issues: [...(acc[type]?.issues ?? []), ...stats.issues], + }, + ]), + ]), + {}, + ); +} + +/** + * @param record record file data + * @param coverageTypes types of coverage to be gathered + * @returns Relevant coverage data from one lcov record file. + */ +function getCoverageStatsFromLcovRecord( + record: LCOVRecord, + coverageTypes: CoverageType[], +): LCOVStats { + return Object.fromEntries( + coverageTypes.map((coverageType): [CoverageType, LCOVStat] => [ + coverageType, + recordToStatFunctionMapper[coverageType](record), + ]), + ); +} diff --git a/packages/plugin-coverage/src/lib/runner/lcov/transform.ts b/packages/plugin-coverage/src/lib/runner/lcov/transform.ts new file mode 100644 index 000000000..23b50ce18 --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/transform.ts @@ -0,0 +1,117 @@ +import { LCOVRecord } from 'parse-lcov'; +import { AuditOutput, Issue } from '@code-pushup/models'; +import { toNumberPrecision, toOrdinal, toUnixPath } from '@code-pushup/utils'; +import { CoverageType } from '../../config'; +import { LCOVStat } from './types'; +import { calculateCoverage, mergeConsecutiveNumbers } from './utils'; + +export function lcovReportToFunctionStat(record: LCOVRecord): LCOVStat { + return { + totalFound: record.functions.found, + totalHit: record.functions.hit, + issues: + record.functions.hit < record.functions.found + ? record.functions.details + .filter(detail => !detail.hit) + .map( + (detail): Issue => ({ + message: `Function ${detail.name} is not called in any test case.`, + severity: 'error', + source: { + file: toUnixPath(record.file), + position: { startLine: detail.line }, + }, + }), + ) + : [], + }; +} + +export function lcovReportToLineStat(record: LCOVRecord): LCOVStat { + const missingCoverage = record.lines.hit < record.lines.found; + const lines = missingCoverage + ? record.lines.details + .filter(detail => !detail.hit) + .map(detail => detail.line) + : []; + + const linePositions = mergeConsecutiveNumbers(lines); + + return { + totalFound: record.lines.found, + totalHit: record.lines.hit, + issues: missingCoverage + ? linePositions.map((linePosition): Issue => { + const lineReference = + linePosition.end == null + ? `Line ${linePosition.start} is` + : `Lines ${linePosition.start}-${linePosition.end} are`; + + return { + message: `${lineReference} not covered in any test case.`, + severity: 'warning', + source: { + file: toUnixPath(record.file), + position: { + startLine: linePosition.start, + endLine: linePosition.end, + }, + }, + }; + }) + : [], + }; +} + +export function lcovReportToBranchStat(record: LCOVRecord): LCOVStat { + return { + totalFound: record.branches.found, + totalHit: record.branches.hit, + issues: + record.branches.hit < record.branches.found + ? record.branches.details + .filter(detail => !detail.taken) + .map( + (detail): Issue => ({ + message: `${toOrdinal( + detail.branch + 1, + )} branch is not taken in any test case.`, + severity: 'error', + source: { + file: toUnixPath(record.file), + position: { startLine: detail.line }, + }, + }), + ) + : [], + }; +} + +export const recordToStatFunctionMapper = { + branch: lcovReportToBranchStat, + line: lcovReportToLineStat, + function: lcovReportToFunctionStat, +}; + +/** + * + * @param stat code coverage result for a given type + * @param coverageType code coverage type + * @returns Result of complete code ccoverage data coverted to AuditOutput + */ +export function lcovCoverageToAuditOutput( + stat: LCOVStat, + coverageType: CoverageType, +): AuditOutput { + const coverage = calculateCoverage(stat.totalHit, stat.totalFound); + const MAX_DECIMAL_PLACES = 4; + const roundedIntValue = toNumberPrecision(coverage * 100, 0); + + return { + slug: `${coverageType}-coverage`, + score: toNumberPrecision(coverage, MAX_DECIMAL_PLACES), + value: roundedIntValue, + displayValue: `${roundedIntValue} %`, + ...(stat.issues.length > 0 && { details: { issues: stat.issues } }), + }; +} diff --git a/packages/plugin-coverage/src/lib/runner/lcov/transform.unit.test.ts b/packages/plugin-coverage/src/lib/runner/lcov/transform.unit.test.ts new file mode 100644 index 000000000..3937f9448 --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/transform.unit.test.ts @@ -0,0 +1,336 @@ +import { LCOVRecord } from 'parse-lcov'; +import { describe, it } from 'vitest'; +import { AuditOutput, Issue } from '@code-pushup/models'; +import { + lcovCoverageToAuditOutput, + lcovReportToBranchStat, + lcovReportToFunctionStat, + lcovReportToLineStat, +} from './transform'; +import { LCOVStat } from './types'; + +const lcovRecordMock: LCOVRecord = { + file: 'cli.ts', + title: '', + branches: { details: [], hit: 0, found: 0 }, + functions: { details: [], hit: 0, found: 0 }, + lines: { details: [], hit: 0, found: 0 }, +}; + +describe('lcovReportToFunctionStat', () => { + it('should transform a fully covered function report to LCOV stat', () => { + expect( + lcovReportToFunctionStat({ + ...lcovRecordMock, + functions: { + hit: 1, + found: 1, + details: [{ line: 12, name: 'yargsCli', hit: 6 }], + }, + }), + ).toEqual({ totalHit: 1, totalFound: 1, issues: [] } satisfies LCOVStat); + }); + + it('should transform an empty LCOV function report to LCOV stat', () => { + expect( + lcovReportToFunctionStat({ + ...lcovRecordMock, + functions: { hit: 0, found: 0, details: [] }, + }), + ).toEqual({ totalHit: 0, totalFound: 0, issues: [] } satisfies LCOVStat); + }); + + it('should transform details from function report to issues', () => { + expect( + lcovReportToFunctionStat({ + ...lcovRecordMock, + functions: { + hit: 0, + found: 1, + details: [{ line: 12, name: 'yargsCli', hit: 0 }], + }, + }), + ).toEqual( + expect.objectContaining({ + issues: [ + { + message: 'Function yargsCli is not called in any test case.', + severity: 'error', + source: { file: 'cli.ts', position: { startLine: 12 } }, + } satisfies Issue, + ], + }), + ); + }); + + it('should skip covered functions when transforming details to issues', () => { + expect( + lcovReportToFunctionStat({ + ...lcovRecordMock, + functions: { + hit: 1, + found: 2, + details: [ + { line: 12, name: 'yargsCli', hit: 6 }, + { line: 20, name: 'cliError', hit: 0 }, + ], + }, + }), + ).toEqual( + expect.objectContaining({ + issues: [ + { + message: 'Function cliError is not called in any test case.', + severity: 'error', + source: { file: 'cli.ts', position: { startLine: 20 } }, + } satisfies Issue, + ], + }), + ); + }); +}); + +describe('lcovReportToLineStat', () => { + it('should transform a fully covered line report to LCOV stat', () => { + expect( + lcovReportToLineStat({ + ...lcovRecordMock, + lines: { + hit: 1, + found: 1, + details: [{ line: 1, hit: 6 }], + }, + }), + ).toEqual({ totalHit: 1, totalFound: 1, issues: [] } satisfies LCOVStat); + }); + + it('should transform an empty LCOV line report to LCOV stat', () => { + expect( + lcovReportToLineStat({ + ...lcovRecordMock, + lines: { hit: 0, found: 0, details: [] }, + }), + ).toEqual({ totalHit: 0, totalFound: 0, issues: [] } satisfies LCOVStat); + }); + + it('should transform details from line report to issues', () => { + expect( + lcovReportToLineStat({ + ...lcovRecordMock, + lines: { + hit: 0, + found: 1, + details: [{ line: 1, hit: 0 }], + }, + }), + ).toEqual( + expect.objectContaining({ + issues: [ + { + message: 'Line 1 is not covered in any test case.', + severity: 'warning', + source: { file: 'cli.ts', position: { startLine: 1 } }, + } satisfies Issue, + ], + }), + ); + }); + + it('should skip covered lines when transforming details to issues', () => { + expect( + lcovReportToLineStat({ + ...lcovRecordMock, + lines: { + hit: 1, + found: 2, + details: [ + { line: 1, hit: 1 }, + { line: 2, hit: 0 }, + ], + }, + }), + ).toEqual( + expect.objectContaining({ + issues: [ + { + message: 'Line 2 is not covered in any test case.', + severity: 'warning', + source: { file: 'cli.ts', position: { startLine: 2 } }, + } satisfies Issue, + ], + }), + ); + }); + + it('should merge consecutive lines to one issue', () => { + expect( + lcovReportToLineStat({ + ...lcovRecordMock, + lines: { + hit: 2, + found: 6, + details: [ + { line: 1, hit: 1 }, + { line: 2, hit: 0 }, + { line: 3, hit: 0 }, + { line: 4, hit: 0 }, + { line: 5, hit: 1 }, + { line: 6, hit: 0 }, + ], + }, + }), + ).toEqual( + expect.objectContaining({ + issues: [ + { + message: 'Lines 2-4 are not covered in any test case.', + severity: 'warning', + source: { file: 'cli.ts', position: { startLine: 2, endLine: 4 } }, + }, + + { + message: 'Line 6 is not covered in any test case.', + severity: 'warning', + source: { file: 'cli.ts', position: { startLine: 6 } }, + }, + ] satisfies Issue[], + }), + ); + }); +}); + +describe('lcovReportToBranchStat', () => { + it('should transform a fully covered branch report to LCOV stat', () => { + expect( + lcovReportToBranchStat({ + ...lcovRecordMock, + branches: { + hit: 1, + found: 1, + details: [{ line: 12, taken: 6, branch: 0, block: 0 }], + }, + }), + ).toEqual({ totalHit: 1, totalFound: 1, issues: [] } satisfies LCOVStat); + }); + + it('should transform an empty LCOV branch report to LCOV stat', () => { + expect( + lcovReportToBranchStat({ + ...lcovRecordMock, + branches: { hit: 0, found: 0, details: [] }, + }), + ).toEqual({ totalHit: 0, totalFound: 0, issues: [] } satisfies LCOVStat); + }); + + it('should transform details from branch report to issues', () => { + expect( + lcovReportToBranchStat({ + ...lcovRecordMock, + branches: { + hit: 0, + found: 1, + details: [{ line: 12, taken: 0, branch: 0, block: 0 }], + }, + }), + ).toEqual( + expect.objectContaining({ + issues: [ + { + message: '1st branch is not taken in any test case.', + severity: 'error', + source: { file: 'cli.ts', position: { startLine: 12 } }, + } satisfies Issue, + ], + }), + ); + }); + + it('should skip a covered branch when transforming details to issues', () => { + expect( + lcovReportToBranchStat({ + ...lcovRecordMock, + branches: { + hit: 1, + found: 2, + details: [ + { line: 15, taken: 3, branch: 0, block: 1 }, + { line: 20, taken: 0, branch: 1, block: 0 }, + ], + }, + }), + ).toEqual( + expect.objectContaining({ + issues: [ + { + message: '2nd branch is not taken in any test case.', + severity: 'error', + source: { file: 'cli.ts', position: { startLine: 20 } }, + } satisfies Issue, + ], + }), + ); + }); +}); + +describe('lcovCoverageToAudit', () => { + it('should transform full branch coverage to audit output', () => { + expect( + lcovCoverageToAuditOutput( + { totalHit: 56, totalFound: 56, issues: [] }, + 'branch', + ), + ).toEqual({ + slug: 'branch-coverage', + score: 1, + value: 100, + displayValue: '100 %', + } satisfies AuditOutput); + }); + + it('should transform an empty function coverage to audit output', () => { + expect( + lcovCoverageToAuditOutput( + { totalHit: 0, totalFound: 0, issues: [] }, + 'function', + ), + ).toEqual({ + slug: 'function-coverage', + score: 1, + value: 100, + displayValue: '100 %', + } satisfies AuditOutput); + }); + + it('should transform a partial line coverage to audit output', () => { + expect( + lcovCoverageToAuditOutput( + { + totalHit: 9, + totalFound: 10, + issues: [ + { + message: 'Line 2 is not covered in any test case.', + severity: 'warning', + source: { file: 'cli.ts', position: { startLine: 2 } }, + }, + ], + }, + 'line', + ), + ).toEqual({ + slug: 'line-coverage', + score: 0.9, + value: 90, + displayValue: '90 %', + details: { + issues: [ + { + message: 'Line 2 is not covered in any test case.', + severity: 'warning', + source: { file: 'cli.ts', position: { startLine: 2 } }, + }, + ], + }, + } satisfies AuditOutput); + }); +}); diff --git a/packages/plugin-coverage/src/lib/runner/lcov/types.ts b/packages/plugin-coverage/src/lib/runner/lcov/types.ts new file mode 100644 index 000000000..47fa450e4 --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/types.ts @@ -0,0 +1,12 @@ +import type { Issue } from '@code-pushup/models'; +import { CoverageType } from '../../config'; + +export type LCOVStat = { + totalFound: number; + totalHit: number; + issues: Issue[]; +}; + +export type LCOVStats = Partial>; + +export type NumberRange = { start: number; end?: number }; diff --git a/packages/plugin-coverage/src/lib/runner/lcov/utils.ts b/packages/plugin-coverage/src/lib/runner/lcov/utils.ts new file mode 100644 index 000000000..6bf4bc5e6 --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/utils.ts @@ -0,0 +1,24 @@ +import { NumberRange } from './types'; + +/** + * This function calculates coverage as ratio of tested entities vs total + * @param hit how many entities were executed in at least one test + * @param found how many entities were found overall + * @returns coverage between 0 and 1 + */ +export function calculateCoverage(hit: number, found: number): number { + return found > 0 ? hit / found : 1; +} + +export function mergeConsecutiveNumbers(numberArr: number[]): NumberRange[] { + return [...numberArr].sort().reduce((acc, currValue) => { + const prevValue = acc.at(-1); + if ( + prevValue != null && + (prevValue.start === currValue - 1 || prevValue.end === currValue - 1) + ) { + return [...acc.slice(0, -1), { start: prevValue.start, end: currValue }]; + } + return [...acc, { start: currValue }]; + }, []); +} diff --git a/packages/plugin-coverage/src/lib/runner/lcov/utils.unit.test.ts b/packages/plugin-coverage/src/lib/runner/lcov/utils.unit.test.ts new file mode 100644 index 000000000..1225a91d0 --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/utils.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { calculateCoverage, mergeConsecutiveNumbers } from './utils'; + +describe('calculateCoverage', () => { + it('should calculate coverage for one type of report', () => { + expect(calculateCoverage(1, 2)).toBe(0.5); + }); + + it('should calculate zero coverage when no entity was covered', () => { + expect(calculateCoverage(0, 25)).toBe(0); + }); + + it('should assign full coverage when no entity was found', () => { + expect(calculateCoverage(0, 0)).toBe(1); + }); +}); + +describe('mergeConsecutiveNumbers', () => { + it('should leave non-consecutive numbers separate', () => { + expect(mergeConsecutiveNumbers([1, 4, 6])).toEqual([ + { start: 1 }, + { start: 4 }, + { start: 6 }, + ]); + }); + + it('should merge consecutive numbers', () => { + expect(mergeConsecutiveNumbers([1, 3, 4, 5, 6, 8, 9])).toEqual([ + { start: 1 }, + { start: 3, end: 6 }, + { start: 8, end: 9 }, + ]); + }); + + it('should handle unsorted arrays', () => { + expect(mergeConsecutiveNumbers([1, 6, 4, 3, 7])).toEqual([ + { start: 1 }, + { start: 3, end: 4 }, + { start: 6, end: 7 }, + ]); + }); + + it('should return empty array for no numbers', () => { + expect(mergeConsecutiveNumbers([])).toEqual([]); + }); +}); diff --git a/packages/plugin-coverage/src/lib/utils.ts b/packages/plugin-coverage/src/lib/utils.ts new file mode 100644 index 000000000..b9df7c1c0 --- /dev/null +++ b/packages/plugin-coverage/src/lib/utils.ts @@ -0,0 +1,16 @@ +import type { AuditOutputs } from '@code-pushup/models'; + +/** + * Since more code coverage does not necessarily mean better score, this optional override allows for defining custom coverage goals. + * @param outputs original results + * @param threshold threshold above which the score is to be 1 + * @returns Outputs with overriden score (not value) to 1 if it reached a defined threshold. + */ +export function applyMaxScoreAboveThreshold( + outputs: AuditOutputs, + threshold: number, +): AuditOutputs { + return outputs.map(output => + output.score >= threshold ? { ...output, score: 1 } : output, + ); +} diff --git a/packages/plugin-coverage/src/lib/utils.unit.test.ts b/packages/plugin-coverage/src/lib/utils.unit.test.ts new file mode 100644 index 000000000..040e38a8a --- /dev/null +++ b/packages/plugin-coverage/src/lib/utils.unit.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import type { AuditOutput } from '@code-pushup/models'; +import { applyMaxScoreAboveThreshold } from './utils'; + +describe('applyMaxScoreAboveThreshold', () => { + it('should transform score above threshold to maximum', () => { + expect( + applyMaxScoreAboveThreshold( + [ + { + slug: 'branch-coverage', + value: 75, + score: 0.75, + } satisfies AuditOutput, + ], + 0.7, + ), + ).toEqual([ + { + slug: 'branch-coverage', + value: 75, + score: 1, + } satisfies AuditOutput, + ]); + }); + + it('should leave score below threshold untouched', () => { + expect( + applyMaxScoreAboveThreshold( + [ + { + slug: 'line-coverage', + value: 60, + score: 0.6, + } satisfies AuditOutput, + ], + 0.7, + ), + ).toEqual([ + { + slug: 'line-coverage', + value: 60, + score: 0.6, + } satisfies AuditOutput, + ]); + }); +}); diff --git a/packages/plugin-coverage/tsconfig.json b/packages/plugin-coverage/tsconfig.json new file mode 100644 index 000000000..893f9a925 --- /dev/null +++ b/packages/plugin-coverage/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/packages/plugin-coverage/tsconfig.lib.json b/packages/plugin-coverage/tsconfig.lib.json new file mode 100644 index 000000000..ef2f7e2b3 --- /dev/null +++ b/packages/plugin-coverage/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "vite.config.unit.ts", + "vite.config.integration.ts", + "src/**/*.test.ts", + "src/**/*.mock.ts", + "mocks/**/*.ts" + ] +} diff --git a/packages/plugin-coverage/tsconfig.test.json b/packages/plugin-coverage/tsconfig.test.json new file mode 100644 index 000000000..9f29d6bb0 --- /dev/null +++ b/packages/plugin-coverage/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] + }, + "include": [ + "vite.config.unit.ts", + "vite.config.integration.ts", + "mocks/**/*.ts", + "src/**/*.test.ts" + ] +} diff --git a/packages/plugin-coverage/vite.config.integration.ts b/packages/plugin-coverage/vite.config.integration.ts new file mode 100644 index 000000000..92dda7e4a --- /dev/null +++ b/packages/plugin-coverage/vite.config.integration.ts @@ -0,0 +1,21 @@ +/// +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-coverage', + plugins: [nxViteTsPaths()], + test: { + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + environment: 'node', + include: ['src/**/*.integration.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + globalSetup: ['global-setup.ts'], + setupFiles: [ + '../../testing-utils/src/lib/setup/console.mock.ts', + '../../testing-utils/src/lib/setup/reset.mocks.ts', + ], + }, +}); diff --git a/packages/plugin-coverage/vite.config.unit.ts b/packages/plugin-coverage/vite.config.unit.ts new file mode 100644 index 000000000..3d1991ab3 --- /dev/null +++ b/packages/plugin-coverage/vite.config.unit.ts @@ -0,0 +1,25 @@ +/// +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-coverage', + plugins: [nxViteTsPaths()], + test: { + globals: true, + cache: { + dir: '../../node_modules/.vitest', + }, + coverage: { + reporter: ['lcov'], + }, + environment: 'node', + include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + globalSetup: ['global-setup.ts'], + setupFiles: [ + '../../testing-utils/src/lib/setup/fs.mock.ts', + '../../testing-utils/src/lib/setup/console.mock.ts', + '../../testing-utils/src/lib/setup/reset.mocks.ts', + ], + }, +}); diff --git a/packages/plugin-eslint/vite.config.unit.ts b/packages/plugin-eslint/vite.config.unit.ts index c0ef19e03..627482fca 100644 --- a/packages/plugin-eslint/vite.config.unit.ts +++ b/packages/plugin-eslint/vite.config.unit.ts @@ -10,6 +10,9 @@ export default defineConfig({ cache: { dir: '../../node_modules/.vitest', }, + coverage: { + reporter: ['lcov'], + }, environment: 'node', include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['global-setup.ts'], diff --git a/packages/plugin-lighthouse/vite.config.unit.ts b/packages/plugin-lighthouse/vite.config.unit.ts index 3d3623c88..a701a1afd 100644 --- a/packages/plugin-lighthouse/vite.config.unit.ts +++ b/packages/plugin-lighthouse/vite.config.unit.ts @@ -10,6 +10,9 @@ export default defineConfig({ cache: { dir: '../../node_modules/.vitest', }, + coverage: { + reporter: ['lcov'], + }, environment: 'node', include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['global-setup.ts'], diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e992c7524..1297ac100 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,4 @@ +export { exists } from '@code-pushup/models'; export { ProcessConfig, ProcessError, @@ -39,6 +40,7 @@ export { } from './lib/guards'; export { logMultipleResults } from './lib/log-results'; export { ProgressBar, getProgressBar } from './lib/progress'; +export { TERMINAL_WIDTH } from './lib/reports/constants'; export { generateMdReport } from './lib/reports/generate-md-report'; export { generateStdoutSummary } from './lib/reports/generate-stdout-summary'; export { ScoredReport, scoreReport } from './lib/reports/scoring'; @@ -51,9 +53,9 @@ export { compareIssueSeverity, loadReport, } from './lib/reports/utils'; -export { TERMINAL_WIDTH } from './lib/reports/constants'; export { CliArgsObject, + capitalize, countOccurrences, distinct, factorOf, @@ -61,6 +63,9 @@ export { objectToEntries, objectToKeys, toArray, + toNumberPrecision, + toOrdinal, + toUnixNewlines, toUnixPath, } from './lib/transform'; export { verboseUtils } from './lib/verbose-utils'; diff --git a/packages/utils/src/lib/transform.ts b/packages/utils/src/lib/transform.ts index 44e5de500..7e9664bad 100644 --- a/packages/utils/src/lib/transform.ts +++ b/packages/utils/src/lib/transform.ts @@ -1,3 +1,5 @@ +import { platform } from 'node:os'; + export function toArray(val: T | T[]): T[] { return Array.isArray(val) ? val : [val]; } @@ -19,6 +21,10 @@ export function countOccurrences( ); } +export function exists(value: T): value is NonNullable { + return value != null; +} + export function distinct(array: T[]): T[] { return [...new Set(array)]; } @@ -111,3 +117,42 @@ export function toUnixPath( return unixPath; } + +export function toUnixNewlines(text: string): string { + return platform() === 'win32' ? text.replace(/\r\n/g, '\n') : text; +} + +export function capitalize(text: T): Capitalize { + return `${text.charAt(0).toLocaleUpperCase()}${text.slice( + 1, + )}` as Capitalize; +} + +export function toNumberPrecision( + value: number, + decimalPlaces: number, +): number { + return Number( + `${Math.round( + Number.parseFloat(`${value}e${decimalPlaces}`), + )}e-${decimalPlaces}`, + ); +} + +/* eslint-disable no-magic-numbers */ +export function toOrdinal(value: number): string { + if (value % 10 === 1 && value % 100 !== 11) { + return `${value}st`; + } + + if (value % 10 === 2 && value % 100 !== 12) { + return `${value}nd`; + } + + if (value % 10 === 3 && value % 100 !== 13) { + return `${value}rd`; + } + + return `${value}th`; +} +/* eslint-enable no-magic-numbers */ diff --git a/packages/utils/src/lib/transform.unit.test.ts b/packages/utils/src/lib/transform.unit.test.ts index d5f17a820..f60726ed2 100644 --- a/packages/utils/src/lib/transform.unit.test.ts +++ b/packages/utils/src/lib/transform.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + capitalize, countOccurrences, deepClone, distinct, @@ -8,6 +9,8 @@ import { objectToEntries, objectToKeys, toArray, + toNumberPrecision, + toOrdinal, toUnixPath, } from './transform'; @@ -187,3 +190,49 @@ describe('toUnixPath', () => { ).toBe('windows/path/config.ts'); }); }); + +describe('capitalize', () => { + it('should transform the first string letter to upper case', () => { + expect(capitalize('code PushUp')).toBe('Code PushUp'); + }); + + it('should leave the first string letter in upper case', () => { + expect(capitalize('Code PushUp')).toBe('Code PushUp'); + }); + + it('should accept empty string', () => { + expect(capitalize('')).toBe(''); + }); +}); + +describe('toNumberPrecision', () => { + it.each([ + [12.1, 0, 12], + [12.3, 1, 12.3], + [12.35, 1, 12.4], + [0.001, 2, 0], + ])( + 'should round %d to %d decimal places as %d', + (value, decimalPlaces, roundedValue) => { + expect(toNumberPrecision(value, decimalPlaces)).toBe(roundedValue); + }, + ); +}); + +describe('toOrdinal', () => { + it.each([ + [1, '1st'], + [2, '2nd'], + [3, '3rd'], + [5, '5th'], + [10, '10th'], + [11, '11th'], + [12, '12th'], + [13, '13th'], + [171, '171st'], + [172, '172nd'], + [173, '173rd'], + ])('should covert %d to ordinal as %s', (value, ordinalValue) => { + expect(toOrdinal(value)).toBe(ordinalValue); + }); +}); diff --git a/packages/utils/vite.config.unit.ts b/packages/utils/vite.config.unit.ts index 1f8ed984b..699d83cc8 100644 --- a/packages/utils/vite.config.unit.ts +++ b/packages/utils/vite.config.unit.ts @@ -10,6 +10,9 @@ export default defineConfig({ cache: { dir: '../../node_modules/.vitest', }, + coverage: { + reporter: ['lcov'], + }, environment: 'node', include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], globalSetup: ['global-setup.ts'], diff --git a/project.json b/project.json index d5466672d..db378e489 100644 --- a/project.json +++ b/project.json @@ -35,6 +35,7 @@ "projects": [ "cli", "plugin-eslint", + "plugin-coverage", "examples-plugins", "react-todos-app" ], diff --git a/tsconfig.base.json b/tsconfig.base.json index 64759fdbf..5403a3fd1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -25,6 +25,7 @@ "@code-pushup/lighthouse-plugin": [ "packages/plugin-lighthouse/src/index.ts" ], + "@code-pushup/coverage-plugin": ["packages/plugin-coverage/src/index.ts"], "@code-pushup/models": ["packages/models/src/index.ts"], "@code-pushup/nx-plugin": ["packages/nx-plugin/src/index.ts"], "@code-pushup/testing-utils": ["testing-utils/src/index.ts"],