diff --git a/e2e/plugin-coverage-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/plugin-coverage-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index bc4f67f68..38d6cdcf5 100644 --- a/e2e/plugin-coverage-e2e/tests/__snapshots__/collect.e2e.test.ts.snap +++ b/e2e/plugin-coverage-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`PLUGIN collect report with coverage-plugin NPM package > should run Code coverage plugin that runs coverage tool and creates report.json 1`] = ` +exports[`PLUGIN collect report with coverage-plugin NPM package > should run Code coverage plugin which parses existing lcov report and creates report.json 1`] = ` { "categories": [ { @@ -23,26 +23,91 @@ exports[`PLUGIN collect report with coverage-plugin NPM package > should run Cod { "description": "Measures how many functions were called in at least one test.", "details": { - "issues": [ + "trees": [ { - "message": "Function formatReportScore is not called in any test case.", - "severity": "error", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/existing-report/src/lib/partly-covered/utils.ts", - "position": { - "startLine": 2, - }, - }, - }, - { - "message": "Function sortReport is not called in any test case.", - "severity": "error", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/existing-report/src/lib/not-covered/sorting.ts", - "position": { - "startLine": 1, + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "scoring.ts", + "values": { + "coverage": 1, + "missing": [], + }, + }, + ], + "name": "fully-covered", + "values": { + "coverage": 1, + }, + }, + { + "children": [ + { + "name": "sorting.ts", + "values": { + "coverage": 0, + "missing": [ + { + "kind": "function", + "name": "sortReport", + "startLine": 1, + }, + ], + }, + }, + ], + "name": "not-covered", + "values": { + "coverage": 0, + }, + }, + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 0.5, + "missing": [ + { + "kind": "function", + "name": "formatReportScore", + "startLine": 2, + }, + ], + }, + }, + ], + "name": "partly-covered", + "values": { + "coverage": 0.5, + }, + }, + ], + "name": "lib", + "values": { + "coverage": 0.6, + }, + }, + ], + "name": "src", + "values": { + "coverage": 0.6, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0.6, }, }, + "title": "Function coverage", + "type": "coverage", }, ], }, @@ -55,46 +120,101 @@ exports[`PLUGIN collect report with coverage-plugin NPM package > should run Cod { "description": "Measures how many branches were executed after conditional statements in at least one test.", "details": { - "issues": [ - { - "message": "2nd branch is not taken in any test case.", - "severity": "error", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/existing-report/src/lib/partly-covered/utils.ts", - "position": { - "startLine": 6, - }, - }, - }, - { - "message": "2nd branch is not taken in any test case.", - "severity": "error", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/existing-report/src/lib/partly-covered/utils.ts", - "position": { - "startLine": 10, - }, - }, - }, + "trees": [ { - "message": "1st branch is not taken in any test case.", - "severity": "error", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/existing-report/src/lib/not-covered/sorting.ts", - "position": { - "startLine": 7, - }, - }, - }, - { - "message": "2nd branch is not taken in any test case.", - "severity": "error", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/existing-report/src/lib/not-covered/sorting.ts", - "position": { - "startLine": 7, + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "scoring.ts", + "values": { + "coverage": 1, + "missing": [], + }, + }, + ], + "name": "fully-covered", + "values": { + "coverage": 1, + }, + }, + { + "children": [ + { + "name": "sorting.ts", + "values": { + "coverage": 0, + "missing": [ + { + "kind": "branch", + "name": "0", + "startLine": 7, + }, + { + "kind": "branch", + "name": "1", + "startLine": 7, + }, + ], + }, + }, + ], + "name": "not-covered", + "values": { + "coverage": 0, + }, + }, + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 0.8, + "missing": [ + { + "kind": "branch", + "name": "1", + "startLine": 6, + }, + { + "kind": "branch", + "name": "1", + "startLine": 10, + }, + ], + }, + }, + ], + "name": "partly-covered", + "values": { + "coverage": 0.8, + }, + }, + ], + "name": "lib", + "values": { + "coverage": 0.7647058823529411, + }, + }, + ], + "name": "src", + "values": { + "coverage": 0.7647058823529411, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0.7647058823529411, }, }, + "title": "Branch coverage", + "type": "coverage", }, ], }, @@ -107,28 +227,89 @@ exports[`PLUGIN collect report with coverage-plugin NPM package > should run Cod { "description": "Measures how many lines of code were executed in at least one test.", "details": { - "issues": [ - { - "message": "Lines 7-9 are not covered in any test case.", - "severity": "warning", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/existing-report/src/lib/partly-covered/utils.ts", - "position": { - "endLine": 9, - "startLine": 7, - }, - }, - }, + "trees": [ { - "message": "Lines 1-5 are not covered in any test case.", - "severity": "warning", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/existing-report/src/lib/not-covered/sorting.ts", - "position": { - "endLine": 5, - "startLine": 1, + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "scoring.ts", + "values": { + "coverage": 1, + "missing": [], + }, + }, + ], + "name": "fully-covered", + "values": { + "coverage": 1, + }, + }, + { + "children": [ + { + "name": "sorting.ts", + "values": { + "coverage": 0, + "missing": [ + { + "endLine": 5, + "startLine": 1, + }, + ], + }, + }, + ], + "name": "not-covered", + "values": { + "coverage": 0, + }, + }, + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 0.7, + "missing": [ + { + "endLine": 9, + "startLine": 7, + }, + ], + }, + }, + ], + "name": "partly-covered", + "values": { + "coverage": 0.7, + }, + }, + ], + "name": "lib", + "values": { + "coverage": 0.68, + }, + }, + ], + "name": "src", + "values": { + "coverage": 0.68, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0.68, }, }, + "title": "Line coverage", + "type": "coverage", }, ], }, @@ -171,7 +352,7 @@ exports[`PLUGIN collect report with coverage-plugin NPM package > should run Cod } `; -exports[`PLUGIN collect report with coverage-plugin NPM package > should run Code coverage plugin which collects passed results and creates report.json 1`] = ` +exports[`PLUGIN collect report with coverage-plugin NPM package > should run Code coverage plugin which runs tests and creates report.json 1`] = ` { "categories": [ { @@ -194,16 +375,39 @@ exports[`PLUGIN collect report with coverage-plugin NPM package > should run Cod { "description": "Measures how many functions were called in at least one test.", "details": { - "issues": [ + "trees": [ { - "message": "Function untested is not called in any test case.", - "severity": "error", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/basic-setup/src/index.mjs", - "position": { - "startLine": 1, + "root": { + "children": [ + { + "children": [ + { + "name": "index.mjs", + "values": { + "coverage": 0.6666666666666666, + "missing": [ + { + "kind": "function", + "name": "untested", + "startLine": 1, + }, + ], + }, + }, + ], + "name": "src", + "values": { + "coverage": 0.6666666666666666, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0.6666666666666666, }, }, + "title": "Function coverage", + "type": "coverage", }, ], }, @@ -216,16 +420,39 @@ exports[`PLUGIN collect report with coverage-plugin NPM package > should run Cod { "description": "Measures how many branches were executed after conditional statements in at least one test.", "details": { - "issues": [ + "trees": [ { - "message": "1st branch is not taken in any test case.", - "severity": "error", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/basic-setup/src/index.mjs", - "position": { - "startLine": 10, + "root": { + "children": [ + { + "children": [ + { + "name": "index.mjs", + "values": { + "coverage": 0.6666666666666666, + "missing": [ + { + "kind": "branch", + "name": "0", + "startLine": 10, + }, + ], + }, + }, + ], + "name": "src", + "values": { + "coverage": 0.6666666666666666, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0.6666666666666666, }, }, + "title": "Branch coverage", + "type": "coverage", }, ], }, @@ -238,28 +465,42 @@ exports[`PLUGIN collect report with coverage-plugin NPM package > should run Cod { "description": "Measures how many lines of code were executed in at least one test.", "details": { - "issues": [ - { - "message": "Lines 2-3 are not covered in any test case.", - "severity": "warning", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/basic-setup/src/index.mjs", - "position": { - "endLine": 3, - "startLine": 2, - }, - }, - }, + "trees": [ { - "message": "Lines 11-12 are not covered in any test case.", - "severity": "warning", - "source": { - "file": "tmp/e2e/plugin-coverage-e2e/__test__/collect/basic-setup/src/index.mjs", - "position": { - "endLine": 12, - "startLine": 11, + "root": { + "children": [ + { + "children": [ + { + "name": "index.mjs", + "values": { + "coverage": 0.7333333333333333, + "missing": [ + { + "endLine": 3, + "startLine": 2, + }, + { + "endLine": 12, + "startLine": 11, + }, + ], + }, + }, + ], + "name": "src", + "values": { + "coverage": 0.7333333333333333, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0.7333333333333333, }, }, + "title": "Line coverage", + "type": "coverage", }, ], }, diff --git a/e2e/plugin-coverage-e2e/tests/collect.e2e.test.ts b/e2e/plugin-coverage-e2e/tests/collect.e2e.test.ts index 0067bc2a9..fd70eac00 100644 --- a/e2e/plugin-coverage-e2e/tests/collect.e2e.test.ts +++ b/e2e/plugin-coverage-e2e/tests/collect.e2e.test.ts @@ -1,11 +1,13 @@ import { cp } from 'node:fs/promises'; import path from 'node:path'; +import { simpleGit } from 'simple-git'; import { afterAll, afterEach, beforeAll } from 'vitest'; import { type Report, reportSchema } from '@code-pushup/models'; import { nxTargetProject } from '@code-pushup/test-nx-utils'; import { E2E_ENVIRONMENTS_DIR, TEST_OUTPUT_DIR, + initGitRepo, omitVariableReportData, teardownTestFolder, } from '@code-pushup/test-utils'; @@ -22,6 +24,8 @@ describe('PLUGIN collect report with coverage-plugin NPM package', () => { beforeAll(async () => { await cp(fixtureDir, testFileDir, { recursive: true }); + await initGitRepo(simpleGit, { baseDir: basicDir }); + await initGitRepo(simpleGit, { baseDir: existingDir }); }); afterAll(async () => { @@ -34,24 +38,24 @@ describe('PLUGIN collect report with coverage-plugin NPM package', () => { await teardownTestFolder(path.join(existingDir, '.code-pushup')); }); - it('should run Code coverage plugin which collects passed results and creates report.json', async () => { + it('should run Code coverage plugin which runs tests and creates report.json', async () => { const { code } = await executeProcess({ command: 'npx', - args: ['@code-pushup/cli', 'collect', '--no-progress'], + args: ['code-pushup', 'collect', '--no-progress'], cwd: basicDir, }); expect(code).toBe(0); - const report = await readJsonFile( + const report = await readJsonFile( path.join(basicDir, '.code-pushup', 'report.json'), ); expect(() => reportSchema.parse(report)).not.toThrow(); - expect(omitVariableReportData(report as Report)).toMatchSnapshot(); + expect(omitVariableReportData(report)).toMatchSnapshot(); }); - it('should run Code coverage plugin that runs coverage tool and creates report.json', async () => { + it('should run Code coverage plugin which parses existing lcov report and creates report.json', async () => { const { code } = await executeProcess({ command: 'npx', args: ['@code-pushup/cli', 'collect', '--no-progress'], @@ -60,11 +64,11 @@ describe('PLUGIN collect report with coverage-plugin NPM package', () => { expect(code).toBe(0); - const report = await readJsonFile( + const report = await readJsonFile( path.join(existingDir, '.code-pushup', 'report.json'), ); expect(() => reportSchema.parse(report)).not.toThrow(); - expect(omitVariableReportData(report as Report)).toMatchSnapshot(); + expect(omitVariableReportData(report)).toMatchSnapshot(); }); }); diff --git a/e2e/plugin-coverage-e2e/vite.config.e2e.ts b/e2e/plugin-coverage-e2e/vite.config.e2e.ts index 7ad716e95..15ef3ba1a 100644 --- a/e2e/plugin-coverage-e2e/vite.config.e2e.ts +++ b/e2e/plugin-coverage-e2e/vite.config.e2e.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vite'; import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; export default defineConfig({ - cacheDir: '../../node_modules/.vite/plugin-lighthouse-e2e', + cacheDir: '../../node_modules/.vite/plugin-coverage-e2e', test: { reporters: ['basic'], testTimeout: 40_000, diff --git a/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.json b/e2e/plugin-jsdocs-e2e/tests/__snapshots__/collect.e2e.test.ts.snap similarity index 72% rename from e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.json rename to e2e/plugin-jsdocs-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index 08d4dc630..b405e7ca6 100644 --- a/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.json +++ b/e2e/plugin-jsdocs-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -1,92 +1,46 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PLUGIN collect report with jsdocs-plugin NPM package > should run JSDoc plugin for Angular example dir and create report.json 1`] = ` { "packageName": "@code-pushup/core", "plugins": [ { - "title": "JSDoc coverage", - "slug": "jsdocs", - "icon": "folder-docs", - "description": "Official Code PushUp JSDoc coverage plugin.", - "docsUrl": "https://www.npmjs.com/package/@code-pushup/jsdocs-plugin/", - "groups": [ - { - "slug": "documentation-coverage", - "refs": [ - { - "slug": "classes-coverage", - "weight": 2 - }, - { - "slug": "methods-coverage", - "weight": 2 - }, - { - "slug": "functions-coverage", - "weight": 2 - }, - { - "slug": "interfaces-coverage", - "weight": 1 - }, - { - "slug": "variables-coverage", - "weight": 1 - }, - { - "slug": "properties-coverage", - "weight": 1 - }, - { - "slug": "types-coverage", - "weight": 1 - }, - { - "slug": "enums-coverage", - "weight": 1 - } - ], - "title": "Documentation coverage", - "description": "Documentation coverage" - } - ], "audits": [ { - "slug": "enums-coverage", - "displayValue": "0 undocumented enums", - "value": 0, - "score": 1, + "description": "Documentation coverage of enums", "details": { - "issues": [] + "issues": [], }, + "displayValue": "0 undocumented enums", + "score": 1, + "slug": "enums-coverage", "title": "Enums coverage", - "description": "Documentation coverage of enums" + "value": 0, }, { - "slug": "interfaces-coverage", - "displayValue": "0 undocumented interfaces", - "value": 0, - "score": 1, + "description": "Documentation coverage of interfaces", "details": { - "issues": [] + "issues": [], }, + "displayValue": "0 undocumented interfaces", + "score": 1, + "slug": "interfaces-coverage", "title": "Interfaces coverage", - "description": "Documentation coverage of interfaces" + "value": 0, }, { - "slug": "types-coverage", - "displayValue": "0 undocumented types", - "value": 0, - "score": 1, + "description": "Documentation coverage of types", "details": { - "issues": [] + "issues": [], }, + "displayValue": "0 undocumented types", + "score": 1, + "slug": "types-coverage", "title": "Types coverage", - "description": "Documentation coverage of types" + "value": 0, }, { - "slug": "functions-coverage", - "displayValue": "2 undocumented functions", - "value": 2, - "score": 0.3333, + "description": "Documentation coverage of functions", "details": { "issues": [ { @@ -95,9 +49,9 @@ "source": { "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/app.component.spec.ts", "position": { - "startLine": 1 - } - } + "startLine": 1, + }, + }, }, { "message": "Missing functions documentation for mapEventToCustomEvent", @@ -105,20 +59,20 @@ "source": { "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/map-event.function.ts", "position": { - "startLine": 3 - } - } - } - ] + "startLine": 3, + }, + }, + }, + ], }, + "displayValue": "2 undocumented functions", + "score": 0.3333, + "slug": "functions-coverage", "title": "Functions coverage", - "description": "Documentation coverage of functions" + "value": 2, }, { - "slug": "variables-coverage", - "displayValue": "1 undocumented variables", - "value": 1, - "score": 0, + "description": "Documentation coverage of variables", "details": { "issues": [ { @@ -127,31 +81,31 @@ "source": { "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/map-event.function.ts", "position": { - "startLine": 1 - } - } - } - ] + "startLine": 1, + }, + }, + }, + ], }, + "displayValue": "1 undocumented variables", + "score": 0, + "slug": "variables-coverage", "title": "Variables coverage", - "description": "Documentation coverage of variables" + "value": 1, }, { - "slug": "classes-coverage", - "displayValue": "0 undocumented classes", - "value": 0, - "score": 1, + "description": "Documentation coverage of classes", "details": { - "issues": [] + "issues": [], }, + "displayValue": "0 undocumented classes", + "score": 1, + "slug": "classes-coverage", "title": "Classes coverage", - "description": "Documentation coverage of classes" + "value": 0, }, { - "slug": "methods-coverage", - "displayValue": "1 undocumented methods", - "value": 1, - "score": 0.5, + "description": "Documentation coverage of methods", "details": { "issues": [ { @@ -160,20 +114,20 @@ "source": { "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/app.component.ts", "position": { - "startLine": 15 - } - } - } - ] + "startLine": 15, + }, + }, + }, + ], }, + "displayValue": "1 undocumented methods", + "score": 0.5, + "slug": "methods-coverage", "title": "Methods coverage", - "description": "Documentation coverage of methods" + "value": 1, }, { - "slug": "properties-coverage", - "displayValue": "1 undocumented properties", - "value": 1, - "score": 0, + "description": "Documentation coverage of properties", "details": { "issues": [ { @@ -182,16 +136,66 @@ "source": { "file": "tmp/e2e/plugin-jsdocs-e2e/__test__/angular/src/app.component.ts", "position": { - "startLine": 5 - } - } - } - ] + "startLine": 5, + }, + }, + }, + ], }, + "displayValue": "1 undocumented properties", + "score": 0, + "slug": "properties-coverage", "title": "Properties coverage", - "description": "Documentation coverage of properties" - } - ] - } - ] -} \ No newline at end of file + "value": 1, + }, + ], + "description": "Official Code PushUp JSDoc coverage plugin.", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/jsdocs-plugin/", + "groups": [ + { + "description": "Documentation coverage", + "refs": [ + { + "slug": "classes-coverage", + "weight": 2, + }, + { + "slug": "methods-coverage", + "weight": 2, + }, + { + "slug": "functions-coverage", + "weight": 2, + }, + { + "slug": "interfaces-coverage", + "weight": 1, + }, + { + "slug": "variables-coverage", + "weight": 1, + }, + { + "slug": "properties-coverage", + "weight": 1, + }, + { + "slug": "types-coverage", + "weight": 1, + }, + { + "slug": "enums-coverage", + "weight": 1, + }, + ], + "slug": "documentation-coverage", + "title": "Documentation coverage", + }, + ], + "icon": "folder-docs", + "slug": "jsdocs", + "title": "JSDoc coverage", + }, + ], +} +`; diff --git a/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.txt b/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.txt deleted file mode 100644 index b5606d69b..000000000 --- a/e2e/plugin-jsdocs-e2e/tests/__snapshots__/report.txt +++ /dev/null @@ -1,31 +0,0 @@ -Code PushUp CLI -[ info ] Run collect... -Code PushUp Report - @code-pushup/core@ - - -JSDoc coverage audits - -● Properties coverage 1 undocumented - properties -● Variables coverage 1 undocumented - variables -● Functions coverage 2 undocumented - functions -● Methods coverage 1 undocumented - methods -● ... 4 audits with perfect scores omitted for brevity ... - -Made with ❤ by code-pushup.dev - -[ success ] Collecting report successful! -[ info ] 💡 Configure categories to see the scores in an overview table. See: https://github.com/code-pushup/cli/blob/main/packages/cli/README.md -╭────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ 💡 Visualize your reports │ -│ │ -│ ❯ npx code-pushup upload - Run upload to upload the created report to the server │ -│ https://github.com/code-pushup/cli/tree/main/packages/cli#upload-command │ -│ ❯ npx code-pushup autorun - Run collect & upload │ -│ https://github.com/code-pushup/cli/tree/main/packages/cli#autorun-command │ -│ │ -╰────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/e2e/plugin-jsdocs-e2e/tests/collect.e2e.test.ts b/e2e/plugin-jsdocs-e2e/tests/collect.e2e.test.ts index 47981967f..c177b04c3 100644 --- a/e2e/plugin-jsdocs-e2e/tests/collect.e2e.test.ts +++ b/e2e/plugin-jsdocs-e2e/tests/collect.e2e.test.ts @@ -7,7 +7,6 @@ import { E2E_ENVIRONMENTS_DIR, TEST_OUTPUT_DIR, omitVariableReportData, - removeColorCodes, teardownTestFolder, } from '@code-pushup/test-utils'; import { executeProcess, readJsonFile } from '@code-pushup/utils'; @@ -48,7 +47,7 @@ describe('PLUGIN collect report with jsdocs-plugin NPM package', () => { }); it('should run JSDoc plugin for Angular example dir and create report.json', async () => { - const { code, stdout } = await executeProcess({ + const { code } = await executeProcess({ command: 'npx', args: ['@code-pushup/cli', 'collect', '--no-progress'], cwd: angularDir, @@ -56,17 +55,11 @@ describe('PLUGIN collect report with jsdocs-plugin NPM package', () => { expect(code).toBe(0); - expect( - removeColorCodes(stdout).replace(/@\d+\.\d+\.\d+/, '@'), - ).toMatchFileSnapshot('__snapshots__/report.txt'); - - const report = await readJsonFile( + const report = await readJsonFile( path.join(angularOutputDir, 'report.json'), ); expect(() => reportSchema.parse(report)).not.toThrow(); - expect( - JSON.stringify(omitVariableReportData(report as Report), null, 2), - ).toMatchFileSnapshot('__snapshots__/report.json'); + expect(omitVariableReportData(report)).toMatchSnapshot(); }); }); diff --git a/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/lcov-runner.integration.test.ts.snap b/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/lcov-runner.integration.test.ts.snap index 3fcc70253..e062ef1c2 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/lcov-runner.integration.test.ts.snap +++ b/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/lcov-runner.integration.test.ts.snap @@ -4,26 +4,68 @@ exports[`lcovResultsToAuditOutputs > should correctly convert lcov results to Au [ { "details": { - "issues": [ + "trees": [ { - "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, + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 0.8, + "missing": [ + { + "kind": "branch", + "name": "0", + "startLine": 6, + }, + { + "kind": "branch", + "name": "0", + "startLine": 10, + }, + ], + }, + }, + ], + "name": "lib", + "values": { + "coverage": 0.8, + }, + }, + ], + "name": "src", + "values": { + "coverage": 0.8, + }, + }, + ], + "name": "cli", + "values": { + "coverage": 0.8, + }, + }, + ], + "name": "packages", + "values": { + "coverage": 0.8, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0.8, }, }, + "title": "Branch coverage", + "type": "coverage", }, ], }, @@ -34,7 +76,59 @@ exports[`lcovResultsToAuditOutputs > should correctly convert lcov results to Au }, { "details": { - "issues": [], + "trees": [ + { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 1, + "missing": [], + }, + }, + ], + "name": "lib", + "values": { + "coverage": 1, + }, + }, + ], + "name": "src", + "values": { + "coverage": 1, + }, + }, + ], + "name": "cli", + "values": { + "coverage": 1, + }, + }, + ], + "name": "packages", + "values": { + "coverage": 1, + }, + }, + ], + "name": ".", + "values": { + "coverage": 1, + }, + }, + "title": "Function coverage", + "type": "coverage", + }, + ], }, "displayValue": "100 %", "score": 1, @@ -43,17 +137,62 @@ exports[`lcovResultsToAuditOutputs > should correctly convert lcov results to Au }, { "details": { - "issues": [ + "trees": [ { - "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, + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 0.7, + "missing": [ + { + "endLine": 9, + "startLine": 7, + }, + ], + }, + }, + ], + "name": "lib", + "values": { + "coverage": 0.7, + }, + }, + ], + "name": "src", + "values": { + "coverage": 0.7, + }, + }, + ], + "name": "cli", + "values": { + "coverage": 0.7, + }, + }, + ], + "name": "packages", + "values": { + "coverage": 0.7, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0.7, }, }, + "title": "Line coverage", + "type": "coverage", }, ], }, @@ -69,17 +208,62 @@ exports[`lcovResultsToAuditOutputs > should correctly merge all lines for covera [ { "details": { - "issues": [ + "trees": [ { - "message": "Lines 1-30 are not covered in any test case.", - "severity": "warning", - "source": { - "file": "packages/cli/src/lib/index.ts", - "position": { - "endLine": 30, - "startLine": 1, + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "index.ts", + "values": { + "coverage": 0, + "missing": [ + { + "endLine": 30, + "startLine": 1, + }, + ], + }, + }, + ], + "name": "lib", + "values": { + "coverage": 0, + }, + }, + ], + "name": "src", + "values": { + "coverage": 0, + }, + }, + ], + "name": "cli", + "values": { + "coverage": 0, + }, + }, + ], + "name": "packages", + "values": { + "coverage": 0, + }, + }, + ], + "name": ".", + "values": { + "coverage": 0, }, }, + "title": "Line coverage", + "type": "coverage", }, ], }, diff --git a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts index 79aec802e..631d839c9 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts @@ -1,7 +1,16 @@ import path from 'node:path'; import type { LCOVRecord } from 'parse-lcov'; import type { AuditOutputs } from '@code-pushup/models'; -import { exists, readTextFile, toUnixNewlines, ui } from '@code-pushup/utils'; +import { + type FileCoverage, + exists, + getGitRoot, + objectFromEntries, + objectToEntries, + readTextFile, + toUnixNewlines, + ui, +} from '@code-pushup/utils'; import type { CoverageResult, CoverageType } from '../../config.js'; import { mergeLcovResults } from './merge-lcov.js'; import { parseLcov } from './parse-lcov.js'; @@ -9,7 +18,6 @@ import { lcovCoverageToAuditOutput, recordToStatFunctionMapper, } from './transform.js'; -import type { LCOVStat, LCOVStats } from './types.js'; // Note: condition or statement coverage is not supported in LCOV // https://stackoverflow.com/questions/48260434/is-it-possible-to-check-condition-coverage-with-gcov @@ -31,18 +39,20 @@ export async function lcovResultsToAuditOutputs( const mergedResults = mergeLcovResults(lcovResults); // Calculate code coverage from all coverage results - const totalCoverageStats = getTotalCoverageFromLcovRecords( + const totalCoverageStats = groupLcovRecordsByCoverageType( mergedResults, coverageTypes, ); + const gitRoot = await getGitRoot(); + return coverageTypes .map(coverageType => { const stats = totalCoverageStats[coverageType]; if (!stats) { return null; } - return lcovCoverageToAuditOutput(stats, coverageType); + return lcovCoverageToAuditOutput(stats, coverageType, gitRoot); }) .filter(exists); } @@ -86,32 +96,22 @@ export async function parseLcovFiles( } /** - * - * @param records This function aggregates coverage stats from all coverage files + * This function aggregates coverage stats from all coverage files + * @param records LCOV record for each file * @param coverageTypes Types of coverage to be gathered * @returns Complete coverage stats for all defined types of coverage. */ -function getTotalCoverageFromLcovRecords( +function groupLcovRecordsByCoverageType( 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], - }, - ]), - ]), + coverageTypes: T[], +): Partial> { + return records.reduce>>( + (acc, record) => + objectFromEntries( + objectToEntries( + getCoverageStatsFromLcovRecord(record, coverageTypes), + ).map(([type, file]) => [type, [...(acc[type] ?? []), file]]), + ), {}, ); } @@ -121,12 +121,12 @@ function getTotalCoverageFromLcovRecords( * @param coverageTypes types of coverage to be gathered * @returns Relevant coverage data from one lcov record file. */ -function getCoverageStatsFromLcovRecord( +function getCoverageStatsFromLcovRecord( record: LCOVRecord, - coverageTypes: CoverageType[], -): LCOVStats { - return Object.fromEntries( - coverageTypes.map((coverageType): [CoverageType, LCOVStat] => [ + coverageTypes: T[], +): Record { + return objectFromEntries( + coverageTypes.map(coverageType => [ 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 index fedbdfd4d..a01c332d8 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/transform.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/transform.ts @@ -1,32 +1,29 @@ import type { LCOVRecord } from 'parse-lcov'; -import type { AuditOutput, Issue } from '@code-pushup/models'; -import { toNumberPrecision, toOrdinal } from '@code-pushup/utils'; +import type { AuditOutput } from '@code-pushup/models'; +import { + type FileCoverage, + capitalize, + filesCoverageToTree, + toNumberPrecision, +} from '@code-pushup/utils'; import type { CoverageType } from '../../config.js'; import { INVALID_FUNCTION_NAME } from '../constants.js'; -import type { LCOVStat } from './types.js'; -import { calculateCoverage, mergeConsecutiveNumbers } from './utils.js'; +import { mergeConsecutiveNumbers } from './utils.js'; -export function lcovReportToFunctionStat(record: LCOVRecord): LCOVStat { +export function lcovReportToFunctionStat(record: LCOVRecord): FileCoverage { const validRecord = removeEmptyReport(record); return { - totalFound: validRecord.functions.found, - totalHit: validRecord.functions.hit, - issues: - validRecord.functions.hit < validRecord.functions.found - ? validRecord.functions.details - .filter(detail => !detail.hit) - .map( - (detail): Issue => ({ - message: `Function ${detail.name} is not called in any test case.`, - severity: 'error', - source: { - file: validRecord.file, - position: { startLine: detail.line }, - }, - }), - ) - : [], + path: validRecord.file, + covered: validRecord.functions.hit, + total: validRecord.functions.found, + missing: validRecord.functions.details + .filter(detail => !detail.hit) + .map(detail => ({ + startLine: detail.line, + kind: 'function', + name: detail.name, + })), }; } @@ -52,67 +49,43 @@ function removeEmptyReport(record: LCOVRecord): LCOVRecord { }; } -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) - : []; +export function lcovReportToLineStat(record: LCOVRecord): FileCoverage { + const lines = record.lines.details + .filter(detail => !detail.hit) + .map(detail => detail.line); - const linePositions = mergeConsecutiveNumbers(lines); + const lineRanges = 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: record.file, - position: { - startLine: linePosition.start, - endLine: linePosition.end, - }, - }, - }; - }) - : [], + path: record.file, + covered: record.lines.hit, + total: record.lines.found, + missing: lineRanges.map(({ start, end }) => ({ + startLine: start, + endLine: end, + })), }; } -export function lcovReportToBranchStat(record: LCOVRecord): LCOVStat { +export function lcovReportToBranchStat(record: LCOVRecord): FileCoverage { 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: record.file, - position: { startLine: detail.line }, - }, - }), - ) - : [], + path: record.file, + covered: record.branches.hit, + total: record.branches.found, + missing: record.branches.details + .filter(detail => !detail.taken) + .map(detail => ({ + startLine: detail.line, + kind: 'branch', + name: detail.branch.toString(), + })), }; } -export const recordToStatFunctionMapper = { +export const recordToStatFunctionMapper: Record< + CoverageType, + (record: LCOVRecord) => FileCoverage +> = { branch: lcovReportToBranchStat, line: lcovReportToLineStat, function: lcovReportToFunctionStat, @@ -120,15 +93,22 @@ export const recordToStatFunctionMapper = { /** * - * @param stat code coverage result for a given type + * @param files code coverage of given type for all files * @param coverageType code coverage type + * @param gitRoot root directory in repo, for relative paths * @returns Result of complete code ccoverage data coverted to AuditOutput */ export function lcovCoverageToAuditOutput( - stat: LCOVStat, + files: FileCoverage[], coverageType: CoverageType, + gitRoot: string, ): AuditOutput { - const coverage = calculateCoverage(stat.totalHit, stat.totalFound); + const tree = filesCoverageToTree( + files, + gitRoot, + `${capitalize(coverageType)} coverage`, + ); + const coverage = tree.root.values.coverage; const MAX_DECIMAL_PLACES = 4; const coveragePercentage = coverage * 100; @@ -138,7 +118,7 @@ export function lcovCoverageToAuditOutput( value: coveragePercentage, displayValue: `${toNumberPrecision(coveragePercentage, 1)} %`, details: { - issues: stat.issues, + trees: [tree], }, }; } 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 index 3125c29c2..864e36597 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/transform.unit.test.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/transform.unit.test.ts @@ -1,6 +1,8 @@ +import path from 'node:path'; import type { LCOVRecord } from 'parse-lcov'; import { describe, it } from 'vitest'; -import type { AuditOutput, Issue } from '@code-pushup/models'; +import type { AuditOutput } from '@code-pushup/models'; +import type { FileCoverage } from '@code-pushup/utils'; import { INVALID_FUNCTION_NAME } from '../constants.js'; import { lcovCoverageToAuditOutput, @@ -8,10 +10,9 @@ import { lcovReportToFunctionStat, lcovReportToLineStat, } from './transform.js'; -import type { LCOVStat } from './types.js'; const lcovRecordMock: LCOVRecord = { - file: 'cli.ts', + file: 'bin.js', title: '', branches: { details: [], hit: 0, found: 0 }, functions: { details: [], hit: 0, found: 0 }, @@ -19,33 +20,41 @@ const lcovRecordMock: LCOVRecord = { }; describe('lcovReportToFunctionStat', () => { - it('should transform a fully covered function report to LCOV stat', () => { + it('should transform a fully covered function report', () => { expect( lcovReportToFunctionStat({ ...lcovRecordMock, + file: 'src/main.ts', functions: { hit: 1, found: 1, details: [{ line: 12, name: 'yargsCli', hit: 6 }], }, }), - ).toEqual({ totalHit: 1, totalFound: 1, issues: [] }); + ).toEqual({ + path: 'src/main.ts', + covered: 1, + total: 1, + missing: [], + }); }); - it('should transform an empty LCOV function report to LCOV stat', () => { + it('should transform an empty LCOV function report', () => { expect( lcovReportToFunctionStat({ ...lcovRecordMock, + file: 'src/main.ts', functions: { hit: 0, found: 0, details: [] }, }), - ).toEqual({ - totalHit: 0, - totalFound: 0, - issues: [], + ).toEqual({ + path: 'src/main.ts', + covered: 0, + total: 0, + missing: [], }); }); - it('should transform details from function report to issues', () => { + it('should transform details from function report to missing lines of code', () => { expect( lcovReportToFunctionStat({ ...lcovRecordMock, @@ -56,19 +65,19 @@ describe('lcovReportToFunctionStat', () => { }, }), ).toEqual( - expect.objectContaining({ - issues: [ + expect.objectContaining>({ + missing: [ { - message: 'Function yargsCli is not called in any test case.', - severity: 'error', - source: { file: 'cli.ts', position: { startLine: 12 } }, - } satisfies Issue, + kind: 'function', + name: 'yargsCli', + startLine: 12, + }, ], }), ); }); - it('should skip covered functions when transforming details to issues', () => { + it('should skip covered functions when transforming details to missing lines of code', () => { expect( lcovReportToFunctionStat({ ...lcovRecordMock, @@ -82,13 +91,13 @@ describe('lcovReportToFunctionStat', () => { }, }), ).toEqual( - expect.objectContaining({ - issues: [ + expect.objectContaining>({ + missing: [ { - message: 'Function cliError is not called in any test case.', - severity: 'error', - source: { file: 'cli.ts', position: { startLine: 20 } }, - } satisfies Issue, + kind: 'function', + name: 'cliError', + startLine: 20, + }, ], }), ); @@ -98,6 +107,7 @@ describe('lcovReportToFunctionStat', () => { expect( lcovReportToFunctionStat({ ...lcovRecordMock, + file: 'src/main.ts', functions: { hit: 1, found: 2, @@ -107,46 +117,51 @@ describe('lcovReportToFunctionStat', () => { ], }, }), - ).toStrictEqual({ - totalFound: 1, - totalHit: 1, - issues: [], + ).toStrictEqual({ + path: 'src/main.ts', + total: 1, + covered: 1, + missing: [], }); }); }); describe('lcovReportToLineStat', () => { - it('should transform a fully covered line report to LCOV stat', () => { + it('should transform a fully covered line report', () => { expect( lcovReportToLineStat({ ...lcovRecordMock, + file: 'src/main.ts', lines: { hit: 1, found: 1, details: [{ line: 1, hit: 6 }], }, }), - ).toEqual({ - totalHit: 1, - totalFound: 1, - issues: [], + ).toEqual({ + path: 'src/main.ts', + covered: 1, + total: 1, + missing: [], }); }); - it('should transform an empty LCOV line report to LCOV stat', () => { + it('should transform an empty LCOV line report', () => { expect( lcovReportToLineStat({ ...lcovRecordMock, + file: 'src/main.ts', lines: { hit: 0, found: 0, details: [] }, }), - ).toEqual({ - totalHit: 0, - totalFound: 0, - issues: [], + ).toEqual({ + path: 'src/main.ts', + covered: 0, + total: 0, + missing: [], }); }); - it('should transform details from line report to issues', () => { + it('should transform details from line report to missing lines of code', () => { expect( lcovReportToLineStat({ ...lcovRecordMock, @@ -157,19 +172,13 @@ describe('lcovReportToLineStat', () => { }, }), ).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, - ], + expect.objectContaining>({ + missing: [{ startLine: 1 }], }), ); }); - it('should skip covered lines when transforming details to issues', () => { + it('should skip covered lines when transforming details to missing lines of code', () => { expect( lcovReportToLineStat({ ...lcovRecordMock, @@ -183,14 +192,8 @@ describe('lcovReportToLineStat', () => { }, }), ).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, - ], + expect.objectContaining>({ + missing: [{ startLine: 2 }], }), ); }); @@ -213,57 +216,49 @@ describe('lcovReportToLineStat', () => { }, }), ).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[], + expect.objectContaining>({ + missing: [{ startLine: 2, endLine: 4 }, { startLine: 6 }], }), ); }); }); describe('lcovReportToBranchStat', () => { - it('should transform a fully covered branch report to LCOV stat', () => { + it('should transform a fully covered branch report', () => { expect( lcovReportToBranchStat({ ...lcovRecordMock, + file: 'src/main.ts', branches: { hit: 1, found: 1, details: [{ line: 12, taken: 6, branch: 0, block: 0 }], }, }), - ).toEqual({ - totalHit: 1, - totalFound: 1, - issues: [], + ).toEqual({ + path: 'src/main.ts', + covered: 1, + total: 1, + missing: [], }); }); - it('should transform an empty LCOV branch report to LCOV stat', () => { + it('should transform an empty LCOV branch report', () => { expect( lcovReportToBranchStat({ ...lcovRecordMock, + file: 'src/main.ts', branches: { hit: 0, found: 0, details: [] }, }), - ).toEqual({ - totalHit: 0, - totalFound: 0, - issues: [], + ).toEqual({ + path: 'src/main.ts', + covered: 0, + total: 0, + missing: [], }); }); - it('should transform details from branch report to issues', () => { + it('should transform details from branch report to missing lines of code', () => { expect( lcovReportToBranchStat({ ...lcovRecordMock, @@ -274,19 +269,19 @@ describe('lcovReportToBranchStat', () => { }, }), ).toEqual( - expect.objectContaining({ - issues: [ + expect.objectContaining>({ + missing: [ { - message: '1st branch is not taken in any test case.', - severity: 'error', - source: { file: 'cli.ts', position: { startLine: 12 } }, - } satisfies Issue, + kind: 'branch', + name: '0', + startLine: 12, + }, ], }), ); }); - it('should skip a covered branch when transforming details to issues', () => { + it('should skip a covered branch when transforming details to missing lines of code', () => { expect( lcovReportToBranchStat({ ...lcovRecordMock, @@ -300,13 +295,13 @@ describe('lcovReportToBranchStat', () => { }, }), ).toEqual( - expect.objectContaining({ - issues: [ + expect.objectContaining>({ + missing: [ { - message: '2nd branch is not taken in any test case.', - severity: 'error', - source: { file: 'cli.ts', position: { startLine: 20 } }, - } satisfies Issue, + kind: 'branch', + name: '1', + startLine: 20, + }, ], }), ); @@ -317,60 +312,134 @@ describe('lcovCoverageToAudit', () => { it('should transform full branch coverage to audit output', () => { expect( lcovCoverageToAuditOutput( - { totalHit: 56, totalFound: 56, issues: [] }, + [ + { + path: path.join(process.cwd(), 'main.js'), + covered: 2, + total: 2, + missing: [], + }, + ], 'branch', + process.cwd(), ), ).toEqual({ slug: 'branch-coverage', score: 1, value: 100, displayValue: '100 %', - details: { issues: [] }, + details: { + trees: [ + { + type: 'coverage', + title: 'Branch coverage', + root: { + name: '.', + values: { coverage: 1 }, + children: [ + { name: 'main.js', values: { coverage: 1, missing: [] } }, + ], + }, + }, + ], + }, }); }); it('should transform an empty function coverage to audit output', () => { expect( lcovCoverageToAuditOutput( - { totalHit: 0, totalFound: 0, issues: [] }, + [ + { + path: path.join(process.cwd(), 'release.js'), + covered: 0, + total: 0, + missing: [], + }, + ], 'function', + process.cwd(), ), ).toEqual({ slug: 'function-coverage', score: 1, value: 100, displayValue: '100 %', - details: { issues: [] }, + details: { + trees: [ + { + type: 'coverage', + title: 'Function coverage', + root: { + name: '.', + values: { coverage: 1 }, + children: [ + { name: 'release.js', values: { coverage: 1, missing: [] } }, + ], + }, + }, + ], + }, }); }); 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 } }, - }, - ], - }, + [ + { + path: path.join(process.cwd(), 'bin.js'), + covered: 0, + total: 5, + missing: [{ startLine: 1, endLine: 5 }], + }, + { + path: path.join(process.cwd(), 'src', 'core.js'), + covered: 50, + total: 50, + missing: [], + }, + { + path: path.join(process.cwd(), 'src', 'utils.js'), + covered: 45, + total: 45, + missing: [], + }, + ], 'line', + process.cwd(), ), ).toEqual({ slug: 'line-coverage', - score: 0.9, - value: 90, - displayValue: '90 %', + score: 0.95, + value: 95, + displayValue: '95 %', details: { - issues: [ + trees: [ { - message: 'Line 2 is not covered in any test case.', - severity: 'warning', - source: { file: 'cli.ts', position: { startLine: 2 } }, + type: 'coverage', + title: 'Line coverage', + root: { + name: '.', + values: { coverage: 0.95 }, + children: [ + { + name: 'src', + values: { coverage: 1 }, + children: [ + { name: 'core.js', values: { coverage: 1, missing: [] } }, + { name: 'utils.js', values: { coverage: 1, missing: [] } }, + ], + }, + { + name: 'bin.js', + values: { + coverage: 0, + missing: [{ startLine: 1, endLine: 5 }], + }, + }, + ], + }, }, ], }, diff --git a/packages/plugin-coverage/src/lib/runner/lcov/types.ts b/packages/plugin-coverage/src/lib/runner/lcov/types.ts index 1fa924a59..184a7fa25 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/types.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/types.ts @@ -1,12 +1 @@ -import type { Issue } from '@code-pushup/models'; -import type { CoverageType } from '../../config.js'; - -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/runner.integration.test.ts b/packages/plugin-coverage/src/lib/runner/runner.integration.test.ts index 814aaa8b9..17dcda01e 100644 --- a/packages/plugin-coverage/src/lib/runner/runner.integration.test.ts +++ b/packages/plugin-coverage/src/lib/runner/runner.integration.test.ts @@ -1,11 +1,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { expect } from 'vitest'; -import type { - AuditOutput, - AuditOutputs, - RunnerConfig, -} from '@code-pushup/models'; +import type { AuditOutputs, RunnerConfig } from '@code-pushup/models'; import { createRunnerFiles, readJsonFile } from '@code-pushup/utils'; import type { FinalCoveragePluginConfig } from '../config.js'; import { createRunnerConfig, executeRunner } from './index.js'; @@ -16,6 +12,7 @@ describe('createRunnerConfig', () => { reports: ['coverage/lcov.info'], coverageTypes: ['branch'], perfectScoreThreshold: 85, + continueOnCommandFail: true, }); expect(runnerConfig).toStrictEqual({ command: 'node', @@ -36,6 +33,7 @@ describe('createRunnerConfig', () => { reports: ['coverage/lcov.info'], coverageToolCommand: { command: 'npm', args: ['run', 'test'] }, perfectScoreThreshold: 85, + continueOnCommandFail: true, }; const { configFile } = await createRunnerConfig( @@ -63,6 +61,7 @@ describe('executeRunner', () => { ), ], coverageTypes: ['line'], + continueOnCommandFail: true, }; const runnerFiles = await createRunnerFiles( @@ -74,19 +73,46 @@ describe('executeRunner', () => { const results = await readJsonFile( runnerFiles.runnerOutputPath, ); - expect(results).toStrictEqual([ - expect.objectContaining({ + expect(results).toStrictEqual([ + { slug: 'line-coverage', score: 0.7, value: 70, + displayValue: '70 %', details: { - issues: [ - expect.objectContaining({ - message: 'Lines 7-9 are not covered in any test case.', - }), + trees: [ + { + type: 'coverage', + title: 'Line coverage', + root: { + name: '.', + values: { coverage: 0.7 }, + children: [ + { + name: 'src', + values: { coverage: 0.7 }, + children: [ + { + name: 'lib', + values: { coverage: 0.7 }, + children: [ + { + name: 'utils.ts', + values: { + coverage: 0.7, + missing: [{ startLine: 7, endLine: 9 }], + }, + }, + ], + }, + ], + }, + ], + }, + }, ], }, - } satisfies AuditOutput), + }, ]); }); }); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e4a79c0f5..3b6b8a556 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -6,6 +6,7 @@ export { toSentenceCase, toTitleCase, } from './lib/case-conversions.js'; +export { filesCoverageToTree, type FileCoverage } from './lib/coverage-tree.js'; export { createRunnerFiles } from './lib/create-runner-files.js'; export { comparePairs, matchArrayItemsByKey, type Diff } from './lib/diff.js'; export { stringifyError } from './lib/errors.js'; diff --git a/packages/utils/src/lib/coverage-tree.ts b/packages/utils/src/lib/coverage-tree.ts new file mode 100644 index 000000000..6f6e70200 --- /dev/null +++ b/packages/utils/src/lib/coverage-tree.ts @@ -0,0 +1,170 @@ +import type { + CoverageTree, + CoverageTreeMissingLOC, + CoverageTreeNode, +} from '@code-pushup/models'; +import { splitFilePath } from './file-system.js'; +import { formatGitPath } from './git/git.js'; + +export type FileCoverage = { + path: string; + total: number; + covered: number; + missing: CoverageTreeMissingLOC[]; +}; + +type CoverageStats = Pick; + +type FileTree = FolderNode | FileNode; + +type FileNode = FileCoverage & { + name: string; +}; + +type FolderNode = { + name: string; + children: FileTree[]; +}; + +export function filesCoverageToTree( + files: FileCoverage[], + gitRoot: string, + title?: string, +): CoverageTree { + const normalizedFiles = files.map(file => ({ + ...file, + path: formatGitPath(file.path, gitRoot), + })); + + const filesTree = normalizedFiles.reduce( + (acc, coverage) => { + const { folders, file } = splitFilePath(coverage.path); + return addNode(acc, folders, file, coverage); + }, + { name: '.', children: [] }, + ); + + const coverageTree = calculateTreeCoverage(filesTree); + const root = sortCoverageTree(coverageTree); + + return { + type: 'coverage', + ...(title && { title }), + root, + }; +} + +function addNode( + root: FileTree, + folders: string[], + file: string, + coverage: FileCoverage, +): FileTree { + const folder = folders[0]; + const rootChildren = 'children' in root ? root.children : []; + + if (folder) { + if (rootChildren.some(({ name }) => name === folder)) { + return { + ...root, + children: rootChildren.map(node => + node.name === folder + ? addNode(node, folders.slice(1), file, coverage) + : node, + ), + }; + } + return { + ...root, + children: [ + ...rootChildren, + addNode( + { name: folder, children: [] }, + folders.slice(1), + file, + coverage, + ), + ], + }; + } + + return { + ...root, + children: [...rootChildren, { ...coverage, name: file }], + }; +} + +function calculateTreeCoverage(root: FileTree): CoverageTreeNode { + if ('children' in root) { + const stats = aggregateChildCoverage(root.children); + const coverage = calculateCoverage(stats); + return { + name: root.name, + values: { coverage }, + children: root.children.map(calculateTreeCoverage), + }; + } + + return { + name: root.name, + values: { + coverage: calculateCoverage(root), + missing: root.missing, + }, + }; +} + +function calculateCoverage({ covered, total }: CoverageStats): number { + if (total === 0) { + return 1; + } + return covered / total; +} + +function aggregateChildCoverage( + nodes: FileTree[], + cache = new Map(), +): CoverageStats { + return nodes.reduce( + (acc, node) => { + const stats = getNodeCoverageStats(node, cache); + return { + covered: acc.covered + stats.covered, + total: acc.total + stats.total, + }; + }, + { covered: 0, total: 0 }, + ); +} + +function getNodeCoverageStats( + node: FileTree, + cache: Map, +): CoverageStats { + if (!('children' in node)) { + return node; + } + const cached = cache.get(node); + if (cached) { + return cached; + } + const stats = aggregateChildCoverage(node.children, cache); + cache.set(node, stats); + return stats; +} + +function sortCoverageTree(root: CoverageTreeNode): CoverageTreeNode { + if (!root.children?.length) { + return root; + } + return { + ...root, + children: root.children + .map(sortCoverageTree) + .toSorted( + (a, b) => + Number(Boolean(b.children?.length)) - + Number(Boolean(a.children?.length)) || a.name.localeCompare(b.name), + ), + }; +} diff --git a/packages/utils/src/lib/coverage-tree.unit.test.ts b/packages/utils/src/lib/coverage-tree.unit.test.ts new file mode 100644 index 000000000..0bf7ecbc9 --- /dev/null +++ b/packages/utils/src/lib/coverage-tree.unit.test.ts @@ -0,0 +1,225 @@ +import path from 'node:path'; +import type { CoverageTree } from '@code-pushup/models'; +import { type FileCoverage, filesCoverageToTree } from './coverage-tree.js'; + +describe('filesCoverageToTree', () => { + it('should convert list of files to folder structure', () => { + const mockCoverage: Omit = { + covered: 0, + total: 0, + missing: [], + }; + const files: FileCoverage[] = [ + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'components', 'CreateTodo.jsx'), + }, + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'components', 'TodoFilter.jsx'), + }, + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'components', 'TodoList.jsx'), + }, + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'hooks', 'useTodos.js'), + }, + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'App.jsx'), + }, + ]; + + expect(filesCoverageToTree(files, process.cwd())).toEqual( + expect.objectContaining({ + root: expect.objectContaining({ + name: '.', + children: [ + expect.objectContaining({ + name: 'src', + children: [ + expect.objectContaining({ + name: 'components', + children: [ + expect.objectContaining({ name: 'CreateTodo.jsx' }), + expect.objectContaining({ name: 'TodoFilter.jsx' }), + expect.objectContaining({ name: 'TodoList.jsx' }), + ], + }), + expect.objectContaining({ + name: 'hooks', + children: [expect.objectContaining({ name: 'useTodos.js' })], + }), + expect.objectContaining({ name: 'App.jsx' }), + ], + }), + ], + }), + }), + ); + }); + + it('should calculate files and folders coverage', () => { + const files: FileCoverage[] = [ + { + path: path.join(process.cwd(), 'src', 'components', 'CreateTodo.jsx'), + covered: 25, + total: 25, + missing: [], + }, + { + path: path.join(process.cwd(), 'src', 'components', 'TodoFilter.jsx'), + covered: 40, + total: 50, + missing: [{ startLine: 11, endLine: 21 }], + }, + { + path: path.join(process.cwd(), 'src', 'components', 'TodoList.jsx'), + covered: 25, + total: 25, + missing: [], + }, + { + path: path.join(process.cwd(), 'src', 'hooks', 'useTodos.js'), + covered: 0, + total: 60, + missing: [{ startLine: 1, endLine: 60 }], + }, + { + path: path.join(process.cwd(), 'src', 'App.jsx'), + covered: 0, + total: 20, + missing: [{ startLine: 1, endLine: 20 }], + }, + ]; + + expect(filesCoverageToTree(files, process.cwd())).toEqual({ + type: 'coverage', + root: { + name: '.', + values: { coverage: 0.5 }, // 90 out of 180 + children: [ + { + name: 'src', + values: { coverage: 0.5 }, // 90 out of 180 + children: [ + { + name: 'components', + values: { coverage: 0.9 }, // 90 out of 100 + children: [ + { + name: 'CreateTodo.jsx', + values: { + coverage: 1, // 25 out of 25 + missing: [], + }, + }, + { + name: 'TodoFilter.jsx', + values: { + coverage: 0.8, // 40 out of 50 + missing: [{ startLine: 11, endLine: 21 }], + }, + }, + { + name: 'TodoList.jsx', + values: { + coverage: 1, // 25 out of 25 + missing: [], + }, + }, + ], + }, + { + name: 'hooks', + values: { coverage: 0 }, // 0 out of 60 + children: [ + { + name: 'useTodos.js', + values: { + coverage: 0, // 0 out of 60 + missing: [{ startLine: 1, endLine: 60 }], + }, + }, + ], + }, + { + name: 'App.jsx', + values: { + coverage: 0, // 0 out of 20 + missing: [{ startLine: 1, endLine: 20 }], + }, + }, + ], + }, + ], + }, + }); + }); + + it('should include title if provided', () => { + expect(filesCoverageToTree([], process.cwd(), 'Branch coverage')).toEqual( + expect.objectContaining({ title: 'Branch coverage' }), + ); + }); + + it('should sort tree alphabetically with folders before files', () => { + const mockCoverage: Omit = { + covered: 0, + total: 0, + missing: [], + }; + const files: FileCoverage[] = [ + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'App.jsx'), + }, + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'components', 'TodoList.jsx'), + }, + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'hooks', 'useTodos.js'), + }, + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'components', 'TodoFilter.jsx'), + }, + { + ...mockCoverage, + path: path.join(process.cwd(), 'src', 'components', 'CreateTodo.jsx'), + }, + ]; + + expect(filesCoverageToTree(files, process.cwd())).toEqual( + expect.objectContaining({ + root: expect.objectContaining({ + name: '.', + children: [ + expect.objectContaining({ + name: 'src', + children: [ + expect.objectContaining({ + name: 'components', + children: [ + expect.objectContaining({ name: 'CreateTodo.jsx' }), + expect.objectContaining({ name: 'TodoFilter.jsx' }), + expect.objectContaining({ name: 'TodoList.jsx' }), + ], + }), + expect.objectContaining({ + name: 'hooks', + children: [expect.objectContaining({ name: 'useTodos.js' })], + }), + expect.objectContaining({ name: 'App.jsx' }), + ], + }), + ], + }), + }), + ); + }); +}); diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index 966dbc536..647a4adfe 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -159,3 +159,24 @@ export function filePathToCliArg(filePath: string): string { export function projectToFilename(project: string): string { return project.replace(/[/\\\s]+/g, '-').replace(/@/g, ''); } + +type SplitFilePath = { + folders: string[]; + file: string; +}; + +export function splitFilePath(filePath: string): SplitFilePath { + const file = path.basename(filePath); + const folders: string[] = []; + // eslint-disable-next-line functional/no-loop-statements + for ( + // eslint-disable-next-line functional/no-let + let dirPath = path.dirname(filePath); + path.dirname(dirPath) !== dirPath; + dirPath = path.dirname(dirPath) + ) { + // eslint-disable-next-line functional/immutable-data + folders.unshift(path.basename(dirPath)); + } + return { folders, file }; +} diff --git a/packages/utils/src/lib/file-system.unit.test.ts b/packages/utils/src/lib/file-system.unit.test.ts index dfb76ee06..5aea3d8b6 100644 --- a/packages/utils/src/lib/file-system.unit.test.ts +++ b/packages/utils/src/lib/file-system.unit.test.ts @@ -12,6 +12,7 @@ import { findNearestFile, logMultipleFileResults, projectToFilename, + splitFilePath, } from './file-system.js'; import * as logResults from './log-results.js'; @@ -263,3 +264,12 @@ describe('projectToFilename', () => { expect(projectToFilename(project)).toBe(file); }); }); + +describe('splitFilePath', () => { + it('should extract folders from file path', () => { + expect(splitFilePath(path.join('src', 'app', 'app.component.ts'))).toEqual({ + folders: ['src', 'app'], + file: 'app.component.ts', + }); + }); +}); diff --git a/testing/test-utils/src/lib/utils/git.ts b/testing/test-utils/src/lib/utils/git.ts index 1b3b00dbd..e89ac750a 100644 --- a/testing/test-utils/src/lib/utils/git.ts +++ b/testing/test-utils/src/lib/utils/git.ts @@ -9,6 +9,7 @@ import type { import { vi } from 'vitest'; export type GitConfig = { name: string; email: string }; + export async function initGitRepo( simpleGit: SimpleGitFactory, opt: { diff --git a/testing/test-utils/src/lib/utils/omit-report-data.ts b/testing/test-utils/src/lib/utils/omit-report-data.ts index 70c21c7ea..26b6f959b 100644 --- a/testing/test-utils/src/lib/utils/omit-report-data.ts +++ b/testing/test-utils/src/lib/utils/omit-report-data.ts @@ -16,24 +16,19 @@ export function omitVariableAuditData({ } export function omitVariablePluginData( - { - date: _, - duration: __, - version: ___, - audits, - ...pluginReport - }: PluginReport, + { date: _, duration: __, version: ___, ...pluginReport }: PluginReport, options?: { omitAuditData: boolean; }, ) { const { omitAuditData } = options ?? {}; - return { - ...pluginReport, - audits: audits.map(plugin => - omitAuditData ? omitVariableAuditData(plugin) : plugin, - ) as AuditReport[], - } as PluginReport; + if (omitAuditData) { + return { + ...pluginReport, + audits: pluginReport.audits.map(omitVariableAuditData), + }; + } + return pluginReport; } export function omitVariableReportData(