diff --git a/README.md b/README.md
index d900926..9209dc4 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ This [Github Action](https://github.com/features/actions) displays test results
- Java / [JUnit](https://junit.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)
+- PHP / [PHPUnit](https://phpunit.de/)
- Ruby / [RSpec](https://rspec.info/)
- Swift / xUnit
@@ -147,6 +148,7 @@ jobs:
# java-junit
# jest-junit
# mocha-json
+ # phpunit-junit
# python-xunit
# rspec-json
# 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.
+
+ phpunit-junit
+
+[PHPUnit](https://phpunit.de/) can generate JUnit XML via CLI:
+`phpunit --log-junit reports/phpunit-junit.xml`
+
+
+
jest-junit
diff --git a/__tests__/__outputs__/phpunit-junit-basic-results.md b/__tests__/__outputs__/phpunit-junit-basic-results.md
new file mode 100644
index 0000000..641b9dd
--- /dev/null
+++ b/__tests__/__outputs__/phpunit-junit-basic-results.md
@@ -0,0 +1,30 @@
+
+|Report|Passed|Failed|Skipped|Time|
+|:---|---:|---:|---:|---:|
+|[fixtures/external/phpunit/junit-basic.xml](#user-content-r0)|8 ✅|1 ❌||16s|
+## ❌ fixtures/external/phpunit/junit-basic.xml
+**9** tests were completed in **16s** with **8** passed, **1** failed and **0** skipped.
+|Test suite|Passed|Failed|Skipped|Time|
+|:---|---:|---:|---:|---:|
+|[Tests.Authentication](#user-content-r0s0)|2 ✅|1 ❌||9s|
+|[Tests.Authentication.Login](#user-content-r0s1)|3 ✅|||4s|
+|[Tests.Registration](#user-content-r0s2)|3 ✅|||7s|
+### ❌ Tests.Authentication
+```
+✅ testCase7
+✅ testCase8
+❌ testCase9
+ AssertionError: Assertion error message
+```
+### ✅ Tests.Authentication.Login
+```
+✅ testCase4
+✅ testCase5
+✅ testCase6
+```
+### ✅ Tests.Registration
+```
+✅ testCase1
+✅ testCase2
+✅ testCase3
+```
\ No newline at end of file
diff --git a/__tests__/__outputs__/phpunit-phpcheckstyle-results.md b/__tests__/__outputs__/phpunit-phpcheckstyle-results.md
new file mode 100644
index 0000000..6966c55
--- /dev/null
+++ b/__tests__/__outputs__/phpunit-phpcheckstyle-results.md
@@ -0,0 +1,88 @@
+
+|Report|Passed|Failed|Skipped|Time|
+|:---|---:|---:|---:|---:|
+|[fixtures/external/phpunit/phpcheckstyle-phpunit.xml](#user-content-r0)|28 ✅|2 ❌||41ms|
+## ❌ fixtures/external/phpunit/phpcheckstyle-phpunit.xml
+**30** tests were completed in **41ms** with **28** passed, **2** failed and **0** skipped.
+|Test suite|Passed|Failed|Skipped|Time|
+|:---|---:|---:|---:|---:|
+|[CommentsTest](#user-content-r0s0)|3 ✅|||7ms|
+|[DeprecationTest](#user-content-r0s1)|1 ✅|||1ms|
+|[GoodTest](#user-content-r0s2)|4 ✅|||5ms|
+|[IndentationTest](#user-content-r0s3)|8 ✅|||8ms|
+|[MetricsTest](#user-content-r0s4)|1 ✅|||4ms|
+|[NamingTest](#user-content-r0s5)|2 ✅|||3ms|
+|[OptimizationTest](#user-content-r0s6)|1 ✅|||1ms|
+|[OtherTest](#user-content-r0s7)|2 ✅|2 ❌||7ms|
+|[PHPTagsTest](#user-content-r0s8)|2 ✅|||1ms|
+|[ProhibitedTest](#user-content-r0s9)|1 ✅|||1ms|
+|[StrictCompareTest](#user-content-r0s10)|1 ✅|||2ms|
+|[UnusedTest](#user-content-r0s11)|2 ✅|||2ms|
+### ✅ CommentsTest
+```
+✅ testGoodDoc
+✅ testComments
+✅ testTODOs
+```
+### ✅ DeprecationTest
+```
+✅ testDeprecations
+```
+### ✅ GoodTest
+```
+✅ testGood
+✅ testDoWhile
+✅ testAnonymousFunction
+✅ testException
+```
+### ✅ IndentationTest
+```
+✅ testTabIndentation
+✅ testSpaceIndentation
+✅ testSpaceIndentationArray
+✅ testGoodSpaceIndentationArray
+✅ testGoodIndentationNewLine
+✅ testGoodIndentationSpaces
+✅ testBadSpaces
+✅ testBadSpaceAfterControl
+```
+### ✅ MetricsTest
+```
+✅ testMetrics
+```
+### ✅ NamingTest
+```
+✅ testNaming
+✅ testFunctionNaming
+```
+### ✅ OptimizationTest
+```
+✅ testTextAfterClosingTag
+```
+### ❌ OtherTest
+```
+❌ testOther
+ PHPUnit\Framework\ExpectationFailedException
+❌ testException
+ PHPUnit\Framework\ExpectationFailedException
+✅ testEmpty
+✅ testSwitchCaseNeedBreak
+```
+### ✅ PHPTagsTest
+```
+✅ testTextAfterClosingTag
+✅ testClosingTagNotNeeded
+```
+### ✅ ProhibitedTest
+```
+✅ testProhibited
+```
+### ✅ StrictCompareTest
+```
+✅ testStrictCompare
+```
+### ✅ UnusedTest
+```
+✅ testGoodUnused
+✅ testBadUnused
+```
\ No newline at end of file
diff --git a/__tests__/__outputs__/phpunit-test-results.md b/__tests__/__outputs__/phpunit-test-results.md
new file mode 100644
index 0000000..67f8258
--- /dev/null
+++ b/__tests__/__outputs__/phpunit-test-results.md
@@ -0,0 +1,41 @@
+
+|Report|Passed|Failed|Skipped|Time|
+|:---|---:|---:|---:|---:|
+|[fixtures/phpunit/phpunit.xml](#user-content-r0)|10 ✅|2 ❌||148ms|
+## ❌ fixtures/phpunit/phpunit.xml
+**12** tests were completed in **148ms** with **10** passed, **2** failed and **0** skipped.
+|Test suite|Passed|Failed|Skipped|Time|
+|:---|---:|---:|---:|---:|
+|[CLI Arguments](#user-content-r0s0)||2 ❌||140ms|
+|[PHPUnit\Event\CollectingDispatcherTest](#user-content-r0s1)|2 ✅|||4ms|
+|[PHPUnit\Event\DeferringDispatcherTest](#user-content-r0s2)|4 ✅|||3ms|
+|[PHPUnit\Event\DirectDispatcherTest](#user-content-r0s3)|4 ✅|||1ms|
+### ❌ CLI Arguments
+```
+❌ targeting-traits-with-coversclass-attribute-is-deprecated.phpt
+ PHPUnit\Framework\PhptAssertionFailedError
+❌ targeting-traits-with-usesclass-attribute-is-deprecated.phpt
+ PHPUnit\Framework\PhptAssertionFailedError
+```
+### ✅ PHPUnit\Event\CollectingDispatcherTest
+```
+PHPUnit.Event.CollectingDispatcherTest
+ ✅ testHasNoCollectedEventsWhenFlushedImmediatelyAfterCreation
+ ✅ testCollectsDispatchedEventsUntilFlushed
+```
+### ✅ PHPUnit\Event\DeferringDispatcherTest
+```
+PHPUnit.Event.DeferringDispatcherTest
+ ✅ testCollectsEventsUntilFlush
+ ✅ testFlushesCollectedEvents
+ ✅ testSubscriberCanBeRegistered
+ ✅ testTracerCanBeRegistered
+```
+### ✅ PHPUnit\Event\DirectDispatcherTest
+```
+PHPUnit.Event.DirectDispatcherTest
+ ✅ testDispatchesEventToKnownSubscribers
+ ✅ testDispatchesEventToTracers
+ ✅ testRegisterRejectsUnknownSubscriber
+ ✅ testDispatchRejectsUnknownEventType
+```
\ No newline at end of file
diff --git a/__tests__/__snapshots__/phpunit-junit.test.ts.snap b/__tests__/__snapshots__/phpunit-junit.test.ts.snap
new file mode 100644
index 0000000..d48d941
--- /dev/null
+++ b/__tests__/__snapshots__/phpunit-junit.test.ts.snap
@@ -0,0 +1,628 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`phpunit-junit tests report from junit-basic.xml matches snapshot 1`] = `
+TestRunResult {
+ "path": "fixtures/external/phpunit/junit-basic.xml",
+ "suites": [
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testCase1",
+ "result": "success",
+ "time": 2113.871,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testCase2",
+ "result": "success",
+ "time": 1051,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testCase3",
+ "result": "success",
+ "time": 3441,
+ },
+ ],
+ },
+ ],
+ "name": "Tests.Registration",
+ "totalTime": 6605.870999999999,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testCase4",
+ "result": "success",
+ "time": 2244,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testCase5",
+ "result": "success",
+ "time": 781,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testCase6",
+ "result": "success",
+ "time": 1331,
+ },
+ ],
+ },
+ ],
+ "name": "Tests.Authentication.Login",
+ "totalTime": 4356,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testCase7",
+ "result": "success",
+ "time": 2508,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testCase8",
+ "result": "success",
+ "time": 1230.8159999999998,
+ },
+ TestCaseResult {
+ "error": {
+ "details": "",
+ "line": undefined,
+ "message": "AssertionError: Assertion error message",
+ "path": undefined,
+ },
+ "name": "testCase9",
+ "result": "failed",
+ "time": 982,
+ },
+ ],
+ },
+ ],
+ "name": "Tests.Authentication",
+ "totalTime": 9076.816,
+ },
+ ],
+ "totalTime": 15682.687,
+}
+`;
+
+exports[`phpunit-junit tests report from phpcheckstyle-phpunit.xml matches snapshot 1`] = `
+TestRunResult {
+ "path": "fixtures/external/phpunit/phpcheckstyle-phpunit.xml",
+ "suites": [
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testGoodDoc",
+ "result": "success",
+ "time": 5.093,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testComments",
+ "result": "success",
+ "time": 0.921,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testTODOs",
+ "result": "success",
+ "time": 0.6880000000000001,
+ },
+ ],
+ },
+ ],
+ "name": "CommentsTest",
+ "totalTime": 6.702,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testDeprecations",
+ "result": "success",
+ "time": 0.9740000000000001,
+ },
+ ],
+ },
+ ],
+ "name": "DeprecationTest",
+ "totalTime": 0.9740000000000001,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testGood",
+ "result": "success",
+ "time": 2.6470000000000002,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testDoWhile",
+ "result": "success",
+ "time": 1.0219999999999998,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testAnonymousFunction",
+ "result": "success",
+ "time": 0.8,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testException",
+ "result": "success",
+ "time": 0.888,
+ },
+ ],
+ },
+ ],
+ "name": "GoodTest",
+ "totalTime": 5.357,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testTabIndentation",
+ "result": "success",
+ "time": 0.857,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testSpaceIndentation",
+ "result": "success",
+ "time": 0.929,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testSpaceIndentationArray",
+ "result": "success",
+ "time": 0.975,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testGoodSpaceIndentationArray",
+ "result": "success",
+ "time": 1.212,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testGoodIndentationNewLine",
+ "result": "success",
+ "time": 0.859,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testGoodIndentationSpaces",
+ "result": "success",
+ "time": 0.78,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testBadSpaces",
+ "result": "success",
+ "time": 1.1199999999999999,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testBadSpaceAfterControl",
+ "result": "success",
+ "time": 0.9219999999999999,
+ },
+ ],
+ },
+ ],
+ "name": "IndentationTest",
+ "totalTime": 7.654,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testMetrics",
+ "result": "success",
+ "time": 4.146999999999999,
+ },
+ ],
+ },
+ ],
+ "name": "MetricsTest",
+ "totalTime": 4.146999999999999,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testNaming",
+ "result": "success",
+ "time": 1.426,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testFunctionNaming",
+ "result": "success",
+ "time": 1.271,
+ },
+ ],
+ },
+ ],
+ "name": "NamingTest",
+ "totalTime": 2.697,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testTextAfterClosingTag",
+ "result": "success",
+ "time": 0.9940000000000001,
+ },
+ ],
+ },
+ ],
+ "name": "OptimizationTest",
+ "totalTime": 0.9940000000000001,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": {
+ "details": "OtherTest::testOther
+We expect 20 warnings
+Failed asserting that 19 matches expected 20.
+
+/workspace/phpcheckstyle/test/OtherTest.php:24",
+ "line": 12,
+ "message": "PHPUnit\\Framework\\ExpectationFailedException",
+ "path": undefined,
+ },
+ "name": "testOther",
+ "result": "failed",
+ "time": 5.2509999999999994,
+ },
+ TestCaseResult {
+ "error": {
+ "details": "OtherTest::testException
+We expect 1 error
+Failed asserting that 0 matches expected 1.
+
+/workspace/phpcheckstyle/test/OtherTest.php:40",
+ "line": 31,
+ "message": "PHPUnit\\Framework\\ExpectationFailedException",
+ "path": undefined,
+ },
+ "name": "testException",
+ "result": "failed",
+ "time": 0.751,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testEmpty",
+ "result": "success",
+ "time": 0.42700000000000005,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testSwitchCaseNeedBreak",
+ "result": "success",
+ "time": 0.901,
+ },
+ ],
+ },
+ ],
+ "name": "OtherTest",
+ "totalTime": 7.329,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testTextAfterClosingTag",
+ "result": "success",
+ "time": 0.641,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testClosingTagNotNeeded",
+ "result": "success",
+ "time": 0.631,
+ },
+ ],
+ },
+ ],
+ "name": "PHPTagsTest",
+ "totalTime": 1.272,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testProhibited",
+ "result": "success",
+ "time": 0.9380000000000001,
+ },
+ ],
+ },
+ ],
+ "name": "ProhibitedTest",
+ "totalTime": 0.9380000000000001,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testStrictCompare",
+ "result": "success",
+ "time": 1.578,
+ },
+ ],
+ },
+ ],
+ "name": "StrictCompareTest",
+ "totalTime": 1.578,
+ },
+ TestSuiteResult {
+ "groups": [
+ TestGroupResult {
+ "name": "",
+ "tests": [
+ TestCaseResult {
+ "error": undefined,
+ "name": "testGoodUnused",
+ "result": "success",
+ "time": 0.94,
+ },
+ TestCaseResult {
+ "error": undefined,
+ "name": "testBadUnused",
+ "result": "success",
+ "time": 0.895,
+ },
+ ],
+ },
+ ],
+ "name": "UnusedTest",
+ "totalTime": 1.835,
+ },
+ ],
+ "totalTime": undefined,
+}
+`;
+
+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,
+}
+`;
diff --git a/__tests__/fixtures/empty/phpunit-empty.xml b/__tests__/fixtures/empty/phpunit-empty.xml
new file mode 100644
index 0000000..0d3d2e2
--- /dev/null
+++ b/__tests__/fixtures/empty/phpunit-empty.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/__tests__/fixtures/external/phpunit/junit-basic.xml b/__tests__/fixtures/external/phpunit/junit-basic.xml
new file mode 100644
index 0000000..27f5771
--- /dev/null
+++ b/__tests__/fixtures/external/phpunit/junit-basic.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/fixtures/external/phpunit/phpcheckstyle-phpunit.xml b/__tests__/fixtures/external/phpunit/phpcheckstyle-phpunit.xml
new file mode 100644
index 0000000..629fe81
--- /dev/null
+++ b/__tests__/fixtures/external/phpunit/phpcheckstyle-phpunit.xml
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+
+
+
+ File "./test/sample/bad_deprecation.php" warning, line 17 - split is deprecated since PHP 5.3. explode($pattern, $string) or preg_split('@'.$pattern.'@', $string) must be used instead.
+File "./test/sample/bad_deprecation.php" warning, line 19 - ereg is deprecated since PHP 5.3. preg_match('@'.$pattern.'@', $string) must be used instead.
+File "./test/sample/bad_deprecation.php" warning, line 21 - session_register is deprecated since PHP 5.3. $_SESSION must be used instead.
+File "./test/sample/bad_deprecation.php" warning, line 23 - mysql_db_query is deprecated since PHP 5.3. mysql_select_db and mysql_query must be used instead.
+File "./test/sample/bad_deprecation.php" warning, line 25 - $HTTP_GET_VARS is deprecated since PHP 5.3. $_GET must be used instead.
+
+
+
+
+
+
+
+
+
+
+
+ File "./test/sample/bad_indentation.php" warning, line 8 - Whitespace indentation must not be used.
+File "./test/sample/bad_indentation.php" warning, line 15 - Whitespace indentation must not be used.
+File "./test/sample/bad_indentation.php" warning, line 17 - Whitespace indentation must not be used.
+File "./test/sample/bad_indentation.php" warning, line 18 - Whitespace indentation must not be used.
+File "./test/sample/bad_indentation.php" warning, line 19 - Whitespace indentation must not be used.
+File "./test/sample/bad_indentation.php" warning, line 20 - Whitespace indentation must not be used.
+
+
+
+ File "./test/sample/bad_indentation.php" warning, line 10 - Tab indentation must not be used.
+File "./test/sample/bad_indentation.php" warning, line 10 - The indentation level must be 4 but was 1.
+File "./test/sample/bad_indentation.php" warning, line 13 - Tab indentation must not be used.
+File "./test/sample/bad_indentation.php" warning, line 13 - The indentation level must be 4 but was 1.
+File "./test/sample/bad_indentation.php" warning, line 15 - The indentation level must be 8 but was 4.
+File "./test/sample/bad_indentation.php" warning, line 16 - Tab indentation must not be used.
+File "./test/sample/bad_indentation.php" warning, line 16 - The indentation level must be 8 but was 1.
+File "./test/sample/bad_indentation.php" warning, line 17 - The indentation level must be 8 but was 3.
+File "./test/sample/bad_indentation.php" warning, line 18 - The indentation level must be 8 but was 5.
+File "./test/sample/bad_indentation.php" warning, line 19 - The indentation level must be 8 but was 6.
+File "./test/sample/bad_indentation.php" warning, line 20 - The indentation level must be 4 but was 1.
+
+
+
+ File "./test/sample/bad_indentation_array.php" warning, line 10 - Tab indentation must not be used.
+File "./test/sample/bad_indentation_array.php" warning, line 10 - The indentation level must be 4 but was 1.
+File "./test/sample/bad_indentation_array.php" warning, line 13 - Tab indentation must not be used.
+File "./test/sample/bad_indentation_array.php" warning, line 13 - The indentation level must be 4 but was 1.
+File "./test/sample/bad_indentation_array.php" warning, line 16 - The indentation level must be 12 but was 8.
+File "./test/sample/bad_indentation_array.php" warning, line 24 - The indentation level must be 12 but was 8.
+File "./test/sample/bad_indentation_array.php" warning, line 29 - The indentation level must be 8 but was 12.
+File "./test/sample/bad_indentation_array.php" warning, line 15 - Undeclared or unused variable: $aVar.
+File "./test/sample/bad_indentation_array.php" warning, line 19 - Undeclared or unused variable: $bVar.
+File "./test/sample/bad_indentation_array.php" warning, line 23 - Undeclared or unused variable: $cVar.
+File "./test/sample/bad_indentation_array.php" warning, line 27 - Undeclared or unused variable: $dVar.
+
+
+
+
+
+
+ File "./test/sample/bad_spaces.php" warning, line 17 - Whitespace must follow ,.
+File "./test/sample/bad_spaces.php" warning, line 17 - Whitespace must precede {.
+File "./test/sample/bad_spaces.php" warning, line 19 - Whitespace must follow if.
+File "./test/sample/bad_spaces.php" warning, line 23 - Whitespace must precede =.
+File "./test/sample/bad_spaces.php" warning, line 23 - Whitespace must follow =.
+File "./test/sample/bad_spaces.php" warning, line 23 - Whitespace must precede +.
+File "./test/sample/bad_spaces.php" warning, line 23 - Whitespace must follow +.
+File "./test/sample/bad_spaces.php" info, line 25 - Whitespace must not precede ,.
+File "./test/sample/bad_spaces.php" info, line 26 - Whitespace must not follow !.
+
+
+
+ File "./test/sample/bad_space_after_control.php" warning, line 19 - Whitespace must not follow if.
+
+
+
+
+
+ File "./test/sample/bad_metrics.php" warning, line 21 - The function testMetrics's number of parameters (6) must not exceed 4.
+File "./test/sample/bad_metrics.php" info, line 55 - Line is too long. [233/160]
+File "./test/sample/bad_metrics.php" warning, line 21 - The Cyclomatic Complexity of function testMetrics is too high. [15/10]
+File "./test/sample/bad_metrics.php" warning, line 244 - The testMetrics function body length is too long. [223/200]
+
+
+
+
+
+ File "./test/sample/_bad_naming.php" error, line 11 - Constant _badly_named_constant name should follow the pattern /^[A-Z][A-Z0-9_]*$/.
+File "./test/sample/_bad_naming.php" error, line 13 - Constant bad_CONST name should follow the pattern /^[A-Z][A-Z0-9_]*$/.
+File "./test/sample/_bad_naming.php" warning, line 17 - Top level variable $XXX name should follow the pattern /^[a-z_][a-zA-Z0-9]*$/.
+File "./test/sample/_bad_naming.php" warning, line 20 - Variable x name length is too short.
+File "./test/sample/_bad_naming.php" error, line 28 - Class badlynamedclass name should follow the pattern /^[A-Z][a-zA-Z0-9_]*$/.
+File "./test/sample/_bad_naming.php" warning, line 32 - Member variable $YYY name should follow the pattern /^[a-z_][a-zA-Z0-9]*$/.
+File "./test/sample/_bad_naming.php" warning, line 37 - The constructor name must be __construct().
+File "./test/sample/_bad_naming.php" error, line 44 - Function Badlynamedfunction name should follow the pattern /^[a-z][a-zA-Z0-9]*$/.
+File "./test/sample/_bad_naming.php" warning, line 47 - Local variable $ZZZ name should follow the pattern /^[a-z_][a-zA-Z0-9]*$/.
+File "./test/sample/_bad_naming.php" error, line 54 - Protected function Badlynamedfunction2 name should follow the pattern /^[a-z][a-zA-Z0-9]*$/.
+File "./test/sample/_bad_naming.php" error, line 61 - Private function badlynamedfunction3 name should follow the pattern /^_[a-z][a-zA-Z0-9]*$/.
+File "./test/sample/_bad_naming.php" error, line 70 - Interface _badlynamedinterface name should follow the pattern /^[A-Z][a-zA-Z0-9_]*$/.
+File "./test/sample/_bad_naming.php" error, line 75 - File _bad_naming.php name should follow the pattern /^[a-zA-Z][a-zA-Z0-9._]*$/.
+
+
+
+
+
+
+ File "./test/sample/bad_optimisation.php" warning, line 18 - count function must not be used inside a loop.
+File "./test/sample/bad_optimisation.php" warning, line 23 - count function must not be used inside a loop.
+
+
+
+
+
+ OtherTest::testOther
+We expect 20 warnings
+Failed asserting that 19 matches expected 20.
+
+/workspace/phpcheckstyle/test/OtherTest.php:24
+ File "./test/sample/bad_other.php" warning, line 17 - All arguments with default values must be at the end of the block or statement.
+File "./test/sample/bad_other.php" warning, line 21 - Errors must not be silenced when calling a function.
+File "./test/sample/bad_other.php" warning, line 23 - Prefer single-quoted strings when you don't need string interpolation.
+File "./test/sample/bad_other.php" warning, line 23 - Encapsed variables must not be used inside a string.
+File "./test/sample/bad_other.php" warning, line 23 - Encapsed variables must not be used inside a string.
+File "./test/sample/bad_other.php" warning, line 23 - Prefer single-quoted strings when you don't need string interpolation.
+File "./test/sample/bad_other.php" warning, line 37 - TODO: Show todos
+File "./test/sample/bad_other.php" warning, line 40 - Avoid empty statements (;;).
+File "./test/sample/bad_other.php" warning, line 42 - Boolean operators (&&) must be used instead of logical operators (AND).
+File "./test/sample/bad_other.php" warning, line 42 - Empty if block.
+File "./test/sample/bad_other.php" warning, line 48 - Heredoc syntax must not be used.
+File "./test/sample/bad_other.php" warning, line 52 - The statement if must contain its code within a {} block.
+File "./test/sample/bad_other.php" warning, line 54 - Consider using a strict comparison operator instead of ==.
+File "./test/sample/bad_other.php" warning, line 54 - The statement while must contain its code within a {} block.
+File "./test/sample/bad_other.php" warning, line 66 - The switch statement must have a default case.
+File "./test/sample/bad_other.php" warning, line 79 - The default case of a switch statement must be located after all other cases.
+File "./test/sample/bad_other.php" warning, line 93 - Unary operators (++ or --) must not be used inside a control statement
+File "./test/sample/bad_other.php" warning, line 95 - Assigments (=) must not be used inside a control statement.
+File "./test/sample/bad_other.php" warning, line 106 - File ./test/sample/bad_other.php must not have multiple class declarations.
+
+
+
+ OtherTest::testException
+We expect 1 error
+Failed asserting that 0 matches expected 1.
+
+/workspace/phpcheckstyle/test/OtherTest.php:40
+
+
+ File "./test/sample/empty.php" warning, line 1 - The file ./test/sample/empty.php is empty.
+
+
+
+ File "./test/sample/switch_multi_case.php" warning, line 10 - The case statement must contain a break.
+
+
+
+
+
+ File "./test/sample/bad_php_tags_text_after_end.php" warning, line 9 - A PHP close tag must not be included at the end of the file.
+
+
+
+ File "./test/sample/bad_php_tags_end_not_needed.php" warning, line 1 - PHP tag should be at the beginning of the line.
+
+
+
+
+
+ File "./test/sample/bad_prohibited.php" warning, line 18 - The function exec must not be called.
+File "./test/sample/bad_prohibited.php" warning, line 20 - Token T_PRINT must not be used.
+
+
+
+
+
+ File "./test/sample/bad_strictcompare.php" warning, line 14 - Consider using a strict comparison operator instead of ==.
+File "./test/sample/bad_strictcompare.php" warning, line 19 - Consider using a strict comparison operator instead of !=.
+File "./test/sample/bad_strictcompare.php" warning, line 24 - Consider using a strict comparison operator instead of ==.
+File "./test/sample/bad_strictcompare.php" warning, line 29 - Consider using a strict comparison operator instead of ==.
+
+
+
+
+
+
+ File "./test/sample/bad_unused.php" warning, line 23 - Function _testUnused has unused code after RETURN.
+File "./test/sample/bad_unused.php" warning, line 27 - The function _testUnused parameter $b is not used.
+File "./test/sample/bad_unused.php" warning, line 18 - Unused private function: _testUnused.
+File "./test/sample/bad_unused.php" warning, line 20 - Undeclared or unused variable: $c.
+
+
+
+
+
+
diff --git a/__tests__/fixtures/phpunit/phpunit-paths.xml b/__tests__/fixtures/phpunit/phpunit-paths.xml
new file mode 100644
index 0000000..46b0a16
--- /dev/null
+++ b/__tests__/fixtures/phpunit/phpunit-paths.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ /home/runner/work/repo/src/Fake.php:42
+
+
+ /home/runner/work/repo/src/Other.php:10
+
+
+ at /home/runner/work/repo/src/Paren.php(123)
+
+
+ C:\repo\src\Win.php:77
+
+
+ at C:\repo\src\WinParen.php(88)
+
+
+ /home/runner/work/repo/tests/Sample.phpt:12
+
+
+
diff --git a/__tests__/fixtures/phpunit/phpunit.xml b/__tests__/fixtures/phpunit/phpunit.xml
new file mode 100644
index 0000000..b0e0cc0
--- /dev/null
+++ b/__tests__/fixtures/phpunit/phpunit.xml
@@ -0,0 +1,79 @@
+
+
+
+
+ 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
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/phpunit-junit.test.ts b/__tests__/phpunit-junit.test.ts
new file mode 100644
index 0000000..8075208
--- /dev/null
+++ b/__tests__/phpunit-junit.test.ts
@@ -0,0 +1,347 @@
+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')
+ }
+ })
+
+ it('maps absolute paths to tracked files for annotations', async () => {
+ const fixturePath = path.join(__dirname, 'fixtures', 'phpunit', 'phpunit-paths.xml')
+ const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
+ const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
+
+ const opts: ParseOptions = {
+ parseErrors: true,
+ trackedFiles: [
+ 'src/Fake.php',
+ 'src/Other.php',
+ 'src/Paren.php',
+ 'src/Win.php',
+ 'src/WinParen.php',
+ 'tests/Sample.phpt'
+ ]
+ }
+
+ const parser = new PhpunitJunitParser(opts)
+ const result = await parser.parse(filePath, fileContent)
+
+ const suite = result.suites.find(s => s.name === 'SampleSuite')
+ expect(suite).toBeDefined()
+
+ const tests = suite!.groups.flatMap(g => g.tests)
+ const fileFailure = tests.find(t => t.name === 'testFailure')
+ expect(fileFailure).toBeDefined()
+ expect(fileFailure!.error).toBeDefined()
+ expect(fileFailure!.error!.path).toBe('src/Fake.php')
+ expect(fileFailure!.error!.line).toBe(42)
+
+ const stringFailure = tests.find(t => t.name === 'testStringFailure')
+ expect(stringFailure).toBeDefined()
+ expect(stringFailure!.error).toBeDefined()
+ expect(stringFailure!.error!.path).toBe('src/Other.php')
+ expect(stringFailure!.error!.line).toBe(10)
+
+ const parenFailure = tests.find(t => t.name === 'testParenFailure')
+ expect(parenFailure).toBeDefined()
+ expect(parenFailure!.error).toBeDefined()
+ expect(parenFailure!.error!.path).toBe('src/Paren.php')
+ expect(parenFailure!.error!.line).toBe(123)
+
+ const windowsFailure = tests.find(t => t.name === 'testWindowsFailure')
+ expect(windowsFailure).toBeDefined()
+ expect(windowsFailure!.error).toBeDefined()
+ expect(windowsFailure!.error!.path).toBe('src/Win.php')
+ expect(windowsFailure!.error!.line).toBe(77)
+
+ const windowsParenFailure = tests.find(t => t.name === 'testWindowsParenFailure')
+ expect(windowsParenFailure).toBeDefined()
+ expect(windowsParenFailure!.error).toBeDefined()
+ expect(windowsParenFailure!.error!.path).toBe('src/WinParen.php')
+ expect(windowsParenFailure!.error!.line).toBe(88)
+
+ const phptFailure = tests.find(t => t.name === 'testPhptFailure')
+ expect(phptFailure).toBeDefined()
+ expect(phptFailure!.error).toBeDefined()
+ expect(phptFailure!.error!.path).toBe('tests/Sample.phpt')
+ expect(phptFailure!.error!.line).toBe(12)
+ })
+
+ it('parses junit-basic.xml with nested suites and failure', async () => {
+ const fixturePath = path.join(__dirname, 'fixtures', 'external', 'phpunit', 'junit-basic.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)
+
+ // Verify test counts
+ expect(result.tests).toBe(9)
+ expect(result.passed).toBe(8)
+ expect(result.failed).toBe(1)
+ expect(result.result).toBe('failed')
+
+ // Verify suites - should have Tests.Registration, Tests.Authentication.Login, and Tests.Authentication
+ expect(result.suites.length).toBe(3)
+
+ const suiteNames = result.suites.map(s => s.name)
+ expect(suiteNames).toContain('Tests.Registration')
+ expect(suiteNames).toContain('Tests.Authentication.Login')
+ expect(suiteNames).toContain('Tests.Authentication')
+
+ // Verify the Registration suite has 3 tests
+ const registrationSuite = result.suites.find(s => s.name === 'Tests.Registration')
+ expect(registrationSuite).toBeDefined()
+ const registrationTests = registrationSuite!.groups.flatMap(g => g.tests)
+ expect(registrationTests.length).toBe(3)
+
+ // Verify the Authentication suite has 3 direct tests (not counting nested suite)
+ const authSuite = result.suites.find(s => s.name === 'Tests.Authentication')
+ expect(authSuite).toBeDefined()
+ const authTests = authSuite!.groups.flatMap(g => g.tests)
+ expect(authTests.length).toBe(3)
+
+ // Verify the Login nested suite has 3 tests
+ const loginSuite = result.suites.find(s => s.name === 'Tests.Authentication.Login')
+ expect(loginSuite).toBeDefined()
+ const loginTests = loginSuite!.groups.flatMap(g => g.tests)
+ expect(loginTests.length).toBe(3)
+
+ // Verify failure is captured
+ const failedTest = authTests.find(t => t.name === 'testCase9')
+ expect(failedTest).toBeDefined()
+ expect(failedTest!.result).toBe('failed')
+ expect(failedTest!.error).toBeDefined()
+ expect(failedTest!.error!.message).toBe('AssertionError: Assertion error message')
+ })
+
+ it('parses phpcheckstyle-phpunit.xml with deeply nested suites', async () => {
+ const fixturePath = path.join(__dirname, 'fixtures', 'external', 'phpunit', 'phpcheckstyle-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)
+
+ // Verify test counts from the XML: tests="30", failures="2"
+ expect(result.tests).toBe(30)
+ expect(result.passed).toBe(28)
+ expect(result.failed).toBe(2)
+ expect(result.result).toBe('failed')
+
+ // Verify the number of test suites extracted (leaf suites with testcases)
+ // CommentsTest, DeprecationTest, GoodTest, IndentationTest, MetricsTest,
+ // NamingTest, OptimizationTest, OtherTest, PHPTagsTest, ProhibitedTest,
+ // StrictCompareTest, UnusedTest = 12 suites
+ expect(result.suites.length).toBe(12)
+
+ const suiteNames = result.suites.map(s => s.name)
+ expect(suiteNames).toContain('CommentsTest')
+ expect(suiteNames).toContain('GoodTest')
+ expect(suiteNames).toContain('IndentationTest')
+ expect(suiteNames).toContain('OtherTest')
+ })
+
+ it('extracts test data from phpcheckstyle-phpunit.xml', async () => {
+ const fixturePath = path.join(__dirname, 'fixtures', 'external', 'phpunit', 'phpcheckstyle-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 CommentsTest suite
+ const commentsSuite = result.suites.find(s => s.name === 'CommentsTest')
+ expect(commentsSuite).toBeDefined()
+
+ // Verify tests are extracted correctly
+ const tests = commentsSuite!.groups.flatMap(g => g.tests)
+ expect(tests.length).toBe(3)
+
+ const testGoodDoc = tests.find(t => t.name === 'testGoodDoc')
+ expect(testGoodDoc).toBeDefined()
+ expect(testGoodDoc!.result).toBe('success')
+ })
+
+ it('captures failure details from phpcheckstyle-phpunit.xml', async () => {
+ const fixturePath = path.join(__dirname, 'fixtures', 'external', 'phpunit', 'phpcheckstyle-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 OtherTest suite which has failures
+ const otherSuite = result.suites.find(s => s.name === 'OtherTest')
+ expect(otherSuite).toBeDefined()
+
+ const failedTests = otherSuite!.groups.flatMap(g => g.tests).filter(t => t.result === 'failed')
+ expect(failedTests.length).toBe(2)
+
+ // Verify failure details
+ const testOther = failedTests.find(t => t.name === 'testOther')
+ expect(testOther).toBeDefined()
+ expect(testOther!.error).toBeDefined()
+ expect(testOther!.error!.details).toContain('We expect 20 warnings')
+ expect(testOther!.error!.details).toContain('Failed asserting that 19 matches expected 20')
+
+ const testException = failedTests.find(t => t.name === 'testException')
+ expect(testException).toBeDefined()
+ expect(testException!.error).toBeDefined()
+ expect(testException!.error!.details).toContain('We expect 1 error')
+ })
+
+ it('report from junit-basic.xml matches snapshot', async () => {
+ const fixturePath = path.join(__dirname, 'fixtures', 'external', 'phpunit', 'junit-basic.xml')
+ const outputPath = path.join(__dirname, '__outputs__', 'phpunit-junit-basic-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('report from phpcheckstyle-phpunit.xml matches snapshot', async () => {
+ const fixturePath = path.join(__dirname, 'fixtures', 'external', 'phpunit', 'phpcheckstyle-phpunit.xml')
+ const outputPath = path.join(__dirname, '__outputs__', 'phpunit-phpcheckstyle-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)
+ })
+})
diff --git a/action.yml b/action.yml
index 530435c..d76fbbd 100644
--- a/action.yml
+++ b/action.yml
@@ -32,6 +32,7 @@ inputs:
- java-junit
- jest-junit
- mocha-json
+ - phpunit-junit
- python-xunit
- rspec-json
- swift-xunit
diff --git a/dist/index.js b/dist/index.js
index 42cb52e..a55a732 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -277,6 +277,7 @@ const golang_json_parser_1 = __nccwpck_require__(5162);
const java_junit_parser_1 = __nccwpck_require__(8342);
const jest_junit_parser_1 = __nccwpck_require__(1042);
const mocha_json_parser_1 = __nccwpck_require__(5402);
+const phpunit_junit_parser_1 = __nccwpck_require__(2674);
const python_xunit_parser_1 = __nccwpck_require__(6578);
const rspec_json_parser_1 = __nccwpck_require__(9768);
const swift_xunit_parser_1 = __nccwpck_require__(7330);
@@ -494,6 +495,8 @@ class TestReporter {
return new jest_junit_parser_1.JestJunitParser(options);
case 'mocha-json':
return new mocha_json_parser_1.MochaJsonParser(options);
+ case 'phpunit-junit':
+ return new phpunit_junit_parser_1.PhpunitJunitParser(options);
case 'python-xunit':
return new python_xunit_parser_1.PythonXunitParser(options);
case 'rspec-json':
@@ -1666,6 +1669,241 @@ class MochaJsonParser {
exports.MochaJsonParser = MochaJsonParser;
+/***/ }),
+
+/***/ 2674:
+/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
+
+"use strict";
+
+Object.defineProperty(exports, "__esModule", ({ value: true }));
+exports.PhpunitJunitParser = void 0;
+const xml2js_1 = __nccwpck_require__(758);
+const path_utils_1 = __nccwpck_require__(9132);
+const test_results_1 = __nccwpck_require__(613);
+class PhpunitJunitParser {
+ options;
+ trackedFiles;
+ trackedFilesList;
+ assumedWorkDir;
+ constructor(options) {
+ this.options = options;
+ this.trackedFilesList = options.trackedFiles.map(f => (0, path_utils_1.normalizeFilePath)(f));
+ this.trackedFiles = new Set(this.trackedFilesList);
+ }
+ async parse(filePath, content) {
+ const reportOrSuite = await this.getPhpunitReport(filePath, content);
+ const isReport = reportOrSuite.testsuites !== undefined;
+ // XML might contain:
+ // - multiple suites under root node
+ // - single as root node
+ let report;
+ if (isReport) {
+ report = reportOrSuite;
+ }
+ else {
+ // Make it behave the same way as if suite was inside root node
+ const suite = reportOrSuite.testsuite;
+ report = {
+ testsuites: {
+ $: { time: suite.$.time },
+ testsuite: [suite]
+ }
+ };
+ }
+ return this.getTestRunResult(filePath, report);
+ }
+ async getPhpunitReport(filePath, content) {
+ try {
+ return await (0, xml2js_1.parseStringPromise)(content);
+ }
+ catch (e) {
+ throw new Error(`Invalid XML at ${filePath}\n\n${e}`);
+ }
+ }
+ getTestRunResult(filePath, report) {
+ const suites = [];
+ this.collectSuites(suites, report.testsuites.testsuite ?? []);
+ const seconds = parseFloat(report.testsuites.$?.time ?? '');
+ const time = isNaN(seconds) ? undefined : seconds * 1000;
+ return new test_results_1.TestRunResult(filePath, suites, time);
+ }
+ collectSuites(results, testsuites) {
+ 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 test_results_1.TestSuiteResult(name, this.getGroups(ts), time));
+ }
+ }
+ }
+ getGroups(suite) {
+ if (!suite.testcase || suite.testcase.length === 0) {
+ return [];
+ }
+ const groups = [];
+ 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 test_results_1.TestCaseResult(name, result, time, error);
+ });
+ return new test_results_1.TestGroupResult(grp.name, tests);
+ });
+ }
+ getTestCaseResult(test) {
+ if (test.failure || test.error)
+ return 'failed';
+ if (test.skipped)
+ return 'skipped';
+ return 'success';
+ }
+ getTestCaseError(tc) {
+ if (!this.options.parseErrors) {
+ return undefined;
+ }
+ // We process and the same way
+ const failures = tc.failure ?? tc.error;
+ if (!failures || failures.length === 0) {
+ return undefined;
+ }
+ const failure = failures[0];
+ const details = typeof failure === 'string' ? failure : failure._ ?? '';
+ // PHPUnit provides file path directly in testcase attributes
+ let filePath;
+ let line;
+ if (tc.$.file) {
+ const relativePath = this.getRelativePath(tc.$.file);
+ if (this.trackedFiles.has(relativePath)) {
+ filePath = relativePath;
+ }
+ 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;
+ if (typeof failure !== 'string' && failure.$) {
+ message = failure.$.message;
+ if (failure.$.type) {
+ message = message ? `${failure.$.type}: ${message}` : failure.$.type;
+ }
+ }
+ return {
+ path: filePath,
+ line,
+ details,
+ message
+ };
+ }
+ extractFileAndLine(details) {
+ // 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 matchColon = str.match(/((?:[A-Za-z]:)?[^\s:()]+?\.(?:php|phpt)):(\d+)/);
+ if (matchColon) {
+ const relativePath = this.getRelativePath(matchColon[1]);
+ if (this.trackedFiles.has(relativePath)) {
+ return { filePath: relativePath, line: parseInt(matchColon[2]) };
+ }
+ }
+ const matchParen = str.match(/((?:[A-Za-z]:)?[^\s:()]+?\.(?:php|phpt))\((\d+)\)/);
+ if (matchParen) {
+ const relativePath = this.getRelativePath(matchParen[1]);
+ if (this.trackedFiles.has(relativePath)) {
+ return { filePath: relativePath, line: parseInt(matchParen[2]) };
+ }
+ }
+ }
+ return undefined;
+ }
+ /**
+ * Converts an absolute file path to a relative path by stripping the working directory prefix.
+ *
+ * @param path - The absolute file path from PHPUnit output (e.g., `/home/runner/work/repo/src/Test.php`)
+ * @returns The relative path (e.g., `src/Test.php`) if a working directory can be determined,
+ * otherwise returns the normalized original path
+ */
+ getRelativePath(path) {
+ path = (0, path_utils_1.normalizeFilePath)(path);
+ const workDir = this.getWorkDir(path);
+ if (workDir !== undefined && path.startsWith(workDir)) {
+ path = path.substr(workDir.length);
+ }
+ return path;
+ }
+ /**
+ * Determines the working directory prefix to strip from absolute file paths.
+ *
+ * The working directory is resolved using the following priority:
+ *
+ * 1. **Explicit configuration** - If `options.workDir` is set, it takes precedence.
+ * This allows users to explicitly specify the working directory.
+ *
+ * 2. **Cached assumption** - If we've previously determined a working directory
+ * (`assumedWorkDir`) and the current path starts with it, we reuse that value.
+ * This avoids redundant computation for subsequent paths.
+ *
+ * 3. **Heuristic detection** - Uses `getBasePath()` to find the common prefix between
+ * the absolute path and the list of tracked files in the repository. For example:
+ * - Absolute path: `/home/runner/work/repo/src/Test.php`
+ * - Tracked file: `src/Test.php`
+ * - Detected workDir: `/home/runner/work/repo/`
+ *
+ * Once detected, the working directory is cached in `assumedWorkDir` for efficiency.
+ *
+ * @param path - The normalized absolute file path to analyze
+ * @returns The working directory prefix (with trailing slash), or `undefined` if it cannot be determined
+ *
+ * @example
+ * // With tracked file 'src/Foo.php' and path '/home/runner/work/repo/src/Foo.php'
+ * // Returns: '/home/runner/work/repo/'
+ */
+ getWorkDir(path) {
+ if (this.options.workDir) {
+ return this.options.workDir;
+ }
+ if (this.assumedWorkDir && path.startsWith(this.assumedWorkDir)) {
+ return this.assumedWorkDir;
+ }
+ const basePath = (0, path_utils_1.getBasePath)(path, this.trackedFilesList);
+ if (basePath !== undefined) {
+ this.assumedWorkDir = basePath;
+ }
+ return basePath;
+ }
+}
+exports.PhpunitJunitParser = PhpunitJunitParser;
+
+
/***/ }),
/***/ 6578:
diff --git a/src/main.ts b/src/main.ts
index e76992a..cf95cbe 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -17,6 +17,7 @@ import {GolangJsonParser} from './parsers/golang-json/golang-json-parser'
import {JavaJunitParser} from './parsers/java-junit/java-junit-parser'
import {JestJunitParser} from './parsers/jest-junit/jest-junit-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 {RspecJsonParser} from './parsers/rspec-json/rspec-json-parser'
import {SwiftXunitParser} from './parsers/swift-xunit/swift-xunit-parser'
@@ -271,6 +272,8 @@ class TestReporter {
return new JestJunitParser(options)
case 'mocha-json':
return new MochaJsonParser(options)
+ case 'phpunit-junit':
+ return new PhpunitJunitParser(options)
case 'python-xunit':
return new PythonXunitParser(options)
case 'rspec-json':
diff --git a/src/parsers/phpunit-junit/phpunit-junit-parser.ts b/src/parsers/phpunit-junit/phpunit-junit-parser.ts
new file mode 100644
index 0000000..844d9c6
--- /dev/null
+++ b/src/parsers/phpunit-junit/phpunit-junit-parser.ts
@@ -0,0 +1,258 @@
+import {ParseOptions, TestParser} from '../../test-parser'
+import {parseStringPromise} from 'xml2js'
+
+import {PhpunitReport, SingleSuiteReport, TestCase, TestSuite} from './phpunit-junit-types'
+import {getBasePath, normalizeFilePath} from '../../utils/path-utils'
+
+import {
+ TestExecutionResult,
+ TestRunResult,
+ TestSuiteResult,
+ TestGroupResult,
+ TestCaseResult,
+ TestCaseError
+} from '../../test-results'
+
+export class PhpunitJunitParser implements TestParser {
+ readonly trackedFiles: Set
+ readonly trackedFilesList: string[]
+ private assumedWorkDir: string | undefined
+
+ constructor(readonly options: ParseOptions) {
+ this.trackedFilesList = options.trackedFiles.map(f => normalizeFilePath(f))
+ this.trackedFiles = new Set(this.trackedFilesList)
+ }
+
+ async parse(filePath: string, content: string): Promise {
+ const reportOrSuite = await this.getPhpunitReport(filePath, content)
+ const isReport = (reportOrSuite as PhpunitReport).testsuites !== undefined
+
+ // XML might contain:
+ // - multiple suites under root node
+ // - single as root node
+ let report: PhpunitReport
+ if (isReport) {
+ report = reportOrSuite as PhpunitReport
+ } else {
+ // Make it behave the same way as if suite was inside 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 {
+ 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 and the same way
+ const failures = tc.failure ?? tc.error
+ if (!failures || failures.length === 0) {
+ return undefined
+ }
+
+ const failure = failures[0]
+ const details = typeof failure === 'string' ? failure : failure._ ?? ''
+
+ // PHPUnit provides file path directly in testcase attributes
+ let filePath: string | undefined
+ let line: number | undefined
+
+ if (tc.$.file) {
+ const relativePath = this.getRelativePath(tc.$.file)
+ if (this.trackedFiles.has(relativePath)) {
+ filePath = relativePath
+ }
+ 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 (typeof failure !== 'string' && 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 matchColon = str.match(/((?:[A-Za-z]:)?[^\s:()]+?\.(?:php|phpt)):(\d+)/)
+ if (matchColon) {
+ const relativePath = this.getRelativePath(matchColon[1])
+ if (this.trackedFiles.has(relativePath)) {
+ return {filePath: relativePath, line: parseInt(matchColon[2])}
+ }
+ }
+
+ const matchParen = str.match(/((?:[A-Za-z]:)?[^\s:()]+?\.(?:php|phpt))\((\d+)\)/)
+ if (matchParen) {
+ const relativePath = this.getRelativePath(matchParen[1])
+ if (this.trackedFiles.has(relativePath)) {
+ return {filePath: relativePath, line: parseInt(matchParen[2])}
+ }
+ }
+ }
+
+ return undefined
+ }
+
+ /**
+ * Converts an absolute file path to a relative path by stripping the working directory prefix.
+ *
+ * @param path - The absolute file path from PHPUnit output (e.g., `/home/runner/work/repo/src/Test.php`)
+ * @returns The relative path (e.g., `src/Test.php`) if a working directory can be determined,
+ * otherwise returns the normalized original path
+ */
+ private getRelativePath(path: string): string {
+ path = normalizeFilePath(path)
+ const workDir = this.getWorkDir(path)
+ if (workDir !== undefined && path.startsWith(workDir)) {
+ path = path.substr(workDir.length)
+ }
+ return path
+ }
+
+ /**
+ * Determines the working directory prefix to strip from absolute file paths.
+ *
+ * The working directory is resolved using the following priority:
+ *
+ * 1. **Explicit configuration** - If `options.workDir` is set, it takes precedence.
+ * This allows users to explicitly specify the working directory.
+ *
+ * 2. **Cached assumption** - If we've previously determined a working directory
+ * (`assumedWorkDir`) and the current path starts with it, we reuse that value.
+ * This avoids redundant computation for subsequent paths.
+ *
+ * 3. **Heuristic detection** - Uses `getBasePath()` to find the common prefix between
+ * the absolute path and the list of tracked files in the repository. For example:
+ * - Absolute path: `/home/runner/work/repo/src/Test.php`
+ * - Tracked file: `src/Test.php`
+ * - Detected workDir: `/home/runner/work/repo/`
+ *
+ * Once detected, the working directory is cached in `assumedWorkDir` for efficiency.
+ *
+ * @param path - The normalized absolute file path to analyze
+ * @returns The working directory prefix (with trailing slash), or `undefined` if it cannot be determined
+ *
+ * @example
+ * // With tracked file 'src/Foo.php' and path '/home/runner/work/repo/src/Foo.php'
+ * // Returns: '/home/runner/work/repo/'
+ */
+ private getWorkDir(path: string): string | undefined {
+ if (this.options.workDir) {
+ return this.options.workDir
+ }
+
+ if (this.assumedWorkDir && path.startsWith(this.assumedWorkDir)) {
+ return this.assumedWorkDir
+ }
+
+ const basePath = getBasePath(path, this.trackedFilesList)
+ if (basePath !== undefined) {
+ this.assumedWorkDir = basePath
+ }
+ return basePath
+ }
+}
diff --git a/src/parsers/phpunit-junit/phpunit-junit-types.ts b/src/parsers/phpunit-junit/phpunit-junit-types.ts
new file mode 100644
index 0000000..8e65b0e
--- /dev/null
+++ b/src/parsers/phpunit-junit/phpunit-junit-types.ts
@@ -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
+ }
+}