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 @@
+
+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 @@
+
+|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 @@
+
+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 @@
+
+|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
+ }
+}