winterfroststrom: build failure?
diff --git a/mobly/records.py b/mobly/records.py
index 051ff9c..171c0ca 100644
--- a/mobly/records.py
+++ b/mobly/records.py
@@ -21,6 +21,7 @@ import io
 import logging
 import sys
 import threading
+import time
 import traceback
 import yaml
 
@@ -52,7 +53,7 @@ class TestSummaryEntryType(enum.Enum):
     RECORD = 'Record'
     # A summary of the test run stats, e.g. how many test failed.
     SUMMARY = 'Summary'
-    # Information on the controllers used in the test.
+    # Information on the controllers used in a test class.
     CONTROLLER_INFO = 'ControllerInfo'
     # Additional data added by users during test.
     # This can be added at any point in the test, so do not assume the location
@@ -155,8 +156,34 @@ class TestResultEnums(object):
     TEST_RESULT_ERROR = 'ERROR'
 
 
+class ControllerInfoRecord(object):
+    """A record representing the controller info in test results."""
+
+    KEY_TEST_CLASS = TestResultEnums.RECORD_CLASS
+    KEY_CONTROLLER_NAME = 'Controller Name'
+    KEY_CONTROLLER_INFO = 'Controller Info'
+    KEY_TIMESTAMP = 'Timestamp'
+
+    def __init__(self, test_class, controller_name, info):
+        self.test_class = test_class
+        self.controller_name = controller_name
+        self.controller_info = info
+        self.timestamp = time.time()
+
+    def to_dict(self):
+        result = {}
+        result[self.KEY_TEST_CLASS] = self.test_class
+        result[self.KEY_CONTROLLER_NAME] = self.controller_name
+        result[self.KEY_CONTROLLER_INFO] = self.controller_info
+        result[self.KEY_TIMESTAMP] = self.timestamp
+        return result
+
+    def __repr__(self):
+        return str(self.to_dict())
+
+
 class ExceptionRecord(object):
-    """A wrapper class for representing exception objects in TestResultRecord.
+    """A record representing exception objects in TestResultRecord.
 
     Attributes:
         exception: Exception object, the original Exception.
@@ -235,7 +262,7 @@ class ExceptionRecord(object):
         result = ExceptionRecord(exception, self.position)
         result.stacktrace = self.stacktrace
         result.details = self.details
-        result.extras = self.extras
+        result.extras = copy.deepcopy(self.extras)
         result.position = self.position
         return result
 
@@ -447,13 +474,14 @@ class TestResult(object):
     This class is essentially a container of TestResultRecord objects.
 
     Attributes:
-        self.requested: A list of strings, each is the name of a test requested
+        requested: A list of strings, each is the name of a test requested
             by user.
-        self.failed: A list of records for tests failed.
-        self.executed: A list of records for tests that were actually executed.
-        self.passed: A list of records for tests passed.
-        self.skipped: A list of records for tests skipped.
-        self.error: A list of records for tests with error result token.
+        failed: A list of records for tests failed.
+        executed: A list of records for tests that were actually executed.
+        passed: A list of records for tests passed.
+        skipped: A list of records for tests skipped.
+        error: A list of records for tests with error result token.
+        controller_info: list of ControllerInfoRecord.
     """
 
     def __init__(self):
@@ -463,7 +491,7 @@ class TestResult(object):
         self.passed = []
         self.skipped = []
         self.error = []
-        self.controller_info = {}
+        self.controller_info = []
 
     def __add__(self, r):
         """Overrides '+' operator for TestResult class.
@@ -486,12 +514,6 @@ class TestResult(object):
             l_value = getattr(self, name)
             if isinstance(r_value, list):
                 setattr(sum_result, name, l_value + r_value)
-            elif isinstance(r_value, dict):
-                # '+' operator for TestResult is only valid when multiple
-                # TestResult objs were created in the same test run, use the
-                # r-value which is more up to date.
-                # TODO(xpconanfan): have a better way to validate this situation.
-                setattr(sum_result, name, r_value)
         return sum_result
 
     def add_record(self, record):
@@ -517,15 +539,28 @@ class TestResult(object):
         else:
             self.error.append(record)
 
-    def add_controller_info(self, name, info):
+    def add_controller_info(self, controller_name, controller_info,
+                            test_class):
+        """Adds controller info to results.
+
+        This can be called multiple times for each
+
+        Args:
+            controller_name: string, name of the controller.
+            controller_info: yaml serializable info about the controller.
+            test_class: string, a tag for identifying a class. This should be
+                the test class's own `TAG` attribute.
+        """
+        info = controller_info
         try:
