2 # Copyright (c) 2014-2016, 2019 Intel Corporation
3 # Copyright © 2020 Valve Corporation.
5 # Permission is hereby granted, free of charge, to any person obtaining a copy
6 # of this software and associated documentation files (the "Software"), to deal
7 # in the Software without restriction, including without limitation the rights
8 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 # copies of the Software, and to permit persons to whom the Software is
10 # furnished to do so, subject to the following conditions:
12 # The above copyright notice and this permission notice shall be included in
13 # all copies or substantial portions of the Software.
15 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 """ Module implementing a JUnitBackend for piglit """
29 from lxml
import etree
31 import xml
.etree
.ElementTree
as etree
33 import simplejson
as json
37 from framework
import grouptools
, results
, exceptions
38 from framework
.core
import PIGLIT_CONFIG
39 from .abstract
import FileBackend
40 from .register
import Registry
48 _JUNIT_SPECIAL_NAMES
= ('api', 'search')
51 _START_TIME_STR
= "start time: "
52 _END_TIME_STR
= "end time: "
54 # XML cannot include certain characters. This regex matches the "invalid XML
55 # text character range".
56 _FORBIDDEN_XML_TEXT_CHARS_RE
= re
.compile(
57 u
'[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\U00010000-\U0010FFFF]+'
61 def escape_forbidden_xml_text_chars(val
, replacement
=''):
63 Strip invalid XML text characters.
65 # See: https://github.com/html5lib/html5lib-python/issues/96
67 # The XML 1.0 spec defines the valid character range as:
68 # Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
71 # https://www.w3.org/TR/REC-xml/#charsets,
72 # https://lsimons.wordpress.com/2011/03/17/stripping-illegal-characters-out-of-xml-in-python/
74 return _FORBIDDEN_XML_TEXT_CHARS_RE
.sub(replacement
, val
)
77 def junit_escape(name
):
78 name
= name
.replace('.', '_')
79 if name
in _JUNIT_SPECIAL_NAMES
:
84 class JUnitWriter(object):
85 """A class that provides a write mechanism for junit tests."""
87 def __init__(self
, test_suffix
, efail
, ecrash
):
88 self
._test
_suffix
= test_suffix
89 self
._expected
_crashes
= ecrash
90 self
._expected
_failures
= efail
93 def _make_names(name
):
94 """Takes a name from piglit (using grouptools.SEPARATOR and returns a
95 split classnam, testname pair in junit format.
97 classname
, testname
= grouptools
.splitname(name
)
98 classname
= classname
.split(grouptools
.SEPARATOR
)
99 classname
= [junit_escape(e
) for e
in classname
]
100 classname
= '.'.join(classname
)
102 # Add the test to the piglit group rather than directly to the root
103 # group, this allows piglit junit to be used in conjunction with other
105 # TODO: It would be nice if other suites integrating with piglit could
106 # set different root names.
107 classname
= 'piglit.' + classname
109 return (classname
, junit_escape(testname
))
112 def _set_xml_err(element
, data
, expected_result
):
113 """Adds the 'system-err' element."""
114 err
= etree
.SubElement(element
, 'system-err')
115 # We cannot control what is in the error output. Let's escape the
116 # forbidden XML characters.
117 err
.text
= escape_forbidden_xml_text_chars(data
.err
)
118 err
.text
+= '\n\n{}{}\n{}{}\n{}{}\n'.format(
120 _START_TIME_STR
, data
.time
.start
,
121 _END_TIME_STR
, data
.time
.end
)
123 if data
.result
in ['fail', 'dmesg-warn', 'dmesg-fail']:
124 if expected_result
== "failure":
125 err
.text
+= "\n\nWARN: passing test as an expected failure"
126 elif expected_result
== 'error':
128 "\n\nERROR: Test should have been crash but was failure"
129 elif data
.result
in ['crash', 'timeout']:
130 if expected_result
== "error":
131 err
.text
+= "\n\nWARN: passing test as an expected crash"
132 elif expected_result
== 'failure':
134 "\n\nERROR: Test should have been failure but was crash"
135 elif expected_result
!= "pass":
136 err
.text
+= "\n\nERROR: This test passed when it "\
137 "expected {0}".format(expected_result
)
140 def _make_result(element
, result
, expected_result
):
141 """Adds the skipped, failure, or error element."""
143 # If the result is skip, then just add the skipped message and go on
144 if result
== 'incomplete':
145 res
= etree
.SubElement(element
, 'failure',
146 message
='Incomplete run.')
147 elif result
in ['fail', 'dmesg-warn', 'dmesg-fail']:
148 if expected_result
== "failure":
149 res
= etree
.SubElement(element
, 'skipped',
150 message
='expected failure')
151 elif expected_result
== 'error':
152 res
= etree
.SubElement(element
, 'failure',
153 message
='expected crash, but got '
156 res
= etree
.SubElement(element
, 'failure')
157 elif result
in ['crash', 'timeout']:
158 if expected_result
== "error":
159 res
= etree
.SubElement(element
, 'skipped',
160 message
='expected crash')
161 elif expected_result
== 'failure':
162 res
= etree
.SubElement(element
, 'error',
163 message
='expected failure, but got '
166 res
= etree
.SubElement(element
, 'error')
167 elif expected_result
!= "pass":
168 res
= etree
.SubElement(element
, 'failure',
169 message
="expected {}, but got {}".format(
170 expected_result
, result
))
171 elif result
== 'skip':
172 res
= etree
.SubElement(element
, 'skipped')
174 # Add the piglit type to the failure result
176 res
.attrib
['type'] = str(result
)
178 def _make_root(self
, testname
, classname
, data
):
179 """Creates and returns the root element."""
180 element
= etree
.Element('testcase',
181 name
=self
._make
_full
_test
_name
(testname
),
183 # Incomplete will not have a time.
184 time
=str(data
.time
.total
),
185 status
=str(data
.result
))
189 def _make_full_test_name(self
, testname
):
190 # Jenkins will display special pages when the test has certain names.
191 # https://jenkins-ci.org/issue/18062
192 # https://jenkins-ci.org/issue/19810
193 # The testname variable is used in the calculate_result closure, and
194 # must not have the suffix appended.
195 return testname
+ self
._test
_suffix
197 def _expected_result(self
, name
):
198 """Get the expected result of the test."""
199 name
= name
.replace("=", ".").replace(":", ".")
200 expected_result
= "pass"
202 if name
in self
._expected
_failures
:
203 expected_result
= "failure"
204 # a test can either fail or crash, but not both
205 assert name
not in self
._expected
_crashes
207 if name
in self
._expected
_crashes
:
208 expected_result
= "error"
210 return expected_result
212 def __call__(self
, f
, name
, data
):
213 classname
, testname
= self
._make
_names
(name
)
214 element
= self
._make
_root
(testname
, classname
, data
)
215 expected_result
= self
._expected
_result
(
216 '{}.{}'.format(classname
, testname
).lower())
218 # If this is an incomplete status then none of these values will be
220 if data
.result
!= 'incomplete':
221 self
._set
_xml
_err
(element
, data
, expected_result
)
224 out
= etree
.SubElement(element
, 'system-out')
225 # We cannot control what is in the output. Let's escape the
226 # forbidden XML characters.
227 out
.text
= escape_forbidden_xml_text_chars(data
.out
)
229 # Prepend command line to stdout
230 out
.text
= data
.command
+ '\n' + out
.text
232 self
._make
_result
(element
, data
.result
, expected_result
)
234 f
.write(str(etree
.tostring(element
).decode('utf-8')))
237 class JUnitSubtestWriter(JUnitWriter
):
238 """A JUnitWriter derived class that treats subtest at testsuites.
240 This class will turn a piglit test with subtests into a testsuite element
241 with each subtest as a testcase element. This subclass is needed because
242 not all JUnit readers (like the JUnit plugin for Jenkins) handle nested
243 testsuites correctly.
246 def _make_root(self
, testname
, classname
, data
):
248 testname
= '{}.{}'.format(classname
, testname
)
249 element
= etree
.Element('testsuite',
251 time
=str(data
.time
.total
),
252 tests
=str(len(data
.subtests
)))
253 for test
, result
in data
.subtests
.items():
254 etree
.SubElement(element
,
256 name
=self
._make
_full
_test
_name
(test
),
261 element
= super(JUnitSubtestWriter
, self
)._make
_root
(
262 testname
, classname
, data
)
265 def __call__(self
, f
, name
, data
):
266 classname
, testname
= self
._make
_names
(name
)
267 element
= self
._make
_root
(testname
, classname
, data
)
269 # If this is an incomplete status then none of these values will be
271 if data
.result
!= 'incomplete':
272 self
._set
_xml
_err
(element
, data
, 'pass')
275 out
= etree
.SubElement(element
, 'system-out')
276 # We cannot control what is in the output. Let's escape the
277 # forbidden XML characters.
278 out
.text
= escape_forbidden_xml_text_chars(data
.out
)
279 # Prepend command line to stdout
280 out
.text
= data
.command
+ '\n' + out
.text
283 for subname
, result
in data
.subtests
.items():
284 # replace special characters and make case insensitive
285 elem
= element
.find('.//testcase[@name="{}"]'.format(
286 self
._make
_full
_test
_name
(subname
)))
287 assert elem
is not None
290 self
._expected
_result
('{}.{}.{}'.format(
291 classname
, testname
, subname
).lower()))
293 self
._make
_result
(element
, data
.result
,
294 self
._expected
_result
('{}.{}'.format(
295 classname
, testname
).lower()))
297 self
._make
_result
(element
, data
.result
,
298 self
._expected
_result
('{}.{}'.format(
299 classname
, testname
).lower()))
301 f
.write(str(etree
.tostring(element
).decode('utf-8')))
304 class JUnitBackend(FileBackend
):
305 """ Backend that produces ANT JUnit XML
307 Based on the following schema:
308 https://svn.jenkins-ci.org/trunk/hudson/dtkit/dtkit-format/dtkit-junit-model/src/main/resources/com/thalesgroup/dtkit/junit/model/xsd/junit-7.xsd
311 _file_extension
= 'xml'
312 _write
= None # this silences the abstract-not-subclassed warning
314 def __init__(self
, dest
, junit_suffix
='', junit_subtests
=False, **options
):
315 super(JUnitBackend
, self
).__init
__(dest
, **options
)
317 # make dictionaries of all test names expected to crash/fail
318 # for quick lookup when writing results. Use lower-case to
319 # provide case insensitive matches.
320 expected_failures
= {}
321 if PIGLIT_CONFIG
.has_section("expected-failures"):
322 for fail
, _
in PIGLIT_CONFIG
.items("expected-failures"):
323 expected_failures
[fail
.lower()] = True
324 expected_crashes
= {}
325 if PIGLIT_CONFIG
.has_section("expected-crashes"):
326 for fail
, _
in PIGLIT_CONFIG
.items("expected-crashes"):
327 expected_crashes
[fail
.lower()] = True
329 if not junit_subtests
:
330 self
._write
= JUnitWriter(
331 junit_suffix
, expected_failures
, expected_crashes
)
333 self
._write
= JUnitSubtestWriter( # pylint: disable=redefined-variable-type
334 junit_suffix
, expected_failures
, expected_crashes
)
336 def initialize(self
, metadata
):
339 Junit doesn't support restore, and doesn't have an initial metadata
340 block to write, so all this method does is create the tests directory
343 tests
= os
.path
.join(self
._dest
, 'tests')
344 if os
.path
.exists(tests
):
348 def finalize(self
, metadata
=None):
349 """ Scoop up all of the individual pieces and put them together """
350 root
= etree
.Element('testsuites')
351 piglit
= etree
.Element('testsuite', name
='piglit')
353 for each
in os
.listdir(os
.path
.join(self
._dest
, 'tests')):
354 with
open(os
.path
.join(self
._dest
, 'tests', each
), 'r') as f
:
355 # parse returns an element tree, and that's not what we want,
356 # we want the first (and only) Element node
357 # If the element cannot be properly parsed then consider it a
358 # failed transaction and ignore it.
360 piglit
.append(etree
.parse(f
).getroot())
361 except etree
.ParseError
:
364 num_tests
= len(piglit
)
366 raise exceptions
.PiglitUserError(
367 'No tests were run, not writing a result file',
370 # set the test count by counting the number of tests.
371 # This must be unicode (py3 str)
372 piglit
.attrib
['tests'] = str(num_tests
)
375 with
open(os
.path
.join(self
._dest
, 'results.xml'), 'w') as f
:
376 f
.write("<?xml version='1.0' encoding='utf-8'?>\n")
377 # lxml has a pretty print we want to use
378 if etree
.__name
__ == 'lxml.etree':
379 out
= etree
.tostring(root
, pretty_print
=True)
381 out
= etree
.tostring(root
)
382 f
.write(out
.decode('utf-8'))
384 shutil
.rmtree(os
.path
.join(self
._dest
, 'tests'))
387 def _load(results_file
):
388 """Load a junit results instance and return a TestrunResult.
390 It's worth noting that junit is not as descriptive as piglit's own json
391 format, so some data structures will be empty compared to json.
393 This tries to not make too many assumptions about the structure of the
397 run_result
= results
.TestrunResult()
399 splitpath
= os
.path
.splitext(results_file
)[0].split(os
.path
.sep
)
400 if splitpath
[-1] != 'results':
401 run_result
.name
= splitpath
[-1]
402 elif len(splitpath
) > 1:
403 run_result
.name
= splitpath
[-2]
405 run_result
.name
= 'junit result'
407 tree
= etree
.parse(results_file
).getroot().find('.//testsuite')
408 for test
in tree
.iterfind('testcase'):
409 result
= results
.TestResult()
410 # Take the class name minus the 'piglit.' element, replace junit's '.'
411 # separator with piglit's separator, and join the group and test names
412 name
= test
.attrib
['name']
413 if 'classname' in test
.attrib
:
414 name
= grouptools
.join(test
.attrib
['classname'], name
)
415 name
= name
.replace('.', grouptools
.SEPARATOR
)
417 if name
.startswith("piglit"):
419 name
= name
.split(grouptools
.SEPARATOR
, 1)[1]
421 # Remove the trailing _ if they were added (such as to api and search)
422 if name
.endswith('_'):
425 result
.result
= test
.attrib
['status']
427 # This is the fallback path, we'll try to overwrite this with the value
429 result
.time
= results
.TimeAttribute()
430 if 'time' in test
.attrib
:
431 result
.time
= results
.TimeAttribute(end
=float(test
.attrib
['time']))
432 syserr
= test
.find('system-err')
433 if syserr
is not None:
434 reversed_err
= syserr
.text
.split('\n')
435 reversed_err
.reverse()
439 # The command is prepended to system-out, so we need to separate those
440 # into two separate elements
441 out_tag
= test
.find('system-out')
442 if out_tag
is not None:
444 out
= out_tag
.text
.split('\n')
445 result
.command
= out
[0]
446 result
.out
= '\n'.join(out
[1:])
448 result
.out
= out_tag
.text
452 # Try to get the values in stderr for time and pid
453 for line
in reversed_err
:
460 if line
.startswith(_START_TIME_STR
):
461 result
.time
.start
= float(line
[len(_START_TIME_STR
):])
463 elif line
.startswith(_END_TIME_STR
):
464 result
.time
.end
= float(line
[len(_END_TIME_STR
):])
466 elif line
.startswith(_PID_STR
):
467 result
.pid
= json
.loads(line
[len(_PID_STR
):])
470 err_list
.append(line
)
473 result
.err
= "\n".join(err_list
)
476 run_result
.tests
[name
] = result
478 run_result
.calculate_group_totals()
483 def load(results_dir
, compression
): # pylint: disable=unused-argument
484 """Searches for a results file and returns a TestrunResult.
486 wraps _load and searches for the result file.
489 if not os
.path
.isdir(results_dir
):
490 return _load(results_dir
)
491 elif os
.path
.exists(os
.path
.join(results_dir
, 'tests')):
492 raise NotImplementedError('resume support of junit not implemented')
493 elif os
.path
.exists(os
.path
.join(results_dir
, 'results.xml')):
494 return _load(os
.path
.join(results_dir
, 'results.xml'))
496 raise exceptions
.PiglitFatalError("No results found")
499 def write_results(results
, file_
, junit_subtests
=False):
500 """Write the values of the results out to a file."""
502 if not junit_subtests
:
503 writer
= JUnitWriter('', {}, {})
505 writer
= JUnitSubtestWriter('', {}, {})
507 with
open(file_
, 'w') as f
:
508 f
.write("<?xml version='1.0' encoding='utf-8'?>\n")
509 f
.write('<testsuites><testsuite name="piglit" tests="{}">'.format(
511 for k
, v
in results
.tests
.items():
513 f
.write("</testsuite></testsuites>")
520 backend
=JUnitBackend
,
522 meta
=lambda x
: x
, # The venerable no-op function