Skip to content

Commit fd9adbd

Browse files
MishaKavclaude
andauthored
feat: add coverage.py JSON report support (pytest-json-coverage-path) (#286)
* Add coverage.py JSON report support (pytest-json-coverage-path) Adds a third coverage input format alongside TXT and XML: the JSON report from `coverage json`. New src/parseJson.ts mirrors the XML parser, reusing the existing toHtml/DataFromXml rendering pipeline, and supports branch coverage, missing-line ranges, and partial-branch arrows (line->target, line->exit). The missing column matches `coverage report -m` (a branch arc whose destination is an already-missing line is omitted). - New input `pytest-json-coverage-path`; parser precedence JSON > XML > TXT - Wired into index.ts, cli.ts, and multiFiles.ts (monorepo, .json detection) - Reuses formatCoverPercent from parseXml; finite-percentage guard - Tests + fixtures (data/coverage_1.json, data/coverage_2.json); README + action.yml docs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: release 1.10.0 (JSON coverage support) Bump version 1.9.0 -> 1.10.0 and add CHANGELOG entry for the new pytest-json-coverage-path input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4a4889e commit fd9adbd

20 files changed

Lines changed: 888 additions & 55 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog of the Pytest Coverage Comment
22

3+
## [Pytest Coverage Comment 1.10.0](https://github.com/MishaKav/pytest-coverage-comment/tree/v1.10.0)
4+
5+
**Release Date:** 2026-07-05
6+
7+
#### Changes
8+
9+
- add support for parsing the JSON coverage report from `coverage json` via the new `pytest-json-coverage-path` input, including branch coverage and partial-branch arrows (`line->target`, `line->exit`) that match `coverage report -m` (#286)
10+
311
## [Pytest Coverage Comment 1.9.0](https://github.com/MishaKav/pytest-coverage-comment/tree/v1.9.0)
412

513
**Release Date:** 2026-07-04

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ The codebase is a TypeScript GitHub Action with the following key components:
5959

6060
- **src/parse.ts**: Parses pytest text coverage output (e.g., from `pytest --cov`)
6161
- **src/parseXml.ts**: Parses XML coverage reports (e.g., from `pytest --cov-report=xml`)
62+
- **src/parseJson.ts**: Parses `coverage json` reports; returns the same `XmlCoverageReport` shape as parseXml and reuses `toHtml` + `DataFromXml`
6263
- **src/junitXml.ts**: Parses JUnit XML test results for test statistics
6364

6465
### Supporting Modules
@@ -99,6 +100,10 @@ The codebase is a TypeScript GitHub Action with the following key components:
99100
- Supports filtering to show only changed files in the current commit
100101
- Can skip files with 100% coverage from XML reports
101102
- Handles both absolute and relative file paths for coverage inputs
103+
- Coverage parser precedence (index.ts, cli.ts, multiFiles.ts): JSON > XML > TXT. multiFiles picks the parser by file extension (`.json`/`.xml`, else txt). JSON and XML expose `coverage` as a `TotalLine` object; TXT exposes it as a string
104+
- coverage.py JSON schema (from `coverage json`): top-level key is `totals` (not `summary`); per-file `summary.missing_lines` is a **count** while `file.missing_lines` is an **array**; `missing_branches` arcs are `[from, to]` pairs where a negative `to` means an exit arc (`from->exit`)
105+
- The JSON "Missing" column matches `coverage report -m`: a branch arc whose destination is an already-missing line is omitted (the line shows on its own). The XML parser predates this and shows a redundant `from->dest` arrow in that case — a known minor divergence, not fixed to keep the diff minimal
106+
- `formatCoverPercent` (exported from parseXml.ts) is the shared rounding helper (caps 99.x→99, floors 0.x→1); JSON uses coverage.py's precomputed `percent_covered` so it never needs `computeCoverPercent`
102107
- xml2js `parseString` is used synchronously via callback pattern (not async)
103108
- `@actions/github` has ESM/CJS compatibility issues in test context — mock it with `vi.mock()` in tests
104109
- `@actions/core` v3+ and `@actions/github` v9+ are pure ESM — incompatible with ncc's webpack CJS bundling. Must stay on `@actions/core` v2.x and `@actions/github` v8.x until ncc supports ESM or the project switches bundlers

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ jobs:
151151
| `github-token` | ✓ | `${{github.token}}` | GitHub token for API access to create/update comments |
152152
| `pytest-coverage-path` | | `./pytest-coverage.txt` | Path to pytest text coverage output (from `--cov-report=term-missing`) |
153153
| `pytest-xml-coverage-path` | | | Path to XML coverage report (from `--cov-report=xml:coverage.xml`) |
154+
| `pytest-json-coverage-path`| | | Path to JSON coverage report (from `coverage json`) |
154155
| `junitxml-path` | | | Path to JUnit XML file for test statistics (passed/failed/skipped) |
155156
| `issue-number` | | | Pull request number to comment on (required for workflow_dispatch/workflow_run events) |
156157

@@ -251,6 +252,28 @@ jobs:
251252

252253
</details>
253254

255+
### Coverage from JSON
256+
257+
<details>
258+
<summary>Using coverage.json instead of text output</summary>
259+
260+
The JSON report's coverage percentages match `coverage report` exactly (statements and branches combined), and it is the most robust format to parse.
261+
262+
```yaml
263+
- name: Generate JSON coverage
264+
run: |
265+
pytest --cov=src tests/
266+
coverage json -o coverage.json
267+
268+
- name: Coverage comment
269+
uses: MishaKav/pytest-coverage-comment@v1
270+
with:
271+
pytest-json-coverage-path: ./coverage.json
272+
junitxml-path: ./pytest.xml
273+
```
274+
275+
</details>
276+
254277
### Monorepo Support
255278

256279
<details>

__tests__/junitXml.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const baseOptions: Options = {
2121
pathPrefix: '',
2222
covFile: '',
2323
covXmlFile: '',
24+
covJsonFile: '',
2425
xmlFile: '',
2526
title: 'Coverage Report',
2627
badgeTitle: 'Coverage',

__tests__/multiFiles.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const baseOptions: Options = {
1717
pathPrefix: '',
1818
covFile: '',
1919
covXmlFile: '',
20+
covJsonFile: '',
2021
xmlFile: '',
2122
title: 'Coverage Report',
2223
badgeTitle: 'Coverage',

__tests__/parse.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const baseOptions: Options = {
2727
pathPrefix: '',
2828
covFile: '',
2929
covXmlFile: '',
30+
covJsonFile: '',
3031
xmlFile: '',
3132
title: 'Coverage Report',
3233
badgeTitle: 'Coverage',

__tests__/parseJson.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { expect, test, describe, beforeEach } from 'vitest';
2+
import * as path from 'path';
3+
import { getCoverageJsonReport, exportedForTesting } from '../src/parseJson';
4+
import { spyCore } from './setup';
5+
import type { Options } from '../src/types';
6+
7+
const { isValidCoverageContent, collapseRanges, getMissing, getTotalCoverage } =
8+
exportedForTesting;
9+
10+
type MissingArg = Parameters<typeof getMissing>[0];
11+
type TotalsArg = Parameters<typeof getTotalCoverage>[0];
12+
13+
const dataPath = path.resolve(__dirname, '..', 'data');
14+
15+
const baseOptions: Options = {
16+
token: 'token_123',
17+
repository: 'MishaKav/pytest-coverage-comment',
18+
commit: 'abc123',
19+
prefix: '',
20+
pathPrefix: '',
21+
covFile: '',
22+
covXmlFile: '',
23+
covJsonFile: '',
24+
xmlFile: '',
25+
title: 'Coverage Report',
26+
badgeTitle: 'Coverage',
27+
hideBadge: false,
28+
hideReport: false,
29+
createNewComment: false,
30+
hideComment: false,
31+
hideEmoji: false,
32+
xmlSkipCovered: false,
33+
reportOnlyChangedFiles: false,
34+
removeLinkFromBadge: false,
35+
removeLinksToFiles: false,
36+
removeLinksToLines: false,
37+
textInsteadBadge: false,
38+
defaultBranch: 'main',
39+
xmlTitle: '',
40+
multipleFiles: [],
41+
repoUrl: 'https://github.com/MishaKav/pytest-coverage-comment',
42+
};
43+
44+
describe('getCoverageJsonReport', () => {
45+
beforeEach(() => {
46+
spyCore.error.mockClear();
47+
spyCore.warning.mockClear();
48+
spyCore.info.mockClear();
49+
});
50+
51+
test('should return null for empty covJsonFile', () => {
52+
const result = getCoverageJsonReport(baseOptions);
53+
expect(result).toBeNull();
54+
});
55+
56+
test('should return null for non-existent file', () => {
57+
const options = { ...baseOptions, covJsonFile: '/nonexistent/coverage.json' };
58+
const result = getCoverageJsonReport(options);
59+
expect(result).toBeNull();
60+
});
61+
62+
test('should warn and return null for malformed json', () => {
63+
const covJsonFile = path.join(dataPath, 'coverage_1.xml'); // not json
64+
const options = { ...baseOptions, covJsonFile };
65+
const result = getCoverageJsonReport(options);
66+
67+
expect(result).toBeNull();
68+
expect(spyCore.warning).toHaveBeenCalled();
69+
});
70+
71+
test('should parse coverage_1.json (no branches) successfully', () => {
72+
const covJsonFile = path.join(dataPath, 'coverage_1.json');
73+
const options = { ...baseOptions, covJsonFile };
74+
const result = getCoverageJsonReport(options);
75+
76+
expect(result).not.toBeNull();
77+
expect(result!.html).toContain('img.shields.io/badge');
78+
expect(result!.coverage).not.toBeNull();
79+
expect(result!.coverage!.name).toBe('TOTAL');
80+
expect(result!.coverage!.cover).toBe('88%');
81+
// foo.py is 80% with missing lines 5 and 12
82+
expect(result!.html).toContain('<td>80%</td>');
83+
expect(result!.html).toContain('>5</a>');
84+
expect(result!.html).toContain('>12</a>');
85+
// no branch columns for a statement-only report
86+
expect(result!.html).not.toContain('<th>Branch</th>');
87+
});
88+
89+
test('should parse coverage_2.json with branch coverage', () => {
90+
const covJsonFile = path.join(dataPath, 'coverage_2.json');
91+
const options = { ...baseOptions, covJsonFile };
92+
const result = getCoverageJsonReport(options);
93+
94+
expect(result).not.toBeNull();
95+
expect(result!.coverage!.cover).toBe('82%');
96+
expect(result!.coverage!.branch).toBe('10');
97+
expect(result!.coverage!.brpart).toBe('3');
98+
expect(result!.html).toContain('<th>Branch</th><th>BrPart</th>');
99+
// branch arcs from getMissing are wired into the missing column
100+
expect(result!.html).toContain('>2->exit</a>');
101+
});
102+
103+
test('should skip covered files when xmlSkipCovered is true', () => {
104+
const covJsonFile = path.join(dataPath, 'coverage_1.json');
105+
const options = { ...baseOptions, covJsonFile, xmlSkipCovered: true };
106+
const result = getCoverageJsonReport(options);
107+
108+
expect(result).not.toBeNull();
109+
// bar.py is 100% covered and should be excluded from the table
110+
expect(result!.html).not.toContain('bar.py');
111+
expect(result!.html).toContain('foo.py');
112+
});
113+
});
114+
115+
describe('parseJson internals', () => {
116+
test('isValidCoverageContent', () => {
117+
expect(isValidCoverageContent(null)).toBe(false);
118+
expect(isValidCoverageContent({ files: {} } as never)).toBe(false);
119+
expect(isValidCoverageContent({ files: {}, totals: {} } as never)).toBe(
120+
true,
121+
);
122+
});
123+
124+
test('collapseRanges groups consecutive lines', () => {
125+
expect(collapseRanges([4, 10, 11, 12])).toEqual([
126+
{ sort: 4, text: '4' },
127+
{ sort: 10, text: '10-12' },
128+
]);
129+
expect(collapseRanges([])).toEqual([]);
130+
});
131+
132+
test('getMissing renders lines, exit arcs, and normal arcs sorted by line', () => {
133+
const asArg = (file: {
134+
missing_lines: number[];
135+
missing_branches: number[][];
136+
}): MissingArg => file as unknown as MissingArg;
137+
138+
expect(
139+
getMissing(asArg({ missing_lines: [5, 14], missing_branches: [[2, 5]] })),
140+
).toEqual(['5', '14']);
141+
expect(
142+
getMissing(asArg({ missing_lines: [], missing_branches: [[2, -1]] })),
143+
).toEqual(['2->exit']);
144+
expect(
145+
getMissing(asArg({ missing_lines: [], missing_branches: [[2, 4]] })),
146+
).toEqual(['2->4']);
147+
expect(
148+
getMissing(asArg({ missing_lines: [6], missing_branches: [[2, 4]] })),
149+
).toEqual(['2->4', '6']);
150+
});
151+
152+
test('getTotalCoverage maps totals to a TotalLine', () => {
153+
expect(
154+
getTotalCoverage({
155+
num_statements: 16,
156+
missing_lines: 2,
157+
percent_covered: 87.5,
158+
} as TotalsArg),
159+
).toEqual({ name: 'TOTAL', stmts: 16, miss: 2, cover: '88%' });
160+
161+
expect(
162+
getTotalCoverage({
163+
num_statements: 18,
164+
missing_lines: 2,
165+
percent_covered: 82.14,
166+
num_branches: 10,
167+
missing_branches: 3,
168+
} as TotalsArg),
169+
).toEqual({
170+
name: 'TOTAL',
171+
stmts: 18,
172+
miss: 2,
173+
cover: '82%',
174+
branch: '10',
175+
brpart: '3',
176+
});
177+
});
178+
179+
test('getTotalCoverage returns null for a non-finite percentage', () => {
180+
spyCore.warning.mockClear();
181+
expect(
182+
getTotalCoverage({
183+
num_statements: 0,
184+
missing_lines: 0,
185+
percent_covered: NaN,
186+
} as TotalsArg),
187+
).toBeNull();
188+
expect(spyCore.warning).toHaveBeenCalled();
189+
});
190+
});

__tests__/parseXml.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const baseOptions: Options = {
1414
pathPrefix: '',
1515
covFile: '',
1616
covXmlFile: '',
17+
covJsonFile: '',
1718
xmlFile: '',
1819
title: 'Coverage Report',
1920
badgeTitle: 'Coverage',

action.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ inputs:
2626
default: ''
2727
required: false
2828

29+
pytest-json-coverage-path:
30+
description: 'Path to JSON coverage report (from coverage json)'
31+
default: ''
32+
required: false
33+
2934
coverage-path-prefix:
3035
description: 'Prefix to add to file paths in coverage report links'
3136
default: ''
@@ -95,7 +100,7 @@ inputs:
95100
description: >
96101
Generate single comment with multiple coverage reports (useful for monorepos).
97102
Format: One report per line as "Title, coverage-path, junit-path".
98-
Coverage path can be TXT (from --cov-report=term-missing) or XML (from --cov-report=xml).
103+
Coverage path can be TXT (from --cov-report=term-missing), XML (from --cov-report=xml), or JSON (from coverage json).
99104
Examples: "Backend API, ./backend/coverage.txt, ./backend/junit.xml"
100105
"Frontend Tests, ./frontend/coverage.xml, ./frontend/junit.xml"
101106
default: ''

data/coverage_1.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"meta": {
3+
"format": 3,
4+
"version": "7.15.0",
5+
"branch_coverage": false,
6+
"show_contexts": false
7+
},
8+
"files": {
9+
"src/foo.py": {
10+
"executed_lines": [1, 2, 3, 4, 6, 7, 8, 9],
11+
"summary": {
12+
"covered_lines": 8,
13+
"num_statements": 10,
14+
"percent_covered": 80.0,
15+
"percent_covered_display": "80",
16+
"missing_lines": 2,
17+
"excluded_lines": 0
18+
},
19+
"missing_lines": [5, 12],
20+
"excluded_lines": []
21+
},
22+
"src/bar.py": {
23+
"executed_lines": [1, 2, 3, 4, 5, 6],
24+
"summary": {
25+
"covered_lines": 6,
26+
"num_statements": 6,
27+
"percent_covered": 100.0,
28+
"percent_covered_display": "100",
29+
"missing_lines": 0,
30+
"excluded_lines": 0
31+
},
32+
"missing_lines": [],
33+
"excluded_lines": []
34+
}
35+
},
36+
"totals": {
37+
"covered_lines": 14,
38+
"num_statements": 16,
39+
"percent_covered": 87.5,
40+
"percent_covered_display": "88",
41+
"missing_lines": 2,
42+
"excluded_lines": 0
43+
}
44+
}

0 commit comments

Comments
 (0)