Show test run metadata in test summary. (#751)

* Encapsulate test run metadata with a proper container.
* Record test run elapsed time.
* Print out the log path to make parsing easier.
* Clarify the ambiguity in test runner logger setup and run.
diff --git a/mobly/logger.py b/mobly/logger.py
index 654c038..200e299 100644
--- a/mobly/logger.py
+++ b/mobly/logger.py
@@ -259,7 +259,7 @@
   """
   utils.create_dir(log_path)
   _setup_test_logger(log_path, prefix)
-  logging.info('Test output folder: "%s"', log_path)
+  logging.debug('Test output folder: "%s"', log_path)
   if alias:
     create_latest_log_alias(log_path, alias=alias)
 
diff --git a/mobly/test_runner.py b/mobly/test_runner.py
index 8cc085a..5324bf3 100644
--- a/mobly/test_runner.py
+++ b/mobly/test_runner.py
@@ -17,6 +17,7 @@
 import logging
 import os
 import sys
+import time
 
 from mobly import base_test
 from mobly import config_parser
@@ -182,8 +183,8 @@
   results for all tests that have been added to this runner.
 
   Attributes:
-    self.results: The test result object used to record the results of
-      this test run.
+    results: records.TestResult, object used to record the results of a test
+      run.
   """
 
   class _TestRunInfo:
@@ -201,6 +202,78 @@
       self.test_class_name_suffix = test_class_name_suffix
       self.tests = tests
 
+  class _TestRunMetaData:
+    """Metadata associated with a test run.
+
+    This class calculates values that are specific to a test run.
+
+    One object of this class corresponds to an entire test run, which could
+    include multiple test classes.
+
+    Attributes:
+      root_output_path: string, the root output path for a test run. All
+        artifacts from this test run shall be stored here.
+      run_id: string, the unique identifier for this test run.
+      time_elapsed_sec: float, the number of seconds elapsed for this test run.
+    """
+
+    def __init__(self, log_dir, testbed_name):
+      self._log_dir = log_dir
+      self._testbed_name = testbed_name
+      self._logger_start_time = None
+      self._start_counter = None
+      self._end_counter = None
+      self.root_output_path = log_dir
+
+    def generate_test_run_log_path(self):
+      """Geneartes the log path for a test run.
+
+      The log path includes a timestamp that is set in this call.
+
+      There is usually a minor difference between this timestamp and the actual
+      starting point of the test run. This is because the log path must be set
+      up *before* the test run actually starts, so all information of a test
+      run can be properly captured.
+
+      The generated value can be accessed via `self.root_output_path`.
+
+      Returns:
+        String, the generated log path.
+      """
+      self._logger_start_time = logger.get_log_file_timestamp()
+      self.root_output_path = os.path.join(self._log_dir, self._testbed_name,
+                                           self._logger_start_time)
+      return self.root_output_path
+
+    def set_start_point(self):
+      """Sets the start point of a test run.
+
+      This is used to calculate the total elapsed time of the test run.
+      """
+      self._start_counter = time.perf_counter()
+
+    def set_end_point(self):
+      """Sets the end point of a test run.
+
+      This is used to calculate the total elapsed time of the test run.
+      """
+      self._end_counter = time.perf_counter()
+
+    @property
+    def run_id(self):
+      """The unique identifier of a test run."""
+      return f'{self._testbed_name}@{self._logger_start_time}'
+
+    @property
+    def time_elapsed_sec(self):
+      """The total time elapsed for a test run in seconds.
+
+      This value is None until the test run has completed.
+      """
+      if self._start_counter is None or self._end_counter is None:
+        return None
+      return self._end_counter - self._start_counter
+
   def __init__(self, log_dir, testbed_name):
     """Constructor for TestRunner.
 
@@ -213,16 +286,7 @@
 
     self.results = records.TestResult()
     self._test_run_infos = []
-
-    # Set default logging values. Necessary if `run` is used outside of the
-    # `mobly_logger` context.
-    self._update_log_path()
-
-  def _update_log_path(self):
-    """Updates the logging values with the current timestamp."""
-    self._start_time = logger.get_log_file_timestamp()
-    self._root_output_path = os.path.join(self._log_dir, self._testbed_name,
-                                          self._start_time)
+    self._test_run_metadata = TestRunner._TestRunMetaData(log_dir, testbed_name)
 
   @contextlib.contextmanager
   def mobly_logger(self, alias='latest'):
@@ -236,12 +300,11 @@
     Yields:
       The host file path where the logs for the test run are stored.
     """
-    self._update_log_path()
-    logger.setup_test_logger(self._root_output_path,
-                             self._testbed_name,
-                             alias=alias)
+    # Refresh the log path at the beginning of the logger context.
+    root_output_path = self._test_run_metadata.generate_test_run_log_path()
+    logger.setup_test_logger(root_output_path, self._testbed_name, alias=alias)
     try:
-      yield self._root_output_path
+      yield self._test_run_metadata.root_output_path
     finally:
       logger.kill_test_logger(logging.getLogger())
 
@@ -305,6 +368,11 @@
     This will instantiate controller and test classes, execute tests, and
     print a summary.
 
+    This meethod should usually be called within the runner's `mobly_logger`
+    context. If you must use this method outside of the context, you should
+    make sure `self._test_run_metadata.generate_test_run_log_path` is called
+    before each invocation of `run`.
+
     Raises:
       Error: if no tests have previously been added to this runner using
         add_test_class(...).
@@ -312,17 +380,21 @@
     if not self._test_run_infos:
       raise Error('No tests to execute.')
 
+    # Officially starts the test run.
+    self._test_run_metadata.set_start_point()
+
     # Ensure the log path exists. Necessary if `run` is used outside of the
     # `mobly_logger` context.
-    utils.create_dir(self._root_output_path)
+    utils.create_dir(self._test_run_metadata.root_output_path)
 
     summary_writer = records.TestSummaryWriter(
-        os.path.join(self._root_output_path, records.OUTPUT_FILE_SUMMARY))
+        os.path.join(self._test_run_metadata.root_output_path,
+                     records.OUTPUT_FILE_SUMMARY))
     try:
       for test_run_info in self._test_run_infos:
         # Set up the test-specific config
         test_config = test_run_info.config.copy()
-        test_config.log_path = self._root_output_path
+        test_config.log_path = self._test_run_metadata.root_output_path
         test_config.summary_writer = summary_writer
         test_config.test_class_name_suffix = test_run_info.test_class_name_suffix
         try:
@@ -335,7 +407,12 @@
     finally:
       summary_writer.dump(self.results.summary_dict(),
                           records.TestSummaryEntryType.SUMMARY)
-      # Stop and show summary.
-      msg = '\nSummary for test run %s@%s: %s\n' % (
-          self._testbed_name, self._start_time, self.results.summary_str())
-      logging.info(msg.strip())
+      self._test_run_metadata.set_end_point()
+      # Show the test run summary.
+      summary_lines = [
+          f'Summary for test run {self._test_run_metadata.run_id}:',
+          f'Total time elapsed {self._test_run_metadata.time_elapsed_sec}s',
+          f'Artifacts are saved in "{self._test_run_metadata.root_output_path}"',
+          f'Test results: {self.results.summary_str()}'
+      ]
+      logging.info('\n'.join(summary_lines))