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`
-
-
-
- Status |
- Category |
- Percentage |
- Covered / Total |
-
-
-
-
- ${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 })}
-
-
-
- `
-}
-
-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`
+
+
+
+ Status |
+ Category |
+ Percentage |
+ Covered / Total |
+
+
+
+
+ ${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 })}
+
+
+
+ `;
+}
+
+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
+ }
+ }
+}