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