Skip to content

Commit 9daaf6c

Browse files
authored
feat(cover): support parsing go test -cover (#88)
* feat(cover): support parsing go test -cover * adjust cover output * formatting * colspan
1 parent dad7695 commit 9daaf6c

13 files changed

Lines changed: 323 additions & 30 deletions

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [Screenshots](#screenshots)
77
- [Examples](#examples)
88
- [Basic](#basic)
9+
- [Coverage](#coverage)
910
- [Using existing test files](#using-existing-test-files)
1011
- [Omitting elements](#omitting-elements)
1112

@@ -33,6 +34,12 @@ Powered by [Job Summaries](https://github.blog/2022-05-09-supercharging-github-a
3334
# Optional. Default is './...'
3435
testArguments:
3536

37+
# If true, appends `-cover` to the go test command. Skipped if testArguments
38+
# already contains a -cover* flag (e.g. -coverprofile=...). Coverage is rendered
39+
# as a column in the summary table.
40+
# Optional. Default is 'false'
41+
cover:
42+
3643
# Parse one or more [test2json](https://pkg.go.dev/cmd/test2json) files (newline-separated) and generate a combined summary.
3744
# Will always exit(0) on successful test file parse.
3845
# fromJSONFile is accepted as an alias for a single file.
@@ -89,6 +96,28 @@ jobs:
8996
uses: robherley/go-test-action@v1
9097
```
9198
99+
### Coverage
100+
101+
When `go test` is invoked with `-cover` (or `-coverprofile=...`), the action parses the per-package `coverage: X.Y% of statements` lines and appends a 📊 Coverage column to the summary table, plus the mean coverage to the header text.
102+
103+
Use the `cover` input as a shortcut to append `-cover`:
104+
105+
```yaml
106+
- name: Test
107+
uses: robherley/go-test-action@v1
108+
with:
109+
cover: true
110+
```
111+
112+
Or pass a coverage flag directly via `testArguments`:
113+
114+
```yaml
115+
- name: Test
116+
uses: robherley/go-test-action@v1
117+
with:
118+
testArguments: '-coverprofile=coverage.out ./...'
119+
```
120+
92121
### Using existing test files
93122

94123
```yaml

__tests__/events.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ describe('events', () => {
7373
}
7474
})
7575

76+
it('parses coverage percentage from output', () => {
77+
const cases: [string, number | undefined][] = [
78+
['coverage: 87.5% of statements\\n', 87.5],
79+
['coverage: 100.0% of statements\\n', 100.0],
80+
['coverage: 0.0% of statements\\n', 0],
81+
['ok \\tgithub.com/foo\\t0.123s\\tcoverage: 42.3% of statements\\n', 42.3],
82+
['hello world\\n', undefined],
83+
]
84+
85+
for (const [output, expected] of cases) {
86+
const stdout = `{"Time":"2022-07-10T22:42:11.92576-04:00","Action":"output","Package":"github.com/foo","Output":"${output}"}`
87+
const [event] = parseTestEvents(stdout)
88+
expect(event.coverage).toEqual(expected)
89+
}
90+
})
91+
7692
it('correctly indicates a cached test', () => {
7793
const cachedStdout =
7894
'{"Time":"2022-07-10T22:42:11.931552-04:00","Action":"output","Package":"github.com/robherley/go-test-example/success","Output":"ok \\tgithub.com/robherley/go-test-example/success\\t(cached)\\n"}'

__tests__/inputs.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,19 @@ describe('renderer', () => {
3030
moduleDirectory: '.',
3131
testArguments: ['./...'],
3232
fromJSONFiles: null,
33+
cover: false,
3334
omit: new Set(),
3435
})
3536
})
3637

38+
it('parses cover', () => {
39+
mockInput('cover', 'true')
40+
mockGetBooleanInput.mockReturnValueOnce(true)
41+
const inputs = getInputs()
42+
43+
expect(inputs.cover).toBe(true)
44+
})
45+
3746
it('parses moduleDirectory', () => {
3847
mockInput('moduleDirectory', 'foo')
3948
const inputs = getInputs()

__tests__/renderer.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,87 @@ describe('renderer', () => {
339339
})
340340
})
341341

342+
describe('coverage', () => {
343+
it('omits coverage column when no package has coverage', async () => {
344+
const renderer = await getRenderer()
345+
await renderer.writeSummary()
346+
const $ = await loadSummaryHTML()
347+
348+
expect($('th:contains(Coverage)')).toHaveLength(0)
349+
expect($.text()).not.toContain('coverage')
350+
})
351+
352+
it('renders coverage column when at least one package has coverage', async () => {
353+
const renderer = await getRenderer()
354+
renderer.packageResults[1].coverage = 42.4
355+
renderer.packageResults[3].coverage = 100.0
356+
await renderer.writeSummary()
357+
const $ = await loadSummaryHTML()
358+
359+
// header spans the percentage + bar columns
360+
const coverageHeader = $('th:contains(📊 Coverage)')
361+
expect(coverageHeader).toHaveLength(1)
362+
expect(coverageHeader.attr('colspan')).toEqual('2')
363+
// single decimal preserved
364+
expect($('td:contains(42.4%)')).toHaveLength(1)
365+
// trailing .0 dropped
366+
expect($('td:contains(100%)')).toHaveLength(1)
367+
expect($('td:contains(100.0%)')).toHaveLength(0)
368+
// Packages without coverage get a placeholder spanning both columns
369+
const placeholder = $('td:contains(—)')
370+
expect(placeholder.length).toBeGreaterThan(0)
371+
expect(placeholder.first().attr('colspan')).toEqual('2')
372+
})
373+
374+
it('renders mean coverage in summary text', async () => {
375+
const renderer = await getRenderer()
376+
renderer.packageResults[1].coverage = 40.0
377+
renderer.packageResults[3].coverage = 60.0
378+
await renderer.writeSummary()
379+
const $ = await loadSummaryHTML()
380+
381+
expect($.text()).toContain('50% coverage')
382+
})
383+
384+
it('renders a 10-segment block progress bar', async () => {
385+
const renderer = await getRenderer()
386+
renderer.packageResults[1].coverage = 70.0
387+
renderer.packageResults[3].coverage = 0
388+
await renderer.writeSummary()
389+
const $ = await loadSummaryHTML()
390+
391+
// 70% → 7 filled + 3 empty
392+
expect($('td:contains(███████░░░)')).toHaveLength(1)
393+
// 0% → 10 empty
394+
expect($('td:contains(░░░░░░░░░░)')).toHaveLength(1)
395+
})
396+
397+
it('renders the percentage and bar in separate columns', async () => {
398+
const renderer = await getRenderer()
399+
renderer.packageResults[1].coverage = 70.0
400+
await renderer.writeSummary()
401+
const $ = await loadSummaryHTML()
402+
403+
const row = $('tr')
404+
.filter((_, el) => $(el).text().includes('70%'))
405+
.first()
406+
const cells = row.find('td')
407+
// package, passed, failed, skipped, duration, pct, bar = 7 cells
408+
expect(cells.length).toEqual(7)
409+
expect($(cells[5]).text()).toContain('70%')
410+
expect($(cells[6]).text()).toContain('███████░░░')
411+
})
412+
413+
it('expands details colspan to include coverage columns', async () => {
414+
const renderer = await getRenderer()
415+
renderer.packageResults[1].coverage = 80.0
416+
await renderer.writeSummary()
417+
const $ = await loadSummaryHTML()
418+
419+
expect($('td[colspan="7"]').length).toBeGreaterThan(0)
420+
})
421+
})
422+
342423
it('scrubs ansi from test output', async () => {
343424
const renderer = await getRenderer()
344425
const placeholder = 'no-ansi-please'

__tests__/results.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,39 @@ describe('results', () => {
7575
`)
7676
})
7777

78+
it('captures coverage from package events', async () => {
79+
const stdout = await getTestStdout()
80+
const testEvents = parseTestEvents(stdout)
81+
const packageEvent = getPackageLevelEvent(testEvents)
82+
83+
const coverageEvent: TestEvent = {
84+
action: 'output',
85+
package: packageEvent.package,
86+
test: undefined as unknown as string,
87+
output: 'coverage: 73.2% of statements\n',
88+
isCached: false,
89+
isSubtest: false,
90+
isPackageLevel: true,
91+
isConclusive: false,
92+
coverage: 73.2,
93+
}
94+
95+
const packageResult = new PackageResult(packageEvent, [
96+
...testEvents,
97+
coverageEvent,
98+
])
99+
expect(packageResult.coverage).toEqual(73.2)
100+
})
101+
102+
it('leaves coverage undefined when not reported', async () => {
103+
const stdout = await getTestStdout()
104+
const testEvents = parseTestEvents(stdout)
105+
const packageEvent = getPackageLevelEvent(testEvents)
106+
const packageResult = new PackageResult(packageEvent, testEvents)
107+
108+
expect(packageResult.coverage).toBeUndefined()
109+
})
110+
78111
it('filters out any mismatched events by package', async () => {
79112
const stdout = await getTestStdout()
80113
const testEvents = parseTestEvents(stdout)

__tests__/runner.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,49 @@ describe('runner', () => {
6868
expect(spyExit).toHaveBeenCalledWith(0)
6969
})
7070

71+
it('appends -cover when cover input is set', async () => {
72+
mockProcessExit()
73+
vi.spyOn(Renderer.prototype, 'writeSummary').mockImplementationOnce(
74+
async () => {}
75+
)
76+
77+
process.env['INPUT_COVER'] = 'true'
78+
const spy = vi.mocked(actionsExec.exec).mockImplementationOnce(async () => 0)
79+
80+
const runner = new Runner()
81+
await runner.run()
82+
83+
expect(spy).toHaveBeenCalledWith(
84+
'go',
85+
['test', '-json', './...', '-cover'],
86+
expect.anything()
87+
)
88+
89+
delete process.env['INPUT_COVER']
90+
})
91+
92+
it('does not duplicate -cover when already in testArguments', async () => {
93+
mockProcessExit()
94+
vi.spyOn(Renderer.prototype, 'writeSummary').mockImplementationOnce(
95+
async () => {}
96+
)
97+
98+
process.env['INPUT_COVER'] = 'true'
99+
process.env['INPUT_TESTARGUMENTS'] = '-coverprofile=cov.out ./...'
100+
const spy = vi.mocked(actionsExec.exec).mockImplementationOnce(async () => 0)
101+
102+
const runner = new Runner()
103+
await runner.run()
104+
105+
expect(spy).toHaveBeenCalledWith(
106+
'go',
107+
['test', '-json', '-coverprofile=cov.out', './...'],
108+
expect.anything()
109+
)
110+
111+
delete process.env['INPUT_COVER']
112+
})
113+
71114
it('exits the process with non-zero exit code on failure', async () => {
72115
const spyExit = mockProcessExit()
73116

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ inputs:
1212
description: 'Arguments to `go test`, `-json` will be prepended'
1313
required: false
1414
default: './...'
15+
cover:
16+
description: 'Append `-cover` to the go test command (skipped if testArguments already contains a -cover* flag). Coverage is rendered as a column in the summary table.'
17+
required: false
18+
default: 'false'
1519
fromJSONFile:
1620
description: 'Alias for fromJSONFiles with a single file'
1721
required: false

dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/events.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ export interface TestEvent {
3636
isPackageLevel: boolean
3737
isConclusive: boolean
3838
isCached: boolean
39+
coverage?: number // percentage of statements covered, if reported
3940
}
4041

42+
// Matches "coverage: 87.5% of statements" emitted by `go test -cover`
43+
const coverageRegex = /coverage:\s+(\d+(?:\.\d+)?)%\s+of\s+statements/
44+
4145
/**
4246
* Convert test2json raw JSON output to TestEvent
4347
* @param stdout raw stdout of go test process
@@ -50,6 +54,10 @@ export function parseTestEvents(stdout: string): TestEvent[] {
5054
for (let line of lines) {
5155
try {
5256
const json = JSON.parse(line)
57+
const coverageMatch =
58+
typeof json.Output === 'string'
59+
? json.Output.match(coverageRegex)
60+
: null
5361
events.push({
5462
time: json.Time && new Date(json.Time),
5563
action: json.Action as TestEventAction,
@@ -61,6 +69,7 @@ export function parseTestEvents(stdout: string): TestEvent[] {
6169
isSubtest: json.Test?.includes('/') || false, // afaik there isn't a better indicator in test2json
6270
isPackageLevel: typeof json.Test === 'undefined',
6371
isConclusive: conclusiveTestEvents.includes(json.Action),
72+
coverage: coverageMatch ? parseFloat(coverageMatch[1]) : undefined,
6473
})
6574
} catch {
6675
core.debug(`unable to parse line: ${line}`)

src/inputs.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export interface Inputs {
44
moduleDirectory: string
55
testArguments: string[]
66
fromJSONFiles: string[] | null
7+
cover: boolean
78
omit: Set<OmitOption>
89
}
910

@@ -26,6 +27,7 @@ export const defaultInputs = (): Inputs => ({
2627
moduleDirectory: '.',
2728
testArguments: ['./...'],
2829
fromJSONFiles: null,
30+
cover: false,
2931
omit: new Set(),
3032
})
3133

@@ -70,6 +72,10 @@ export function getInputs(): Inputs {
7072
.filter(f => f.length > 0)
7173
}
7274

75+
if (core.getInput('cover') && core.getBooleanInput('cover')) {
76+
inputs.cover = true
77+
}
78+
7379
const omit = core.getInput('omit')
7480
if (omit) {
7581
omit

0 commit comments

Comments
 (0)