-            yaml.dump(info)
+            yaml.dump(controller_info)
         except TypeError:
-            logging.warning('Controller info for %s is not YAML serializable!'
-                            ' Coercing it to string.' % name)
-            self.controller_info[name] = str(info)
-            return
-        self.controller_info[name] = info
+            logging.warning('The info of controller %s in class "%s" is not '
+                            'YAML serializable! Coercing it to string.',
+                            controller_name, test_class)
+            info = str(controller_info)
+        self.controller_info.append(
+            ControllerInfoRecord(test_class, controller_name, info))
 
     def add_class_error(self, test_record):
         """Add a record to indicate a test class has failed before any test

Custom controller info may not be recorded in summary yaml
Controller modules may choose to allow customization of controller info during the test. E.g. `AndroidDevice.add_device_info` adds extra info of the controller objects to the summary yaml.

But `TestRunner` only record the controller info at the controller registration time, so the customized info may never get recorded depending on the implementation of the controller module.

I.e. If the info is a static string, we it will be set at registration time, and subsequent changes to the info field won't be picked up.
diff --git a/mobly/base_test.py b/mobly/base_test.py
index e4139d9..045fee8 100644
--- a/mobly/base_test.py
+++ b/mobly/base_test.py
@@ -134,8 +134,9 @@ class BaseTestClass(object):
         self.current_test_name = None
         self._generated_test_table = collections.OrderedDict()
         # Controller object management.
-        self._controller_registry = {}
-        self._controller_destructors = {}
+        self._controller_registry = collections.OrderedDict(
+        )  # controller_name: objects
+        self._controller_modules = {}  # controller_name: module
 
     def __enter__(self):
         return self
@@ -312,22 +313,9 @@ class BaseTestClass(object):
         # Save a shallow copy of the list for internal usage, so tests can't
         # affect internal registry by manipulating the object list.
         self._controller_registry[module_ref_name] = copy.copy(objects)
-        # Collect controller information and write to test result.
-        # Implementation of 'get_info' is optional for a controller module.
-        if hasattr(module, 'get_info'):
-            controller_info = module.get_info(copy.copy(objects))
-            logging.debug('Controller %s: %s', module_config_name,
-                          controller_info)
-            self.results.add_controller_info(module_config_name,
-                                             controller_info)
-        else:
-            logging.warning('No optional debug info found for controller %s. '
-                            'To provide it, implement get_info in this '
-                            'controller module.', module_config_name)
         logging.debug('Found %d objects for controller %s', len(objects),
                       module_config_name)
-        destroy_func = module.destroy
-        self._controller_destructors[module_ref_name] = destroy_func
+        self._controller_modules[module_ref_name] = module
         return objects
 
     def _unregister_controllers(self):
@@ -337,14 +325,30 @@ class BaseTestClass(object):
         """
         # TODO(xpconanfan): actually record these errors instead of just
         # logging them.
-        for name, destroy in self._controller_destructors.items():
+        for name, module in self._controller_modules.items():
             try:
                 logging.debug('Destroying %s.', name)
-                destroy(self._controller_registry[name])
+                module.destroy(self._controller_registry[name])
             except:
                 logging.exception('Exception occurred destroying %s.', name)
