Skip to content

Commit

Permalink
Add support for XUnit files (#288)
Browse files Browse the repository at this point in the history
  • Loading branch information
EnricoMi committed Jun 21, 2022
1 parent e633876 commit 7d18cf0
Show file tree
Hide file tree
Showing 40 changed files with 970 additions and 31 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/ci-cd.yml
Expand Up @@ -202,7 +202,7 @@ jobs:
id: test-results
if: always()
run: |
docker run --workdir $GITHUB_WORKSPACE --rm -e INPUT_CHECK_NAME -e INPUT_JUNIT_FILES -e INPUT_TRX_FILES -e INPUT_TIME_UNIT -e INPUT_GITHUB_TOKEN -e INPUT_GITHUB_RETRIES -e INPUT_COMMIT -e INPUT_COMMENT_TITLE -e INPUT_FAIL_ON -e INPUT_REPORT_INDIVIDUAL_RUNS -e INPUT_DEDUPLICATE_CLASSES_BY_FILE_NAME -e INPUT_IGNORE_RUNS -e INPUT_HIDE_COMMENTS -e INPUT_COMMENT_ON_PR -e INPUT_COMMENT_MODE -e INPUT_COMPARE_TO_EARLIER_COMMIT -e INPUT_PULL_REQUEST_BUILD -e INPUT_EVENT_FILE -e INPUT_EVENT_NAME -e INPUT_TEST_CHANGES_LIMIT -e INPUT_CHECK_RUN_ANNOTATIONS -e INPUT_CHECK_RUN_ANNOTATIONS_BRANCH -e INPUT_SECONDS_BETWEEN_GITHUB_READS -e INPUT_SECONDS_BETWEEN_GITHUB_WRITES -e INPUT_JSON_FILE -e INPUT_JSON_THOUSANDS_SEPARATOR -e INPUT_JOB_SUMMARY -e HOME -e GITHUB_JOB -e GITHUB_REF -e GITHUB_SHA -e GITHUB_REPOSITORY -e GITHUB_REPOSITORY_OWNER -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RETENTION_DAYS -e GITHUB_ACTOR -e GITHUB_WORKFLOW -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GITHUB_EVENT_NAME -e GITHUB_SERVER_URL -e GITHUB_API_URL -e GITHUB_GRAPHQL_URL -e GITHUB_WORKSPACE -e GITHUB_ACTION -e GITHUB_EVENT_PATH -e GITHUB_ACTION_REPOSITORY -e GITHUB_ACTION_REF -e GITHUB_PATH -e GITHUB_ENV -e GITHUB_STEP_SUMMARY -e RUNNER_OS -e RUNNER_TOOL_CACHE -e RUNNER_TEMP -e RUNNER_WORKSPACE -e ACTIONS_RUNTIME_URL -e ACTIONS_RUNTIME_TOKEN -e ACTIONS_CACHE_URL -e GITHUB_ACTIONS=true -e CI=true -v "/var/run/docker.sock":"/var/run/docker.sock" -v "$RUNNER_TEMP":"$RUNNER_TEMP" -v "$GITHUB_WORKSPACE":"$GITHUB_WORKSPACE" enricomi/publish-unit-test-result-action:latest
docker run --workdir $GITHUB_WORKSPACE --rm -e INPUT_CHECK_NAME -e INPUT_JUNIT_FILES -e INPUT_XUNIT_FILES -e INPUT_TRX_FILES -e INPUT_TIME_UNIT -e INPUT_GITHUB_TOKEN -e INPUT_GITHUB_RETRIES -e INPUT_COMMIT -e INPUT_COMMENT_TITLE -e INPUT_FAIL_ON -e INPUT_REPORT_INDIVIDUAL_RUNS -e INPUT_DEDUPLICATE_CLASSES_BY_FILE_NAME -e INPUT_IGNORE_RUNS -e INPUT_HIDE_COMMENTS -e INPUT_COMMENT_ON_PR -e INPUT_COMMENT_MODE -e INPUT_COMPARE_TO_EARLIER_COMMIT -e INPUT_PULL_REQUEST_BUILD -e INPUT_EVENT_FILE -e INPUT_EVENT_NAME -e INPUT_TEST_CHANGES_LIMIT -e INPUT_CHECK_RUN_ANNOTATIONS -e INPUT_CHECK_RUN_ANNOTATIONS_BRANCH -e INPUT_SECONDS_BETWEEN_GITHUB_READS -e INPUT_SECONDS_BETWEEN_GITHUB_WRITES -e INPUT_JSON_FILE -e INPUT_JSON_THOUSANDS_SEPARATOR -e INPUT_JOB_SUMMARY -e HOME -e GITHUB_JOB -e GITHUB_REF -e GITHUB_SHA -e GITHUB_REPOSITORY -e GITHUB_REPOSITORY_OWNER -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RETENTION_DAYS -e GITHUB_ACTOR -e GITHUB_WORKFLOW -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GITHUB_EVENT_NAME -e GITHUB_SERVER_URL -e GITHUB_API_URL -e GITHUB_GRAPHQL_URL -e GITHUB_WORKSPACE -e GITHUB_ACTION -e GITHUB_EVENT_PATH -e GITHUB_ACTION_REPOSITORY -e GITHUB_ACTION_REF -e GITHUB_PATH -e GITHUB_ENV -e GITHUB_STEP_SUMMARY -e RUNNER_OS -e RUNNER_TOOL_CACHE -e RUNNER_TEMP -e RUNNER_WORKSPACE -e ACTIONS_RUNTIME_URL -e ACTIONS_RUNTIME_TOKEN -e ACTIONS_CACHE_URL -e GITHUB_ACTIONS=true -e CI=true -v "/var/run/docker.sock":"/var/run/docker.sock" -v "$RUNNER_TEMP":"$RUNNER_TEMP" -v "$GITHUB_WORKSPACE":"$GITHUB_WORKSPACE" enricomi/publish-unit-test-result-action:latest
env:
INPUT_GITHUB_TOKEN: ${{ github.token }}
INPUT_CHECK_NAME: Test Results (Docker Image)
Expand Down Expand Up @@ -423,6 +423,7 @@ jobs:
check_name: Test Results (Test Files)
fail_on: nothing
junit_files: "test-files/junit-xml/**/*.xml"
xunit_files: "test-files/xunit/**/*.xml"
trx_files: "test-files/trx/**/*.trx"
json_file: "tests.json"
log_level: DEBUG
Expand Down
12 changes: 7 additions & 5 deletions README.md
Expand Up @@ -22,7 +22,8 @@ You can add this action to your GitHub workflow for ![Ubuntu Linux](https://badg
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
junit_files: "test-results/**/*.xml"
junit_files: "test-results/junit/**/*.xml"
xunit_files: "test-results/xunit/**/*.xml"
trx_files: "test-results/**/*.trx"
```

Expand All @@ -34,7 +35,8 @@ and ![Windows](https://badgen.net/badge/icon/Windows?icon=windows&label) (e.g. `
uses: EnricoMi/publish-unit-test-result-action/composite@v2
if: always()
with:
junit_files: "test-results/**/*.xml"
junit_files: "test-results/junit/**/*.xml"
xunit_files: "test-results/xunit/**/*.xml"
trx_files: "test-results/**/*.trx"
```

Expand Down Expand Up @@ -182,11 +184,11 @@ With `comment_mode: off`, the `pull-requests: write` permission is not needed.

## Configuration

Files can be selected via the `junit_files` and `trx_files` options.
Files can be selected via the `junit_files`, `xunit_files`, and `trx_files` options.
They support [glob wildcards](https://docs.python.org/3/library/glob.html#glob.glob) like `*`, `**`, `?` and `[]`.
The `**` wildcard matches all files and directories recursively: `./`, `./*/`, `./*/*/`, etc.

At least one of `junit_files` and `trx_files` options have to be set.
At least one of `junit_files`, `xunit_files`, and `trx_files` options have to be set.

You can provide multiple file patterns, one pattern per line. Patterns starting with `!` exclude the matching files.
There have to be at least one pattern starting without a `!`:
Expand All @@ -202,7 +204,7 @@ The list of most notable options:

|Option|Default Value|Description|
|:-----|:-----:|:----------|
|`junit_files`<br/>`trx_files`|At least one of these `*_files` must be set.|File patterns of JUnit XML and TRX test result files, respectively. Supports `*`, `**`, `?`, and `[]`. Use multiline string for multiple patterns. Patterns starting with `!` exclude the matching files. There have to be at least one pattern starting without a `!`.|
|`junit_files`<br/>`xunit_files`<br/>`trx_files`|At least one of these `*_files` must be set.|File patterns of JUnit XML, XUnit XML, and TRX test result files, respectively. Supports `*`, `**`, `?`, and `[]`. Use multiline string for multiple patterns. Patterns starting with `!` exclude the matching files. There have to be at least one pattern starting without a `!`.|
|`check_name`|`"Test Results"`|An alternative name for the check result.|
|`comment_title`|same as `check_name`|An alternative name for the pull request comment.|
|`comment_mode`|`always`|The action posts comments to pull requests that are associated with the commit. Set to:<br/>`always` - always comment<br/>`changes` - comment when changes w.r.t. the target branch exist<br/>`changes in failures` - when changes in the number of failures and errors exist<br/>`changes in errors` - when changes in the number of (only) errors exist<br/>`failures` - when failures or errors exist<br/>`errors` - when (only) errors exist<br/>`off` - to not create pull request comments.|
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Expand Up @@ -32,6 +32,9 @@ inputs:
junit_files:
description: 'File patterns of JUnit XML test result files. Supports *, **, ?, and []. Use multiline string for multiple patterns. Patterns starting with ! exclude the matching files. There have to be at least one pattern starting without a `!`.'
required: false
xunit_files:
description: 'File patterns of XUnit XML test result files. Supports *, **, ?, and []. Use multiline string for multiple patterns. Patterns starting with ! exclude the matching files. There have to be at least one pattern starting without a `!`.'
required: false
trx_files:
description: 'File patterns of TRX test result files. Supports *, **, ?, and []. Use multiline string for multiple patterns. Patterns starting with ! exclude the matching files. There have to be at least one pattern starting without a `!`.'
required: false
Expand Down
4 changes: 4 additions & 0 deletions composite/action.yml
Expand Up @@ -32,6 +32,9 @@ inputs:
junit_files:
description: 'File patterns of JUnit XML test result files. Supports *, **, ?, and []. Use multiline string for multiple patterns. Patterns starting with ! exclude the matching files. There have to be at least one pattern starting without a `!`.'
required: false
xunit_files:
description: 'File patterns of XUnit XML test result files. Supports *, **, ?, and []. Use multiline string for multiple patterns. Patterns starting with ! exclude the matching files. There have to be at least one pattern starting without a `!`.'
required: false
trx_files:
description: 'File patterns of TRX test result files. Supports *, **, ?, and []. Use multiline string for multiple patterns. Patterns starting with ! exclude the matching files. There have to be at least one pattern starting without a `!`.'
required: false
Expand Down Expand Up @@ -157,6 +160,7 @@ runs:
# deprecated
FILES: ${{ inputs.files }}
JUNIT_FILES: ${{ inputs.junit_files }}
XUNIT_FILES: ${{ inputs.xunit_files }}
TRX_FILES: ${{ inputs.trx_files }}
TIME_UNIT: ${{ inputs.time_unit }}
REPORT_INDIVIDUAL_RUNS: ${{ inputs.report_individual_runs }}
Expand Down
12 changes: 7 additions & 5 deletions python/publish/junit.py
Expand Up @@ -127,6 +127,7 @@ def close(self) -> Element:
def parse_junit_xml_files(files: Iterable[str],
drop_testcases: bool = False,
progress: Callable[[ParsedJUnitFile], ParsedJUnitFile] = lambda x: x) -> Iterable[ParsedJUnitFile]:
"""Parses junit xml files."""
def parse(path: str) -> JUnitTreeOrException:
"""Parses a junit xml file and returns either a JUnitTree or an Exception."""
if not os.path.exists(path):
Expand Down Expand Up @@ -172,11 +173,12 @@ def create_junitxml(filepath: str, tree: JUnitTree) -> Union[JUnitXml, JUnitXmlE
for result_file, junit in junits
for suite in (junit if junit._tag == "testsuites" else [junit])]

suite_tests = sum([suite.tests for result_file, suite in suites])
suite_skipped = sum([suite.skipped + suite.disabled for result_file, suite in suites])
suite_failures = sum([suite.failures for result_file, suite in suites])
suite_errors = sum([suite.errors for result_file, suite in suites])
suite_time = int(sum([suite.time for result_file, suite in suites if not math.isnan(suite.time)]) * time_factor)
suite_tests = sum([suite.tests for result_file, suite in suites if suite.tests])
suite_skipped = sum([suite.skipped + suite.disabled for result_file, suite in suites if suite.skipped])
suite_failures = sum([suite.failures for result_file, suite in suites if suite.failures])
suite_errors = sum([suite.errors for result_file, suite in suites if suite.errors])
suite_time = int(sum([suite.time for result_file, suite in suites
if suite.time and not math.isnan(suite.time)]) * time_factor)

def int_opt(string: Optional[str]) -> Optional[int]:
try:
Expand Down
1 change: 1 addition & 0 deletions python/publish/publisher.py
Expand Up @@ -45,6 +45,7 @@ class Settings:
fail_on_failures: bool
# one of these *_files_glob must be set
junit_files_glob: Optional[str]
xunit_files_glob: Optional[str]
trx_files_glob: Optional[str]
time_factor: float
check_name: str
Expand Down
1 change: 1 addition & 0 deletions python/publish/trx.py
Expand Up @@ -12,6 +12,7 @@

def parse_trx_files(files: Iterable[str],
progress: Callable[[ParsedJUnitFile], ParsedJUnitFile] = lambda x: x) -> Iterable[ParsedJUnitFile]:
"""Parses trx files."""
def parse(path: str) -> JUnitTreeOrException:
"""Parses a trx file and returns either a JUnitTree or an Exception."""
if not os.path.exists(path):
Expand Down
63 changes: 63 additions & 0 deletions python/publish/xslt/xunit-to-junit.xslt
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- based on https://gist.github.com/cdroulers/e23eeb31d6c1c2cade6f680e321aed8d -->
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/">
<testsuites>
<xsl:for-each select="//assembly">
<testsuite>
<xsl:attribute name="name"><xsl:value-of select="@name"/></xsl:attribute>
<xsl:attribute name="tests"><xsl:value-of select="@total"/></xsl:attribute>
<xsl:attribute name="failures"><xsl:value-of select="@failed"/></xsl:attribute>
<xsl:if test="@errors">
<xsl:attribute name="errors"><xsl:value-of select="@errors"/></xsl:attribute>
</xsl:if>
<xsl:attribute name="time"><xsl:value-of select="@time"/></xsl:attribute>
<xsl:attribute name="skipped"><xsl:value-of select="@skipped"/></xsl:attribute>
<xsl:attribute name="timestamp"><xsl:value-of select="@run-date"/>T<xsl:value-of select="@run-time"/></xsl:attribute>

<xsl:for-each select="collection | class">
<testsuite>
<xsl:attribute name="name"><xsl:value-of select="@name"/></xsl:attribute>
<xsl:attribute name="tests"><xsl:value-of select="@total"/></xsl:attribute>
<xsl:attribute name="failures"><xsl:value-of select="@failed"/></xsl:attribute>
<xsl:if test="@errors">
<xsl:attribute name="errors"><xsl:value-of select="@errors"/></xsl:attribute>
</xsl:if>
<xsl:attribute name="time"><xsl:value-of select="@time"/></xsl:attribute>
<xsl:attribute name="skipped"><xsl:value-of select="@skipped"/></xsl:attribute>

<xsl:for-each select="test">
<testcase>
<xsl:attribute name="name"><xsl:value-of select="@method"/></xsl:attribute>
<xsl:attribute name="time"><xsl:value-of select="@time"/></xsl:attribute>
<xsl:attribute name="classname"><xsl:value-of select="@type"/></xsl:attribute>
<xsl:if test="reason">
<skipped>
<xsl:attribute name="message"><xsl:value-of select="reason/text()"/></xsl:attribute>
</skipped>
</xsl:if>
<xsl:apply-templates select="failure"/>
</testcase>
</xsl:for-each>

</testsuite>
</xsl:for-each>

</testsuite>
</xsl:for-each>
</testsuites>
</xsl:template>

<xsl:template match="failure">
<failure>
<xsl:if test="@exception-type">
<xsl:attribute name="type"><xsl:value-of select="@exception-type"/></xsl:attribute>
</xsl:if>
<xsl:attribute name="message"><xsl:value-of select="message"/></xsl:attribute>
<xsl:value-of select="message"/>
<xsl:value-of select="stack-trace"/>
</failure>
</xsl:template>

</xsl:stylesheet>
29 changes: 29 additions & 0 deletions python/publish/xunit.py
@@ -0,0 +1,29 @@
import os
import pathlib
from typing import Iterable, Callable

from lxml import etree

from publish.junit import JUnitTreeOrException, ParsedJUnitFile

with (pathlib.Path(__file__).parent / 'xslt' / 'xunit-to-junit.xslt').open('r', encoding='utf-8') as r:
transform_xunit_to_junit = etree.XSLT(etree.parse(r))


def parse_xunit_files(files: Iterable[str],
progress: Callable[[ParsedJUnitFile], ParsedJUnitFile] = lambda x: x) -> Iterable[ParsedJUnitFile]:
"""Parses xunit files."""
def parse(path: str) -> JUnitTreeOrException:
"""Parses an xunit file and returns either a JUnitTree or an Exception."""
if not os.path.exists(path):
return FileNotFoundError(f'File does not exist.')
if os.stat(path).st_size == 0:
return Exception(f'File is empty.')

try:
trx = etree.parse(path)
return transform_xunit_to_junit(trx)
except BaseException as e:
return e

return [progress((result_file, parse(result_file))) for result_file in files]
9 changes: 7 additions & 2 deletions python/publish_unit_test_results.py
Expand Up @@ -93,20 +93,24 @@ def get_number_of_files(files: List[str]) -> str:
def parse_files(settings: Settings, gha: GithubAction) -> ParsedUnitTestResultsWithCommit:
# expand file globs
junit_files = expand_glob(settings.junit_files_glob, gha)
xunit_files = expand_glob(settings.xunit_files_glob, gha)
trx_files = expand_glob(settings.trx_files_glob, gha)

elems = []

# parse files, log the progress
# https://github.com/EnricoMi/publish-unit-test-result-action/issues/304
with progress_logger(items=len(junit_files + trx_files),
with progress_logger(items=len(junit_files + xunit_files + trx_files),
interval_seconds=10,
progress_template='Read {progress} files in {time}',
finish_template='Finished reading {observations} files in {duration}',
progress_item_type=Tuple[str, Any],
logger=logger) as progress:
if junit_files:
elems.extend(parse_junit_xml_files(junit_files, settings.ignore_runs, progress))
if xunit_files:
from publish.xunit import parse_xunit_files
elems.extend(parse_xunit_files(xunit_files, progress))
if trx_files:
from publish.trx import parse_trx_files
elems.extend(parse_trx_files(trx_files, progress))
Expand Down Expand Up @@ -325,7 +329,7 @@ def get_settings(options: dict, gha: Optional[GithubAction] = None) -> Settings:
# replace with error when deprecated FILES is removed
default_junit_files_glob = None
if not any([get_var(f'{flavour}_FILES', options)
for flavour in ['JUNIT', 'TRX']]):
for flavour in ['JUNIT', 'XUNIT', 'TRX']]):
default_junit_files_glob = '*.xml'
gha.warning(f'At least one of the *_FILES options has to be set! '
f'Falling back to deprecated default "{default_junit_files_glob}"')
Expand Down Expand Up @@ -369,6 +373,7 @@ def get_settings(options: dict, gha: Optional[GithubAction] = None) -> Settings:
fail_on_errors=fail_on_errors,
fail_on_failures=fail_on_failures,
junit_files_glob=get_var('JUNIT_FILES', options) or get_var('FILES', options) or default_junit_files_glob,
xunit_files_glob=get_var('XUNIT_FILES', options),
trx_files_glob=get_var('TRX_FILES', options),
time_factor=time_factor,
check_name=check_name,
Expand Down
2 changes: 1 addition & 1 deletion python/test/files/junit-xml/tst/disabled.results
Expand Up @@ -3,7 +3,7 @@ publish.unittestresults.ParsedUnitTestResults(
errors=[],
suites=2,
suite_tests=31,
suite_skipped=5,
suite_skipped=0,
suite_failures=19,
suite_errors=1,
suite_time=0,
Expand Down
1 change: 1 addition & 0 deletions python/test/files/update_expectations.sh
Expand Up @@ -3,5 +3,6 @@
base=$(dirname "$0")

python $base/../test_junit.py
python $base/../test_xunit.py
python $base/../test_trx.py

1 change: 1 addition & 0 deletions python/test/files/xunit/README.md
@@ -0,0 +1 @@
[mstest/fixie.xml](https://raw.githubusercontent.com/fixie/fixie/42b43dc6cc57476958eea8b507aa9d0d72cedae6/src/Fixie.Tests/Reports/XUnitXmlReport.xml)
26 changes: 26 additions & 0 deletions python/test/files/xunit/mstest/fixie.junit-xml
@@ -0,0 +1,26 @@
<?xml version='1.0' encoding='utf-8'?>
<testsuites>
<testsuite name="[assemblyLocation]" tests="7" failures="3" time="1.234" skipped="1" timestamp="YYYY-MM-DDTHH:MM:SS">
<testsuite name="[genericTestClass]" tests="3" failures="1" time="1.234" skipped="0">
<testcase name="ShouldBeString" time="1.234" classname="[genericTestClass]"/>
<testcase name="ShouldBeString" time="1.234" classname="[genericTestClass]"/>
<testcase name="ShouldBeString" time="1.234" classname="[genericTestClass]">
<failure type="Fixie.Tests.Assertions.AssertException" message="Expected: System.String&#10;Actual: System.Int32">Expected: System.String
Actual: System.Int32 at [genericTestClassForStackTrace].ShouldBeString[T](T genericArgument) in [fileLocation]:line #</failure>
</testcase>
</testsuite>
<testsuite name="[testClass]" tests="4" failures="2" time="1.234" skipped="1">
<testcase name="Fail" time="1.234" classname="[testClass]">
<failure type="Fixie.Tests.FailureException" message="'Fail' failed!">'Fail' failed! at [testClassForStackTrace].Fail() in [fileLocation]:line #</failure>
</testcase>
<testcase name="FailByAssertion" time="1.234" classname="[testClass]">
<failure type="Fixie.Tests.Assertions.AssertException" message="Expected: 2&#10;Actual: 1">Expected: 2
Actual: 1 at [testClassForStackTrace].FailByAssertion() in [fileLocation]:line #</failure>
</testcase>
<testcase name="Pass" time="1.234" classname="[testClass]"/>
<testcase name="Skip" time="1.234" classname="[testClass]">
<skipped message="&#x26A0; Skipped with attribute."/>
</testcase>
</testsuite>
</testsuite>
</testsuites>

0 comments on commit 7d18cf0

Please sign in to comment.