diff --git a/__tests__/__outputs__/lcov-report-results.md b/__tests__/__outputs__/lcov-report-results.md new file mode 100644 index 00000000..d8f4bfbd --- /dev/null +++ b/__tests__/__outputs__/lcov-report-results.md @@ -0,0 +1,21 @@ +![Tests failed](https://img.shields.io/badge/tests-4%20passed%2C%202%20failed-critical) +## ❌ fixtures/lcov.info +**6** tests were completed in **0ms** with **4** passed, **2** failed and **0** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[src/services/notifier/NotifierService.js](#r0s0)|2✅|1❌||0ms| +|[src/services/notifier/providers/DiscordNotifierProvider.js](#r0s1)|2✅|1❌||0ms| +### ❌ src/services/notifier/NotifierService.js +``` +src/services/notifier/NotifierService.js + ✅ lines 100% (21/21) + ✅ functions 100% (10/10) + ❌ branches 50% (3/6) +``` +### ❌ src/services/notifier/providers/DiscordNotifierProvider.js +``` +src/services/notifier/providers/DiscordNotifierProvider.js + ✅ lines 100% (17/17) + ✅ functions 100% (3/3) + ❌ branches 75% (3/4) +``` \ No newline at end of file diff --git a/__tests__/__snapshots__/lcov.test.ts.snap b/__tests__/__snapshots__/lcov.test.ts.snap new file mode 100644 index 00000000..76e242ad --- /dev/null +++ b/__tests__/__snapshots__/lcov.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`lcov report coverage report from facebook/jest test results matches snapshot 1`] = ` +TestRunResult { + "path": "fixtures/lcov.info", + "suites": [ + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "src/services/notifier/NotifierService.js", + "tests": [ + { + "name": "lines 100% (21/21)", + "result": "success", + "time": 0, + }, + { + "name": "functions 100% (10/10)", + "result": "success", + "time": 0, + }, + { + "name": "branches 50% (3/6)", + "result": "failed", + "time": 0, + }, + ], + }, + ], + "name": "src/services/notifier/NotifierService.js", + "totalTime": undefined, + }, + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "src/services/notifier/providers/DiscordNotifierProvider.js", + "tests": [ + { + "name": "lines 100% (17/17)", + "result": "success", + "time": 0, + }, + { + "name": "functions 100% (3/3)", + "result": "success", + "time": 0, + }, + { + "name": "branches 75% (3/4)", + "result": "failed", + "time": 0, + }, + ], + }, + ], + "name": "src/services/notifier/providers/DiscordNotifierProvider.js", + "totalTime": undefined, + }, + ], + "totalTime": undefined, +} +`; diff --git a/__tests__/fixtures/lcov.info b/__tests__/fixtures/lcov.info new file mode 100644 index 00000000..8c2bb40f --- /dev/null +++ b/__tests__/fixtures/lcov.info @@ -0,0 +1,92 @@ +TN: +SF:src/services/notifier/NotifierService.js +FN:9,(anonymous_0) +FN:10,(anonymous_1) +FN:26,(anonymous_2) +FN:27,(anonymous_3) +FN:29,(anonymous_4) +FN:30,(anonymous_5) +FN:46,(anonymous_6) +FN:47,(anonymous_7) +FN:48,(anonymous_8) +FN:49,(anonymous_9) +FNF:10 +FNH:10 +FNDA:1,(anonymous_0) +FNDA:1,(anonymous_1) +FNDA:1,(anonymous_2) +FNDA:3,(anonymous_3) +FNDA:3,(anonymous_4) +FNDA:3,(anonymous_5) +FNDA:1,(anonymous_6) +FNDA:3,(anonymous_7) +FNDA:3,(anonymous_8) +FNDA:3,(anonymous_9) +DA:9,1 +DA:10,1 +DA:11,1 +DA:13,1 +DA:14,1 +DA:26,1 +DA:27,3 +DA:29,1 +DA:30,3 +DA:31,3 +DA:33,3 +DA:34,3 +DA:46,1 +DA:47,3 +DA:48,3 +DA:51,3 +DA:53,3 +DA:54,3 +DA:58,3 +DA:61,1 +DA:64,1 +LF:21 +LH:21 +BRDA:11,0,0,1 +BRDA:11,0,1,0 +BRDA:31,1,0,3 +BRDA:31,1,1,0 +BRDA:51,2,0,3 +BRDA:51,2,1,0 +BRF:6 +BRH:3 +end_of_record +TN: +SF:src/services/notifier/providers/DiscordNotifierProvider.js +FN:12,(anonymous_0) +FN:33,(anonymous_1) +FN:51,(anonymous_2) +FNF:3 +FNH:3 +FNDA:1,(anonymous_0) +FNDA:1,(anonymous_1) +FNDA:1,(anonymous_2) +DA:3,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:22,1 +DA:23,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:40,1 +DA:41,1 +DA:51,1 +DA:52,1 +DA:53,1 +DA:58,1 +DA:59,1 +DA:62,1 +LF:17 +LH:17 +BRDA:18,0,0,0 +BRDA:18,0,1,1 +BRDA:20,1,0,1 +BRDA:20,1,1,1 +BRF:4 +BRH:3 +end_of_record diff --git a/__tests__/lcov.test.ts b/__tests__/lcov.test.ts new file mode 100644 index 00000000..4678329f --- /dev/null +++ b/__tests__/lcov.test.ts @@ -0,0 +1,23 @@ +import * as fs from 'fs' +import * as path from 'path' + +import {getReport} from '../src/report/get-report' +import {normalizeFilePath} from '../src/utils/path-utils' +import {LcovParser} from '../src/parsers/lcov/lcov-parser' + +describe('lcov report coverage', () => { + it('report from facebook/jest test results matches snapshot', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'lcov.info') + const outputPath = path.join(__dirname, '__outputs__', 'lcov-report-results.md') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const parser = new LcovParser({parseErrors: true, trackedFiles: []}) + const result = await parser.parse(filePath, fileContent) + expect(result).toMatchSnapshot() + + const report = getReport([result]) + fs.mkdirSync(path.dirname(outputPath), {recursive: true}) + fs.writeFileSync(outputPath, report) + }) +}) diff --git a/action.yml b/action.yml index 6f35ebfe..ca438587 100644 --- a/action.yml +++ b/action.yml @@ -32,6 +32,7 @@ inputs: - jest-junit - mocha-json - swift-xunit + - lcov required: true list-suites: description: | diff --git a/dist/index.js b/dist/index.js index 47a1b8d2..33588c82 100644 --- a/dist/index.js +++ b/dist/index.js @@ -268,6 +268,7 @@ const mocha_json_parser_1 = __nccwpck_require__(6043); const swift_xunit_parser_1 = __nccwpck_require__(5366); const path_utils_1 = __nccwpck_require__(4070); const github_utils_1 = __nccwpck_require__(3522); +const lcov_parser_1 = __nccwpck_require__(5698); function main() { return __awaiter(this, void 0, void 0, function* () { try { @@ -436,6 +437,8 @@ class TestReporter { return new mocha_json_parser_1.MochaJsonParser(options); case 'swift-xunit': return new swift_xunit_parser_1.SwiftXunitParser(options); + case 'lcov': + return new lcov_parser_1.LcovParser(options); default: throw new Error(`Input variable 'reporter' is set to invalid value '${reporter}'`); } @@ -1290,6 +1293,120 @@ class JestJunitParser { exports.JestJunitParser = JestJunitParser; +/***/ }), + +/***/ 5698: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.LcovParser = void 0; +const test_results_1 = __nccwpck_require__(2768); +const lcov_utils_1 = __nccwpck_require__(4750); +class LcovParser { + constructor(options) { + this.options = options; + } + parse(path, content) { + return __awaiter(this, void 0, void 0, function* () { + const report = yield this.parseFile(path, content); + return this.getTestRunResult(path, report); + }); + } + parseFile(path, content) { + return __awaiter(this, void 0, void 0, function* () { + try { + return (0, lcov_utils_1.parseProm)(content); + //return JSON.parse(content) as LcovReport + } + catch (e) { + throw new Error(`Invalid JSON at ${path}\n\n${e}`); + } + }); + } + getTestRunResult(path, report) { + return __awaiter(this, void 0, void 0, function* () { + const suites = []; + for (const reportElement of report) { + const fileName = reportElement.file; + const statementCaseResult = { + name: `lines ${this.getPartInfo(reportElement.lines)}`, + time: 0, + result: this.getPercentage(reportElement.lines) >= 80 ? 'success' : 'failed' + }; + const fonctionCaseResult = { + name: `functions ${this.getPartInfo(reportElement.functions)}`, + time: 0, + result: this.getPercentage(reportElement.functions) >= 80 ? 'success' : 'failed' + }; + const brancheCaseResult = { + name: `branches ${this.getPartInfo(reportElement.branches)}`, + time: 0, + result: this.getPercentage(reportElement.branches) >= 80 ? 'success' : 'failed' + }; + const testCases = [statementCaseResult, fonctionCaseResult, brancheCaseResult]; + const groups = [new test_results_1.TestGroupResult(fileName, testCases)]; + const suite = new test_results_1.TestSuiteResult(fileName, groups); + suites.push(suite); + } + return new test_results_1.TestRunResult(path, suites); + }); + } + getPercentage(stat) { + return stat ? (stat.hit / stat.found) * 100 : 100; + } + getPartInfo(stat) { + return `${this.getPercentage(stat)}% (${stat.hit}/${stat.found})`; + } +} +exports.LcovParser = LcovParser; + + +/***/ }), + +/***/ 4750: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.parseProm = void 0; +const lcov_parse_1 = __importDefault(__nccwpck_require__(7454)); +const parseProm = (pathOrStr) => __awaiter(void 0, void 0, void 0, function* () { + return new Promise((resolve, reject) => { + (0, lcov_parse_1.default)(pathOrStr, (err, data) => { + if (err) { + reject(err); + } + resolve(data !== null && data !== void 0 ? data : []); + }); + }); +}); +exports.parseProm = parseProm; + + /***/ }), /***/ 6043: @@ -23434,6 +23551,139 @@ class Keyv extends EventEmitter { module.exports = Keyv; +/***/ }), + +/***/ 7454: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +/* +Copyright (c) 2012, Yahoo! Inc. All rights reserved. +Code licensed under the BSD License: +http://yuilibrary.com/license/ +*/ + +var fs = __nccwpck_require__(7147), + path = __nccwpck_require__(1017); + +/* istanbul ignore next */ +var exists = fs.exists || path.exists; + +var walkFile = function(str, cb) { + var data = [], item; + + [ 'end_of_record' ].concat(str.split('\n')).forEach(function(line) { + line = line.trim(); + var allparts = line.split(':'), + parts = [allparts.shift(), allparts.join(':')], + lines, fn; + + switch (parts[0].toUpperCase()) { + case 'TN': + item.title = parts[1].trim(); + break; + case 'SF': + item.file = parts.slice(1).join(':').trim(); + break; + case 'FNF': + item.functions.found = Number(parts[1].trim()); + break; + case 'FNH': + item.functions.hit = Number(parts[1].trim()); + break; + case 'LF': + item.lines.found = Number(parts[1].trim()); + break; + case 'LH': + item.lines.hit = Number(parts[1].trim()); + break; + case 'DA': + lines = parts[1].split(','); + item.lines.details.push({ + line: Number(lines[0]), + hit: Number(lines[1]) + }); + break; + case 'FN': + fn = parts[1].split(','); + item.functions.details.push({ + name: fn[1], + line: Number(fn[0]) + }); + break; + case 'FNDA': + fn = parts[1].split(','); + item.functions.details.some(function(i, k) { + if (i.name === fn[1] && i.hit === undefined) { + item.functions.details[k].hit = Number(fn[0]); + return true; + } + }); + break; + case 'BRDA': + fn = parts[1].split(','); + item.branches.details.push({ + line: Number(fn[0]), + block: Number(fn[1]), + branch: Number(fn[2]), + taken: ((fn[3] === '-') ? 0 : Number(fn[3])) + }); + break; + case 'BRF': + item.branches.found = Number(parts[1]); + break; + case 'BRH': + item.branches.hit = Number(parts[1]); + break; + } + + if (line.indexOf('end_of_record') > -1) { + data.push(item); + item = { + lines: { + found: 0, + hit: 0, + details: [] + }, + functions: { + hit: 0, + found: 0, + details: [] + }, + branches: { + hit: 0, + found: 0, + details: [] + } + }; + } + }); + + data.shift(); + + if (data.length) { + cb(null, data); + } else { + cb('Failed to parse string'); + } +}; + +var parse = function(file, cb) { + exists(file, function(x) { + if (!x) { + return walkFile(file, cb); + } + fs.readFile(file, 'utf8', function(err, str) { + walkFile(str, cb); + }); + }); + +}; + + +module.exports = parse; +module.exports.source = walkFile; + + /***/ }), /***/ 9662: diff --git a/dist/licenses.txt b/dist/licenses.txt index 0eeea049..27c94182 100644 --- a/dist/licenses.txt +++ b/dist/licenses.txt @@ -1053,6 +1053,35 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. keyv MIT +lcov-parse +BSD-3-Clause +Copyright 2012 Yahoo! Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Yahoo! Inc. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL YAHOO! INC. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + lowercase-keys MIT MIT License diff --git a/package-lock.json b/package-lock.json index 3edad7e1..f2176fa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "adm-zip": "^0.5.10", "fast-glob": "^3.3.2", "got": "^11.8.2", + "lcov-parse": "^1.0.0", "picomatch": "^3.0.1", "xml2js": "^0.6.2" }, @@ -25,6 +26,7 @@ "@types/adm-zip": "^0.5.5", "@types/github-slugger": "^1.3.0", "@types/jest": "^29.5.11", + "@types/lcov-parse": "^1.0.2", "@types/node": "^20.10.4", "@types/picomatch": "^2.3.3", "@types/xml2js": "^0.4.14", @@ -1702,6 +1704,12 @@ "@types/node": "*" } }, + "node_modules/@types/lcov-parse": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/lcov-parse/-/lcov-parse-1.0.2.tgz", + "integrity": "sha512-tdoxiYm04XdDEdR7UMwkWj78UAVo9U2IOcxI6tmX2/s9TK/ue/9T8gbpS/07yeWyVkVO0UumFQ5EUIBQbVejzQ==", + "dev": true + }, "node_modules/@types/node": { "version": "20.10.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", @@ -5858,6 +5866,14 @@ "language-subtag-registry": "~0.3.2" } }, + "node_modules/lcov-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", + "integrity": "sha512-aprLII/vPzuQvYZnDRU78Fns9I2Ag3gi4Ipga/hxnVMCZC8DnR2nI7XBqrPoywGfxqIx/DgarGvDJZAD3YBTgQ==", + "bin": { + "lcov-parse": "bin/cli.js" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index d1f86a9e..3cdf042d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "adm-zip": "^0.5.10", "fast-glob": "^3.3.2", "got": "^11.8.2", + "lcov-parse": "^1.0.0", "picomatch": "^3.0.1", "xml2js": "^0.6.2" }, @@ -48,6 +49,7 @@ "@types/adm-zip": "^0.5.5", "@types/github-slugger": "^1.3.0", "@types/jest": "^29.5.11", + "@types/lcov-parse": "^1.0.2", "@types/node": "^20.10.4", "@types/picomatch": "^2.3.3", "@types/xml2js": "^0.4.14", diff --git a/src/main.ts b/src/main.ts index c3c89efd..9c39dc75 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,7 @@ import {SwiftXunitParser} from './parsers/swift-xunit/swift-xunit-parser' import {normalizeDirPath, normalizeFilePath} from './utils/path-utils' import {getCheckRunContext} from './utils/github-utils' +import {LcovParser} from './parsers/lcov/lcov-parser' async function main(): Promise { try { @@ -225,6 +226,8 @@ class TestReporter { return new MochaJsonParser(options) case 'swift-xunit': return new SwiftXunitParser(options) + case 'lcov': + return new LcovParser(options) default: throw new Error(`Input variable 'reporter' is set to invalid value '${reporter}'`) } diff --git a/src/parsers/lcov/lcov-parser.ts b/src/parsers/lcov/lcov-parser.ts new file mode 100644 index 00000000..bee98f84 --- /dev/null +++ b/src/parsers/lcov/lcov-parser.ts @@ -0,0 +1,59 @@ +import {ParseOptions, TestParser} from '../../test-parser' + +import {TestCaseResult, TestGroupResult, TestRunResult, TestSuiteResult} from '../../test-results' +import {parseProm} from './lcov-utils' +import {LcovBranch, LcovFile, LcovFunc, LcovLine, LcovPart} from 'lcov-parse' + +export class LcovParser implements TestParser { + constructor(readonly options: ParseOptions) {} + async parse(path: string, content: string): Promise { + const report = await this.parseFile(path, content) + return this.getTestRunResult(path, report) + } + + private async parseFile(path: string, content: string): Promise { + try { + return parseProm(content) + //return JSON.parse(content) as LcovReport + } catch (e) { + throw new Error(`Invalid JSON at ${path}\n\n${e}`) + } + } + private async getTestRunResult(path: string, report: LcovFile[]): Promise { + const suites: TestSuiteResult[] = [] + + for (const reportElement of report) { + const fileName = reportElement.file + + const statementCaseResult: TestCaseResult = { + name: `lines ${this.getPartInfo(reportElement.lines)}`, + time: 0, + result: this.getPercentage(reportElement.lines) >= 80 ? 'success' : 'failed' + } + const fonctionCaseResult: TestCaseResult = { + name: `functions ${this.getPartInfo(reportElement.functions)}`, + time: 0, + result: this.getPercentage(reportElement.functions) >= 80 ? 'success' : 'failed' + } + const brancheCaseResult: TestCaseResult = { + name: `branches ${this.getPartInfo(reportElement.branches)}`, + time: 0, + result: this.getPercentage(reportElement.branches) >= 80 ? 'success' : 'failed' + } + + const testCases: TestCaseResult[] = [statementCaseResult, fonctionCaseResult, brancheCaseResult] + const groups: TestGroupResult[] = [new TestGroupResult(fileName, testCases)] + const suite: TestSuiteResult = new TestSuiteResult(fileName, groups) + + suites.push(suite) + } + return new TestRunResult(path, suites) + } + + private getPercentage(stat: LcovPart): number { + return stat ? (stat.hit / stat.found) * 100 : 100 + } + private getPartInfo(stat: LcovPart): string { + return `${this.getPercentage(stat)}% (${stat.hit}/${stat.found})` + } +} diff --git a/src/parsers/lcov/lcov-types.ts b/src/parsers/lcov/lcov-types.ts new file mode 100644 index 00000000..9fbe2ce3 --- /dev/null +++ b/src/parsers/lcov/lcov-types.ts @@ -0,0 +1,21 @@ +export interface LcovReport { + [str: string]: { + path: string + statementMap: unknown + fnMap: unknown + branchMap: unknown + s: CovStats + f: CovStats + b: CovStats + } +} + +export interface CovStats { + [str: string]: number +} + +export interface CovParsedStat { + max: number + nonCovered: number + percentage: number +} diff --git a/src/parsers/lcov/lcov-utils.ts b/src/parsers/lcov/lcov-utils.ts new file mode 100644 index 00000000..864a5244 --- /dev/null +++ b/src/parsers/lcov/lcov-utils.ts @@ -0,0 +1,14 @@ +import parse, {LcovFile} from 'lcov-parse' + +const parseProm = async (pathOrStr: string): Promise => { + return new Promise((resolve, reject) => { + parse(pathOrStr, (err, data) => { + if (err) { + reject(err) + } + resolve(data ?? []) + }) + }) +} + +export {parseProm}