diff --git a/README.md b/README.md index d7396d3..ec6c2f6 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,16 @@ This action requires the `pull-request: write` permission to add a comment to yo ### Options -| Option | Description | Default | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `working-directory` | The main path to search for coverage- and configuration files (adjusting this is especially useful in monorepos). | `./` | -| `json-summary-path` | The path to the json summary file. | `${working-directory}/coverage/coverage-summary.json` | -| `json-final-path` | The path to the json final file. | `${working-directory}/coverage/coverage-final.json` | -| `vite-config-path` | The path to the vite config file. Will check the same paths as vite and vitest | Checks pattern `${working-directory}/vite[st].config.{t\|mt\|ct\|j\|mj\|cj}s` | -| `github-token` | A GitHub access token with permissions to write to issues (defaults to `secrets.GITHUB_TOKEN`). | `${{ github.token }}` | -| `file-coverage-mode` | Defines how file-based coverage is reported. Possible values are `all`, `changes` or `none`. | `changes` | -| `name` | Give the report a custom name. This is useful if you want multiple reports for different test suites within the same PR. Needs to be unique. | '' | +| Option | Description | Default | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `working-directory` | The main path to search for coverage- and configuration files (adjusting this is especially useful in monorepos). | `./` | +| `json-summary-path` | The path to the json summary file. | `${working-directory}/coverage/coverage-summary.json` | +| `json-final-path` | The path to the json final file. | `${working-directory}/coverage/coverage-final.json` | +| `vite-config-path` | The path to the vite config file. Will check the same paths as vite and vitest | Checks pattern `${working-directory}/vite[st].config.{t\|mt\|ct\|j\|mj\|cj}s` | +| `github-token` | A GitHub access token with permissions to write to issues (defaults to `secrets.GITHUB_TOKEN`). | `${{ github.token }}` | +| `file-coverage-mode` | Defines how file-based coverage is reported. Possible values are `all`, `changes` or `none`. | `changes` | +| `name` | Give the report a custom name. This is useful if you want multiple reports for different test suites within the same PR. Needs to be unique. | '' | +| `json-summary-compare-path` | The path to the json summary file to compare against. If given, will display a trend indicator and the difference in the summary. Respects the `working-directory` option. | undefined | #### File Coverage Mode @@ -142,6 +143,69 @@ With the above configuration, the report would appear as follows: If no thresholds are defined, the status will display as '🔵'. +### Coverage Trend Indicator + +By using the `json-summary-compare-path` option, the action will display both a trend indicator and the coverage difference in the summary. This feature is particularly useful for tracking changes between the main branch and a previous run. + +![Screenshot of the action-result showcasing the trend indicator](./docs/coverage-report-trend-indicator.png) + +The most straightforward method to obtain the comparison file within a pull request is to run the tests and generate the coverage for the target branch within a matrix job: + +```yml +name: "Test" +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + branch: + - ${{ github.head_ref }} + - "main" + + permissions: + # Required to checkout the code + contents: read + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ matrix.branch }} + - name: "Install Node" + uses: actions/setup-node@v4 + with: + node-version: "20.x" + - name: "Install Deps" + run: npm install + - name: "Test" + run: npx vitest --coverage.enabled true + - name: "Upload Coverage" + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.branch }} + path: coverage + + report-coverage: + needs: test + runs-on: ubuntu-latest + steps: + - name: "Download Coverage Artifacts" + uses: actions/download-artifact@v4 + with: + name: coverage-${{ github.head_ref }} + path: coverage + - uses: actions/download-artifact@v4 + with: + name: coverage-main + path: coverage-main + - name: "Report Coverage" + uses: davelosert/vitest-coverage-report-action@v2 + with: + json-summary-compare-path: coverage-main/coverage-summary.json +``` + ### Workspaces If you're using a monorepo with [Vitest Workspaces](https://vitest.dev/guide/workspace.html) and running Vitest from your project's root, Vitest will disregard the `coverage` property in individual project-level Vite configuration files. This is because some [configuration options](https://vitest.dev/guide/workspace.html#configuration), such as coverage, apply to the entire workspace and are not allowed in a project config. diff --git a/action.yml b/action.yml index 4620230..496fbf3 100644 --- a/action.yml +++ b/action.yml @@ -13,14 +13,17 @@ inputs: required: false description: 'The path to the json summary file. Uses "coverage/coverage-summary.json" by default.' default: coverage/coverage-summary.json - file-coverage-mode: + json-summary-compare-path: required: false - description: 'How to show summary for files coverage. Uses "changes" by default.' - default: changes + description: 'The path to the json summary file of the previous run to get trend indicators.' json-final-path: required: false description: 'The path to the json final file. Uses "coverage/coverage-final.json" by default.' default: coverage/coverage-final.json + file-coverage-mode: + required: false + description: 'How to show summary for files coverage. Uses "changes" by default.' + default: changes working-directory: required: false description: 'Custom working directory' diff --git a/docs/coverage-report-trend-indicator.png b/docs/coverage-report-trend-indicator.png new file mode 100644 index 0000000..a12418d Binary files /dev/null and b/docs/coverage-report-trend-indicator.png differ diff --git a/package.json b/package.json index 3b6194f..ebc2194 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "test": "vitest", "test:coverage": "vitest run --coverage.enabled true", + "test:export": "npx ts-node test/exportTestTable.ts", "build": "rm -rf dist && node esbuild.cjs", "start": "npm run build && node dist/index.js", "typecheck": "npx tsc --noEmit" diff --git a/src/generateSummaryTableHtml.ts b/src/generateSummaryTableHtml.ts deleted file mode 100644 index 3525180..0000000 --- a/src/generateSummaryTableHtml.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { icons } from './icons'; -import { oneLine } from 'common-tags'; -import { Thresholds } from './types/Threshold'; -import { CoverageReport, ReportNumbers } from './types/JsonSummary'; - -const generateTableRow = ({ reportNumbers, category, threshold }: { reportNumbers: ReportNumbers; category: string; threshold?: number; }): string => { - - let status = icons.blue; - let percent = `${reportNumbers.pct}%`; - - if(threshold) { - status = reportNumbers.pct >= threshold ? icons.green : icons.red; - percent = `${percent} / ${threshold}%`; - } - - return ` - ${status} - ${category} - ${percent} - ${reportNumbers.covered} / ${reportNumbers.total} - ` -} - - -const generateSummaryTableHtml = (jsonReport: CoverageReport, thresholds: Thresholds = {}): string => { - return oneLine` - - - - - - - - - - - - ${generateTableRow({ reportNumbers: jsonReport.lines, category: 'Lines', threshold: thresholds.lines })} - - - ${generateTableRow({ reportNumbers: jsonReport.statements, category: 'Statements', threshold: thresholds.statements })} - - - ${generateTableRow({ reportNumbers: jsonReport.functions, category: 'Functions',threshold: thresholds.functions })} - - - ${generateTableRow({ reportNumbers: jsonReport.branches, category: 'Branches', threshold: thresholds.branches })} - - -
StatusCategoryPercentageCovered / Total
- ` -} - -export { - generateSummaryTableHtml -}; diff --git a/src/icons.ts b/src/icons.ts index be92c2e..aaef5c1 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -2,6 +2,10 @@ const icons = { red: '🔴', green: '🟢', blue: '🔵', + increase: '⬆️', + decrease: '⬇️', + equal: '🟰', + target: '🎯' } export { diff --git a/src/index.ts b/src/index.ts index 0fc5779..ec48bb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,34 @@ -import { generateSummaryTableHtml } from './generateSummaryTableHtml.js'; -import { parseVitestJsonFinal, parseVitestJsonSummary } from './parseJsonReports.js'; +import { FileCoverageMode } from './inputs/FileCoverageMode.js' +import { generateFileCoverageHtml } from './report/generateFileCoverageHtml.js'; +import { generateHeadline } from './report/generateHeadline.js'; +import { generateSummaryTableHtml } from './report/generateSummaryTableHtml.js'; +import { getPullChanges } from './inputs/getPullChanges.js'; +import { parseVitestJsonFinal, parseVitestJsonSummary } from './inputs/parseJsonReports.js'; +import { readOptions } from './inputs/options.js'; +import { RequestError } from '@octokit/request-error' import { writeSummaryToPR } from './writeSummaryToPR.js'; import * as core from '@actions/core'; import * as github from '@actions/github'; -import { RequestError } from '@octokit/request-error' -import { generateFileCoverageHtml } from './generateFileCoverageHtml.js'; -import { getPullChanges } from './getPullChanges.js'; -import { FileCoverageMode } from './FileCoverageMode.js' -import { readOptions } from './options.js'; -import { generateHeadline } from './generateHeadline.js'; const run = async () => { const { fileCoverageMode, jsonFinalPath, jsonSummaryPath, + jsonSummaryComparePath, name, thresholds, workingDirectory } = await readOptions(); const jsonSummary = await parseVitestJsonSummary(jsonSummaryPath); - const tableData = generateSummaryTableHtml(jsonSummary.total, thresholds); + + let jsonSummaryCompare; + if(jsonSummaryComparePath) { + jsonSummaryCompare = await parseVitestJsonSummary(jsonSummaryComparePath); + } + + const tableData = generateSummaryTableHtml(jsonSummary.total, thresholds, jsonSummaryCompare?.total); const summary = core.summary .addHeading(generateHeadline({ workingDirectory, name }), 2) .addRaw(tableData) diff --git a/src/FileCoverageMode.test.ts b/src/inputs/FileCoverageMode.test.ts similarity index 100% rename from src/FileCoverageMode.test.ts rename to src/inputs/FileCoverageMode.test.ts diff --git a/src/FileCoverageMode.ts b/src/inputs/FileCoverageMode.ts similarity index 100% rename from src/FileCoverageMode.ts rename to src/inputs/FileCoverageMode.ts diff --git a/src/getPullChanges.ts b/src/inputs/getPullChanges.ts similarity index 96% rename from src/getPullChanges.ts rename to src/inputs/getPullChanges.ts index 12e9b1c..f436009 100644 --- a/src/getPullChanges.ts +++ b/src/inputs/getPullChanges.ts @@ -56,4 +56,3 @@ export async function getPullChanges(fileCoverageMode: FileCoverageMode): Promis core.endGroup() } } - diff --git a/src/getViteConfigPath.test.ts b/src/inputs/getViteConfigPath.test.ts similarity index 99% rename from src/getViteConfigPath.test.ts rename to src/inputs/getViteConfigPath.test.ts index c4ec262..1ede6f2 100644 --- a/src/getViteConfigPath.test.ts +++ b/src/inputs/getViteConfigPath.test.ts @@ -7,6 +7,7 @@ describe("getViteConfigPath", () => { const mockWorkingDirectory = path.resolve( __dirname, "..", + '..', "test", "mockConfig" ); diff --git a/src/getViteConfigPath.ts b/src/inputs/getViteConfigPath.ts similarity index 100% rename from src/getViteConfigPath.ts rename to src/inputs/getViteConfigPath.ts diff --git a/src/options.ts b/src/inputs/options.ts similarity index 82% rename from src/options.ts rename to src/inputs/options.ts index bcbbceb..096717f 100644 --- a/src/options.ts +++ b/src/inputs/options.ts @@ -14,6 +14,13 @@ async function readOptions() { const jsonSummaryPath = path.resolve(workingDirectory, core.getInput('json-summary-path')); const jsonFinalPath = path.resolve(workingDirectory, core.getInput('json-final-path')); + + const jsonSummaryCompareInput = core.getInput('json-summary-compare-path'); + let jsonSummaryComparePath; + if(jsonSummaryCompareInput) { + jsonSummaryComparePath = path.resolve(workingDirectory, jsonSummaryCompareInput); + } + const name = core.getInput('name'); // ViteConfig is optional, as it is only required for thresholds. If no vite config is provided, we will not include thresholds in the final report. @@ -24,9 +31,10 @@ async function readOptions() { fileCoverageMode, jsonFinalPath, jsonSummaryPath, + jsonSummaryComparePath, name, thresholds, - workingDirectory + workingDirectory, } } diff --git a/src/parseCoverageThresholds.test.ts b/src/inputs/parseCoverageThresholds.test.ts similarity index 94% rename from src/parseCoverageThresholds.test.ts rename to src/inputs/parseCoverageThresholds.test.ts index 2f3ae3d..ae3fe85 100644 --- a/src/parseCoverageThresholds.test.ts +++ b/src/inputs/parseCoverageThresholds.test.ts @@ -9,7 +9,7 @@ vi.mock('@actions/core', async (importOriginal) => ({ })); describe('generateTableData', () => { - const mockConfigPath = path.resolve(__dirname, '..', 'test', 'mockConfig'); + const mockConfigPath = path.resolve(__dirname, '..', '..', 'test', 'mockConfig'); const getConfig = (configName: string) => path.resolve(mockConfigPath, configName) it('returns no thresholds if config file can not be found.', async (): Promise => { diff --git a/src/parseCoverageThresholds.ts b/src/inputs/parseCoverageThresholds.ts similarity index 96% rename from src/parseCoverageThresholds.ts rename to src/inputs/parseCoverageThresholds.ts index dd24457..f1c901c 100644 --- a/src/parseCoverageThresholds.ts +++ b/src/inputs/parseCoverageThresholds.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import * as core from '@actions/core'; import { promises as fs } from 'fs'; -import { Thresholds } from './types/Threshold'; +import { Thresholds } from '../types/Threshold'; const regex100 = /100"?\s*:\s*true/; const regexStatements = /statements\s*:\s*(\d+)/; diff --git a/src/parseJsonReports.ts b/src/inputs/parseJsonReports.ts similarity index 93% rename from src/parseJsonReports.ts rename to src/inputs/parseJsonReports.ts index 6ad889c..c2002cc 100644 --- a/src/parseJsonReports.ts +++ b/src/inputs/parseJsonReports.ts @@ -1,8 +1,8 @@ import { readFile } from 'node:fs/promises'; import * as core from '@actions/core'; import path from 'node:path'; -import { JsonFinal } from './types/JsonFinal'; -import { JsonSummary } from './types/JsonSummary'; +import { JsonFinal } from '../types/JsonFinal'; +import { JsonSummary } from '../types/JsonSummary'; import { stripIndent } from 'common-tags'; const parseVitestCoverageReport = async (jsonPath: string): Promise => { diff --git a/src/generateFileCoverageHtml.test.ts b/src/report/generateFileCoverageHtml.test.ts similarity index 94% rename from src/generateFileCoverageHtml.test.ts rename to src/report/generateFileCoverageHtml.test.ts index 5a4df1a..fd877b8 100644 --- a/src/generateFileCoverageHtml.test.ts +++ b/src/report/generateFileCoverageHtml.test.ts @@ -1,11 +1,11 @@ -import { createJsonFinalEntry } from './types/JsonFinalMockFactory'; +import { createJsonFinalEntry } from '../types/JsonFinalMockFactory'; import { generateFileCoverageHtml } from './generateFileCoverageHtml'; -import { getTableLine } from '../test/queryHelper'; -import { JsonFinal } from './types/JsonFinal'; -import { JsonSummary } from './types/JsonSummary'; -import { createMockCoverageReport, createMockJsonSummary, createMockReportNumbers } from './types/JsonSummaryMockFactory'; +import { getTableLine } from '../../test/queryHelper'; +import { JsonFinal } from '../types/JsonFinal'; +import { JsonSummary } from '../types/JsonSummary'; +import { createMockCoverageReport, createMockJsonSummary, createMockReportNumbers } from '../types/JsonSummaryMockFactory'; import { describe, it, expect } from 'vitest'; -import { FileCoverageMode } from './FileCoverageMode'; +import { FileCoverageMode } from '../inputs/FileCoverageMode'; import * as path from 'path'; const workspacePath = process.cwd(); diff --git a/src/generateFileCoverageHtml.ts b/src/report/generateFileCoverageHtml.ts similarity index 95% rename from src/generateFileCoverageHtml.ts rename to src/report/generateFileCoverageHtml.ts index 83c5324..b0ba9d5 100644 --- a/src/generateFileCoverageHtml.ts +++ b/src/report/generateFileCoverageHtml.ts @@ -1,11 +1,10 @@ import * as path from 'path' -import * as core from '@actions/core' import { generateBlobFileUrl } from './generateFileUrl' import { LineRange, getUncoveredLinesFromStatements } from './getUncoveredLinesFromStatements' -import { JsonFinal } from './types/JsonFinal' -import { JsonSummary } from './types/JsonSummary' +import { JsonFinal } from '../types/JsonFinal' +import { JsonSummary } from '../types/JsonSummary' import { oneLine } from 'common-tags' -import { FileCoverageMode } from './FileCoverageMode' +import { FileCoverageMode } from '../inputs/FileCoverageMode' type FileCoverageInputs = { jsonSummary: JsonSummary; diff --git a/src/generateFileUrl.ts b/src/report/generateFileUrl.ts similarity index 100% rename from src/generateFileUrl.ts rename to src/report/generateFileUrl.ts diff --git a/src/generateHeadline.test.ts b/src/report/generateHeadline.test.ts similarity index 100% rename from src/generateHeadline.test.ts rename to src/report/generateHeadline.test.ts diff --git a/src/generateHeadline.ts b/src/report/generateHeadline.ts similarity index 100% rename from src/generateHeadline.ts rename to src/report/generateHeadline.ts diff --git a/src/generateSummaryTableHtml.test.ts b/src/report/generateSummaryTableHtml.test.ts similarity index 52% rename from src/generateSummaryTableHtml.test.ts rename to src/report/generateSummaryTableHtml.test.ts index f384670..0600a8c 100644 --- a/src/generateSummaryTableHtml.test.ts +++ b/src/report/generateSummaryTableHtml.test.ts @@ -1,14 +1,14 @@ import { generateSummaryTableHtml } from './generateSummaryTableHtml'; -import { getTableLine } from '../test/queryHelper'; -import { icons } from './icons'; -import { Thresholds } from './types/Threshold'; -import { createMockCoverageReport, createMockReportNumbers } from './types/JsonSummaryMockFactory'; +import { getTableLine } from '../../test/queryHelper'; +import { icons } from '../icons'; +import { Thresholds } from '../types/Threshold'; +import { createMockCoverageReport, createMockReportNumbers } from '../types/JsonSummaryMockFactory'; import { describe, it, expect } from 'vitest'; describe('generateSummaryTabelHtml()', () => { it('generates the headline', () => { const mockReport = createMockCoverageReport(); - const summaryHtml = generateSummaryTableHtml(mockReport); + const summaryHtml = generateSummaryTableHtml(mockReport, undefined, undefined); const headline = getTableLine(0, summaryHtml); expect(headline).toContain('Status'); @@ -19,7 +19,7 @@ describe('generateSummaryTabelHtml()', () => { it('generates all categories as rows', async (): Promise => { const mockReport = createMockCoverageReport(); - const summaryHtml = generateSummaryTableHtml(mockReport); + const summaryHtml = generateSummaryTableHtml(mockReport, undefined, undefined); expect(getTableLine(1, summaryHtml)).toContain('Lines'); expect(getTableLine(2, summaryHtml)).toContain('Statements'); @@ -30,7 +30,7 @@ describe('generateSummaryTabelHtml()', () => { it('adds status blue-circle if no threshold provided.', async (): Promise => { const mockReport = createMockCoverageReport(); - const summaryHtml = generateSummaryTableHtml(mockReport); + const summaryHtml = generateSummaryTableHtml(mockReport, undefined, undefined); expect(summaryHtml).toContain(icons.blue); }); @@ -41,7 +41,7 @@ describe('generateSummaryTabelHtml()', () => { lines: createMockReportNumbers({ pct: 80 }) }); - const summaryHtml = generateSummaryTableHtml(mockReport); + const summaryHtml = generateSummaryTableHtml(mockReport, undefined, undefined); expect(getTableLine(1, summaryHtml)).toContain('80%'); }); @@ -54,7 +54,7 @@ describe('generateSummaryTabelHtml()', () => { }) }); - const summaryHtml = generateSummaryTableHtml(mockReport); + const summaryHtml = generateSummaryTableHtml(mockReport, undefined, undefined); expect(getTableLine(1, summaryHtml)).toContain('8 / 10'); }); @@ -66,7 +66,7 @@ describe('generateSummaryTabelHtml()', () => { pct: 81, }) }); - const summaryHtml = generateSummaryTableHtml(mockReport, thresholds); + const summaryHtml = generateSummaryTableHtml(mockReport, thresholds, undefined); expect(getTableLine(1, summaryHtml)).toContain(icons.green); }); @@ -78,12 +78,12 @@ describe('generateSummaryTabelHtml()', () => { pct: 81, }) }); - const summaryHtml = generateSummaryTableHtml(mockReport, thresholds); + const summaryHtml = generateSummaryTableHtml(mockReport, thresholds, undefined); expect(getTableLine(1, summaryHtml)).toContain(icons.red); }); - it('if threshold is given, provides the threshold in the percentage column.', async (): Promise => { + it('if threshold is given, provides the threshold in the category column.', async (): Promise => { const thresholds: Thresholds = { lines: 100 }; const mockReport = createMockCoverageReport({ lines: createMockReportNumbers({ @@ -91,8 +91,59 @@ describe('generateSummaryTabelHtml()', () => { }) }); - const summaryHtml = generateSummaryTableHtml(mockReport, thresholds); - - expect(getTableLine(1, summaryHtml)).toContain('80% / 100%'); + const summaryHtml = generateSummaryTableHtml(mockReport, thresholds, undefined); + + expect(getTableLine(1, summaryHtml)).toContain('80% (🎯 100%)'); }); + + it('if compare report is given and coverage decreased, provides the difference in the percentage column.', async (): Promise => { + const mockReport = createMockCoverageReport({ + lines: createMockReportNumbers({ + pct: 80, + }) + }); + const mockCompareReport = createMockCoverageReport({ + lines: createMockReportNumbers({ + pct: 90, + }) + }); + + const summaryHtml = generateSummaryTableHtml(mockReport, undefined, mockCompareReport); + + expect(getTableLine(1, summaryHtml)).toContain('80%
⬇️ -10.00%'); + }); + + it('if compare report is given and coverage increased, provides the difference in the percentage column.', async (): Promise => { + const mockReport = createMockCoverageReport({ + lines: createMockReportNumbers({ + pct: 90, + }) + }); + const mockCompareReport = createMockCoverageReport({ + lines: createMockReportNumbers({ + pct: 80, + }) + }); + + const summaryHtml = generateSummaryTableHtml(mockReport, undefined, mockCompareReport); + + expect(getTableLine(1, summaryHtml)).toContain('90%
⬆️ +10.00%'); + }); + + it('if compare report is given and coverage stayed the same, provides the difference in the percentage column.', async (): Promise => { + const mockReport = createMockCoverageReport({ + lines: createMockReportNumbers({ + pct: 90, + }) + }); + const mockCompareReport = createMockCoverageReport({ + lines: createMockReportNumbers({ + pct: 90, + }) + }); + + const summaryHtml = generateSummaryTableHtml(mockReport, undefined, mockCompareReport); + + expect(getTableLine(1, summaryHtml)).toContain('90%
🟰 ±0%'); + }); }); diff --git a/src/report/generateSummaryTableHtml.ts b/src/report/generateSummaryTableHtml.ts new file mode 100644 index 0000000..c13e858 --- /dev/null +++ b/src/report/generateSummaryTableHtml.ts @@ -0,0 +1,88 @@ +import { icons } from '../icons'; +import { oneLine } from 'common-tags'; +import { Thresholds } from '../types/Threshold'; +import { CoverageReport, ReportNumbers } from '../types/JsonSummary'; + +function generateSummaryTableHtml( + jsonReport: CoverageReport, + thresholds: Thresholds = {}, + jsonCompareReport: CoverageReport | undefined + ): string { + return oneLine` + + + + + + + + + + + + ${generateTableRow({ reportNumbers: jsonReport.lines, category: 'Lines', threshold: thresholds.lines, reportCompareNumbers: jsonCompareReport?.lines })} + + + ${generateTableRow({ reportNumbers: jsonReport.statements, category: 'Statements', threshold: thresholds.statements, reportCompareNumbers: jsonCompareReport?.statements })} + + + ${generateTableRow({ reportNumbers: jsonReport.functions, category: 'Functions', threshold: thresholds.functions, reportCompareNumbers: jsonCompareReport?.functions })} + + + ${generateTableRow({ reportNumbers: jsonReport.branches, category: 'Branches', threshold: thresholds.branches, reportCompareNumbers: jsonCompareReport?.branches })} + + +
StatusCategoryPercentageCovered / Total
+ `; +} + +function generateTableRow({ + reportNumbers, + category, + threshold, + reportCompareNumbers +}: { + reportNumbers: ReportNumbers; + category: string; + threshold?: number; + reportCompareNumbers?: ReportNumbers; +}): string { + + let status = icons.blue; + let percent = `${reportNumbers.pct}%`; + + if(threshold) { + percent = `${percent} (${icons.target} ${threshold}%)`; + status = reportNumbers.pct >= threshold ? icons.green : icons.red; + } + + if(reportCompareNumbers) { + const percentDiff = reportNumbers.pct - reportCompareNumbers.pct; + const compareString = getCompareString(percentDiff); + percent = `${percent}
${compareString}`; + } + + return ` + ${status} + ${category} + ${percent} + ${reportNumbers.covered} / ${reportNumbers.total} + `; +} + +function getCompareString(percentDiff: number): string { + if(percentDiff === 0) { + return `${icons.equal} ±0%`; + } + + if(percentDiff > 0) { + return `${icons.increase} +${percentDiff.toFixed(2)}%`; + } + + // The - char is already included in a negative number + return `${icons.decrease} ${percentDiff.toFixed(2)}%`; +} + +export { + generateSummaryTableHtml +}; diff --git a/src/getUncoveredLinesFromStatements.test.ts b/src/report/getUncoveredLinesFromStatements.test.ts similarity index 97% rename from src/getUncoveredLinesFromStatements.test.ts rename to src/report/getUncoveredLinesFromStatements.test.ts index 9013212..fc07fd2 100644 --- a/src/getUncoveredLinesFromStatements.test.ts +++ b/src/report/getUncoveredLinesFromStatements.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect} from 'vitest'; import { getUncoveredLinesFromStatements } from './getUncoveredLinesFromStatements'; -import { StatementCoverageReport } from './types/JsonFinal'; +import { StatementCoverageReport } from '../types/JsonFinal'; describe('getUncoveredLinesFromStatements()', () => { it('returns a single line range for only one untested line.', () => { diff --git a/src/getUncoveredLinesFromStatements.ts b/src/report/getUncoveredLinesFromStatements.ts similarity index 92% rename from src/getUncoveredLinesFromStatements.ts rename to src/report/getUncoveredLinesFromStatements.ts index 6e2d60a..ce5a40b 100644 --- a/src/getUncoveredLinesFromStatements.ts +++ b/src/report/getUncoveredLinesFromStatements.ts @@ -1,4 +1,4 @@ -import { StatementCoverageReport } from './types/JsonFinal'; +import { StatementCoverageReport } from '../types/JsonFinal'; type LineRange = { start: number, diff --git a/test/exportTestTable.ts b/test/exportTestTable.ts new file mode 100644 index 0000000..9b2d17d --- /dev/null +++ b/test/exportTestTable.ts @@ -0,0 +1,31 @@ +import path from 'path'; +import { writeFile, mkdir } from 'fs/promises'; +import { parseVitestJsonSummary } from '../src/inputs/parseJsonReports'; +import { generateSummaryTableHtml } from '../src/report/generateSummaryTableHtml'; + +const basePath = path.join(__dirname, 'mockReports', 'coverage'); +const coveragePath = path.join(basePath, 'coverage-summary.json'); +const coverageComparePath = path.join(basePath, 'coverage-summary-compare.json'); + +const targetPath = path.join(__dirname, '..', 'tmp'); + + +async function generateMarkdown() { + // Parse the coverage reports + const coverageSummary = await parseVitestJsonSummary(coveragePath); + const coverageSummaryCompare = await parseVitestJsonSummary(coverageComparePath); + + // Generate the HTML table + const htmlTable = generateSummaryTableHtml(coverageSummary.total, { + branches: 60, + functions: 50, + lines: 40, + statements: 20, + }, coverageSummaryCompare.total); + + // Write the HTML into a markdown file + await mkdir(targetPath, { recursive: true }); + await writeFile(path.join(targetPath, 'coverage-summary.md'), htmlTable); +} + +generateMarkdown().catch(console.error); diff --git a/test/mockReports/coverage/coverage-summary-compare.json b/test/mockReports/coverage/coverage-summary-compare.json new file mode 100644 index 0000000..1d47fed --- /dev/null +++ b/test/mockReports/coverage/coverage-summary-compare.json @@ -0,0 +1,164 @@ +{ + "total": { + "lines": { + "total": 145, + "covered": 69, + "skipped": 0, + "pct": 47.58 + }, + "statements": { + "total": 150, + "covered": 74.4, + "skipped": 0, + "pct": 49.6 + }, + "functions": { + "total": 29, + "covered": 17.1, + "skipped": 0, + "pct": 58.96 + }, + "branches": { + "total": 58, + "covered": 20.7, + "skipped": 0, + "pct": 35.68 + }, + "branchesTrue": { + "total": 0, + "covered": 0, + "skipped": 0, + "pct": "Unknown" + } + }, + "/absolute/path/src/constants.js": { + "lines": { + "total": 1, + "covered": 1.15, + "skipped": 0, + "pct": 115 + }, + "functions": { + "total": 0, + "covered": 0, + "skipped": 0, + "pct": 95 + }, + "statements": { + "total": 1, + "covered": 1.2, + "skipped": 0, + "pct": 120 + }, + "branches": { + "total": 0, + "covered": 0, + "skipped": 0, + "pct": 90 + } + }, + "/absolute/path/src/format.js": { + "lines": { + "total": 33, + "covered": 27.6, + "skipped": 0, + "pct": 83.63 + }, + "functions": { + "total": 10, + "covered": 7.6, + "skipped": 0, + "pct": 76 + }, + "statements": { + "total": 33, + "covered": 28.8, + "skipped": 0, + "pct": 87.26 + }, + "branches": { + "total": 15, + "covered": 7.2, + "skipped": 0, + "pct": 48 + } + }, + "/absolute/path/src/index.js": { + "lines": { + "total": 69, + "covered": 0, + "skipped": 0, + "pct": 0 + }, + "functions": { + "total": 8, + "covered": 0, + "skipped": 0, + "pct": 0 + }, + "statements": { + "total": 72, + "covered": 0, + "skipped": 0, + "pct": 0 + }, + "branches": { + "total": 24, + "covered": 0, + "skipped": 0, + "pct": 0 + } + }, + "/absolute/path/src/parse.js": { + "lines": { + "total": 20, + "covered": 17.25, + "skipped": 0, + "pct": 86.25 + }, + "functions": { + "total": 3, + "covered": 1.9, + "skipped": 0, + "pct": 63.33 + }, + "statements": { + "total": 20, + "covered": 18, + "skipped": 0, + "pct": 90 + }, + "branches": { + "total": 5, + "covered": 2.7, + "skipped": 0, + "pct": 54 + } + }, + "/absolute/path/src/utils.js": { + "lines": { + "total": 22, + "covered": 23, + "skipped": 0, + "pct": 104.54 + }, + "functions": { + "total": 8, + "covered": 7.6, + "skipped": 0, + "pct": 95 + }, + "statements": { + "total": 24, + "covered": 26.4, + "skipped": 0, + "pct": 110 + }, + "branches": { + "total": 14, + "covered": 10.8, + "skipped": 0, + "pct": 77.14 + } + } +}