-        self._controller_registry = {}
-        self._controller_destructors = {}
+        self._controller_registry = collections.OrderedDict()
+        self._controller_modules = {}
+
+    def _record_controller_info(self):
+        # Collect controller information and write to test result.
+        for module_ref_name, objects in self._controller_registry.items():
+            module = self._controller_modules[module_ref_name]
+            try:
+                controller_info = module.get_info(copy.copy(objects))
+            except AttributeError:
+                logging.warning('No optional debug info found for controller '
+                                '%s. To provide it, implement `get_info`.',
+                                module_ref_name)
+                continue
+            self.results.add_controller_info(
+                controller_name=module.MOBLY_CONTROLLER_CONFIG_NAME,
+                controller_info=controller_info,
+                test_class=self.TAG)
 
     def _setup_generated_tests(self):
         """Proxy function to guarantee the base implementation of
@@ -901,6 +905,12 @@ class BaseTestClass(object):
             setattr(e, 'results', self.results)
             raise e
         finally:
+            # Write controller info and summary to summary file.
+            self._record_controller_info()
+            for controller_info in self.results.controller_info:
+                self.summary_writer.dump(
+                    controller_info.to_dict(),
+                    records.TestSummaryEntryType.CONTROLLER_INFO)
             self._teardown_class()
             self._unregister_controllers()
             logging.info('Summary for test class %s: %s', self.TAG,
diff --git a/mobly/test_runner.py b/mobly/test_runner.py
index 9c57715..909385b 100644
--- a/mobly/test_runner.py
+++ b/mobly/test_runner.py
@@ -351,9 +351,6 @@ class TestRunner(object):
                         'Abort all subsequent test classes. Reason: %s', e)
                     raise
         finally:
-            # Write controller info and summary to summary file.
-            summary_writer.dump(self.results.controller_info,
-                                records.TestSummaryEntryType.CONTROLLER_INFO)
             summary_writer.dump(self.results.summary_dict(),
                                 records.TestSummaryEntryType.SUMMARY)
             # Stop and show summary.
diff --git a/tests/lib/mock_second_controller.py b/tests/lib/mock_second_controller.py
new file mode 100644
index 0000000..a4a847a
--- /dev/null
+++ b/tests/lib/mock_second_controller.py
@@ -0,0 +1,59 @@
+# Copyright 2016 Google Inc.
+# 
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# 
+#     http://www.apache.org/licenses/LICENSE-2.0
+# 
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This is a second mock third-party controller module used for testing Mobly's
+# handling of multiple controller modules.
+
+import logging
+
+MOBLY_CONTROLLER_CONFIG_NAME = "AnotherMagicDevice"
+
+
+def create(configs):
+    objs = []
+    for c in configs:
+        if isinstance(c, dict):
+            c.pop("serial")
+        objs.append(AnotherMagicDevice(c))
+    return objs
+
+
+def destroy(objs):
+    print("Destroying other magic")
+
+
+def get_info(objs):
+    infos = []
+    for obj in objs:
+        infos.append(obj.who_am_i())
+    return infos
+
+
+class AnotherMagicDevice(object):
+    """This controller supports adding controller's info during test.
+
+    It is used for testing that this info is correctly recorded by Mobly.
+    """
+    def __init__(self, config):
+        self.magic = config
+
+    def get_magic(self):
+        logging.info("My other magic is %s.", self.magic)
+        return self.magic
+
+    def set_magic(self, extra_magic):
+        self.magic['extra_magic'] = extra_magic
+
+    def who_am_i(self):
+        return {"MyOtherMagic": self.magic}
diff --git a/tests/mobly/base_test_test.py b/tests/mobly/base_test_test.py
index 898ab5d..ead1bf7 100755
--- a/tests/mobly/base_test_test.py
+++ b/tests/mobly/base_test_test.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import copy
 import io
 import os
 import mock
@@ -30,6 +31,7 @@ from mobly import signals
 
 from tests.lib import utils
 from tests.lib import mock_controller
+from tests.lib import mock_second_controller
 
 MSG_EXPECTED_EXCEPTION = "This is an expected exception."
 MSG_EXPECTED_TEST_FAILURE = "This is an expected test failure."
@@ -1784,6 +1786,56 @@ class BaseTestTest(unittest.TestCase):
                 self.assertIsNotNone(c['timestamp'])
         self.assertTrue(hit)
 
+    def test_record_controller_info(self):
+        """Verifies that controller info is correctly recorded.
+
+        1. Info added in test is recorded.
+        2. Info of multiple controller types are recorded.
+        """
+        mock_test_config = self.mock_test_cls_configs.copy()
+        mock_ctrlr_config_name = mock_controller.MOBLY_CONTROLLER_CONFIG_NAME
+        mock_ctrlr_2_config_name = mock_second_controller.MOBLY_CONTROLLER_CONFIG_NAME
+        my_config = [{'serial': 'xxxx', 'magic': 'Magic'}]
+        mock_test_config.controller_configs[mock_ctrlr_config_name] = my_config
+        mock_test_config.controller_configs[
+            mock_ctrlr_2_config_name] = copy.copy(my_config)
+
+        class ControllerInfoTest(base_test.BaseTestClass):
+            """Registers two different controller types and modifies controller
+            info at runtime.
+            """
+
+            def setup_class(self):
+                self.register_controller(mock_controller)
+                second_controller = self.register_controller(
+                    mock_second_controller)[0]
+                # This should appear in recorded controller info.
+                second_controller.set_magic('haha')
+
+            def test_func(self):
+                pass
+
+        bt_cls = ControllerInfoTest(mock_test_config)
+        bt_cls.run()
+        info1 = bt_cls.results.controller_info[0]
+        info2 = bt_cls.results.controller_info[1]
+        self.assertNotEqual(info1.timestamp, info2.timestamp)
+        self.assertEqual(info1.test_class, 'ControllerInfoTest')
+        self.assertEqual(info1.controller_name, 'MagicDevice')
+        self.assertEqual(info1.controller_info, [{
+            'MyMagic': {
+                'magic': 'Magic'
+            }
+        }])
+        self.assertEqual(info2.test_class, 'ControllerInfoTest')
+        self.assertEqual(info2.controller_name, 'AnotherMagicDevice')
+        self.assertEqual(info2.controller_info, [{
+            'MyOtherMagic': {
+                'magic': 'Magic',
+                'extra_magic': 'haha'
+            }
+        }])
+
     def test_register_controller_no_config(self):
         bt_cls = MockEmptyBaseTest(self.mock_test_cls_configs)
         with self.assertRaisesRegex(signals.ControllerError,
@@ -1812,7 +1864,7 @@ class BaseTestTest(unittest.TestCase):
         mock_ctrlrs = bt_cls._controller_registry[registered_name]
         self.assertEqual(mock_ctrlrs[0].magic, 'magic1')
         self.assertEqual(mock_ctrlrs[1].magic, 'magic2')
-        self.assertTrue(bt_cls._controller_destructors[registered_name])
+        self.assertTrue(bt_cls._controller_modules[registered_name])
         expected_msg = 'Controller module .* has already been registered.'
         with self.assertRaisesRegex(signals.ControllerError, expected_msg):
             bt_cls.register_controller(mock_controller)
@@ -1828,7 +1880,7 @@ class BaseTestTest(unittest.TestCase):
             }
             bt_cls = MockEmptyBaseTest(mock_test_config)
             bt_cls.register_controller(mock_controller)
-            self.assertEqual(bt_cls.results.controller_info, {})
+            self.assertEqual(bt_cls.results.controller_info, [])
         finally:
             setattr(mock_controller, 'get_info', get_info)
 
diff --git a/tests/mobly/records_test.py b/tests/mobly/records_test.py
index f818e62..a967b8b 100755
--- a/tests/mobly/records_test.py
+++ b/tests/mobly/records_test.py
@@ -1,13 +1,13 @@
 # Copyright 2016 Google Inc.
 #
-# Licensed under the Apache License, Version 2.0 (the "License");
+# Licensed under the Apache License, Version 2.0 (the 'License');
 # you may not use this file except in compliance with the License.
 # You may obtain a copy of the License at
 #
 #     http://www.apache.org/licenses/LICENSE-2.0
 #
 # Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
+# distributed under the License is distributed on an 'AS IS' BASIS,
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
@@ -46,10 +46,10 @@ class RecordsTest(unittest.TestCase):
     """
 
     def setUp(self):
