Compare commits

..

12 Commits

Author SHA1 Message Date
Jozef Izso
8fd5fc58ca Add missing golang-json reporter to action.yml
The golang-json reporter has been fully implemented since earlier versions
but was missing from the action.yml documentation. This made it undiscoverable
for users looking for Go test support.

Changes:
- Added golang-json to the list of supported reporters in action.yml

This aligns the action.yml with:
- The actual implementation in src/main.ts (lines 264-265)
- The README.md documentation (line 145)
- The existing parser and tests

Fixes #689

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:47:11 +01:00
Jozef Izso
3b9dad208e Merge pull request #681 from phactum-mnestler/main
Update sax.js to fix large XML file parsing #681
2025-11-15 11:24:15 +01:00
Jozef Izso
7c636a991c Merge pull request #643 from micmarc/feature/python-support 2025-11-15 11:12:45 +01:00
Michael Nestler
cfce4bda71 Add saxjs to version overrides 2025-11-15 11:07:56 +01:00
Michael Marcus
fe87682515 Improve testing with robust schema for unittest report 2025-11-14 21:59:25 -05:00
Michael Marcus
9b8d3b002e Python support
Add python-xunit-parser.ts with associated case statement
Add python-xunit to reporter docs in action.yml
Add tests
Update README

Resolves #244
Resolves #633
2025-11-14 16:29:58 -05:00
Jozef Izso
e2f0ff6339 Merge pull request #645 from micmarc/fix/report-title-short-summary 2025-11-14 20:00:35 +01:00
Jozef Izso
bc8c29617e test-reporter release v2.2.0
Merge pull request #679 from dorny/release/v2.2.0
2025-11-14 18:46:03 +01:00
Michael Marcus
9aef9d168f Remove info log 2025-11-14 12:01:42 -05:00
Michael Marcus
6b64465c34 Rebuild index.js after rebase from main 2025-11-14 11:59:46 -05:00
Michael Marcus
6617053f9c Fix short summary formatting when a report title is present 2025-11-14 11:58:16 -05:00
Michael Nestler
43a747d94c Update sax.js to fix large XML file parsing 2025-11-14 16:06:35 +01:00
14 changed files with 888 additions and 430 deletions

View File

