From d1de4d5f062a6f258112982d3316129f1e457f0a Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Mon, 29 Dec 2025 12:56:17 +0100 Subject: [PATCH] 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 Co-Authored-By: Claude Code --- README.md | 10 + __tests__/__outputs__/phpunit-test-results.md | 38 ++++ .../__snapshots__/phpunit-junit.test.ts.snap | 188 +++++++++++++++++ __tests__/fixtures/empty/phpunit-empty.xml | 2 + __tests__/fixtures/phpunit/phpunit.xml | 79 ++++++++ __tests__/phpunit-junit.test.ts | 102 ++++++++++ action.yml | 1 + src/main.ts | 3 + .../phpunit-junit/phpunit-junit-parser.ts | 190 ++++++++++++++++++ .../phpunit-junit/phpunit-junit-types.ts | 52 +++++ 10 files changed, 665 insertions(+) create mode 100644 __tests__/__outputs__/phpunit-test-results.md create mode 100644 __tests__/__snapshots__/phpunit-junit.test.ts.snap create mode 100644 __tests__/fixtures/empty/phpunit-empty.xml create mode 100644 __tests__/fixtures/phpunit/phpunit.xml create mode 100644 __tests__/phpunit-junit.test.ts create mode 100644 src/parsers/phpunit-junit/phpunit-junit-parser.ts create mode 100644 src/parsers/phpunit-junit/phpunit-junit-types.ts 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-test-results.md b/__tests__/__outputs__/phpunit-test-results.md new file mode 100644 index 0000000..45d2526 --- /dev/null +++ b/__tests__/__outputs__/phpunit-test-results.md @@ -0,0 +1,38 @@ +![Tests failed](https://img.shields.io/badge/tests-10%20passed%2C%202%20failed-critical) +## ❌ 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](#r0s0)||2❌||140ms| +|[PHPUnit\Event\CollectingDispatcherTest](#r0s1)|2✅|||4ms| +|[PHPUnit\Event\DeferringDispatcherTest](#r0s2)|4✅|||3ms| +|[PHPUnit\Event\DirectDispatcherTest](#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..518eaa5 --- /dev/null +++ b/__tests__/__snapshots__/phpunit-junit.test.ts.snap @@ -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, +} +`; 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/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..4150d69 --- /dev/null +++ b/__tests__/phpunit-junit.test.ts @@ -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') + } + }) +}) 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/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..79e6056 --- /dev/null +++ b/src/parsers/phpunit-junit/phpunit-junit-parser.ts @@ -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 + + constructor(readonly options: ParseOptions) { + this.trackedFiles = new Set(options.trackedFiles.map(f => normalizeFilePath(f))) + } + + 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 = 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 + } +} 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 + } +}