-        self.tn = "test_name"
-        self.details = "Some details about the test execution."
+        self.tn = 'test_name'
+        self.details = 'Some details about the test execution.'
         self.float_extra = 12345.56789
-        self.json_extra = {"ha": "whatever"}
+        self.json_extra = {'ha': 'whatever'}
         self.tmp_path = tempfile.mkdtemp()
 
     def tearDown(self):
@@ -62,8 +62,8 @@ class RecordsTest(unittest.TestCase):
         self.assertEqual(record.result, result)
         self.assertEqual(record.details, details)
         self.assertEqual(record.extras, extras)
-        self.assertTrue(record.begin_time, "begin time should not be empty.")
-        self.assertTrue(record.end_time, "end time should not be empty.")
+        self.assertTrue(record.begin_time, 'begin time should not be empty.')
+        self.assertTrue(record.end_time, 'end time should not be empty.')
         # UID is not used at the moment, should always be None.
         self.assertIsNone(record.uid)
         # Verify to_dict.
@@ -88,7 +88,7 @@ class RecordsTest(unittest.TestCase):
         self.assertDictEqual(actual_d, d)
         # Verify that these code paths do not cause crashes and yield non-empty
         # results.
-        self.assertTrue(str(record), "str of the record should not be empty.")
+        self.assertTrue(str(record), 'str of the record should not be empty.')
         self.assertTrue(repr(record), "the record's repr shouldn't be empty.")
 
     """ Begin of Tests """
@@ -239,17 +239,18 @@ class RecordsTest(unittest.TestCase):
         record1.test_pass(s)
         tr1 = records.TestResult()
         tr1.add_record(record1)
-        tr1.add_controller_info("MockDevice", ["magicA", "magicB"])
+        tr1.add_controller_info('SomeClass', 'MockDevice',
+                                ['magicA', 'magicB'])
         record2 = records.TestResultRecord(self.tn)
         record2.test_begin()
         s = signals.TestPass(self.details, self.json_extra)
         record2.test_pass(s)
         tr2 = records.TestResult()
         tr2.add_record(record2)
