From d1de4d5f062a6f258112982d3316129f1e457f0a Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Mon, 29 Dec 2025 12:56:17 +0100 Subject: [PATCH 1/5] 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 + } +} From 837045e72bcfc2c6a9bea565ba1f87b25c3f0360 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Sat, 27 Dec 2025 00:08:06 +0100 Subject: [PATCH 2/5] Add sample files from PHPUnit results in JUnit XML format Co-Authored-By: Claude Code --- .../phpunit-junit-basic-results.md | 30 ++ .../phpunit-phpcheckstyle-results.md | 88 ++++ __tests__/__outputs__/phpunit-test-results.md | 21 +- .../__snapshots__/phpunit-junit.test.ts.snap | 440 ++++++++++++++++++ .../fixtures/external/phpunit/junit-basic.xml | 28 ++ .../phpunit/phpcheckstyle-phpunit.xml | 212 +++++++++ __tests__/phpunit-junit.test.ts | 184 ++++++++ 7 files changed, 994 insertions(+), 9 deletions(-) create mode 100644 __tests__/__outputs__/phpunit-junit-basic-results.md create mode 100644 __tests__/__outputs__/phpunit-phpcheckstyle-results.md create mode 100644 __tests__/fixtures/external/phpunit/junit-basic.xml create mode 100644 __tests__/fixtures/external/phpunit/phpcheckstyle-phpunit.xml 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 @@ +![Tests failed](https://img.shields.io/badge/tests-8%20passed%2C%201%20failed-critical) +|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 @@ +![Tests failed](https://img.shields.io/badge/tests-28%20passed%2C%202%20failed-critical) +|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 index 45d2526..67f8258 100644 --- a/__tests__/__outputs__/phpunit-test-results.md +++ b/__tests__/__outputs__/phpunit-test-results.md @@ -1,26 +1,29 @@ ![Tests failed](https://img.shields.io/badge/tests-10%20passed%2C%202%20failed-critical) -## ❌ fixtures/phpunit/phpunit.xml +|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](#r0s0)||2❌||140ms| -|[PHPUnit\Event\CollectingDispatcherTest](#r0s1)|2✅|||4ms| -|[PHPUnit\Event\DeferringDispatcherTest](#r0s2)|4✅|||3ms| -|[PHPUnit\Event\DirectDispatcherTest](#r0s3)|4✅|||1ms| -### ❌ CLI Arguments +|[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 ``` PHPUnit.Event.CollectingDispatcherTest ✅ testHasNoCollectedEventsWhenFlushedImmediatelyAfterCreation ✅ testCollectsDispatchedEventsUntilFlushed ``` -### ✅ PHPUnit\Event\DeferringDispatcherTest +### ✅ PHPUnit\Event\DeferringDispatcherTest ``` PHPUnit.Event.DeferringDispatcherTest ✅ testCollectsEventsUntilFlush @@ -28,7 +31,7 @@ PHPUnit.Event.DeferringDispatcherTest ✅ testSubscriberCanBeRegistered ✅ testTracerCanBeRegistered ``` -### ✅ PHPUnit\Event\DirectDispatcherTest +### ✅ PHPUnit\Event\DirectDispatcherTest ``` PHPUnit.Event.DirectDispatcherTest ✅ testDispatchesEventToKnownSubscribers diff --git a/__tests__/__snapshots__/phpunit-junit.test.ts.snap b/__tests__/__snapshots__/phpunit-junit.test.ts.snap index 518eaa5..d48d941 100644 --- a/__tests__/__snapshots__/phpunit-junit.test.ts.snap +++ b/__tests__/__snapshots__/phpunit-junit.test.ts.snap @@ -1,5 +1,445 @@ // 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", 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_comments.php" warning, line 4 - Avoid Shell/Perl like comments. +File "./test/sample/bad_comments.php" warning, line 6 - The class Comments must have a docblock comment. +File "./test/sample/bad_comments.php" warning, line 10 - The function testComment must have a docblock comment. +File "./test/sample/bad_comments.php" warning, line 18 - The function testComment returns a value and must include @returns in its docblock. +File "./test/sample/bad_comments.php" warning, line 18 - The function testComment parameters must match those in its docblock @param. +File "./test/sample/bad_comments.php" warning, line 18 - The function testComment throws an exception and must include @throws in its docblock. + + + + File "./test/sample/todo.php" warning, line 3 - TODO: The todo message. + + + + + + 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__/phpunit-junit.test.ts b/__tests__/phpunit-junit.test.ts index 4150d69..03a4306 100644 --- a/__tests__/phpunit-junit.test.ts +++ b/__tests__/phpunit-junit.test.ts @@ -99,4 +99,188 @@ describe('phpunit-junit tests', () => { expect(test.error!.details).toContain('Failed asserting that string matches format description') } }) + + 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) + }) }) From a97700c53ceb15ac6d0eb859e6972a5d071e5f84 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Mon, 29 Dec 2025 13:53:47 +0100 Subject: [PATCH 3/5] Include tests for parsing files names and line numbers in the `PhpunitJunitParser` Co-Authored-By: Codex --- __tests__/fixtures/phpunit/phpunit-paths.xml | 23 +++++++ __tests__/phpunit-junit.test.ts | 61 ++++++++++++++++++ .../phpunit-junit/phpunit-junit-parser.ts | 62 ++++++++++++++----- 3 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 __tests__/fixtures/phpunit/phpunit-paths.xml 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__/phpunit-junit.test.ts b/__tests__/phpunit-junit.test.ts index 03a4306..8075208 100644 --- a/__tests__/phpunit-junit.test.ts +++ b/__tests__/phpunit-junit.test.ts @@ -100,6 +100,67 @@ describe('phpunit-junit tests', () => { } }) + 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)) diff --git a/src/parsers/phpunit-junit/phpunit-junit-parser.ts b/src/parsers/phpunit-junit/phpunit-junit-parser.ts index 79e6056..c24e87d 100644 --- a/src/parsers/phpunit-junit/phpunit-junit-parser.ts +++ b/src/parsers/phpunit-junit/phpunit-junit-parser.ts @@ -2,7 +2,7 @@ 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 {getBasePath, normalizeFilePath} from '../../utils/path-utils' import { TestExecutionResult, @@ -15,9 +15,12 @@ import { export class PhpunitJunitParser implements TestParser { readonly trackedFiles: Set + readonly trackedFilesList: string[] + private assumedWorkDir: string | undefined constructor(readonly options: ParseOptions) { - this.trackedFiles = new Set(options.trackedFiles.map(f => normalizeFilePath(f))) + this.trackedFilesList = options.trackedFiles.map(f => normalizeFilePath(f)) + this.trackedFiles = new Set(this.trackedFilesList) } async parse(filePath: string, content: string): Promise { @@ -127,16 +130,16 @@ export class PhpunitJunitParser implements TestParser { } const failure = failures[0] - const details = failure._ ?? '' + 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 normalizedPath = normalizeFilePath(tc.$.file) - if (this.trackedFiles.has(normalizedPath)) { - filePath = normalizedPath + const relativePath = this.getRelativePath(tc.$.file) + if (this.trackedFiles.has(relativePath)) { + filePath = relativePath } if (tc.$.line) { line = parseInt(tc.$.line) @@ -153,7 +156,7 @@ export class PhpunitJunitParser implements TestParser { } let message: string | undefined - if (failure.$) { + if (typeof failure !== 'string' && failure.$) { message = failure.$.message if (failure.$.type) { message = message ? `${failure.$.type}: ${message}` : failure.$.type @@ -174,17 +177,48 @@ export class PhpunitJunitParser implements TestParser { 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)} + 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 } + + 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 + } + + 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 + } } From 4ee97617f76d137449902c2fac6fcec6bc7e08b8 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Mon, 29 Dec 2025 14:36:20 +0100 Subject: [PATCH 4/5] Document the behavior of `getRelativePath()` and `getWorkDir()` functions Co-Authored-By: Claude Code --- .../phpunit-junit/phpunit-junit-parser.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/parsers/phpunit-junit/phpunit-junit-parser.ts b/src/parsers/phpunit-junit/phpunit-junit-parser.ts index c24e87d..844d9c6 100644 --- a/src/parsers/phpunit-junit/phpunit-junit-parser.ts +++ b/src/parsers/phpunit-junit/phpunit-junit-parser.ts @@ -197,6 +197,13 @@ export class PhpunitJunitParser implements TestParser { 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) @@ -206,6 +213,33 @@ export class PhpunitJunitParser implements TestParser { 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 From 0be3971fec56884feb915a1fb6bdb237fa3dcac8 Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Mon, 29 Dec 2025 13:06:49 +0100 Subject: [PATCH 5/5] Rebuild the `dist/index.js` file --- dist/index.js | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) 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: