diff --git a/README.md b/README.md index 38ebf48..7af1698 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ ![tests](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fgist.githubusercontent.com%2FGaelGirodon%2F715c62717519f634185af0ebde234992%2Fraw%2Fci-badges-action-junit-tests.json) ![coverage](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fgist.githubusercontent.com%2FGaelGirodon%2F715c62717519f634185af0ebde234992%2Fraw%2Fci-badges-action-cobertura-coverage.json) -This action generates badges (as JSON files) from Go, JUnit, Cobertura and -JaCoCo test and coverage reports (most test runners and code coverage tools, +This action generates badges (as JSON files) from Go, JUnit, Cobertura, JaCoCo +and LCOV test and coverage reports (most test runners and code coverage tools, including Mocha, Jest, PHPUnit, c8, Istanbul/nyc, and more, support at least one of these formats) and upload them to a Gist to make them available to Shields through the endpoint feature with (almost) zero configuration. @@ -69,6 +69,7 @@ from test report(s). ![coverage](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fgist.githubusercontent.com%2FGaelGirodon%2F715c62717519f634185af0ebde234992%2Fraw%2Frepo-go-coverage.json) ![coverage](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fgist.githubusercontent.com%2FGaelGirodon%2F715c62717519f634185af0ebde234992%2Fraw%2Frepo-cobertura-coverage.json) ![coverage](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fgist.githubusercontent.com%2FGaelGirodon%2F715c62717519f634185af0ebde234992%2Fraw%2Frepo-jacoco-coverage.json) +![coverage](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fgist.githubusercontent.com%2FGaelGirodon%2F715c62717519f634185af0ebde234992%2Fraw%2Frepo-lcov-coverage.json) This badge displays the percentage of covered lines extracted from a coverage report. @@ -157,6 +158,24 @@ from the first matching and valid report file. ➡️ `{repo}-[{ref}-]jacoco-coverage.json` +### LCOV + +Write the coverage report to a file matching: + +- `**/lcov.*` +- `**/*.lcov` + +This is the default format and location with LCOV, but some code coverage +tools support this format too, natively or using an additional reporter: + +- **c8**: `c8 --reporter lcov [...]` → `coverage/lcov.info` +- **Deno**: `deno test --coverage=cov_profile && deno coverage cov_profile --lcov --output=cov_profile.lcov` + +The coverage will be computed using `LF` and `LH` keys, from the first +matching and valid report file. + +➡️ `{repo}-[{ref}-]lcov-coverage.json` + ## Notes Storing badge JSON files on a Gist may seem tedious, but: diff --git a/eslint.config.js b/eslint.config.js index d4c528f..687e0fc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,7 +2,6 @@ import globals from 'globals'; import pluginJs from '@eslint/js'; export default [ - { languageOptions: { globals: globals.node } }, - { languageOptions: { globals: globals.mocha } }, + { languageOptions: { globals: { ...globals.node, ...globals.mocha } } }, pluginJs.configs.recommended ]; diff --git a/src/reports/index.js b/src/reports/index.js index 71e14c5..fc279e0 100644 --- a/src/reports/index.js +++ b/src/reports/index.js @@ -3,12 +3,13 @@ import * as go from './go.js'; import * as junit from './junit.js'; import * as cobertura from './cobertura.js'; import * as jacoco from './jacoco.js'; +import * as lcov from './lcov.js'; /** * Available report loaders * @type {{ [key: string]: { getReports: ReportsLoader } }} */ -const loaders = { go, junit, cobertura, jacoco }; +const loaders = { go, junit, cobertura, jacoco, lcov }; /** * Load all available reports in the current workspace. diff --git a/src/reports/lcov.js b/src/reports/lcov.js new file mode 100644 index 0000000..8f7c581 --- /dev/null +++ b/src/reports/lcov.js @@ -0,0 +1,64 @@ +import * as core from '@actions/core'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { globNearest } from '../util/index.js'; + +/** + * Load coverage reports using LCOV format. + * @param {string} root Root search directory + * @returns LCOV coverage report + */ +export async function getReports(root) { + core.info('Load LCOV coverage report'); + const patterns = [ + join(root, '**/lcov.*'), + join(root, '**/*.lcov') + ]; + const files = await globNearest(patterns); + /** @type {Omit[]} */ + const reports = []; + for (const f of files) { + core.info(`Load LCOV report '${f}'`); + const coverage = await getCoverage(f); + if (coverage < 0) { + core.info('Report is not a valid LCOV report'); + continue; // Invalid report file, trying the next one + } + reports.push({ type: 'coverage', data: { coverage } }); + break; // Successfully loaded a report file, can return now + } + core.info(`Loaded ${reports.length} LCOV report(s)`); + return reports; +} + +/** + * Compute the total line coverage rate from the given LCOV report file, + * i.e., the ratio of all hit (LH) to found (LF) lines, deduplicated by source + * file (SF) (only the best coverage for each source file is considered). + * @param {string} path Path to the LCOV coverage report file + * @returns {Promise} The total line coverage rate (%), + * or -1 if no coverage data can be read from this file + */ +async function getCoverage(path) { + const contents = await fs.readFile(path, { encoding: 'utf8' }); + /** @type {{ [sf: string]: { lh: number, lf: number } }} */ + const sourceFiles = {}; + let from = 0, to = 0; + while ((to = contents.indexOf('end_of_record', from)) > 0) { + const record = contents.slice(from, to); + const sf = record.match(/^SF:(.+)$/m)?.[1] ?? ''; + const lh = parseInt(record.match(/^LH:([0-9]+)$/m)?.[1] ?? '0'); + const lf = parseInt(record.match(/^LF:([0-9]+)$/m)?.[1] ?? '0'); + const value = sourceFiles[sf]; + if (sf && (!value || (lf >= value.lf && lh / lf > value.lh / value.lf))) { + sourceFiles[sf] = { lh, lf }; + } + from = to + 13; + } + let lh = 0, lf = 0; + for (let value of Object.values(sourceFiles)) { + lh += value.lh; + lf += value.lf; + } + return lf > 0 ? (lh / lf) * 100 : -1; +} diff --git a/src/reports/lcov.test.js b/src/reports/lcov.test.js new file mode 100644 index 0000000..06c722e --- /dev/null +++ b/src/reports/lcov.test.js @@ -0,0 +1,15 @@ +import assert from 'assert/strict'; +import { join } from 'path'; +import { getReports } from './lcov.js'; + +describe('reports/lcov', () => { + describe('#getReports()', () => { + it('should return a coverage report', async () => { + const reports = await getReports(join(process.cwd(), 'test/data/lcov')); + assert.equal(reports.length, 1); + assert.deepEqual(reports, [ + { type: 'coverage', data: { coverage: 47 } } + ]); + }); + }); +}); diff --git a/test/data/lcov/lcov.info b/test/data/lcov/lcov.info new file mode 100644 index 0000000..dd283b6 --- /dev/null +++ b/test/data/lcov/lcov.info @@ -0,0 +1,22 @@ +TN: +SF:a.js +LH:20 +LF:40 +end_of_record +SF:b.js +LH:30 +LF:70 +end_of_record +SF:c.js +LH:30 +LF:90 +end_of_record +TN: +SF:a.js +LH:34 +LF:40 +end_of_record +SF:b.js +LH:20 +LF:70 +end_of_record diff --git a/test/e2e.test.js b/test/e2e.test.js index 8775f8d..db46cea 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -32,7 +32,8 @@ describe('CI Badges action', function () { 'repo-go-coverage.json', 'repo-go-tests.json', 'repo-jacoco-coverage.json', - 'repo-junit-tests.json' + 'repo-junit-tests.json', + 'repo-lcov-coverage.json' ].forEach(f => assert.ok(files.includes(f))); }); });