@@ -19,6 +19,7 @@ This [Github Action](https://github.com/features/actions) displays test results
- Go / [go test](https://pkg.go.dev/testing) - Go / [go test](https://pkg.go.dev/testing)
- Java / [JUnit](https://junit.org/) - Java / [JUnit](https://junit.org/)
- JavaScript / [JEST](https://jestjs.io/) / [Mocha](https://mochajs.org/) - JavaScript / [JEST](https://jestjs.io/) / [Mocha](https://mochajs.org/)
- Python / [pytest](https://docs.pytest.org/en/stable/) / [unittest](https://docs.python.org/3/library/unittest.html)
- Swift / xUnit - Swift / xUnit
For more information see [Supported formats](#supported-formats) section. For more information see [Supported formats](#supported-formats) section.
@@ -145,7 +146,9 @@ jobs:
# java-junit # java-junit
# jest-junit # jest-junit
# mocha-json # mocha-json
# python-xunit
# rspec-json # rspec-json
# swift-xunit
reporter: '' reporter: ''
# Allows you to generate only the summary. # Allows you to generate only the summary.
@@ -349,6 +352,16 @@ Before version [v9.1.0](https://github.com/mochajs/mocha/releases/tag/v9.1.0), M
Please update Mocha to version [v9.1.0](https://github.com/mochajs/mocha/releases/tag/v9.1.0) or above if you encounter this issue. Please update Mocha to version [v9.1.0](https://github.com/mochajs/mocha/releases/tag/v9.1.0) or above if you encounter this issue.
</details> </details>
<details>
<summary>python-xunit (Experimental)</summary>
Support for Python test results in xUnit format is experimental - should work but it was not extensively tested.
For pytest support, configure [JUnit XML output](https://docs.pytest.org/en/stable/how-to/output.html#creating-junitxml-format-files) and run with the `--junit-xml` option, which also lets you specify the output path for test results.
For unittest support, use a test runner that outputs the JUnit report format, such as [unittest-xml-reporting](https://pypi.org/project/unittest-xml-reporting/).
</details>
<details> <details>
<summary>swift-xunit (Experimental)</summary> <summary>swift-xunit (Experimental)</summary>

View File

@@ -0,0 +1,23 @@
![Tests failed](https://img.shields.io/badge/tests-4%20passed%2C%202%20failed%2C%202%20skipped-critical)
|Report|Passed|Failed|Skipped|Time|
|:---|---:|---:|---:|---:|
|[fixtures/python-xunit-unittest.xml](#user-content-r0)|4 ✅|2 ❌|2 ⚪|1ms|
## ❌ <a id="user-content-r0" href="#user-content-r0">fixtures/python-xunit-unittest.xml</a>
**8** tests were completed in **1ms** with **4** passed, **2** failed and **2** skipped.
|Test suite|Passed|Failed|Skipped|Time|
|:---|---:|---:|---:|---:|
|[TestAcme-20251114214921](#user-content-r0s0)|4 ✅|2 ❌|2 ⚪|1ms|
### ❌ <a id="user-content-r0s0" href="#user-content-r0s0">TestAcme-20251114214921</a>
```
TestAcme
✅ test_always_pass
✅ test_parameterized_0_param1
✅ test_parameterized_1_param2
✅ test_with_subtests
❌ test_always_fail
AssertionError: failed
❌ test_error
Exception: error
⚪ test_always_skip
⚪ test_expected_failure
```

View File

@@ -0,0 +1,87 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`python-xunit unittest report report from python test results matches snapshot 1`] = `
TestRunResult {
"path": "fixtures/python-xunit-unittest.xml",
"suites": [
TestSuiteResult {
"groups": [
TestGroupResult {
"name": "TestAcme",
"tests": [
TestCaseResult {
"error": undefined,
"name": "test_always_pass",
"result": "success",
"time": 0,
},
TestCaseResult {
"error": undefined,
"name": "test_parameterized_0_param1",
"result": "success",
"time": 1,
},
TestCaseResult {
"error": undefined,
"name": "test_parameterized_1_param2",
"result": "success",
"time": 0,
},
TestCaseResult {
"error": undefined,
"name": "test_with_subtests",
"result": "success",
"time": 0,
},
TestCaseResult {
"error": {
"details": "Traceback (most recent call last):
File "/Users/foo/Projects/python-test/tests/test_lib.py", line 24, in test_always_fail
self.fail("failed")
AssertionError: failed
",
"line": undefined,
"message": "AssertionError: failed",
"path": undefined,
},
"name": "test_always_fail",
"result": "failed",
"time": 0,
},
TestCaseResult {
"error": {
"details": "Traceback (most recent call last):
File "/Users/foo/Projects/python-test/tests/test_lib.py", line 31, in test_error
raise Exception("error")
Exception: error
",
"line": undefined,
"message": "Exception: error",
"path": undefined,
},
"name": "test_error",
"result": "failed",
"time": 0,
},
TestCaseResult {
"error": undefined,
"name": "test_always_skip",
"result": "skipped",
"time": 0,
},
TestCaseResult {
"error": undefined,
"name": "test_expected_failure",
"result": "skipped",
"time": 0,
},
],
},
],
"name": "TestAcme-20251114214921",
"totalTime": 1,
},
],
"totalTime": 1,
}
`;

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="TestAcme-20251114214921" tests="8" file=".py" time="0.001" timestamp="2025-11-14T21:49:22" failures="1" errors="1" skipped="2">
<testcase classname="TestAcme" name="test_always_pass" time="0.000" timestamp="2025-11-14T21:49:22" file="tests/test_lib.py" line="8"/>
<testcase classname="TestAcme" name="test_parameterized_0_param1" time="0.001" timestamp="2025-11-14T21:49:22" file="tests/test_lib.py" line="618"/>
<testcase classname="TestAcme" name="test_parameterized_1_param2" time="0.000" timestamp="2025-11-14T21:49:22" file="tests/test_lib.py" line="618"/>
<testcase classname="TestAcme" name="test_with_subtests" time="0.000" timestamp="2025-11-14T21:49:22" file="tests/test_lib.py" line="11"/>
<testcase classname="TestAcme" name="test_always_fail" time="0.000" timestamp="2025-11-14T21:49:22" file="tests/test_lib.py" line="23">
<failure type="AssertionError" message="failed"><![CDATA[Traceback (most recent call last):
File "/Users/foo/Projects/python-test/tests/test_lib.py", line 24, in test_always_fail
self.fail("failed")
AssertionError: failed
]]></failure>
</testcase>
<testcase classname="TestAcme" name="test_error" time="0.000" timestamp="2025-11-14T21:49:22" file="tests/test_lib.py" line="30">
<error type="Exception" message="error"><![CDATA[Traceback (most recent call last):
File "/Users/foo/Projects/python-test/tests/test_lib.py", line 31, in test_error
raise Exception("error")
Exception: error
]]></error>
</testcase>
<testcase classname="TestAcme" name="test_always_skip" time="0.000" timestamp="2025-11-14T21:49:22" file="tests/test_lib.py" line="20">
<skipped type="skip" message="skipped"/>
</testcase>
<testcase classname="TestAcme" name="test_expected_failure" time="0.000" timestamp="2025-11-14T21:49:22" file="tests/test_lib.py" line="26">
<skipped type="XFAIL" message="expected failure: (&lt;class 'AssertionError'&gt;, AssertionError('expected failure'), &lt;traceback object at 0x100c125c0&gt;)"/>
</testcase>
</testsuite>

View File

@@ -303,4 +303,47 @@ describe('jest-junit tests', () => {
expect(report).not.toContain('<details><summary>Expand for details</summary>') expect(report).not.toContain('<details><summary>Expand for details</summary>')
expect(report).not.toContain('</details>') expect(report).not.toContain('</details>')
}) })
it('report includes the short summary', async () => {
const fixturePath = path.join(__dirname, 'fixtures', 'jest-junit.xml')
const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
const opts: ParseOptions = {
parseErrors: true,
trackedFiles: []
}
const parser = new JestJunitParser(opts)
const result = await parser.parse(filePath, fileContent)
const shortSummary = '1 passed, 4 failed and 1 skipped'
const report = getReport([result], DEFAULT_OPTIONS, shortSummary)
// Report should have the title as the first line
expect(report).toMatch(/^## 1 passed, 4 failed and 1 skipped\n/)
})
it('report includes a custom report title and short summary', async () => {
const fixturePath = path.join(__dirname, 'fixtures', 'jest-junit.xml')
const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
const opts: ParseOptions = {
parseErrors: true,
trackedFiles: []
}
const parser = new JestJunitParser(opts)
const result = await parser.parse(filePath, fileContent)
const shortSummary = '1 passed, 4 failed and 1 skipped'
const report = getReport(
[result],
{
...DEFAULT_OPTIONS,
reportTitle: 'My Custom Title'
},
shortSummary
)
// Report should have the title as the first line
expect(report).toMatch(/^# My Custom Title\n## 1 passed, 4 failed and 1 skipped\n/)
})
}) })

View File

@@ -0,0 +1,70 @@
import * as fs from 'fs'
import * as path from 'path'
import {PythonXunitParser} from '../src/parsers/python-xunit/python-xunit-parser'
import {ParseOptions} from '../src/test-parser'
import {DEFAULT_OPTIONS, getReport} from '../src/report/get-report'
import {normalizeFilePath} from '../src/utils/path-utils'
const defaultOpts: ParseOptions = {
parseErrors: true,
trackedFiles: []
}
describe('python-xunit unittest report', () => {
const fixturePath = path.join(__dirname, 'fixtures', 'python-xunit-unittest.xml')
const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
it('report from python test results matches snapshot', async () => {
const outputPath = path.join(__dirname, '__outputs__', 'python-xunit.md')
const trackedFiles = ['tests/test_lib.py']
const opts: ParseOptions = {
...defaultOpts,
trackedFiles
}
const parser = new PythonXunitParser(opts)
const result = await parser.parse(filePath, fileContent)
expect(result).toMatchSnapshot()
const report = getReport([result])
fs.mkdirSync(path.dirname(outputPath), {recursive: true})
fs.writeFileSync(outputPath, report)
})
it('report does not include a title by default', async () => {
const parser = new PythonXunitParser(defaultOpts)
const result = await parser.parse(filePath, fileContent)
const report = getReport([result])
// Report should have the badge as the first line
expect(report).toMatch(/^!\[Tests failed]/)
})
it.each([
['empty string', ''],
['space', ' '],
['tab', '\t'],
['newline', '\n']
])('report does not include a title when configured value is %s', async (_, reportTitle) => {
const parser = new PythonXunitParser(defaultOpts)
const result = await parser.parse(filePath, fileContent)
const report = getReport([result], {
...DEFAULT_OPTIONS,
reportTitle
})
// Report should have the badge as the first line
expect(report).toMatch(/^!\[Tests failed]/)
})
it('report includes a custom report title', async () => {
const parser = new PythonXunitParser(defaultOpts)
const result = await parser.parse(filePath, fileContent)
const report = getReport([result], {
...DEFAULT_OPTIONS,
reportTitle: 'My Custom Title'
})
// Report should have the title as the first line
expect(report).toMatch(/^# My Custom Title\n/)
})
})

View File

@@ -29,9 +29,11 @@ inputs:
- dotnet-nunit - dotnet-nunit
- dotnet-trx - dotnet-trx
- flutter-json - flutter-json
- golang-json
- java-junit - java-junit
- jest-junit - jest-junit
- mocha-json - mocha-json
- python-xunit
- rspec-json - rspec-json
- swift-xunit - swift-xunit
required: true required: true

846
dist/index.js generated vendored

File diff suppressed because it is too large Load Diff

80
dist/licenses.txt generated vendored
View File

@@ -1350,48 +1350,62 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
sax sax
ISC BlueOak-1.0.0
The ISC License # Blue Oak Model License
Copyright (c) Isaac Z. Schlueter and Contributors Version 1.0.0
Permission to use, copy, modify, and/or distribute this software for any ## Purpose
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES This license gives everyone as much permission to work with
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF this software as possible, while protecting contributors
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR from liability.
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
==== ## Acceptance
`String.fromCodePoint` by Mathias Bynens used according to terms of MIT In order to receive this license, you must agree to its
License, as follows: rules. The rules of this license are both obligations
under that agreement and conditions to your license.
You must not do anything with this software that triggers
a rule that you cannot or will not follow.
Copyright Mathias Bynens <https://mathiasbynens.be/> ## Copyright
Permission is hereby granted, free of charge, to any person obtaining Each contributor licenses you to do everything with this
a copy of this software and associated documentation files (the software that would otherwise infringe that contributor's
"Software"), to deal in the Software without restriction, including copyright in it.
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be ## Notices
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, You must ensure that everyone who gets a copy of
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF any part of this software from you, with or without
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND changes, also gets the text of this license or a link to
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE <https://blueoakcouncil.org/license/1.0.0>.
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION ## Excuse
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
If anyone notifies you in writing that you have not
complied with [Notices](#notices), you can keep your
license by taking all practical steps to comply within 30
days after the notice. If you do not do so, your license
ends immediately.
## Patent
Each contributor licenses you to do everything with this
software that would otherwise infringe any patent claims
they can license or become able to license.
## Reliability
No contributor can revoke this license.
## No Liability
***As far as the law allows, this software comes as is,
without any warranty or condition, and no contributor
will be liable to anyone for any damages related to this
software or this license, under any kind of legal claim.***
to-regex-range to-regex-range

7
package-lock.json generated
View File

@@ -7580,9 +7580,10 @@
} }
}, },
"node_modules/sax": { "node_modules/sax": {
"version": "1.2.4", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",

View File

@@ -69,6 +69,9 @@
"ts-jest": "^29.4.5", "ts-jest": "^29.4.5",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"overrides": {
"sax": "^1.4.3"
},
"jest-junit": { "jest-junit": {
"suiteName": "jest tests", "suiteName": "jest tests",
"outputDirectory": "__tests__/__results__", "outputDirectory": "__tests__/__results__",

View File

@@ -17,9 +17,9 @@ import {GolangJsonParser} from './parsers/golang-json/golang-json-parser'
import {JavaJunitParser} from './parsers/java-junit/java-junit-parser' import {JavaJunitParser} from './parsers/java-junit/java-junit-parser'
import {JestJunitParser} from './parsers/jest-junit/jest-junit-parser' import {JestJunitParser} from './parsers/jest-junit/jest-junit-parser'
import {MochaJsonParser} from './parsers/mocha-json/mocha-json-parser' import {MochaJsonParser} from './parsers/mocha-json/mocha-json-parser'
import {PythonXunitParser} from './parsers/python-xunit/python-xunit-parser'
import {RspecJsonParser} from './parsers/rspec-json/rspec-json-parser' import {RspecJsonParser} from './parsers/rspec-json/rspec-json-parser'
import {SwiftXunitParser} from './parsers/swift-xunit/swift-xunit-parser' import {SwiftXunitParser} from './parsers/swift-xunit/swift-xunit-parser'
import {normalizeDirPath, normalizeFilePath} from './utils/path-utils' import {normalizeDirPath, normalizeFilePath} from './utils/path-utils'
import {getCheckRunContext} from './utils/github-utils' import {getCheckRunContext} from './utils/github-utils'
@@ -181,7 +181,9 @@ class TestReporter {
let baseUrl = '' let baseUrl = ''
if (this.useActionsSummary) { if (this.useActionsSummary) {
const summary = getReport(results, { const summary = getReport(
results,
{
listSuites, listSuites,
listTests, listTests,
baseUrl, baseUrl,
@@ -190,11 +192,12 @@ class TestReporter {
badgeTitle, badgeTitle,
reportTitle, reportTitle,
collapsed collapsed
}) },
shortSummary
)
core.info('Summary content:') core.info('Summary content:')
core.info(summary) core.info(summary)
core.summary.addRaw(`# ${shortSummary}`)
await core.summary.addRaw(summary).write() await core.summary.addRaw(summary).write()
} else { } else {
core.info(`Creating check run ${name}`) core.info(`Creating check run ${name}`)
@@ -268,6 +271,8 @@ class TestReporter {
return new JestJunitParser(options) return new JestJunitParser(options)
case 'mocha-json': case 'mocha-json':
return new MochaJsonParser(options) return new MochaJsonParser(options)
case 'python-xunit':
return new PythonXunitParser(options)
case 'rspec-json': case 'rspec-json':
return new RspecJsonParser(options) return new RspecJsonParser(options)
case 'swift-xunit': case 'swift-xunit':

View File

@@ -0,0 +1,8 @@
import {ParseOptions} from '../../test-parser'
import {JavaJunitParser} from '../java-junit/java-junit-parser'
export class PythonXunitParser extends JavaJunitParser {
constructor(readonly options: ParseOptions) {
super(options)
}
}

View File

@@ -30,13 +30,15 @@ export const DEFAULT_OPTIONS: ReportOptions = {
collapsed: 'auto' collapsed: 'auto'
} }
export function getReport(results: TestRunResult[], options: ReportOptions = DEFAULT_OPTIONS): string { export function getReport(
core.info('Generating check run summary') results: TestRunResult[],
options: ReportOptions = DEFAULT_OPTIONS,
shortSummary = ''
): string {
applySort(results) applySort(results)
const opts = {...options} const opts = {...options}
let lines = renderReport(results, opts) let lines = renderReport(results, opts, shortSummary)
let report = lines.join('\n') let report = lines.join('\n')
if (getByteLength(report) <= getMaxReportLength(options)) { if (getByteLength(report) <= getMaxReportLength(options)) {
@@ -46,7 +48,7 @@ export function getReport(results: TestRunResult[], options: ReportOptions = DEF
if (opts.listTests === 'all') { if (opts.listTests === 'all') {
core.info("Test report summary is too big - setting 'listTests' to 'failed'") core.info("Test report summary is too big - setting 'listTests' to 'failed'")
opts.listTests = 'failed' opts.listTests = 'failed'
lines = renderReport(results, opts) lines = renderReport(results, opts, shortSummary)
report = lines.join('\n') report = lines.join('\n')
if (getByteLength(report) <= getMaxReportLength(options)) { if (getByteLength(report) <= getMaxReportLength(options)) {
return report return report
@@ -103,7 +105,7 @@ function getByteLength(text: string): number {
return Buffer.byteLength(text, 'utf8') return Buffer.byteLength(text, 'utf8')
} }
function renderReport(results: TestRunResult[], options: ReportOptions): string[] { function renderReport(results: TestRunResult[], options: ReportOptions, shortSummary: string): string[] {
const sections: string[] = [] const sections: string[] = []
const reportTitle: string = options.reportTitle.trim() const reportTitle: string = options.reportTitle.trim()
@@ -111,6 +113,10 @@ function renderReport(results: TestRunResult[], options: ReportOptions): string[
sections.push(`# ${reportTitle}`) sections.push(`# ${reportTitle}`)
} }
if (shortSummary) {
sections.push(`## ${shortSummary}`)
}
const badge = getReportBadge(results, options) const badge = getReportBadge(results, options)
sections.push(badge) sections.push(badge)