-        tr2.add_controller_info("MockDevice", ["magicC"])
+        tr2.add_controller_info('SomeClass', 'MockDevice', ['magicC'])
         tr2 += tr1
         self.assertTrue(tr2.passed, [tr1, tr2])
-        self.assertTrue(tr2.controller_info, {"MockDevice": ["magicC"]})
+        self.assertTrue(tr2.controller_info, {'MockDevice': ['magicC']})
 
     def test_result_add_operator_type_mismatch(self):
         record1 = records.TestResultRecord(self.tn)
@@ -258,9 +259,9 @@ class RecordsTest(unittest.TestCase):
         record1.test_pass(s)
         tr1 = records.TestResult()
         tr1.add_record(record1)
-        expected_msg = "Operand .* of type .* is not a TestResult."
+        expected_msg = 'Operand .* of type .* is not a TestResult.'
         with self.assertRaisesRegex(TypeError, expected_msg):
-            tr1 += "haha"
+            tr1 += 'haha'
 
     def test_result_add_class_error_with_test_signal(self):
         record1 = records.TestResultRecord(self.tn)
@@ -270,7 +271,7 @@ class RecordsTest(unittest.TestCase):
         tr = records.TestResult()
         tr.add_record(record1)
         s = signals.TestFailure(self.details, self.float_extra)
-        record2 = records.TestResultRecord("SomeTest", s)
+        record2 = records.TestResultRecord('SomeTest', s)
         tr.add_class_error(record2)
         self.assertEqual(len(tr.passed), 1)
         self.assertEqual(len(tr.error), 1)
@@ -289,10 +290,10 @@ class RecordsTest(unittest.TestCase):
 
         class SpecialError(Exception):
             def __init__(self, arg1, arg2):
-                self.msg = "%s %s" % (arg1, arg2)
+                self.msg = '%s %s' % (arg1, arg2)
 
-        se = SpecialError("haha", 42)
-        record2 = records.TestResultRecord("SomeTest", se)
+        se = SpecialError('haha', 42)
+        record2 = records.TestResultRecord('SomeTest', se)
         tr.add_class_error(record2)
         self.assertEqual(len(tr.passed), 1)
         self.assertEqual(len(tr.error), 1)
@@ -334,7 +335,7 @@ class RecordsTest(unittest.TestCase):
         """
         record1 = records.TestResultRecord(self.tn)
         record1.test_begin()
-        record1.test_fail(Exception("haha"))
+        record1.test_fail(Exception('haha'))
         tr = records.TestResult()
         tr.add_class_error(record1)
         self.assertFalse(tr.is_all_pass)
@@ -342,7 +343,7 @@ class RecordsTest(unittest.TestCase):
     def test_is_test_executed(self):
         record1 = records.TestResultRecord(self.tn)
         record1.test_begin()
-        record1.test_fail(Exception("haha"))
+        record1.test_fail(Exception('haha'))
         tr = records.TestResult()
         tr.add_record(record1)
         self.assertTrue(tr.is_test_executed(record1.test_name))
@@ -415,20 +416,22 @@ class RecordsTest(unittest.TestCase):
     def test_add_controller_info(self):
         tr = records.TestResult()
         self.assertFalse(tr.controller_info)
-        tr.add_controller_info('MockDevice', ['magicA', 'magicB'])
-        self.assertTrue(tr.controller_info)
-        self.assertEqual(tr.controller_info['MockDevice'],
+        tr.add_controller_info('MockDevice', ['magicA', 'magicB'], 'MyTest')
+        self.assertTrue(tr.controller_info[0])
+        self.assertEqual(tr.controller_info[0].controller_name, 'MockDevice')
+        self.assertEqual(tr.controller_info[0].controller_info,
                          ['magicA', 'magicB'])
 
     @mock.patch('yaml.dump', side_effect=TypeError('ha'))
     def test_add_controller_info_not_serializable(self, mock_yaml_dump):
         tr = records.TestResult()
         self.assertFalse(tr.controller_info)
-        tr.add_controller_info('MockDevice', ['magicA', 'magicB'])
-        self.assertTrue(tr.controller_info)
-        self.assertEqual(tr.controller_info['MockDevice'],
+        tr.add_controller_info('MockDevice', ['magicA', 'magicB'], 'MyTest')
+        self.assertTrue(tr.controller_info[0])
+        self.assertEqual(tr.controller_info[0].controller_name, 'MockDevice')
+        self.assertEqual(tr.controller_info[0].controller_info,
                          "['magicA', 'magicB']")
 
 
-if __name__ == "__main__":
+if __name__ == '__main__':
     unittest.main()
diff --git a/tests/mobly/test_runner_test.py b/tests/mobly/test_runner_test.py
index d86f60c..9459f44 100755
--- a/tests/mobly/test_runner_test.py
+++ b/tests/mobly/test_runner_test.py
@@ -55,6 +55,13 @@ class TestRunnerTest(unittest.TestCase):
     def tearDown(self):
         shutil.rmtree(self.tmp_dir)
 
+    def _assertControllerInfoEqual(self, info, expected_info_dict):
+        self.assertEqual(expected_info_dict['Controller Name'],
+                         info.controller_name)
+        self.assertEqual(expected_info_dict['Test Class'], info.test_class)
+        self.assertEqual(expected_info_dict['Controller Info'],
+                         info.controller_info)
+
     def test_run_twice(self):
         """Verifies that:
         1. Repeated run works properly.
