From 63870298f549a17388dd5b30b8a634e449a9eb2d Mon Sep 17 00:00:00 2001 From: Jozef Izso Date: Thu, 1 Jan 2026 18:29:37 +0100 Subject: [PATCH] Create `tester-junit` reporter for Nette Tester tool --- README.md | 15 +- .../nette-tester-bootstrap-test-results.md | 20 + .../nette-tester-v1.7-test-results.md | 87 ++++ .../tester-bootstrap-test-results.md | 20 + .../__outputs__/tester-v1.7-test-results.md | 87 ++++ .../__snapshots__/tester-junit.test.ts.snap | 485 ++++++++++++++++++ .../BootstrapFormRenderer-report.xml | 9 + .../nette-tester/tester-v1.7-report.xml | 83 +++ __tests__/tester-junit.test.ts | 224 ++++++++ action.yml | 1 + src/main.ts | 3 + .../tester-junit/tester-junit-parser.ts | 260 ++++++++++ .../tester-junit/tester-junit-types.ts | 46 ++ 13 files changed, 1339 insertions(+), 1 deletion(-) create mode 100644 __tests__/__outputs__/nette-tester-bootstrap-test-results.md create mode 100644 __tests__/__outputs__/nette-tester-v1.7-test-results.md create mode 100644 __tests__/__outputs__/tester-bootstrap-test-results.md create mode 100644 __tests__/__outputs__/tester-v1.7-test-results.md create mode 100644 __tests__/__snapshots__/tester-junit.test.ts.snap create mode 100644 __tests__/fixtures/nette-tester/BootstrapFormRenderer-report.xml create mode 100644 __tests__/fixtures/nette-tester/tester-v1.7-report.xml create mode 100644 __tests__/tester-junit.test.ts create mode 100644 src/parsers/tester-junit/tester-junit-parser.ts create mode 100644 src/parsers/tester-junit/tester-junit-types.ts diff --git a/README.md b/README.md index 9209dc4..bdced5c 100644 --- a/README.md +++ b/README.md @@ -20,7 +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/) +- PHP / [PHPUnit](https://phpunit.de/) / [Nette Tester](https://tester.nette.org/) - Ruby / [RSpec](https://rspec.info/) - Swift / xUnit @@ -324,6 +324,19 @@ Some heuristic was necessary to figure out the mapping between the line in the s +
+ tester-junit + +[Nette Tester](https://tester.nette.org/) can generate JUnit XML via CLI: + +```bash +tester -s -o junit tests/ > reports/tester-junit.xml +``` + +**Note:** Nette Tester's JUnit output doesn't include test suite names. The parser will use the report file name as the suite name and automatically group tests by directory structure. + +
+
jest-junit diff --git a/__tests__/__outputs__/nette-tester-bootstrap-test-results.md b/__tests__/__outputs__/nette-tester-bootstrap-test-results.md new file mode 100644 index 0000000..93359ab --- /dev/null +++ b/__tests__/__outputs__/nette-tester-bootstrap-test-results.md @@ -0,0 +1,20 @@ +![Tests passed successfully](https://img.shields.io/badge/tests-4%20passed-success) +
Expand for details + +|Report|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[fixtures/nette-tester/BootstrapFormRenderer-report.xml](#user-content-r0)|4 ✅|||300ms| +## ✅ fixtures/nette-tester/BootstrapFormRenderer-report.xml +**4** tests were completed in **300ms** with **4** passed, **0** failed and **0** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[BootstrapFormRenderer-report.xml](#user-content-r0s0)|4 ✅|||300ms| +### ✅ BootstrapFormRenderer-report.xml +``` +KdybyTests/BootstrapFormRenderer + ✅ BootstrapRendererTest.phpt::testRenderingBasics + ✅ BootstrapRendererTest.phpt::testRenderingIndividual + ✅ BootstrapRendererTest.phpt::testRenderingComponents + ✅ BootstrapRendererTest.phpt::testMultipleFormsInTemplate +``` +
\ No newline at end of file diff --git a/__tests__/__outputs__/nette-tester-v1.7-test-results.md b/__tests__/__outputs__/nette-tester-v1.7-test-results.md new file mode 100644 index 0000000..253837f --- /dev/null +++ b/__tests__/__outputs__/nette-tester-v1.7-test-results.md @@ -0,0 +1,87 @@ +![Tests failed](https://img.shields.io/badge/tests-61%20passed%2C%201%20failed%2C%203%20skipped-critical) +|Report|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[fixtures/nette-tester/tester-v1.7-report.xml](#user-content-r0)|61 ✅|1 ❌|3 ⚪|2s| +## ❌ fixtures/nette-tester/tester-v1.7-report.xml +**65** tests were completed in **2s** with **61** passed, **1** failed and **3** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[tester-v1.7-report.xml](#user-content-r0s0)|61 ✅|1 ❌|3 ⚪|2s| +### ❌ tester-v1.7-report.xml +``` +tests/Framework + ⚪ Dumper.toPhp.php7.phpt + ✅ Assert.contains.phpt + ✅ Assert.count.phpt + ✅ Assert.equal.phpt + ✅ Assert.equal.recursive.phpt::testSimple + ✅ Assert.equal.recursive.phpt::testMultiple + ✅ Assert.equal.recursive.phpt::testDeep + ✅ Assert.equal.recursive.phpt::testCross + ✅ Assert.equal.recursive.phpt::testThirdParty + ✅ Assert.error.phpt + ✅ Assert.exception.phpt + ✅ Assert.false.phpt + ✅ Assert.match.phpt + ✅ Assert.match.regexp.phpt + ✅ Assert.nan.phpt + ✅ Assert.noError.phpt + ✅ Assert.same.phpt + ✅ Assert.null.phpt + ✅ Assert.true.phpt + ✅ Assert.truthy.phpt + ✅ DataProvider.load.phpt + ✅ Assert.type.phpt + ✅ DataProvider.parseAnnotation.phpt + ✅ DataProvider.testQuery.phpt + ✅ DomQuery.css2Xpath.phpt + ✅ DomQuery.fromHtml.phpt + ✅ DomQuery.fromXml.phpt + ✅ Dumper.dumpException.phpt + ✅ Dumper.color.phpt + ✅ Dumper.toLine.phpt + ✅ Dumper.toPhp.recursion.phpt + ✅ Dumper.toPhp.phpt + ✅ FileMock.phpt + ✅ Helpers.escapeArg.phpt + ✅ Helpers.parseDocComment.phpt + ✅ TestCase.annotationThrows.phpt + ✅ TestCase.annotationThrows.setUp.tearDown.phpt + ✅ TestCase.annotationThrows.syntax.phpt + ✅ TestCase.basic.phpt + ✅ TestCase.dataProvider.generator.phpt + ✅ TestCase.dataProvider.phpt + ✅ TestCase.invalidMethods.phpt + ✅ TestCase.invalidProvider.phpt + ✅ TestCase.order.error.phpt + ✅ TestCase.order.errorMuted.phpt + ✅ TestCase.order.phpt + ✅ Prevent loop in error handling. The #268 regression. (TestCase.ownErrorHandler.phpt) +tests/CodeCoverage + ⚪ Collector.start.phpt + ✅ PhpParser.parse.lines.phpt + ✅ PhpParser.parse.methods.phpt + ✅ CloverXMLGenerator.phpt + ✅ PhpParser.parse.edge.phpt + ✅ PhpParser.parse.lines-of-code.phpt + ✅ PhpParser.parse.namespaces.phpt +tests/Runner + ✅ CommandLine.phpt + ⚪ HhvmPhpInterpreter.phpt + ✅ Runner.find-tests.phpt + ✅ Job.phpt + ✅ ZendPhpExecutable.phpt + ✅ Runner.multiple.phpt + ✅ Runner.edge.phpt + ✅ Runner.stop-on-fail.phpt + ❌ Runner.multiple-fails.phpt + Failed: '... in /Users/izso/Developer/nette/tester/tests/Runner/multiple-fails/...' should match + ... '..., unexpected end of file in %a%testcase-syntax-error.phptx on line ...' + + diff '/Users/izso/Developer/nette/tester/tests/Runner/output/Runner.multiple-fails.expected' '/Users/izso/Developer/nette/tester/tests/Runner/output/Runner.multiple-fails.actual' + + in tests/Runner/Runner.multiple-fails.phpt(78) Tester\Assert::match() + ✅ Runner.annotations.phpt +tests/RunnerOutput + ✅ JUnitPrinter.phpt +``` \ No newline at end of file diff --git a/__tests__/__outputs__/tester-bootstrap-test-results.md b/__tests__/__outputs__/tester-bootstrap-test-results.md new file mode 100644 index 0000000..93359ab --- /dev/null +++ b/__tests__/__outputs__/tester-bootstrap-test-results.md @@ -0,0 +1,20 @@ +![Tests passed successfully](https://img.shields.io/badge/tests-4%20passed-success) +
Expand for details + +|Report|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[fixtures/nette-tester/BootstrapFormRenderer-report.xml](#user-content-r0)|4 ✅|||300ms| +## ✅ fixtures/nette-tester/BootstrapFormRenderer-report.xml +**4** tests were completed in **300ms** with **4** passed, **0** failed and **0** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[BootstrapFormRenderer-report.xml](#user-content-r0s0)|4 ✅|||300ms| +### ✅ BootstrapFormRenderer-report.xml +``` +KdybyTests/BootstrapFormRenderer + ✅ BootstrapRendererTest.phpt::testRenderingBasics + ✅ BootstrapRendererTest.phpt::testRenderingIndividual + ✅ BootstrapRendererTest.phpt::testRenderingComponents + ✅ BootstrapRendererTest.phpt::testMultipleFormsInTemplate +``` +
\ No newline at end of file diff --git a/__tests__/__outputs__/tester-v1.7-test-results.md b/__tests__/__outputs__/tester-v1.7-test-results.md new file mode 100644 index 0000000..253837f --- /dev/null +++ b/__tests__/__outputs__/tester-v1.7-test-results.md @@ -0,0 +1,87 @@ +![Tests failed](https://img.shields.io/badge/tests-61%20passed%2C%201%20failed%2C%203%20skipped-critical) +|Report|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[fixtures/nette-tester/tester-v1.7-report.xml](#user-content-r0)|61 ✅|1 ❌|3 ⚪|2s| +## ❌ fixtures/nette-tester/tester-v1.7-report.xml +**65** tests were completed in **2s** with **61** passed, **1** failed and **3** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[tester-v1.7-report.xml](#user-content-r0s0)|61 ✅|1 ❌|3 ⚪|2s| +### ❌ tester-v1.7-report.xml +``` +tests/Framework + ⚪ Dumper.toPhp.php7.phpt + ✅ Assert.contains.phpt + ✅ Assert.count.phpt + ✅ Assert.equal.phpt + ✅ Assert.equal.recursive.phpt::testSimple + ✅ Assert.equal.recursive.phpt::testMultiple + ✅ Assert.equal.recursive.phpt::testDeep + ✅ Assert.equal.recursive.phpt::testCross + ✅ Assert.equal.recursive.phpt::testThirdParty + ✅ Assert.error.phpt + ✅ Assert.exception.phpt + ✅ Assert.false.phpt + ✅ Assert.match.phpt + ✅ Assert.match.regexp.phpt + ✅ Assert.nan.phpt + ✅ Assert.noError.phpt + ✅ Assert.same.phpt + ✅ Assert.null.phpt + ✅ Assert.true.phpt + ✅ Assert.truthy.phpt + ✅ DataProvider.load.phpt + ✅ Assert.type.phpt + ✅ DataProvider.parseAnnotation.phpt + ✅ DataProvider.testQuery.phpt + ✅ DomQuery.css2Xpath.phpt + ✅ DomQuery.fromHtml.phpt + ✅ DomQuery.fromXml.phpt + ✅ Dumper.dumpException.phpt + ✅ Dumper.color.phpt + ✅ Dumper.toLine.phpt + ✅ Dumper.toPhp.recursion.phpt + ✅ Dumper.toPhp.phpt + ✅ FileMock.phpt + ✅ Helpers.escapeArg.phpt + ✅ Helpers.parseDocComment.phpt + ✅ TestCase.annotationThrows.phpt + ✅ TestCase.annotationThrows.setUp.tearDown.phpt + ✅ TestCase.annotationThrows.syntax.phpt + ✅ TestCase.basic.phpt + ✅ TestCase.dataProvider.generator.phpt + ✅ TestCase.dataProvider.phpt + ✅ TestCase.invalidMethods.phpt + ✅ TestCase.invalidProvider.phpt + ✅ TestCase.order.error.phpt + ✅ TestCase.order.errorMuted.phpt + ✅ TestCase.order.phpt + ✅ Prevent loop in error handling. The #268 regression. (TestCase.ownErrorHandler.phpt) +tests/CodeCoverage + ⚪ Collector.start.phpt + ✅ PhpParser.parse.lines.phpt + ✅ PhpParser.parse.methods.phpt + ✅ CloverXMLGenerator.phpt + ✅ PhpParser.parse.edge.phpt + ✅ PhpParser.parse.lines-of-code.phpt + ✅ PhpParser.parse.namespaces.phpt +tests/Runner + ✅ CommandLine.phpt + ⚪ HhvmPhpInterpreter.phpt + ✅ Runner.find-tests.phpt + ✅ Job.phpt + ✅ ZendPhpExecutable.phpt + ✅ Runner.multiple.phpt + ✅ Runner.edge.phpt + ✅ Runner.stop-on-fail.phpt + ❌ Runner.multiple-fails.phpt + Failed: '... in /Users/izso/Developer/nette/tester/tests/Runner/multiple-fails/...' should match + ... '..., unexpected end of file in %a%testcase-syntax-error.phptx on line ...' + + diff '/Users/izso/Developer/nette/tester/tests/Runner/output/Runner.multiple-fails.expected' '/Users/izso/Developer/nette/tester/tests/Runner/output/Runner.multiple-fails.actual' + + in tests/Runner/Runner.multiple-fails.phpt(78) Tester\Assert::match() + ✅ Runner.annotations.phpt +tests/RunnerOutput + ✅ JUnitPrinter.phpt +``` \ No newline at end of file diff --git a/__tests__/__snapshots__/tester-junit.test.ts.snap b/__tests__/__snapshots__/tester-junit.test.ts.snap new file mode 100644 index 0000000..9bf445a --- /dev/null +++ b/__tests__/__snapshots__/tester-junit.test.ts.snap @@ -0,0 +1,485 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`tester-junit tests parses complex test names from BootstrapFormRenderer-report.xml 1`] = ` +TestRunResult { + "path": "fixtures/nette-tester/BootstrapFormRenderer-report.xml", + "suites": [ + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "KdybyTests/BootstrapFormRenderer", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "BootstrapRendererTest.phpt::testRenderingBasics", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "BootstrapRendererTest.phpt::testRenderingIndividual", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "BootstrapRendererTest.phpt::testRenderingComponents", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "BootstrapRendererTest.phpt::testMultipleFormsInTemplate", + "result": "success", + "time": 0, + }, + ], + }, + ], + "name": "BootstrapFormRenderer-report.xml", + "totalTime": 300, + }, + ], + "totalTime": undefined, +} +`; + +exports[`tester-junit tests report from tester-v1.7-report.xml matches snapshot 1`] = ` +TestRunResult { + "path": "fixtures/nette-tester/tester-v1.7-report.xml", + "suites": [ + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "tests/Framework", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "Dumper.toPhp.php7.phpt", + "result": "skipped", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.contains.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.count.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.equal.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.equal.recursive.phpt::testSimple", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.equal.recursive.phpt::testMultiple", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.equal.recursive.phpt::testDeep", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.equal.recursive.phpt::testCross", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.equal.recursive.phpt::testThirdParty", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.error.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.exception.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.false.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.match.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.match.regexp.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.nan.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.noError.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.same.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.null.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.true.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.truthy.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "DataProvider.load.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Assert.type.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "DataProvider.parseAnnotation.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "DataProvider.testQuery.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "DomQuery.css2Xpath.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "DomQuery.fromHtml.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "DomQuery.fromXml.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Dumper.dumpException.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Dumper.color.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Dumper.toLine.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Dumper.toPhp.recursion.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Dumper.toPhp.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "FileMock.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Helpers.escapeArg.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Helpers.parseDocComment.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.annotationThrows.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.annotationThrows.setUp.tearDown.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.annotationThrows.syntax.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.basic.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.dataProvider.generator.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.dataProvider.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.invalidMethods.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.invalidProvider.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.order.error.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.order.errorMuted.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "TestCase.order.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Prevent loop in error handling. The #268 regression. (TestCase.ownErrorHandler.phpt)", + "result": "success", + "time": 0, + }, + ], + }, + TestGroupResult { + "name": "tests/CodeCoverage", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "Collector.start.phpt", + "result": "skipped", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "PhpParser.parse.lines.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "PhpParser.parse.methods.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "CloverXMLGenerator.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "PhpParser.parse.edge.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "PhpParser.parse.lines-of-code.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "PhpParser.parse.namespaces.phpt", + "result": "success", + "time": 0, + }, + ], + }, + TestGroupResult { + "name": "tests/Runner", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "CommandLine.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "HhvmPhpInterpreter.phpt", + "result": "skipped", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Runner.find-tests.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Job.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "ZendPhpExecutable.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Runner.multiple.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Runner.edge.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Runner.stop-on-fail.phpt", + "result": "success", + "time": 0, + }, + TestCaseResult { + "error": { + "details": "Failed: '... in /Users/izso/Developer/nette/tester/tests/Runner/multiple-fails/...' should match + ... '..., unexpected end of file in %a%testcase-syntax-error.phptx on line ...' + +diff '/Users/izso/Developer/nette/tester/tests/Runner/output/Runner.multiple-fails.expected' '/Users/izso/Developer/nette/tester/tests/Runner/output/Runner.multiple-fails.actual' + +in tests/Runner/Runner.multiple-fails.phpt(78) Tester\\Assert::match()", + "line": undefined, + "message": "Failed: '... in /Users/izso/Developer/nette/tester/tests/Runner/multiple-fails/...' should match + ... '..., unexpected end of file in %a%testcase-syntax-error.phptx on line ...' + +diff '/Users/izso/Developer/nette/tester/tests/Runner/output/Runner.multiple-fails.expected' '/Users/izso/Developer/nette/tester/tests/Runner/output/Runner.multiple-fails.actual' + +in tests/Runner/Runner.multiple-fails.phpt(78) Tester\\Assert::match()", + "path": undefined, + }, + "name": "Runner.multiple-fails.phpt", + "result": "failed", + "time": 0, + }, + TestCaseResult { + "error": undefined, + "name": "Runner.annotations.phpt", + "result": "success", + "time": 0, + }, + ], + }, + TestGroupResult { + "name": "tests/RunnerOutput", + "tests": [ + TestCaseResult { + "error": undefined, + "name": "JUnitPrinter.phpt", + "result": "success", + "time": 0, + }, + ], + }, + ], + "name": "tester-v1.7-report.xml", + "totalTime": 2100, + }, + ], + "totalTime": undefined, +} +`; diff --git a/__tests__/fixtures/nette-tester/BootstrapFormRenderer-report.xml b/__tests__/fixtures/nette-tester/BootstrapFormRenderer-report.xml new file mode 100644 index 0000000..cde3cd5 --- /dev/null +++ b/__tests__/fixtures/nette-tester/BootstrapFormRenderer-report.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/__tests__/fixtures/nette-tester/tester-v1.7-report.xml b/__tests__/fixtures/nette-tester/tester-v1.7-report.xml new file mode 100644 index 0000000..7de1127 --- /dev/null +++ b/__tests__/fixtures/nette-tester/tester-v1.7-report.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/__tests__/tester-junit.test.ts b/__tests__/tester-junit.test.ts new file mode 100644 index 0000000..86f564d --- /dev/null +++ b/__tests__/tester-junit.test.ts @@ -0,0 +1,224 @@ +import * as fs from 'fs' +import * as path from 'path' + +import {NetteTesterJunitParser} from '../src/parsers/tester-junit/tester-junit-parser' +import {ParseOptions} from '../src/test-parser' +import {getReport} from '../src/report/get-report' +import {normalizeFilePath} from '../src/utils/path-utils' + +describe('tester-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 NetteTesterJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + expect(result.tests).toBe(0) + expect(result.result).toBe('success') + }) + + it('report from tester-v1.7-report.xml matches snapshot', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'nette-tester', 'tester-v1.7-report.xml') + const outputPath = path.join(__dirname, '__outputs__', 'tester-v1.7-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 NetteTesterJunitParser(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 tester-v1.7-report.xml correctly', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'nette-tester', 'tester-v1.7-report.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: [] + } + + const parser = new NetteTesterJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + + // Verify test counts from XML: tests="65" errors="1" skipped="3" + expect(result.tests).toBe(65) + expect(result.failed).toBe(1) + expect(result.skipped).toBe(3) + expect(result.passed).toBe(61) + + // Verify suite name uses file name + expect(result.suites.length).toBe(1) + expect(result.suites[0].name).toBe('tester-v1.7-report.xml') + }) + + it('groups tests by directory structure', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'nette-tester', 'tester-v1.7-report.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: [] + } + + const parser = new NetteTesterJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + + // Get all group names + const groupNames = result.suites[0].groups.map(g => g.name) + + // Verify expected directory groups exist + expect(groupNames).toContain('tests/Framework') + expect(groupNames).toContain('tests/CodeCoverage') + expect(groupNames).toContain('tests/Runner') + expect(groupNames).toContain('tests/RunnerOutput') + }) + + it('parses test names with method suffixes correctly', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'nette-tester', 'tester-v1.7-report.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: [] + } + + const parser = new NetteTesterJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + + // Find the Framework group which has tests with method suffixes + const frameworkGroup = result.suites[0].groups.find(g => g.name === 'tests/Framework') + expect(frameworkGroup).toBeDefined() + + // Find tests with method suffixes + const testWithMethod = frameworkGroup!.tests.find(t => t.name.includes('::testSimple')) + expect(testWithMethod).toBeDefined() + expect(testWithMethod!.name).toBe('Assert.equal.recursive.phpt::testSimple') + }) + + it('parses complex test names from BootstrapFormRenderer-report.xml', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'nette-tester', 'BootstrapFormRenderer-report.xml') + const outputPath = path.join(__dirname, '__outputs__', 'tester-bootstrap-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 NetteTesterJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + + // Verify test counts: 4 tests, all passed + expect(result.tests).toBe(4) + expect(result.passed).toBe(4) + expect(result.failed).toBe(0) + expect(result.skipped).toBe(0) + + // Verify suite name + expect(result.suites[0].name).toBe('BootstrapFormRenderer-report.xml') + + // All tests should have method names + const allTests = result.suites[0].groups.flatMap(g => g.tests) + expect(allTests.every(t => t.name.includes('::'))).toBe(true) + expect(allTests.some(t => t.name.includes('::testRenderingBasics'))).toBe(true) + expect(allTests.some(t => t.name.includes('::testRenderingIndividual'))).toBe(true) + + expect(result).toMatchSnapshot() + + const report = getReport([result]) + fs.mkdirSync(path.dirname(outputPath), {recursive: true}) + fs.writeFileSync(outputPath, report) + }) + + it('extracts error details from failures', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'nette-tester', 'tester-v1.7-report.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: [] + } + + const parser = new NetteTesterJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + + // Find the failed test + const allTests = result.suites[0].groups.flatMap(g => g.tests) + const failedTests = allTests.filter(t => t.result === 'failed') + + expect(failedTests.length).toBe(1) + + // Verify error details are captured + const failedTest = failedTests[0] + expect(failedTest.error).toBeDefined() + expect(failedTest.error!.details).toContain('Failed:') + expect(failedTest.error!.details).toContain('multiple-fails') + }) + + it('correctly identifies skipped tests', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'nette-tester', 'tester-v1.7-report.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: [] + } + + const parser = new NetteTesterJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + + // Find skipped tests + const allTests = result.suites[0].groups.flatMap(g => g.tests) + const skippedTests = allTests.filter(t => t.result === 'skipped') + + expect(skippedTests.length).toBe(3) + + // Verify some known skipped tests + expect(skippedTests.some(t => t.name.includes('Dumper.toPhp.php7.phpt'))).toBe(true) + expect(skippedTests.some(t => t.name.includes('Collector.start.phpt'))).toBe(true) + }) + + it('parses test with description prefix correctly', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'nette-tester', 'tester-v1.7-report.xml') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: [] + } + + const parser = new NetteTesterJunitParser(opts) + const result = await parser.parse(filePath, fileContent) + + // Find test with description prefix + const allTests = result.suites[0].groups.flatMap(g => g.tests) + // The test name is generated from the basename, and the description is shown in parentheses + const testWithDescription = allTests.find(t => t.name.includes('Prevent loop')) + + expect(testWithDescription).toBeDefined() + expect(testWithDescription!.name).toContain('Prevent loop') + expect(testWithDescription!.name).toContain('TestCase.ownErrorHandler.phpt') + }) +}) diff --git a/action.yml b/action.yml index d76fbbd..8dbc85c 100644 --- a/action.yml +++ b/action.yml @@ -32,6 +32,7 @@ inputs: - java-junit - jest-junit - mocha-json + - tester-junit - phpunit-junit - python-xunit - rspec-json diff --git a/src/main.ts b/src/main.ts index cf95cbe..9b24d38 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ 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' +import {NetteTesterJunitParser} from './parsers/tester-junit/tester-junit-parser' import {normalizeDirPath, normalizeFilePath} from './utils/path-utils' import {getCheckRunContext} from './utils/github-utils' @@ -280,6 +281,8 @@ class TestReporter { return new RspecJsonParser(options) case 'swift-xunit': return new SwiftXunitParser(options) + case 'tester-junit': + return new NetteTesterJunitParser(options) default: throw new Error(`Input variable 'reporter' is set to invalid value '${reporter}'`) } diff --git a/src/parsers/tester-junit/tester-junit-parser.ts b/src/parsers/tester-junit/tester-junit-parser.ts new file mode 100644 index 0000000..1ac94a0 --- /dev/null +++ b/src/parsers/tester-junit/tester-junit-parser.ts @@ -0,0 +1,260 @@ +import * as path from 'path' +import {ParseOptions, TestParser} from '../../test-parser' +import {parseStringPromise} from 'xml2js' + +import {NetteTesterReport, SingleSuiteReport, TestCase, TestSuite} from './tester-junit-types' +import {normalizeFilePath} from '../../utils/path-utils' + +import { + TestExecutionResult, + TestRunResult, + TestSuiteResult, + TestGroupResult, + TestCaseResult, + TestCaseError +} from '../../test-results' + +interface ParsedTestName { + filePath: string + method?: string + description?: string + className?: string + displayName: string +} + +export class NetteTesterJunitParser implements TestParser { + readonly trackedFiles: Set + readonly trackedFilesList: string[] + + constructor(readonly options: ParseOptions) { + this.trackedFilesList = options.trackedFiles.map(f => normalizeFilePath(f)) + this.trackedFiles = new Set(this.trackedFilesList) + } + + async parse(filePath: string, content: string): Promise { + const reportOrSuite = await this.getNetteTesterReport(filePath, content) + const isReport = (reportOrSuite as NetteTesterReport).testsuites !== undefined + + // XML might contain: + // - multiple suites under root node + // - single as root node + let report: NetteTesterReport + if (isReport) { + report = reportOrSuite as NetteTesterReport + } 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 getNetteTesterReport( + 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: NetteTesterReport): TestRunResult { + const suites = + report.testsuites.testsuite === undefined + ? [] + : report.testsuites.testsuite.map((ts, index) => { + // Use report file name as suite name (user preference) + const fileName = path.basename(filePath) + // If there are multiple test suites, add index to distinguish them + const name = + report.testsuites.testsuite && report.testsuites.testsuite.length > 1 + ? `${fileName} #${index + 1}` + : fileName + const time = parseFloat(ts.$.time) * 1000 + const sr = new TestSuiteResult(name, this.getGroups(ts), time) + return sr + }) + + const seconds = parseFloat(report.testsuites.$?.time ?? '') + const time = isNaN(seconds) ? undefined : seconds * 1000 + return new TestRunResult(filePath, suites, time) + } + + private getGroups(suite: TestSuite): TestGroupResult[] { + if (!suite.testcase || suite.testcase.length === 0) { + return [] + } + + // Group tests by directory structure + const groups: Map = new Map() + + for (const tc of suite.testcase) { + const parsed = this.parseTestCaseName(tc.$.classname) + const directory = path.dirname(parsed.filePath) + + if (!groups.has(directory)) { + groups.set(directory, []) + } + groups.get(directory)!.push(tc) + } + + return Array.from(groups.entries()).map(([dir, tests]) => { + const testResults = tests.map(tc => { + const parsed = this.parseTestCaseName(tc.$.classname) + const result = this.getTestCaseResult(tc) + const time = parseFloat(tc.$.time || '0') * 1000 + const error = this.getTestCaseError(tc, parsed.filePath) + return new TestCaseResult(parsed.displayName, result, time, error) + }) + return new TestGroupResult(dir, testResults) + }) + } + + /** + * Parse test case name from classname attribute. + * + * Handles multiple patterns: + * 1. Simple: "tests/Framework/Assert.equal.phpt" + * 2. With method: "tests/Framework/Assert.equal.recursive.phpt [method=testSimple]" + * 3. With description: "Prevent loop in error handling. The #268 regression. | tests/Framework/TestCase.ownErrorHandler.phpt" + * 4. With class and method: "Kdyby\BootstrapFormRenderer\BootstrapRenderer. | KdybyTests/BootstrapFormRenderer/BootstrapRendererTest.phpt [method=testRenderingBasics]" + */ + private parseTestCaseName(classname: string): ParsedTestName { + let filePath = classname + let method: string | undefined + let description: string | undefined + let className: string | undefined + + // Pattern: "Description | filepath [method=methodName]" + // or "ClassName | filepath [method=methodName]" + const pipePattern = /^(.+?)\s*\|\s*(.+?)(?:\s*\[method=(.+?)\])?$/ + const pipeMatch = classname.match(pipePattern) + + if (pipeMatch) { + const prefix = pipeMatch[1].trim() + filePath = pipeMatch[2].trim() + method = pipeMatch[3] + + // Check if prefix looks like a class name (contains backslash AND ends with dot) + // Examples: "Kdyby\BootstrapFormRenderer\BootstrapRenderer." + // vs description: "Prevent loop in error handling. The #268 regression." + if (prefix.includes('\\') && prefix.endsWith('.')) { + className = prefix + } else { + description = prefix + } + } else { + // Pattern: "filepath [method=methodName]" + const methodPattern = /^(.+?)\s*\[method=(.+?)\]$/ + const methodMatch = classname.match(methodPattern) + + if (methodMatch) { + filePath = methodMatch[1].trim() + method = methodMatch[2].trim() + } + } + + // Generate display name + const baseName = path.basename(filePath) + let displayName = baseName + + if (method) { + displayName = `${baseName}::${method}` + } + + if (description) { + displayName = `${description} (${baseName})` + } else if (className && method) { + // For class names, keep them but still show the file + displayName = `${baseName}::${method}` + } + + return {filePath, method, description, className, displayName} + } + + private getTestCaseResult(test: TestCase): TestExecutionResult { + if (test.failure || test.error) return 'failed' + if (test.skipped) return 'skipped' + return 'success' + } + + private getTestCaseError(tc: TestCase, filePath: string): 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] + // For Nette Tester, details are in the message attribute, not as inner text + const details = typeof failure === 'string' ? failure : failure._ ?? failure.$?.message ?? '' + + // Try to extract file path and line from error details + let errorFilePath: string | undefined + let line: number | undefined + + if (details) { + const extracted = this.extractFileAndLine(details) + if (extracted) { + errorFilePath = extracted.filePath + line = extracted.line + } + } + + // Fallback: use test file path if tracked + if (!errorFilePath) { + const normalized = normalizeFilePath(filePath) + if (this.trackedFiles.has(normalized)) { + errorFilePath = normalized + } + } + + let message: string | undefined + if (typeof failure !== 'string' && failure.$) { + message = failure.$.message + if (failure.$.type) { + message = message ? `${failure.$.type}: ${message}` : failure.$.type + } + } + + return { + path: errorFilePath, + line, + details, + message + } + } + + /** + * Extract file path and line number from error details. + * Matches patterns like: /path/to/file.phpt:123 or /path/to/file.php:456 + */ + private extractFileAndLine(details: string): {filePath: string; line: number} | undefined { + const lines = details.split(/\r?\n/) + + for (const str of lines) { + // Match PHP file patterns: /path/to/file.phpt:123 or /path/to/file.php:456 + const match = str.match(/((?:[A-Za-z]:)?[^\s:()]+?\.(?:php|phpt)):(\d+)/) + if (match) { + const normalized = normalizeFilePath(match[1]) + if (this.trackedFiles.has(normalized)) { + return {filePath: normalized, line: parseInt(match[2])} + } + } + } + + return undefined + } +} diff --git a/src/parsers/tester-junit/tester-junit-types.ts b/src/parsers/tester-junit/tester-junit-types.ts new file mode 100644 index 0000000..5bab391 --- /dev/null +++ b/src/parsers/tester-junit/tester-junit-types.ts @@ -0,0 +1,46 @@ +export interface NetteTesterReport { + testsuites: TestSuites +} + +export interface SingleSuiteReport { + testsuite: TestSuite +} + +export interface TestSuites { + $?: { + time?: string + } + testsuite?: TestSuite[] +} + +export interface TestSuite { + $: { + // NOTE: name attribute is intentionally omitted - Nette Tester doesn't provide it + tests: string + errors: string + failures?: string + skipped: string + time: string + timestamp?: string + } + testcase?: TestCase[] +} + +export interface TestCase { + $: { + classname: string // File path, possibly with method or description prefix + name: string // Usually same as classname + time: string + } + failure?: Failure[] + error?: Failure[] + skipped?: string[] +} + +export interface Failure { + _?: string + $?: { + type?: string + message?: string + } +}