Support for the PHPUnit dialect of JUnit

Refactor PHPUnit support into separate phpunit-junit parser

Instead of modifying the Java JUnit parser, this creates a dedicated
PHPUnit parser that properly handles PHPUnit's nested testsuite elements.
This keeps the parsers cleanly separated and allows for future PHPUnit-
specific features.

Co-Authored-By: Matteo Beccati <matteo@beccati.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Jozef Izso
2025-12-29 12:56:17 +01:00
parent ee446707ff
commit d1de4d5f06
10 changed files with 665 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ This [Github Action](https://github.com/features/actions) displays test results
- 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) - Python / [pytest](https://docs.pytest.org/en/stable/) / [unittest](https://docs.python.org/3/library/unittest.html)
- PHP / [PHPUnit](https://phpunit.de/)
- Ruby / [RSpec](https://rspec.info/) - Ruby / [RSpec](https://rspec.info/)
- Swift / xUnit - Swift / xUnit
@@ -147,6 +148,7 @@ jobs:
# java-junit # java-junit
# jest-junit # jest-junit
# mocha-json # mocha-json
# phpunit-junit
# python-xunit # python-xunit
# rspec-json # rspec-json
# swift-xunit # swift-xunit
@@ -314,6 +316,14 @@ This is due to the fact Java stack traces don't contain a full path to the sourc
Some heuristic was necessary to figure out the mapping between the line in the stack trace and an actual source file. Some heuristic was necessary to figure out the mapping between the line in the stack trace and an actual source file.
</details> </details>
<details>
<summary>phpunit-junit</summary>
[PHPUnit](https://phpunit.de/) can generate JUnit XML via CLI:
`phpunit --log-junit reports/phpunit-junit.xml`
</details>
<details> <details>
<summary>jest-junit</summary> <summary>jest-junit</summary>

View File

@@ -0,0 +1,38 @@
![Tests failed](https://img.shields.io/badge/tests-10%20passed%2C%202%20failed-critical)
## ❌ <a id="user-content-r0" href="#r0">fixtures/phpunit/phpunit.xml</a>
**12** tests were completed in **148ms** with **10** passed, **2** failed and **0** skipped.
|Test suite|Passed|Failed|Skipped|Time|
|:---|---:|---:|---:|---:|
|[CLI Arguments](#r0s0)||2❌||140ms|
|[PHPUnit\Event\CollectingDispatcherTest](#r0s1)|2✅|||4ms|
|[PHPUnit\Event\DeferringDispatcherTest](#r0s2)|4✅|||3ms|
|[PHPUnit\Event\DirectDispatcherTest](#r0s3)|4✅|||1ms|
### ❌ <a id="user-content-r0s0" href="#r0s0">CLI Arguments</a>
```
❌ targeting-traits-with-coversclass-attribute-is-deprecated.phpt
PHPUnit\Framework\PhptAssertionFailedError
❌ targeting-traits-with-usesclass-attribute-is-deprecated.phpt
PHPUnit\Framework\PhptAssertionFailedError
```
### ✅ <a id="user-content-r0s1" href="#r0s1">PHPUnit\Event\CollectingDispatcherTest</a>
```
PHPUnit.Event.CollectingDispatcherTest
✅ testHasNoCollectedEventsWhenFlushedImmediatelyAfterCreation
✅ testCollectsDispatchedEventsUntilFlushed
```
### ✅ <a id="user-content-r0s2" href="#r0s2">PHPUnit\Event\DeferringDispatcherTest</a>
```
PHPUnit.Event.DeferringDispatcherTest
✅ testCollectsEventsUntilFlush
✅ testFlushesCollectedEvents
✅ testSubscriberCanBeRegistered
✅ testTracerCanBeRegistered
```
### ✅ <a id="user-content-r0s3" href="#r0s3">PHPUnit\Event\DirectDispatcherTest</a>
```
PHPUnit.Event.DirectDispatcherTest
✅ testDispatchesEventToKnownSubscribers
✅ testDispatchesEventToTracers
✅ testRegisterRejectsUnknownSubscriber
✅ testDispatchRejectsUnknownEventType
```

View File

@@ -0,0 +1,188 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`phpunit-junit tests report from phpunit test results matches snapshot 1`] = `
TestRunResult {
"path": "fixtures/phpunit/phpunit.xml",
"suites": [
TestSuiteResult {
"groups": [
TestGroupResult {
"name": "PHPUnit.Event.CollectingDispatcherTest",
"tests": [
TestCaseResult {
"error": undefined,
"name": "testHasNoCollectedEventsWhenFlushedImmediatelyAfterCreation",
"result": "success",
"time": 1.441,
},
TestCaseResult {
"error": undefined,
"name": "testCollectsDispatchedEventsUntilFlushed",
"result": "success",
"time": 2.815,
},
],
},
],
"name": "PHPUnit\\Event\\CollectingDispatcherTest",
"totalTime": 4.256,
},
TestSuiteResult {
"groups": [
TestGroupResult {
"name": "PHPUnit.Event.DeferringDispatcherTest",
"tests": [
TestCaseResult {
"error": undefined,
"name": "testCollectsEventsUntilFlush",
"result": "success",
"time": 1.6720000000000002,
},
TestCaseResult {
"error": undefined,
"name": "testFlushesCollectedEvents",
"result": "success",
"time": 0.661,
},
TestCaseResult {
"error": undefined,
"name": "testSubscriberCanBeRegistered",
"result": "success",
"time": 0.33399999999999996,
},
TestCaseResult {
"error": undefined,
"name": "testTracerCanBeRegistered",
"result": "success",
"time": 0.262,
},
],
},
],
"name": "PHPUnit\\Event\\DeferringDispatcherTest",
"totalTime": 2.928,
},
TestSuiteResult {
"groups": [
TestGroupResult {
"name": "PHPUnit.Event.DirectDispatcherTest",
"tests": [
TestCaseResult {
"error": undefined,
"name": "testDispatchesEventToKnownSubscribers",
"result": "success",
"time": 0.17,
},
TestCaseResult {
"error": undefined,
"name": "testDispatchesEventToTracers",
"result": "success",
"time": 0.248,
},
TestCaseResult {
"error": undefined,
"name": "testRegisterRejectsUnknownSubscriber",
"result": "success",
"time": 0.257,
},
TestCaseResult {
"error": undefined,
"name": "testDispatchRejectsUnknownEventType",
"result": "success",
"time": 0.11900000000000001,
},
],
},
],
"name": "PHPUnit\\Event\\DirectDispatcherTest",
"totalTime": 0.794,
},
TestSuiteResult {
"groups": [
TestGroupResult {
"name": "",
"tests": [
TestCaseResult {
"error": {
"details": "targeting-traits-with-coversclass-attribute-is-deprecated.phptFailed asserting that string matches format description.
--- Expected
+++ Actual
@@ @@
PHPUnit Started (PHPUnit 11.2-g0c2333363 using PHP 8.2.17 (cli) on Linux)
Test Runner Configured
Test Suite Loaded (1 test)
+Test Runner Triggered Warning (No code coverage driver available)
Event Facade Sealed
Test Runner Started
Test Suite Sorted
@@ @@
Test Preparation Started (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithCoversClassTest::testSomething)
Test Prepared (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithCoversClassTest::testSomething)
Test Passed (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithCoversClassTest::testSomething)
-Test Runner Triggered Deprecation (Targeting a trait such as PHPUnit\\TestFixture\\CoveredTrait with #[CoversClass] is deprecated, please refactor your test to use #[CoversTrait] instead.)
Test Finished (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithCoversClassTest::testSomething)
Test Suite Finished (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithCoversClassTest, 1 test)
Test Runner Execution Finished
Test Runner Finished
-PHPUnit Finished (Shell Exit Code: 0)
+PHPUnit Finished (Shell Exit Code: 1)
/home/matteo/OSS/phpunit/tests/end-to-end/metadata/targeting-traits-with-coversclass-attribute-is-deprecated.phpt:28
/home/matteo/OSS/phpunit/src/Framework/TestSuite.php:369
/home/matteo/OSS/phpunit/src/TextUI/TestRunner.php:62
/home/matteo/OSS/phpunit/src/TextUI/Application.php:200",
"line": undefined,
"message": "PHPUnit\\Framework\\PhptAssertionFailedError",
"path": undefined,
},
"name": "targeting-traits-with-coversclass-attribute-is-deprecated.phpt",
"result": "failed",
"time": 68.151,
},
TestCaseResult {
"error": {
"details": "targeting-traits-with-usesclass-attribute-is-deprecated.phptFailed asserting that string matches format description.
--- Expected
+++ Actual
@@ @@
PHPUnit Started (PHPUnit 11.2-g0c2333363 using PHP 8.2.17 (cli) on Linux)
Test Runner Configured
Test Suite Loaded (1 test)
+Test Runner Triggered Warning (No code coverage driver available)
Event Facade Sealed
Test Runner Started
Test Suite Sorted
@@ @@
Test Preparation Started (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithUsesClassTest::testSomething)
Test Prepared (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithUsesClassTest::testSomething)
Test Passed (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithUsesClassTest::testSomething)
-Test Runner Triggered Deprecation (Targeting a trait such as PHPUnit\\TestFixture\\CoveredTrait with #[UsesClass] is deprecated, please refactor your test to use #[UsesTrait] instead.)
Test Finished (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithUsesClassTest::testSomething)
Test Suite Finished (PHPUnit\\DeprecatedAnnotationsTestFixture\\TraitTargetedWithUsesClassTest, 1 test)
Test Runner Execution Finished
Test Runner Finished
-PHPUnit Finished (Shell Exit Code: 0)
+PHPUnit Finished (Shell Exit Code: 1)
/home/matteo/OSS/phpunit/tests/end-to-end/metadata/targeting-traits-with-usesclass-attribute-is-deprecated.phpt:28
/home/matteo/OSS/phpunit/src/Framework/TestSuite.php:369
/home/matteo/OSS/phpunit/src/TextUI/TestRunner.php:62
/home/matteo/OSS/phpunit/src/TextUI/Application.php:200",
"line": undefined,
"message": "PHPUnit\\Framework\\PhptAssertionFailedError",
"path": undefined,
},
"name": "targeting-traits-with-usesclass-attribute-is-deprecated.phpt",
"result": "failed",
"time": 64.268,
},
],
},
],
"name": "CLI Arguments",
"totalTime": 140.397,
},
],
"totalTime": undefined,
}
`;

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites/>

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="CLI Arguments" tests="12" assertions="12" errors="0" failures="2" skipped="0" time="0.140397">
<testcase name="targeting-traits-with-coversclass-attribute-is-deprecated.phpt" file="/home/matteo/OSS/phpunit/tests/end-to-end/metadata/targeting-traits-with-coversclass-attribute-is-deprecated.phpt" assertions="1" time="0.068151">
<failure type="PHPUnit\Framework\PhptAssertionFailedError">targeting-traits-with-coversclass-attribute-is-deprecated.phptFailed asserting that string matches format description.
--- Expected
+++ Actual
@@ @@
PHPUnit Started (PHPUnit 11.2-g0c2333363 using PHP 8.2.17 (cli) on Linux)
Test Runner Configured
Test Suite Loaded (1 test)
+Test Runner Triggered Warning (No code coverage driver available)
Event Facade Sealed
Test Runner Started
Test Suite Sorted
@@ @@
Test Preparation Started (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithCoversClassTest::testSomething)
Test Prepared (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithCoversClassTest::testSomething)
Test Passed (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithCoversClassTest::testSomething)
-Test Runner Triggered Deprecation (Targeting a trait such as PHPUnit\TestFixture\CoveredTrait with #[CoversClass] is deprecated, please refactor your test to use #[CoversTrait] instead.)
Test Finished (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithCoversClassTest::testSomething)
Test Suite Finished (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithCoversClassTest, 1 test)
Test Runner Execution Finished
Test Runner Finished
-PHPUnit Finished (Shell Exit Code: 0)
+PHPUnit Finished (Shell Exit Code: 1)
/home/matteo/OSS/phpunit/tests/end-to-end/metadata/targeting-traits-with-coversclass-attribute-is-deprecated.phpt:28
/home/matteo/OSS/phpunit/src/Framework/TestSuite.php:369
/home/matteo/OSS/phpunit/src/TextUI/TestRunner.php:62
/home/matteo/OSS/phpunit/src/TextUI/Application.php:200</failure>
</testcase>
<testcase name="targeting-traits-with-usesclass-attribute-is-deprecated.phpt" file="/home/matteo/OSS/phpunit/tests/end-to-end/metadata/targeting-traits-with-usesclass-attribute-is-deprecated.phpt" assertions="1" time="0.064268">
<failure type="PHPUnit\Framework\PhptAssertionFailedError">targeting-traits-with-usesclass-attribute-is-deprecated.phptFailed asserting that string matches format description.
--- Expected
+++ Actual
@@ @@
PHPUnit Started (PHPUnit 11.2-g0c2333363 using PHP 8.2.17 (cli) on Linux)
Test Runner Configured
Test Suite Loaded (1 test)
+Test Runner Triggered Warning (No code coverage driver available)
Event Facade Sealed
Test Runner Started
Test Suite Sorted
@@ @@
Test Preparation Started (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithUsesClassTest::testSomething)
Test Prepared (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithUsesClassTest::testSomething)
Test Passed (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithUsesClassTest::testSomething)
-Test Runner Triggered Deprecation (Targeting a trait such as PHPUnit\TestFixture\CoveredTrait with #[UsesClass] is deprecated, please refactor your test to use #[UsesTrait] instead.)
Test Finished (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithUsesClassTest::testSomething)
Test Suite Finished (PHPUnit\DeprecatedAnnotationsTestFixture\TraitTargetedWithUsesClassTest, 1 test)
Test Runner Execution Finished
Test Runner Finished
-PHPUnit Finished (Shell Exit Code: 0)
+PHPUnit Finished (Shell Exit Code: 1)
/home/matteo/OSS/phpunit/tests/end-to-end/metadata/targeting-traits-with-usesclass-attribute-is-deprecated.phpt:28
/home/matteo/OSS/phpunit/src/Framework/TestSuite.php:369
/home/matteo/OSS/phpunit/src/TextUI/TestRunner.php:62
/home/matteo/OSS/phpunit/src/TextUI/Application.php:200</failure>
</testcase>
<testsuite name="PHPUnit\Event\CollectingDispatcherTest" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/CollectingDispatcherTest.php" tests="2" assertions="2" errors="0" failures="0" skipped="0" time="0.004256">
<testcase name="testHasNoCollectedEventsWhenFlushedImmediatelyAfterCreation" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/CollectingDispatcherTest.php" line="20" class="PHPUnit\Event\CollectingDispatcherTest" classname="PHPUnit.Event.CollectingDispatcherTest" assertions="1" time="0.001441"/>
<testcase name="testCollectsDispatchedEventsUntilFlushed" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/CollectingDispatcherTest.php" line="27" class="PHPUnit\Event\CollectingDispatcherTest" classname="PHPUnit.Event.CollectingDispatcherTest" assertions="1" time="0.002815"/>
</testsuite>
<testsuite name="PHPUnit\Event\DeferringDispatcherTest" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DeferringDispatcherTest.php" tests="4" assertions="4" errors="0" failures="0" skipped="0" time="0.002928">
<testcase name="testCollectsEventsUntilFlush" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DeferringDispatcherTest.php" line="22" class="PHPUnit\Event\DeferringDispatcherTest" classname="PHPUnit.Event.DeferringDispatcherTest" assertions="1" time="0.001672"/>
<testcase name="testFlushesCollectedEvents" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DeferringDispatcherTest.php" line="35" class="PHPUnit\Event\DeferringDispatcherTest" classname="PHPUnit.Event.DeferringDispatcherTest" assertions="1" time="0.000661"/>
<testcase name="testSubscriberCanBeRegistered" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DeferringDispatcherTest.php" line="53" class="PHPUnit\Event\DeferringDispatcherTest" classname="PHPUnit.Event.DeferringDispatcherTest" assertions="1" time="0.000334"/>
<testcase name="testTracerCanBeRegistered" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DeferringDispatcherTest.php" line="69" class="PHPUnit\Event\DeferringDispatcherTest" classname="PHPUnit.Event.DeferringDispatcherTest" assertions="1" time="0.000262"/>
</testsuite>
<testsuite name="PHPUnit\Event\DirectDispatcherTest" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DirectDispatcherTest.php" tests="4" assertions="4" errors="0" failures="0" skipped="0" time="0.000794">
<testcase name="testDispatchesEventToKnownSubscribers" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DirectDispatcherTest.php" line="24" class="PHPUnit\Event\DirectDispatcherTest" classname="PHPUnit.Event.DirectDispatcherTest" assertions="1" time="0.000170"/>
<testcase name="testDispatchesEventToTracers" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DirectDispatcherTest.php" line="43" class="PHPUnit\Event\DirectDispatcherTest" classname="PHPUnit.Event.DirectDispatcherTest" assertions="1" time="0.000248"/>
<testcase name="testRegisterRejectsUnknownSubscriber" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DirectDispatcherTest.php" line="62" class="PHPUnit\Event\DirectDispatcherTest" classname="PHPUnit.Event.DirectDispatcherTest" assertions="1" time="0.000257"/>
<testcase name="testDispatchRejectsUnknownEventType" file="/home/matteo/OSS/phpunit/tests/unit/Event/Dispatcher/DirectDispatcherTest.php" line="73" class="PHPUnit\Event\DirectDispatcherTest" classname="PHPUnit.Event.DirectDispatcherTest" assertions="1" time="0.000119"/>
</testsuite>
</testsuite>
</testsuites>

View File

@@ -0,0 +1,102 @@
import * as fs from 'fs'
import * as path from 'path'
import {PhpunitJunitParser} from '../src/parsers/phpunit-junit/phpunit-junit-parser'
import {ParseOptions} from '../src/test-parser'
import {getReport} from '../src/report/get-report'
import {normalizeFilePath} from '../src/utils/path-utils'
describe('phpunit-junit tests', () => {
it('produces empty test run result when there are no test cases', async () => {
const fixturePath = path.join(__dirname, 'fixtures', 'empty', 'phpunit-empty.xml')
const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
const opts: ParseOptions = {
parseErrors: true,
trackedFiles: []
}
const parser = new PhpunitJunitParser(opts)
const result = await parser.parse(filePath, fileContent)
expect(result.tests).toBe(0)
expect(result.result).toBe('success')
})
it('report from phpunit test results matches snapshot', async () => {
const fixturePath = path.join(__dirname, 'fixtures', 'phpunit', 'phpunit.xml')
const outputPath = path.join(__dirname, '__outputs__', 'phpunit-test-results.md')
const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
const opts: ParseOptions = {
parseErrors: true,
trackedFiles: []
}
const parser = new PhpunitJunitParser(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('parses nested test suites correctly', async () => {
const fixturePath = path.join(__dirname, 'fixtures', 'phpunit', 'phpunit.xml')
const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
const opts: ParseOptions = {
parseErrors: true,
trackedFiles: []
}
const parser = new PhpunitJunitParser(opts)
const result = await parser.parse(filePath, fileContent)
// Should have 4 test suites (3 nested ones plus the parent with direct testcases)
expect(result.suites.length).toBe(4)
// Verify suite names
const suiteNames = result.suites.map(s => s.name)
expect(suiteNames).toContain('PHPUnit\\Event\\CollectingDispatcherTest')
expect(suiteNames).toContain('PHPUnit\\Event\\DeferringDispatcherTest')
expect(suiteNames).toContain('PHPUnit\\Event\\DirectDispatcherTest')
expect(suiteNames).toContain('CLI Arguments')
// Verify total test count
expect(result.tests).toBe(12)
expect(result.passed).toBe(10)
expect(result.failed).toBe(2)
})
it('extracts error details from failures', async () => {
const fixturePath = path.join(__dirname, 'fixtures', 'phpunit', 'phpunit.xml')
const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
const opts: ParseOptions = {
parseErrors: true,
trackedFiles: []
}
const parser = new PhpunitJunitParser(opts)
const result = await parser.parse(filePath, fileContent)
// Find the CLI Arguments suite which has failures
const cliSuite = result.suites.find(s => s.name === 'CLI Arguments')
expect(cliSuite).toBeDefined()
// Get the failed tests
const failedTests = cliSuite!.groups.flatMap(g => g.tests).filter(t => t.result === 'failed')
expect(failedTests.length).toBe(2)
// Verify error details are captured
for (const test of failedTests) {
expect(test.error).toBeDefined()
expect(test.error!.details).toContain('Failed asserting that string matches format description')
}
})
})

View File

@@ -32,6 +32,7 @@ inputs:
- java-junit - java-junit
- jest-junit - jest-junit
- mocha-json - mocha-json
- phpunit-junit
- python-xunit - python-xunit
- rspec-json - rspec-json
- swift-xunit - swift-xunit

View File

@@ -17,6 +17,7 @@ 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 {PhpunitJunitParser} from './parsers/phpunit-junit/phpunit-junit-parser'
import {PythonXunitParser} from './parsers/python-xunit/python-xunit-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'
@@ -271,6 +272,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 'phpunit-junit':
return new PhpunitJunitParser(options)
case 'python-xunit': case 'python-xunit':
return new PythonXunitParser(options) return new PythonXunitParser(options)
case 'rspec-json': case 'rspec-json':

View File

@@ -0,0 +1,190 @@
import {ParseOptions, TestParser} from '../../test-parser'
import {parseStringPromise} from 'xml2js'
import {PhpunitReport, SingleSuiteReport, TestCase, TestSuite} from './phpunit-junit-types'
import {normalizeFilePath} from '../../utils/path-utils'
import {
TestExecutionResult,
TestRunResult,
TestSuiteResult,
TestGroupResult,
TestCaseResult,
TestCaseError
} from '../../test-results'
export class PhpunitJunitParser implements TestParser {
readonly trackedFiles: Set<string>
constructor(readonly options: ParseOptions) {
this.trackedFiles = new Set(options.trackedFiles.map(f => normalizeFilePath(f)))
}
async parse(filePath: string, content: string): Promise<TestRunResult> {
const reportOrSuite = await this.getPhpunitReport(filePath, content)
const isReport = (reportOrSuite as PhpunitReport).testsuites !== undefined
// XML might contain:
// - multiple suites under <testsuites> root node
// - single <testsuite> as root node
let report: PhpunitReport
if (isReport) {
report = reportOrSuite as PhpunitReport
} else {
// Make it behave the same way as if suite was inside <testsuites> root node
const suite = (reportOrSuite as SingleSuiteReport).testsuite
report = {
testsuites: {
$: {time: suite.$.time},
testsuite: [suite]
}
}
}
return this.getTestRunResult(filePath, report)
}
private async getPhpunitReport(filePath: string, content: string): Promise<PhpunitReport | SingleSuiteReport> {
try {
return await parseStringPromise(content)
} catch (e) {
throw new Error(`Invalid XML at ${filePath}\n\n${e}`)
}
}
private getTestRunResult(filePath: string, report: PhpunitReport): TestRunResult {
const suites: TestSuiteResult[] = []
this.collectSuites(suites, report.testsuites.testsuite ?? [])
const seconds = parseFloat(report.testsuites.$?.time ?? '')
const time = isNaN(seconds) ? undefined : seconds * 1000
return new TestRunResult(filePath, suites, time)
}
private collectSuites(results: TestSuiteResult[], testsuites: TestSuite[]): void {
for (const ts of testsuites) {
// Recursively process nested test suites first (depth-first)
if (ts.testsuite) {
this.collectSuites(results, ts.testsuite)
}
// Only add suites that have direct test cases
// This avoids adding container suites that only hold nested suites
if (ts.testcase && ts.testcase.length > 0) {
const name = ts.$.name.trim()
const time = parseFloat(ts.$.time) * 1000
results.push(new TestSuiteResult(name, this.getGroups(ts), time))
}
}
}
private getGroups(suite: TestSuite): TestGroupResult[] {
if (!suite.testcase || suite.testcase.length === 0) {
return []
}
const groups: {name: string; tests: TestCase[]}[] = []
for (const tc of suite.testcase) {
// Use classname (PHPUnit style) for grouping
// If classname matches suite name, use empty string to avoid redundancy
const className = tc.$.classname ?? tc.$.class ?? ''
const groupName = className === suite.$.name ? '' : className
let grp = groups.find(g => g.name === groupName)
if (grp === undefined) {
grp = {name: groupName, tests: []}
groups.push(grp)
}
grp.tests.push(tc)
}
return groups.map(grp => {
const tests = grp.tests.map(tc => {
const name = tc.$.name.trim()
const result = this.getTestCaseResult(tc)
const time = parseFloat(tc.$.time) * 1000
const error = this.getTestCaseError(tc)
return new TestCaseResult(name, result, time, error)
})
return new TestGroupResult(grp.name, tests)
})
}
private getTestCaseResult(test: TestCase): TestExecutionResult {
if (test.failure || test.error) return 'failed'
if (test.skipped) return 'skipped'
return 'success'
}
private getTestCaseError(tc: TestCase): TestCaseError | undefined {
if (!this.options.parseErrors) {
return undefined
}
// We process <error> and <failure> the same way
const failures = tc.failure ?? tc.error
if (!failures || failures.length === 0) {
return undefined
}
const failure = failures[0]
const details = failure._ ?? ''
// PHPUnit provides file path directly in testcase attributes
let filePath: string | undefined
let line: number | undefined
if (tc.$.file) {
const normalizedPath = normalizeFilePath(tc.$.file)
if (this.trackedFiles.has(normalizedPath)) {
filePath = normalizedPath
}
if (tc.$.line) {
line = parseInt(tc.$.line)
}
}
// If file not in tracked files, try to extract from error details
if (!filePath && details) {
const extracted = this.extractFileAndLine(details)
if (extracted) {
filePath = extracted.filePath
line = extracted.line
}
}
let message: string | undefined
if (failure.$) {
message = failure.$.message
if (failure.$.type) {
message = message ? `${failure.$.type}: ${message}` : failure.$.type
}
}
return {
path: filePath,
line,
details,
message
}
}
private extractFileAndLine(details: string): {filePath: string; line: number} | undefined {
// PHPUnit stack traces typically have format: /path/to/file.php:123
const lines = details.split(/\r?\n/)
for (const str of lines) {
// Match patterns like /path/to/file.php:123 or at /path/to/file.php(123)
const match = str.match(/([^\s:()]+\.php):(\d+)|([^\s:()]+\.php)\((\d+)\)/)
if (match) {
const path = match[1] ?? match[3]
const lineStr = match[2] ?? match[4]
const normalizedPath = normalizeFilePath(path)
if (this.trackedFiles.has(normalizedPath)) {
return {filePath: normalizedPath, line: parseInt(lineStr)}
}
}
}
return undefined
}
}

View File

@@ -0,0 +1,52 @@
export interface PhpunitReport {
testsuites: TestSuites
}
export interface SingleSuiteReport {
testsuite: TestSuite
}
export interface TestSuites {
$?: {
time?: string
}
testsuite?: TestSuite[]
}
export interface TestSuite {
$: {
name: string
tests?: string
assertions?: string
errors?: string
failures?: string
skipped?: string
time: string
file?: string
}
testcase?: TestCase[]
testsuite?: TestSuite[]
}
export interface TestCase {
$: {
name: string
class?: string
classname?: string
file?: string
line?: string
assertions?: string
time: string
}
failure?: Failure[]
error?: Failure[]
skipped?: string[]
}
export interface Failure {
_: string
$?: {
type?: string
message?: string
}
}