1#!/usr/bin/env python3
2
3#  Copyright (C) 2024 The Android Open Source Project
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"""A converter for Mobly result schema to Resultstore schema.
18
19Each Mobly test class maps to a Resultstore testsuite and each Mobly test method
20maps to a Resultstore testcase. For example:
21
22  Mobly schema:
23
24  Test Class: HelloWorldTest
25  Test Name: test_hello
26  Type: Record
27  Result: PASS
28
29  Resultstore schema:
30
31  <testsuite name="HelloWorldTest" tests=1>
32    <testcase name="test_hello"/>
33  </testsuite>
34"""
35
36import dataclasses
37import datetime
38import enum
39import logging
40import pathlib
41import re
42from typing import Any, Dict, Iterator, List, Mapping, Optional
43from xml.etree import ElementTree
44
45from mobly import records
46import yaml
47
48_MOBLY_RECORD_TYPE_KEY = 'Type'
49
50_MOBLY_TEST_SUITE_NAME = 'MoblyTest'
51
52_TEST_INTERRUPTED_MESSAGE = 'Details: Test was interrupted manually.'
53
54_ILLEGAL_XML_CHARS = re.compile(
55    '[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]'
56)
57
58_ILLEGAL_YAML_CHARS = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]')
59
60
61class MoblyResultstoreProperties(enum.Enum):
62    """Resultstore properties defined specifically for all Mobly tests.
63
64    All these properties apply to the testcase level. TEST_CLASS and
65    TEST_TYPE apply to both the testcase and testsuite level.
66    """
67
68    BEGIN_TIME = 'mobly_begin_time'
69    END_TIME = 'mobly_end_time'
70    TEST_CLASS = 'mobly_test_class'
71    TEST_TYPE = 'test_type'
72    UID = 'mobly_uid'
73    TEST_OUTPUT = 'test_output'
74    TEST_SIGNATURE = 'mobly_signature'
75    SKIP_REASON = 'skip_reason'
76    ERROR_MESSAGE = 'mobly_error_message'
77    ERROR_TYPE = 'mobly_error_type'
78    STACK_TRACE = 'mobly_stack_trace'
79
80
81_MOBLY_PROPERTY_VALUES = frozenset(e.value for e in MoblyResultstoreProperties)
82
83
84class ResultstoreTreeTags(enum.Enum):
85    """Common tags for Resultstore tree nodes."""
86
87    TESTSUITES = 'testsuites'
88    TESTSUITE = 'testsuite'
89    TESTCASE = 'testcase'
90    PROPERTIES = 'properties'
91    PROPERTY = 'property'
92    FAILURE = 'failure'
93    ERROR = 'error'
94
95
96class ResultstoreTreeAttributes(enum.Enum):
97    """Common attributes for Resultstore tree nodes."""
98
99    ERRORS = 'errors'
100    FAILURES = 'failures'
101    TESTS = 'tests'
102    CLASS_NAME = 'classname'
103    RESULT = 'result'
104    STATUS = 'status'
105    TIME = 'time'
106    TIMESTAMP = 'timestamp'
107    NAME = 'name'
108    VALUE = 'value'
109    MESSAGE = 'message'
110    RETRY_NUMBER = 'retrynumber'
111    REPEAT_NUMBER = 'repeatnumber'
112    ERROR_TYPE = 'type'
113    RERAN_TEST_NAME = 'rerantestname'
114
115
116@dataclasses.dataclass
117class TestSuiteSummary:
118    num_tests: int
119    num_errors: int
120    num_failures: int
121
122
123@dataclasses.dataclass
124class ReranNode:
125    reran_test_name: str
126    original_test_name: str
127    index: int
128    node_type: records.TestParentType
129
130
131def _find_all_elements(
132        mobly_root: ElementTree.Element,
133        class_name: Optional[str],
134        test_name: Optional[str],
135) -> Iterator[ElementTree.Element]:
136    """Finds all elements in the Resultstore tree with class name and/or
137    test_name.
138
139    If class name is absent, it will find all elements with the test name
140    across all test classes. If test name is absent it will find all elements
141    with class name. If both are absent, it will just return the Mobly root
142    tree.
143
144    Args:
145      mobly_root: Root element of the Mobly test Resultstore tree.
146      class_name: Mobly test class name to get the elements for.
147      test_name: Mobly test names to get the elements for.
148
149    Yields:
150      Iterator of elements satisfying the class_name and test_name search
151      criteria.
152    """
153    if class_name is None and test_name is None:
154        yield mobly_root
155        return
156
157    xpath = f'./{ResultstoreTreeTags.TESTSUITE.value}'
158    if class_name is not None:
159        xpath += f'[@{ResultstoreTreeAttributes.NAME.value}="{class_name}"]'
160    if test_name is not None:
161        xpath += (
162            f'/{ResultstoreTreeTags.TESTCASE.value}'
163            f'[@{ResultstoreTreeAttributes.NAME.value}="{test_name}"]'
164        )
165
166    yield from mobly_root.iterfind(xpath)
167
168
169def _create_or_return_properties_element(
170        element: ElementTree.Element,
171) -> ElementTree.Element:
172    properties_element = element.find(
173        f'./{ResultstoreTreeTags.PROPERTIES.value}')
174    if properties_element is not None:
175        return properties_element
176    return ElementTree.SubElement(element, ResultstoreTreeTags.PROPERTIES.value)
177
178
179def _add_or_update_property_element(
180        properties_element: ElementTree.Element, name: str, value: str
181):
182    """Adds a property element or update the property value."""
183    name = _ILLEGAL_XML_CHARS.sub('', name)
184    value = _ILLEGAL_XML_CHARS.sub('', value)
185    property_element = properties_element.find(
186        f'./{ResultstoreTreeTags.PROPERTY.value}'
187        f'[@{ResultstoreTreeAttributes.NAME.value}="{name}"]'
188    )
189    if property_element is None:
190        property_element = ElementTree.SubElement(
191            properties_element, ResultstoreTreeTags.PROPERTY.value
192        )
193        property_element.set(ResultstoreTreeAttributes.NAME.value, name)
194    property_element.set(ResultstoreTreeAttributes.VALUE.value, value)
195
196
197def _add_file_annotations(
198        entry: Mapping[str, Any],
199        properties_element: ElementTree.Element,
200        mobly_base_directory: Optional[pathlib.Path],
201) -> None:
202    """Adds file annotations for a Mobly test case files.
203
204    The mobly_base_directory is used to find the files belonging to a test case.
205    The files under "mobly_base_directory/test_class/test_method" belong to the
206    test_class#test_method Resultstore node. Additionally, it is used to
207    determine the relative path of the files for Resultstore undeclared outputs.
208    The file annotation must be written for the relative path.
209
210    Args:
211      entry: Mobly summary entry for the test case.
212      properties_element: Test case properties element.
213      mobly_base_directory: Base directory of the Mobly test.
214    """
215    # If mobly_base_directory is not provided, the converter will not add the
216    # annotations to associate the files with the test cases.
217    if (
218            mobly_base_directory is None
219            or entry.get(records.TestResultEnums.RECORD_SIGNATURE, None) is None
220    ):
221        return
222
223    test_class = entry[records.TestResultEnums.RECORD_CLASS]
224    test_case_directory = mobly_base_directory.joinpath(
225        test_class,
226        entry[records.TestResultEnums.RECORD_SIGNATURE]
227    )
228
229    test_case_files = test_case_directory.rglob('*')
230    file_counter = 0
231    for file_path in test_case_files:
232        if not file_path.is_file():
233            continue
234        relative_path = file_path.relative_to(mobly_base_directory)
235        _add_or_update_property_element(
236            properties_element,
237            f'test_output{file_counter}',
238            str(relative_path.as_posix()),
239        )
240        file_counter += 1
241
242
243def _create_mobly_root_element(
244        summary_record: Mapping[str, Any]
245) -> ElementTree.Element:
246    """Creates a Resultstore XML testsuite node for a Mobly test summary."""
247    full_summary = TestSuiteSummary(
248        num_tests=summary_record['Requested'],
249        num_errors=summary_record['Error'],
250        num_failures=summary_record['Failed'],
251    )
252    # Create the root Resultstore node to wrap the Mobly test.
253    main_wrapper = ElementTree.Element(ResultstoreTreeTags.TESTSUITES.value)
254    main_wrapper.set(ResultstoreTreeAttributes.NAME.value, '__main__')
255    main_wrapper.set(ResultstoreTreeAttributes.TIME.value, '0')
256    main_wrapper.set(
257        ResultstoreTreeAttributes.ERRORS.value, str(full_summary.num_errors)
258    )
259    main_wrapper.set(
260        ResultstoreTreeAttributes.FAILURES.value, str(full_summary.num_failures)
261    )
262    main_wrapper.set(
263        ResultstoreTreeAttributes.TESTS.value, str(full_summary.num_tests)
264    )
265
266    mobly_test_root = ElementTree.SubElement(
267        main_wrapper, ResultstoreTreeTags.TESTSUITE.value
268    )
269    mobly_test_root.set(
270        ResultstoreTreeAttributes.NAME.value, _MOBLY_TEST_SUITE_NAME
271    )
272    mobly_test_root.set(ResultstoreTreeAttributes.TIME.value, '0')
273    mobly_test_root.set(
274        ResultstoreTreeAttributes.ERRORS.value, str(full_summary.num_errors)
275    )
276    mobly_test_root.set(
277        ResultstoreTreeAttributes.FAILURES.value, str(full_summary.num_failures)
278    )
279    mobly_test_root.set(
280        ResultstoreTreeAttributes.TESTS.value, str(full_summary.num_tests)
281    )
282
283    return main_wrapper
284
285
286def _create_class_element(
287        class_name: str, class_summary: TestSuiteSummary
288) -> ElementTree.Element:
289    """Creates a Resultstore XML testsuite node for a Mobly test class summary.
290
291    Args:
292      class_name: Mobly test class name.
293      class_summary: Mobly test class summary.
294
295    Returns:
296      A Resultstore testsuite node representing one Mobly test class.
297    """
298    class_element = ElementTree.Element(ResultstoreTreeTags.TESTSUITE.value)
299    class_element.set(ResultstoreTreeAttributes.NAME.value, class_name)
300    class_element.set(ResultstoreTreeAttributes.TIME.value, '0')
301    class_element.set(
302        ResultstoreTreeAttributes.TESTS.value, str(class_summary.num_tests)
303    )
304    class_element.set(
305        ResultstoreTreeAttributes.ERRORS.value, str(class_summary.num_errors)
306    )
307    class_element.set(
308        ResultstoreTreeAttributes.FAILURES.value,
309        str(class_summary.num_failures)
310    )
311
312    properties_element = _create_or_return_properties_element(class_element)
313    _add_or_update_property_element(
314        properties_element,
315        MoblyResultstoreProperties.TEST_CLASS.value,
316        class_name,
317    )
318    _add_or_update_property_element(
319        properties_element,
320        MoblyResultstoreProperties.TEST_TYPE.value,
321        'mobly_class',
322    )
323
324    return class_element
325
326
327def _set_rerun_node(
328        signature: str,
329        child_parent_map: Mapping[str, str],
330        parent_type_map: Mapping[str, records.TestParentType],
331        signature_test_name_map: Mapping[str, str],
332        rerun_node_map: Dict[str, ReranNode],
333) -> None:
334    """Sets the rerun node in the rerun node map for the current test signature.
335
336    This function traverses the child parent map recursively until it finds the
337    root test run for the rerun chain. Then it uses the original test name from
338    there and builds the indices.
339
340    Args:
341      signature: Current test signature.
342      child_parent_map: Map of test signature to the parent test signature.
343      parent_type_map: Map of parent test signature to the parent type.
344      signature_test_name_map: Map of test signature to test name.
345      rerun_node_map: Map of test signature to rerun information.
346    """
347    if signature in rerun_node_map:
348        return
349
350    # If there is no parent, then this is the root test in the retry chain.
351    if signature not in child_parent_map:
352        if parent_type_map[signature] == records.TestParentType.REPEAT:
353            # If repeat, remove the '_#' suffix to get the original test name.
354            original_test_name = \
355              signature_test_name_map[signature].rsplit('_', 1)[0]
356        else:
357            original_test_name = signature_test_name_map[signature]
358        rerun_node_map[signature] = ReranNode(
359            signature_test_name_map[signature],
360            original_test_name,
361            0,
362            parent_type_map[signature],
363        )
364        return
365
366    parent_signature = child_parent_map[signature]
367    _set_rerun_node(
368        parent_signature,
369        child_parent_map,
370        parent_type_map,
371        signature_test_name_map,
372        rerun_node_map,
373    )
374
375    parent_node = rerun_node_map[parent_signature]
376    rerun_node_map[signature] = ReranNode(
377        signature_test_name_map[signature],
378        parent_node.original_test_name,
379        parent_node.index + 1,
380        parent_node.node_type,
381    )
382
383
384def _get_reran_nodes(
385        entries: List[Mapping[str, Any]]
386) -> Mapping[str, ReranNode]:
387    """Gets the nodes for any test case reruns.
388
389    Args:
390      entries: Summary entries for the Mobly test runs.
391
392    Returns:
393      A map of test signature to node information.
394    """
395    child_parent_map = {}
396    parent_type_map = {}
397    signature_test_name_map = {}
398    for entry in entries:
399        if records.TestResultEnums.RECORD_SIGNATURE not in entry:
400            continue
401        current_signature = entry[records.TestResultEnums.RECORD_SIGNATURE]
402        signature_test_name_map[current_signature] = entry[
403            records.TestResultEnums.RECORD_NAME
404        ]
405        # This is a dictionary with parent and type.
406        rerun_parent = entry.get(records.TestResultEnums.RECORD_PARENT, None)
407        if rerun_parent is not None:
408            parent_signature = rerun_parent['parent']
409            parent_type = (
410                records.TestParentType.RETRY
411                if rerun_parent['type'] == 'retry'
412                else records.TestParentType.REPEAT
413            )
414            child_parent_map[current_signature] = parent_signature
415            parent_type_map[parent_signature] = parent_type
416
417    rerun_node_map = {}
418    for signature in child_parent_map:
419        # Populates the rerun node map.
420        _set_rerun_node(
421            signature,
422            child_parent_map,
423            parent_type_map,
424            signature_test_name_map,
425            rerun_node_map,
426        )
427
428    return rerun_node_map
429
430
431def _process_record(
432        entry: Mapping[str, Any],
433        reran_node: Optional[ReranNode],
434        mobly_base_directory: Optional[pathlib.Path],
435) -> ElementTree.Element:
436    """Processes a single Mobly test record entry to a Resultstore test case
437    node.
438
439    Args:
440      entry: Summary of a single Mobly test case.
441      reran_node: Rerun information if this test case is a rerun. Only present
442        if this test is part of a rerun chain.
443      mobly_base_directory: Base directory for the Mobly test. Artifacts from
444        the Mobly test will be saved here.
445
446    Returns:
447      A Resultstore XML node representing a single test case.
448    """
449    begin_time = entry[records.TestResultEnums.RECORD_BEGIN_TIME]
450    end_time = entry[records.TestResultEnums.RECORD_END_TIME]
451    testcase_element = ElementTree.Element(ResultstoreTreeTags.TESTCASE.value)
452    result = entry[records.TestResultEnums.RECORD_RESULT]
453
454    if reran_node is not None:
455        if reran_node.node_type == records.TestParentType.RETRY:
456            testcase_element.set(
457                ResultstoreTreeAttributes.RETRY_NUMBER.value,
458                str(reran_node.index)
459            )
460        elif reran_node.node_type == records.TestParentType.REPEAT:
461            testcase_element.set(
462                ResultstoreTreeAttributes.REPEAT_NUMBER.value,
463                str(reran_node.index)
464            )
465        testcase_element.set(
466            ResultstoreTreeAttributes.NAME.value, reran_node.original_test_name
467        )
468        testcase_element.set(
469            ResultstoreTreeAttributes.RERAN_TEST_NAME.value,
470            reran_node.reran_test_name,
471        )
472    else:
473        testcase_element.set(
474            ResultstoreTreeAttributes.NAME.value,
475            entry[records.TestResultEnums.RECORD_NAME],
476        )
477        testcase_element.set(
478            ResultstoreTreeAttributes.RERAN_TEST_NAME.value,
479            entry[records.TestResultEnums.RECORD_NAME],
480        )
481    testcase_element.set(
482        ResultstoreTreeAttributes.CLASS_NAME.value,
483        entry[records.TestResultEnums.RECORD_CLASS],
484    )
485    if result == records.TestResultEnums.TEST_RESULT_SKIP:
486        testcase_element.set(ResultstoreTreeAttributes.RESULT.value, 'skipped')
487        testcase_element.set(ResultstoreTreeAttributes.STATUS.value, 'notrun')
488        testcase_element.set(ResultstoreTreeAttributes.TIME.value, '0')
489    elif result is None:
490        testcase_element.set(ResultstoreTreeAttributes.RESULT.value,
491                             'completed')
492        testcase_element.set(ResultstoreTreeAttributes.STATUS.value, 'run')
493        testcase_element.set(ResultstoreTreeAttributes.TIME.value, '0')
494        testcase_element.set(
495            ResultstoreTreeAttributes.TIMESTAMP.value,
496            datetime.datetime.fromtimestamp(
497                begin_time / 1000, tz=datetime.timezone.utc
498            ).strftime('%Y-%m-%dT%H:%M:%SZ'),
499        )
500    else:
501        testcase_element.set(ResultstoreTreeAttributes.RESULT.value,
502                             'completed')
503        testcase_element.set(ResultstoreTreeAttributes.STATUS.value, 'run')
504        testcase_element.set(
505            ResultstoreTreeAttributes.TIME.value, str(end_time - begin_time)
506        )
507        testcase_element.set(
508            ResultstoreTreeAttributes.TIMESTAMP.value,
509            datetime.datetime.fromtimestamp(
510                begin_time / 1000, tz=datetime.timezone.utc
511            ).strftime('%Y-%m-%dT%H:%M:%SZ'),
512        )
513
514    # Add Mobly specific test case properties.
515    properties_element = _create_or_return_properties_element(testcase_element)
516    if result == records.TestResultEnums.TEST_RESULT_SKIP:
517        _add_or_update_property_element(
518            properties_element,
519            MoblyResultstoreProperties.SKIP_REASON.value,
520            f'Details: {entry[records.TestResultEnums.RECORD_DETAILS]}',
521        )
522    elif result is None:
523        _add_or_update_property_element(
524            properties_element,
525            MoblyResultstoreProperties.BEGIN_TIME.value,
526            str(begin_time),
527        )
528    else:
529        _add_or_update_property_element(
530            properties_element,
531            MoblyResultstoreProperties.BEGIN_TIME.value,
532            str(begin_time),
533        )
534        _add_or_update_property_element(
535            properties_element,
536            MoblyResultstoreProperties.END_TIME.value,
537            str(end_time),
538        )
539    _add_or_update_property_element(
540        properties_element,
541        MoblyResultstoreProperties.TEST_CLASS.value,
542        entry[records.TestResultEnums.RECORD_CLASS],
543    )
544    _add_or_update_property_element(
545        properties_element,
546        MoblyResultstoreProperties.TEST_TYPE.value,
547        'mobly_test',
548    )
549
550    if entry.get(records.TestResultEnums.RECORD_SIGNATURE, None) is not None:
551        _add_or_update_property_element(
552            properties_element,
553            MoblyResultstoreProperties.TEST_SIGNATURE.value,
554            entry[records.TestResultEnums.RECORD_SIGNATURE],
555        )
556
557    _add_file_annotations(
558        entry,
559        properties_element,
560        mobly_base_directory,
561    )
562
563    if entry[records.TestResultEnums.RECORD_UID] is not None:
564        _add_or_update_property_element(
565            properties_element,
566            MoblyResultstoreProperties.UID.value,
567            entry[records.TestResultEnums.RECORD_UID],
568        )
569
570    if result is None:
571        error_element = ElementTree.SubElement(
572            testcase_element, ResultstoreTreeTags.ERROR.value
573        )
574        error_element.set(
575            ResultstoreTreeAttributes.MESSAGE.value, _TEST_INTERRUPTED_MESSAGE
576        )
577        error_element.text = _TEST_INTERRUPTED_MESSAGE
578    elif (
579            result == records.TestResultEnums.TEST_RESULT_FAIL
580            or result == records.TestResultEnums.TEST_RESULT_ERROR
581    ):
582        error_message = (
583            f'Details: {entry[records.TestResultEnums.RECORD_DETAILS]}')
584        tag = (
585            ResultstoreTreeTags.FAILURE.value
586            if result == records.TestResultEnums.TEST_RESULT_FAIL
587            else ResultstoreTreeTags.ERROR.value
588        )
589        failure_or_error_element = ElementTree.SubElement(testcase_element, tag)
590        failure_or_error_element.set(
591            ResultstoreTreeAttributes.MESSAGE.value, error_message
592        )
593        _add_or_update_property_element(
594            properties_element,
595            MoblyResultstoreProperties.ERROR_MESSAGE.value,
596            error_message,
597        )
598
599        # Add termination signal type and stack trace to the failure XML element
600        # and the test case properties.
601        termination_signal_type = entry[
602            records.TestResultEnums.RECORD_TERMINATION_SIGNAL_TYPE
603        ]
604        if termination_signal_type is None:
605            logging.warning(
606                'Test %s has %s result without a termination signal type.',
607                entry[records.TestResultEnums.RECORD_NAME],
608                result,
609            )
610        else:
611            failure_or_error_element.set(
612                ResultstoreTreeAttributes.ERROR_TYPE.value,
613                termination_signal_type
614            )
615            _add_or_update_property_element(
616                properties_element,
617                MoblyResultstoreProperties.ERROR_TYPE.value,
618                termination_signal_type,
619            )
620        stack_trace = entry[records.TestResultEnums.RECORD_STACKTRACE]
621        if stack_trace is None:
622            logging.warning(
623                'Test %s has %s result without a stack trace.',
624                entry[records.TestResultEnums.RECORD_NAME],
625                result,
626            )
627        else:
628            failure_or_error_element.text = stack_trace
629            _add_or_update_property_element(
630                properties_element,
631                MoblyResultstoreProperties.STACK_TRACE.value,
632                stack_trace,
633            )
634    return testcase_element
635
636
637def convert(
638        mobly_results_path: pathlib.Path,
639        mobly_base_directory: Optional[pathlib.Path] = None,
640) -> ElementTree.ElementTree:
641    """Converts a Mobly results summary file to Resultstore XML schema.
642
643    The mobly_base_directory will be used to compute the file links for each
644    Resultstore tree element. If it is absent then the file links will be
645    omitted.
646
647    Args:
648      mobly_results_path: Path to the Mobly summary YAML file.
649      mobly_base_directory: Base directory of the Mobly test.
650
651    Returns:
652      A Resultstore XML tree for the Mobly test.
653    """
654    logging.info('Generating Resultstore tree...')
655
656    with mobly_results_path.open('r', encoding='utf-8') as f:
657        summary_entries = list(
658            yaml.safe_load_all(_ILLEGAL_YAML_CHARS.sub('', f.read()))
659        )
660
661    summary_record = next(
662        entry
663        for entry in summary_entries
664        if entry[_MOBLY_RECORD_TYPE_KEY]
665        == records.TestSummaryEntryType.SUMMARY.value
666    )
667
668    main_root = _create_mobly_root_element(summary_record)
669
670    mobly_test_root = main_root[0]
671    mobly_root_properties = _create_or_return_properties_element(
672        mobly_test_root)
673    # Add files under the Mobly root directory to the Mobly test suite node.
674    if mobly_base_directory is not None:
675        file_counter = 0
676        for file_path in mobly_base_directory.iterdir():
677            if not file_path.is_file():
678                continue
679            relative_path = file_path.relative_to(mobly_base_directory)
680            _add_or_update_property_element(
681                mobly_root_properties,
682                f'test_output{file_counter}',
683                str(relative_path.as_posix()),
684            )
685            file_counter += 1
686
687    test_case_entries = [
688        entry
689        for entry in summary_entries
690        if (entry[_MOBLY_RECORD_TYPE_KEY]
691            == records.TestSummaryEntryType.RECORD.value)
692    ]
693    # Populate the class summaries.
694    class_summaries = {}
695    for entry in test_case_entries:
696        class_name = entry[records.TestResultEnums.RECORD_CLASS]
697
698        if class_name not in class_summaries:
699            class_summaries[class_name] = TestSuiteSummary(
700                num_tests=0, num_errors=0, num_failures=0
701            )
702
703        class_summaries[class_name].num_tests += 1
704        if (
705                entry[records.TestResultEnums.RECORD_RESULT]
706                == records.TestResultEnums.TEST_RESULT_ERROR
707        ):
708            class_summaries[class_name].num_errors += 1
709        elif (
710                entry[records.TestResultEnums.RECORD_RESULT]
711                == records.TestResultEnums.TEST_RESULT_FAIL
712        ):
713            class_summaries[class_name].num_failures += 1
714
715    # Create class nodes.
716    class_elements = {}
717    for class_name, summary in class_summaries.items():
718        class_elements[class_name] = _create_class_element(class_name, summary)
719        mobly_test_root.append(class_elements[class_name])
720
721    # Append test case nodes to test class nodes.
722    reran_nodes = _get_reran_nodes(test_case_entries)
723    for entry in test_case_entries:
724        class_name = entry[records.TestResultEnums.RECORD_CLASS]
725        if (
726                records.TestResultEnums.RECORD_SIGNATURE in entry
727                and
728                entry[records.TestResultEnums.RECORD_SIGNATURE] in reran_nodes
729        ):
730            reran_node = reran_nodes[
731                entry[records.TestResultEnums.RECORD_SIGNATURE]]
732        else:
733            reran_node = None
734        class_elements[class_name].append(
735            _process_record(entry, reran_node, mobly_base_directory)
736        )
737
738    user_data_entries = [
739        entry
740        for entry in summary_entries
741        if (entry[_MOBLY_RECORD_TYPE_KEY]
742            == records.TestSummaryEntryType.USER_DATA.value)
743    ]
744
745    for user_data_entry in user_data_entries:
746        class_name = user_data_entry.get(records.TestResultEnums.RECORD_CLASS,
747                                         None)
748        test_name = user_data_entry.get(records.TestResultEnums.RECORD_NAME,
749                                        None)
750
751        properties = user_data_entry.get('properties', None)
752        if not isinstance(properties, dict):
753            continue
754        for element in _find_all_elements(mobly_test_root, class_name,
755                                          test_name):
756            properties_element = _create_or_return_properties_element(element)
757            for name, value in properties.items():
758                if name in _MOBLY_PROPERTY_VALUES:
759                    # Do not override Mobly properties.
760                    continue
761                _add_or_update_property_element(
762                    properties_element, str(name), str(value)
763                )
764
765    return ElementTree.ElementTree(main_root)
766