Skip to content

Commit 236e1b9

Browse files
committed
ci: Add tool to update xunig report for nicer test results in CI
1 parent 51fdb5a commit 236e1b9

1 file changed

Lines changed: 159 additions & 0 deletions

File tree

utilities/xom.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Copyright 2008-2015 Nokia Networks
2+
# Copyright 2016-2022 Robot Framework Foundation
3+
# Copyright 2022- Risto Polojärvi
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""XUnit Output Modifier.
18+
Simple implementation to form a custom XUnit output from Robot Framework test run.
19+
This work is derived from Robot Framework's XUnitFileWriter:
20+
https://github.com/robotframework/robotframework/blob/master/src/robot/reporting/xunitwriter.py
21+
22+
See RF prerebotmodifier:
23+
https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#toc-entry-498
24+
25+
See JUnit team's xds:
26+
https://github.com/junit-team/junit5/blob/main/platform-tests/src/test/resources/jenkins-junit.xsd
27+
28+
See about RF API, visitor and testsuite:
29+
https://robot-framework.readthedocs.io/en/stable/index.html
30+
https://robot-framework.readthedocs.io/en/stable/autodoc/robot.model.html?highlight=SuiteVisitor#module-robot.model.visitor
31+
https://robot-framework.readthedocs.io/en/stable/autodoc/robot.model.html#module-robot.model.testsuite
32+
33+
Example usages:
34+
35+
--prerebotmodifier [module_name].[class_name]:[output_filename]
36+
37+
Get `xunit.xml` output file from the modifier:
38+
robot --pythonpath . --prerebotmodifier xom.XUnitOut:xunit.xml test.robot
39+
40+
NOTE: This won't work, because RF's XUnitWriter (specified with -x) overwrites the target file:
41+
robot --pythonpath . --prerebotmodifier xom.XUnitOut:xunit.xml -x xunit.xml test.robot
42+
43+
This works fine:
44+
robot --pythonpath . --prerebotmodifier xom.XUnitOut:xcustom.xml -x xdefault.xml test.robot
45+
46+
Example code modification points are denoted with `# *` comment lines in the code
47+
- Root node <testsuites> attributes.
48+
- Testsuite attribute `hostname`
49+
- Testcase attribute `file` as testcase's source filename.
50+
- Testcase attribute `lineno` as line number of testcase in the source file.
51+
- Method `_starttime_to_isoformat` to custom timestamp.
52+
"""
53+
54+
# * import platform # Used for testsuite hostname attribute.
55+
import time
56+
from robot.api import SuiteVisitor
57+
from robot.utils import XmlWriter, timestamp_to_secs, secs_to_timestamp
58+
59+
# Configuration flags:
60+
# Root node to <testsuites> when multiple suites in a test run.
61+
ROOT_NODE_PLURAL = True
62+
# Timestamp from local system time to UTC time.
63+
XUNIT_UTC_TIME_IN_USE = False
64+
# Local time offset to UTC time in seconds to Suite's property.
65+
REPORT_UTC_TIME_OFFSET = False
66+
67+
68+
class XUnitOut(SuiteVisitor):
69+
"""Creates modified XUnit output."""
70+
71+
def __init__(self, name='xunit_mod.xml'):
72+
self._writer = XmlWriter(output=name, usage='xunit')
73+
self._utc_offset = None
74+
75+
def start_suite(self, suite):
76+
"""When suite is started writes testsuite/testsuites element's start tag and attributes."""
77+
stats = suite.statistics # Accessing property only once.
78+
attrs = {'name': suite.name,
79+
'tests': f'{stats.total}',
80+
# No meaningful data available from suite for `errors`.
81+
'errors': '0',
82+
'failures': f'{stats.failed}',
83+
'skipped': f'{stats.skipped}',
84+
'time': self._time_as_seconds(suite.elapsedtime),
85+
'timestamp': self._starttime_to_isoformat(suite.starttime),
86+
# * Define custom suite attributes here:
87+
# * 'hostname': platform.uname().node,
88+
}
89+
if ROOT_NODE_PLURAL and suite.parent is None and suite.suites:
90+
# * Define custom attributes for <testsuites> element here:
91+
# See JUnit team's xds:
92+
# https://github.com/junit-team/junit5/blob/main/platform-tests/src/test/resources/jenkins-junit.xsd
93+
# * attrs = {}
94+
self._writer.start('testsuites', attrs)
95+
else:
96+
self._writer.start('testsuite', attrs)
97+
98+
def end_suite(self, suite):
99+
"""When suite is ended, writes properties and end tag for testsuite/testsuites."""
100+
if suite.metadata or suite.doc or REPORT_UTC_TIME_OFFSET:
101+
self._writer.start('properties')
102+
if suite.doc:
103+
self._writer.element('property', attrs={
104+
'name': 'Documentation', 'value': suite.doc})
105+
for meta_name, meta_value in suite.metadata.items():
106+
self._writer.element('property', attrs={
107+
'name': meta_name, 'value': meta_value})
108+
if REPORT_UTC_TIME_OFFSET:
109+
self._writer.element('property', attrs={
110+
'name': 'utc-offset', 'value': str(self._utc_offset)})
111+
self._writer.end('properties')
112+
if ROOT_NODE_PLURAL:
113+
if suite.parent is not None:
114+
self._writer.end('testsuite')
115+
elif suite.suites:
116+
# handling root node and it has sub-suites.
117+
self._writer.end('testsuites')
118+
else:
119+
self._writer.end('testsuite') # single suite
120+
else:
121+
self._writer.end('testsuite')
122+
if suite.parent is None:
123+
self._writer.close()
124+
125+
def visit_test(self, test):
126+
"""Writes testcase element"""
127+
attrs = {'classname': test.parent.longname,
128+
'name': test.name,
129+
'time': self._time_as_seconds(test.elapsedtime),
130+
# * Define Custom test attributes here:
131+
# * 'file': test.source,
132+
# * 'lineno': f'{test.lineno}',
133+
}
134+
self._writer.start('testcase', attrs)
135+
if test.failed:
136+
self._writer.element('failure', attrs={'message': test.message,
137+
'type': 'AssertionError'})
138+
if test.skipped:
139+
self._writer.element('skipped', attrs={'message': test.message,
140+
'type': 'SkipExecution'})
141+
self._writer.end('testcase')
142+
143+
def _time_as_seconds(self, millis):
144+
"""Convert milliseconds to seconds"""
145+
return f'{millis / 1000:.3f}'
146+
147+
def _starttime_to_isoformat(self, stime):
148+
"""RF start time have precision .XYZ seconds, while JUnit have timestamps .XYZabc seconds.
149+
Adjust padding to comply or modify timestamp format to your favor."""
150+
if not stime:
151+
return None
152+
padding = '000'
153+
if XUNIT_UTC_TIME_IN_USE:
154+
offset = time.timezone if (time.localtime().tm_isdst == 0) else time.altzone
155+
if REPORT_UTC_TIME_OFFSET and self._utc_offset is None:
156+
self._utc_offset = offset
157+
utc = timestamp_to_secs(stime) + offset
158+
stime = secs_to_timestamp(utc)
159+
return f'{stime[:4]}-{stime[4:6]}-{stime[6:8]}T{stime[9:]}{padding}'

0 commit comments

Comments
 (0)