@@ -81,8 +88,8 @@ class TestRunnerTest(unittest.TestCase):
         self.assertEqual(results['Requested'], 2)
         self.assertEqual(results['Executed'], 2)
         self.assertEqual(results['Passed'], 2)
-        expected_info = {
-            'MagicDevice': [{
+        expected_info_dict = {
+            'Controller Info': [{
                 'MyMagic': {
                     'magic': 'Magic1'
                 }
@@ -90,9 +97,18 @@ class TestRunnerTest(unittest.TestCase):
                 'MyMagic': {
                     'magic': 'Magic2'
                 }
-            }]
+            }],
+            'Controller Name':
+            'MagicDevice',
+            'Test Class':
+            'IntegrationTest',
         }
-        self.assertEqual(tr.results.controller_info, expected_info)
+        self._assertControllerInfoEqual(tr.results.controller_info[0],
+                                        expected_info_dict)
+        self._assertControllerInfoEqual(tr.results.controller_info[1],
+                                        expected_info_dict)
+        self.assertNotEqual(tr.results.controller_info[0].timestamp,
+                            tr.results.controller_info[1].timestamp)
 
     def test_summary_file_entries(self):
         """Verifies the output summary's file format.
@@ -123,6 +139,10 @@ class TestRunnerTest(unittest.TestCase):
                          records.TestSummaryEntryType.TEST_NAME_LIST.value)
         self.assertEqual(summary_entries[1]['Type'],
                          records.TestSummaryEntryType.RECORD.value)
+        self.assertEqual(summary_entries[2]['Type'],
+                         records.TestSummaryEntryType.CONTROLLER_INFO.value)
+        self.assertEqual(summary_entries[3]['Type'],
+                         records.TestSummaryEntryType.SUMMARY.value)
 
     @mock.patch(
         'mobly.controllers.android_device_lib.adb.AdbProxy',

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest mock numpy>=1.16.0 pandas>=1.0.0
apt-get update apt-get install -y gcc
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
exceptiongroup==1.2.2
future==1.0.0
iniconfig==2.1.0
-e git+https://github.com/google/mobly.git@c86a37b358b2a04f5838bb8721ce136db1ba356d#egg=mobly
mock==5.2.0
numpy==2.0.2
packaging==24.2
pandas==2.2.3
pluggy==1.5.0
portpicker==1.6.0
psutil==7.0.0
pyserial==3.5
pytest==8.3.5
python-dateutil==2.9.0.post0
pytz==2025.2
PyYAML==6.0.2
six==1.17.0
timeout-decorator==0.5.0
tomli==2.2.1
tzdata==2025.2

name: mobly
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - exceptiongroup==1.2.2
      - future==1.0.0
      - iniconfig==2.1.0
      - mock==5.2.0
      - numpy==2.0.2
      - packaging==24.2
      - pandas==2.2.3
      - pluggy==1.5.0
      - portpicker==1.6.0
      - psutil==7.0.0
      - pyserial==3.5
      - pytest==8.3.5
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pyyaml==6.0.2
      - six==1.17.0
      - timeout-decorator==0.5.0
      - tomli==2.2.1
      - tzdata==2025.2
prefix: /opt/conda/envs/mobly

tests/mobly/base_test_test.py::BaseTestTest::test_record_controller_info tests/mobly/base_test_test.py::BaseTestTest::test_register_controller_no_get_info tests/mobly/records_test.py::RecordsTest::test_add_controller_info tests/mobly/records_test.py::RecordsTest::test_add_controller_info_not_serializable tests/mobly/records_test.py::RecordsTest::test_result_add_operator_success tests/mobly/test_runner_test.py::TestRunnerTest::test_run_twice tests/mobly/test_runner_test.py::TestRunnerTest::test_run_two_test_classes tests/mobly/test_runner_test.py::TestRunnerTest::test_run_two_test_classes_different_configs_and_aliases
tests/mobly/base_test_test.py::BaseTestTest::test_write_user_data tests/mobly/records_test.py::RecordsTest::test_summary_user_data tests/mobly/records_test.py::RecordsTest::test_summary_write_dump tests/mobly/records_test.py::RecordsTest::test_summary_write_dump_with_unicode tests/mobly/test_runner_test.py::TestRunnerTest::test_summary_file_entries
tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_on_fail tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_on_fail_from_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_setup_test tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_teardown_class tests/mobly/base_test_test.py::BaseTestTest::test_abort_all_in_test tests/mobly/base_test_test.py::BaseTestTest::test_abort_class_in_on_fail tests/mobly/base_test_test.py::BaseTestTest::test_abort_class_in_setup_test tests/mobly/base_test_test.py::BaseTestTest::test_abort_class_in_test tests/mobly/base_test_test.py::BaseTestTest::test_abort_class_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_assert_equal_fail tests/mobly/base_test_test.py::BaseTestTest::test_assert_equal_fail_with_msg tests/mobly/base_test_test.py::BaseTestTest::test_assert_equal_pass tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_fail_with_noop tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_fail_with_wrong_error tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_fail_with_wrong_regex tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_pass tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_regex_fail_with_noop tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_regex_fail_with_wrong_error tests/mobly/base_test_test.py::BaseTestTest::test_assert_raises_regex_pass tests/mobly/base_test_test.py::BaseTestTest::test_assert_true tests/mobly/base_test_test.py::BaseTestTest::test_both_teardown_and_test_body_raise_exceptions tests/mobly/base_test_test.py::BaseTestTest::test_cli_test_selection_fail_by_convention tests/mobly/base_test_test.py::BaseTestTest::test_cli_test_selection_override_self_tests_list tests/mobly/base_test_test.py::BaseTestTest::test_current_test_info tests/mobly/base_test_test.py::BaseTestTest::test_current_test_info_in_setup_class tests/mobly/base_test_test.py::BaseTestTest::test_current_test_name tests/mobly/base_test_test.py::BaseTestTest::test_default_execution_of_all_tests tests/mobly/base_test_test.py::BaseTestTest::test_exception_objects_in_record tests/mobly/base_test_test.py::BaseTestTest::test_expect_equal tests/mobly/base_test_test.py::BaseTestTest::test_expect_false tests/mobly/base_test_test.py::BaseTestTest::test_expect_in_teardown_test tests/mobly/base_test_test.py::BaseTestTest::test_expect_multiple_fails tests/mobly/base_test_test.py::BaseTestTest::test_expect_no_op tests/mobly/base_test_test.py::BaseTestTest::test_expect_no_raises_custom_msg tests/mobly/base_test_test.py::BaseTestTest::test_expect_no_raises_default_msg tests/mobly/base_test_test.py::BaseTestTest::test_expect_true tests/mobly/base_test_test.py::BaseTestTest::test_expect_true_and_assert_true tests/mobly/base_test_test.py::BaseTestTest::test_expect_two_tests tests/mobly/base_test_test.py::BaseTestTest::test_explicit_pass tests/mobly/base_test_test.py::BaseTestTest::test_explicit_pass_but_teardown_test_raises_an_exception tests/mobly/base_test_test.py::BaseTestTest::test_fail tests/mobly/base_test_test.py::BaseTestTest::test_failure_in_procedure_functions_is_recorded tests/mobly/base_test_test.py::BaseTestTest::test_failure_to_call_procedure_function_is_recorded tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_call_outside_of_setup_generated_tests tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_dup_test_name tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_run tests/mobly/base_test_test.py::BaseTestTest::test_generate_tests_selected_run tests/mobly/base_test_test.py::BaseTestTest::test_implicit_pass tests/mobly/base_test_test.py::BaseTestTest::test_missing_requested_test_func tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_cannot_modify_original_record tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_both_test_and_teardown_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_setup_class_fails_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_setup_test_fails_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_teardown_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_executed_if_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_raise_exception tests/mobly/base_test_test.py::BaseTestTest::test_on_fail_triggered_by_setup_class_failure_then_fail_too tests/mobly/base_test_test.py::BaseTestTest::test_on_pass_cannot_modify_original_record tests/mobly/base_test_test.py::BaseTestTest::test_on_pass_raise_exception tests/mobly/base_test_test.py::BaseTestTest::test_procedure_function_gets_correct_record tests/mobly/base_test_test.py::BaseTestTest::test_promote_extra_errors_to_termination_signal tests/mobly/base_test_test.py::BaseTestTest::test_register_controller_change_return_value tests/mobly/base_test_test.py::BaseTestTest::test_register_controller_dup_register tests/mobly/base_test_test.py::BaseTestTest::test_register_controller_less_than_min_number tests/mobly/base_test_test.py::BaseTestTest::test_register_controller_no_config tests/mobly/base_test_test.py::BaseTestTest::test_register_controller_no_config_for_not_required tests/mobly/base_test_test.py::BaseTestTest::test_register_controller_return_value tests/mobly/base_test_test.py::BaseTestTest::test_self_tests_list tests/mobly/base_test_test.py::BaseTestTest::test_self_tests_list_fail_by_convention tests/mobly/base_test_test.py::BaseTestTest::test_setup_and_teardown_execution_count tests/mobly/base_test_test.py::BaseTestTest::test_setup_class_fail_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_setup_generated_tests_failure tests/mobly/base_test_test.py::BaseTestTest::test_setup_test_fail_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_setup_test_fail_by_test_signal tests/mobly/base_test_test.py::BaseTestTest::test_skip tests/mobly/base_test_test.py::BaseTestTest::test_skip_if tests/mobly/base_test_test.py::BaseTestTest::test_skip_in_setup_test tests/mobly/base_test_test.py::BaseTestTest::test_teardown_class_fail_by_exception tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_assert_fail tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_executed_if_setup_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_executed_if_test_fails tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_executed_if_test_pass tests/mobly/base_test_test.py::BaseTestTest::test_teardown_test_raise_exception tests/mobly/base_test_test.py::BaseTestTest::test_uncaught_exception tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_basic tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_default_None tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_default_overwrite tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_default_overwrite_by_optional_param_list tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_default_overwrite_by_required_param_list tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_optional tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_optional_missing tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_optional_with_default tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_required tests/mobly/base_test_test.py::BaseTestTest::test_unpack_userparams_required_missing tests/mobly/base_test_test.py::BaseTestTest::test_verify_controller_module tests/mobly/base_test_test.py::BaseTestTest::test_verify_controller_module_missing_attr tests/mobly/base_test_test.py::BaseTestTest::test_verify_controller_module_null_attr tests/mobly/records_test.py::RecordsTest::test_exception_record_deepcopy tests/mobly/records_test.py::RecordsTest::test_is_all_pass tests/mobly/records_test.py::RecordsTest::test_is_all_pass_negative tests/mobly/records_test.py::RecordsTest::test_is_all_pass_with_add_class_error tests/mobly/records_test.py::RecordsTest::test_is_test_executed tests/mobly/records_test.py::RecordsTest::test_result_add_class_error_with_special_error tests/mobly/records_test.py::RecordsTest::test_result_add_class_error_with_test_signal tests/mobly/records_test.py::RecordsTest::test_result_add_operator_type_mismatch tests/mobly/records_test.py::RecordsTest::test_result_record_fail_none tests/mobly/records_test.py::RecordsTest::test_result_record_fail_stacktrace tests/mobly/records_test.py::RecordsTest::test_result_record_fail_with_float_extra tests/mobly/records_test.py::RecordsTest::test_result_record_fail_with_json_extra tests/mobly/records_test.py::RecordsTest::test_result_record_fail_with_unicode_exception tests/mobly/records_test.py::RecordsTest::test_result_record_fail_with_unicode_test_signal tests/mobly/records_test.py::RecordsTest::test_result_record_pass_none tests/mobly/records_test.py::RecordsTest::test_result_record_pass_with_float_extra tests/mobly/records_test.py::RecordsTest::test_result_record_pass_with_json_extra tests/mobly/records_test.py::RecordsTest::test_result_record_skip_none tests/mobly/records_test.py::RecordsTest::test_result_record_skip_with_float_extra tests/mobly/records_test.py::RecordsTest::test_result_record_skip_with_json_extra tests/mobly/test_runner_test.py::TestRunnerTest::test_add_test_class_mismatched_log_path tests/mobly/test_runner_test.py::TestRunnerTest::test_add_test_class_mismatched_test_bed_name tests/mobly/test_runner_test.py::TestRunnerTest::test_main_parse_args tests/mobly/test_runner_test.py::TestRunnerTest::test_run_no_tests tests/mobly/test_runner_test.py::TestRunnerTest::test_run_with_abort_all tests/mobly/test_runner_test.py::TestRunnerTest::test_teardown_logger_before_setup_logger
mobly/records.py
mobly/records.py