• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2018 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import json
6import logging
7import os
8
9import dateutil.parser
10
11from autotest_lib.client.common_lib import base_job
12from autotest_lib.client.common_lib import error
13from autotest_lib.client.common_lib.cros import dev_server
14from autotest_lib.client.common_lib.cros import tpm_utils
15from autotest_lib.server import test
16from autotest_lib.server import utils
17from autotest_lib.server.cros.network import wifi_test_context_manager
18from autotest_lib.server.hosts import cros_host
19from autotest_lib.server.hosts import servo_host
20from autotest_lib.server.hosts import servo_constants
21from autotest_lib.utils import labellib
22
23
24# A datetime.DateTime representing the Unix epoch in UTC.
25_UNIX_EPOCH = dateutil.parser.parse('1970-01-01T00:00:00Z')
26
27
28def _encode_utf8_json(j):
29    """Takes JSON object parsed by json.load() family, and encode each unicode
30    strings into utf-8.
31    """
32    if isinstance(j, unicode):
33        return j.encode('utf-8')
34    if isinstance(j, list):
35        return [_encode_utf8_json(x) for x in j]
36    if isinstance(j, dict):
37        return dict((_encode_utf8_json(k), _encode_utf8_json(v))
38                    for k, v in j.iteritems())
39    return j
40
41
42class tast(test.test):
43    """Autotest server test that runs a Tast test suite.
44
45    Tast is an integration-testing framework analagous to the test-running
46    portion of Autotest. See
47    https://chromium.googlesource.com/chromiumos/platform/tast/ for more
48    information.
49
50    This class runs the "tast" command locally to execute a Tast test suite on a
51    remote DUT.
52    """
53    version = 1
54
55    # Maximum time to wait for various tast commands to complete, in seconds.
56    # Note that _LIST_TIMEOUT_SEC includes time to download private test bundles
57    # if run_private_tests=True.
58    _VERSION_TIMEOUT_SEC = 10
59    _LIST_TIMEOUT_SEC = 60
60
61    # Additional time to add to the run timeout (e.g. for collecting crashes and
62    # logs).
63    _RUN_OVERHEAD_SEC = 20
64    # Additional time given to the run command to exit before it's killed.
65    _RUN_EXIT_SEC = 5
66
67    # Number of times to retry SSH connection attempts to the DUT.
68    _SSH_CONNECT_RETRIES = 2
69
70    # File written by the tast command containing test results, as
71    # newline-terminated JSON TestResult objects.
72    _STREAMED_RESULTS_FILENAME = 'streamed_results.jsonl'
73
74    # Text file written by the tast command if a global error caused the test
75    # run to fail (e.g. SSH connection to DUT was lost).
76    _RUN_ERROR_FILENAME = 'run_error.txt'
77
78    # Maximum number of failing and missing tests to include in error messages.
79    _MAX_TEST_NAMES_IN_ERROR = 3
80
81    # Default paths where Tast files are installed by Portage packages.
82    _PORTAGE_TAST_PATH = '/usr/bin/tast'
83
84    # Alternate locations for Tast files when using Server-Side Packaging.
85    # These files are installed from autotest_server_package.tar.bz2.
86    _SSP_ROOT = '/usr/local/tast'
87    _SSP_TAST_PATH = os.path.join(_SSP_ROOT, 'tast')
88    _SSP_REMOTE_BUNDLE_DIR = os.path.join(_SSP_ROOT, 'bundles/remote')
89    _SSP_REMOTE_DATA_DIR = os.path.join(_SSP_ROOT, 'data')
90    _SSP_REMOTE_TEST_RUNNER_PATH = os.path.join(_SSP_ROOT, 'remote_test_runner')
91    _SSP_DEFAULT_VARS_DIR_PATH = os.path.join(_SSP_ROOT, 'vars')
92
93    # Prefix added to Tast test names when writing their results to TKO
94    # status.log files.
95    _TEST_NAME_PREFIX = 'tast.'
96
97    # Prefixes of keyval keys recorded for missing tests.
98    _MISSING_TEST_KEYVAL_PREFIX = 'tast_missing_test.'
99
100    # Job start/end TKO event status codes from base_client_job._rungroup in
101    # client/bin/job.py.
102    _JOB_STATUS_START = 'START'
103    _JOB_STATUS_END_GOOD = 'END GOOD'
104    _JOB_STATUS_END_FAIL = 'END FAIL'
105    _JOB_STATUS_END_ABORT = 'END ABORT'
106
107    # In-job TKO event status codes from base_client_job._run_test_base in
108    # client/bin/job.py and client/common_lib/error.py.
109    _JOB_STATUS_GOOD = 'GOOD'
110    _JOB_STATUS_FAIL = 'FAIL'
111
112    # Status reason used when an individual Tast test doesn't finish running.
113    _TEST_DID_NOT_FINISH_MSG = 'Test did not finish'
114
115    def initialize(self,
116                   host,
117                   test_exprs,
118                   ignore_test_failures=False,
119                   max_run_sec=3600,
120                   command_args=[],
121                   install_root='/',
122                   ssp=None,
123                   build=None,
124                   build_bundle='cros',
125                   run_private_tests=True,
126                   varsfiles=[],
127                   download_data_lazily=True,
128                   clear_tpm=False,
129                   varslist=[]):
130        """
131        @param host: remote.RemoteHost instance representing DUT.
132        @param test_exprs: Array of strings describing tests to run.
133        @param ignore_test_failures: If False, this test will fail if individual
134            Tast tests report failure. If True, this test will only fail in
135            response to the tast command failing to run successfully. This
136            should generally be False when the test is running inline and True
137            when it's running asynchronously.
138        @param max_run_sec: Integer maximum running time for the "tast run"
139            command in seconds.
140        @param command_args: List of arguments passed on the command line via
141            test_that's --args flag, i.e. |args| in control file.
142        @param install_root: Root directory under which Tast binaries are
143            installed. Alternate values may be passed by unit tests.
144        @param ssp: Whether to use SSP files. Default is to auto-detect.
145        @param build: Whether to build test runners and test bundles.
146            Default is to build if and only if SSP is unavailable
147            (i.e. build = not ssp).
148        @param build_bundle: Test bundle name to build. Effective only when
149            build=True.
150        @param run_private_tests: Download and run private tests. Effective
151            only when build=False. When build=True, build_bundle can be
152            specified to build and run a private bundle.
153        @param varsfiles: list of names of yaml files containing variables set
154            in |-varsfile| arguments.
155        @param download_data_lazily: If True, external data files are downloaded
156            lazily between tests. If false, external data files are downloaded
157            in a batch before running tests.
158        @param clear_tpm: clear the TPM first before running the tast tests.
159        @param varslist: list of strings to pass to tast run command as |-vars|
160            arguments. Each string should be formatted as "name=value".
161
162        @raises error.TestFail if the Tast installation couldn't be found.
163        """
164        if ssp is None:
165            ssp = os.path.exists(self._SSP_TAST_PATH)
166        if build is None:
167            build = not ssp
168
169        self._host = host
170        self._test_exprs = test_exprs
171        self._ignore_test_failures = ignore_test_failures
172        self._max_run_sec = max_run_sec
173        self._command_args = command_args
174        self._install_root = install_root
175        self._ssp = ssp
176        self._build = build
177        self._build_bundle = build_bundle
178        self._run_private_tests = run_private_tests
179        self._fake_now = None
180        self._varsfiles = varsfiles
181        self._varslist = varslist
182        self._download_data_lazily = download_data_lazily
183        self._clear_tpm = clear_tpm
184
185        # List of JSON objects describing tests that will be run. See Test in
186        # src/platform/tast/src/chromiumos/tast/testing/test.go for details.
187        self._tests_to_run = []
188
189        # List of JSON objects corresponding to tests from
190        # _STREAMED_RESULTS_FILENAME. See TestResult in
191        # src/platform/tast/src/chromiumos/cmd/tast/run/results.go for details.
192        self._test_results = []
193
194        # Error message read from _RUN_ERROR_FILENAME, if any.
195        self._run_error = None
196
197        self._tast_path = self._get_path(
198            self._SSP_TAST_PATH if ssp else self._PORTAGE_TAST_PATH)
199
200        # Register a hook to write the results of individual Tast tests as
201        # top-level entries in the TKO status.log file.
202        self.job.add_post_run_hook(self._log_all_tests)
203
204    def run_once(self):
205        """Runs a single iteration of the test."""
206
207        if self._clear_tpm:
208            tpm_utils.ClearTPMOwnerRequest(self._host, wait_for_ready=True)
209
210        self._log_version()
211        self._find_devservers()
212
213        # Shortcut if no test belongs to the specified test_exprs.
214        if not self._get_tests_to_run():
215            return
216
217        run_failed = False
218        try:
219            self._run_tests()
220        except:
221            run_failed = True
222            raise
223        finally:
224            self._read_run_error()
225            # Parse partial results even if the tast command didn't finish.
226            self._parse_results(run_failed)
227
228    def set_fake_now_for_testing(self, now):
229        """Sets a fake timestamp to use in place of time.time() for unit tests.
230
231        @param now Numeric timestamp as would be returned by time.time().
232        """
233        self._fake_now = now
234
235    def _get_path(self, path):
236        """Returns the path to an installed Tast-related file or directory.
237
238        @param path: Absolute paths in root filesystem, e.g. "/usr/bin/tast".
239
240        @returns Absolute path within install root, e.g.
241            "/usr/local/tast/usr/bin/tast".
242        """
243        return os.path.join(self._install_root, os.path.relpath(path, '/'))
244
245    def _get_servo_args(self):
246        """Gets servo-related arguments to pass to "tast run".
247
248        @returns List of command-line flag strings that should be inserted in
249            the command line after "tast run".
250        """
251        # Start with information provided by the Autotest database.
252        merged_args = {}
253        host_args = servo_host.get_servo_args_for_host(self._host)
254        if host_args:
255            merged_args.update(host_args)
256
257        # Incorporate information that was passed manually.
258        args_dict = utils.args_to_dict(self._command_args)
259        merged_args.update(cros_host.CrosHost.get_servo_arguments(args_dict))
260
261        logging.info('Autotest servo-related args: %s', merged_args)
262        host_arg = merged_args.get(servo_constants.SERVO_HOST_ATTR)
263        port_arg = merged_args.get(servo_constants.SERVO_PORT_ATTR)
264        if not host_arg or not port_arg:
265            return []
266        return ['-var=servo=%s:%s' % (host_arg, port_arg)]
267
268    def _get_wificell_args(self):
269        """Gets wificell-related (router, pcap) arguments to pass to "tast run".
270
271        @returns List of command-line flag strings that should be inserted in
272            the command line after "tast run".
273        """
274        # Incorporate information that was passed manually.
275        args_dict = utils.args_to_dict(self._command_args)
276        args = []
277        # Alias of WiFiTestContextManager.
278        WiFiManager = wifi_test_context_manager.WiFiTestContextManager
279        # TODO(crbug.com/1065601): plumb other WiFi test specific arguments,
280        #     e.g. pcap address. See: WiFiTestContextManager's constants.
281        forward_args = [
282            (WiFiManager.CMDLINE_ROUTER_ADDR, 'router=%s'),
283            (WiFiManager.CMDLINE_PCAP_ADDR, 'pcap=%s'),
284        ]
285        for key, var_arg in forward_args:
286            if key in args_dict:
287                args += ['-var=' + var_arg % args_dict[key]]
288        logging.info('Autotest wificell-related args: %s', args)
289        return args
290
291    def _get_cloud_storage_info(self):
292        """Gets the cloud storage bucket URL to pass to tast.
293
294        @returns Cloud storage bucket URL that should be inserted in
295            the command line after "tast run".
296        """
297        gs_bucket = dev_server._get_image_storage_server()
298        args_dict = utils.args_to_dict(self._command_args)
299        build = args_dict.get('build')
300        if not build:
301            labels = self._host.host_info_store.get().labels
302            build = labellib.LabelsMapping(labels).get(
303                labellib.Key.CROS_VERSION)
304
305        if not gs_bucket or not build:
306            return []
307        gs_path = gs_bucket + build
308        if not gs_path.endswith('/'):
309            gs_path += '/'
310        logging.info('Cloud storage bucket: %s', gs_path)
311        return ['-buildartifactsurl=%s' % gs_path]
312
313    def _find_devservers(self):
314        """Finds available devservers.
315
316        The result is saved as self._devserver_args.
317        """
318        devservers, _ = dev_server.ImageServer.get_available_devservers(
319            self._host.hostname, prefer_local_devserver=True)
320        logging.info('Using devservers: %s', ', '.join(devservers))
321        self._devserver_args = ['-devservers=%s' % ','.join(devservers)]
322
323    def _log_version(self):
324        """Runs the tast command locally to log its version."""
325        try:
326            utils.run([self._tast_path, '-version'],
327                      timeout=self._VERSION_TIMEOUT_SEC,
328                      stdout_tee=utils.TEE_TO_LOGS,
329                      stderr_tee=utils.TEE_TO_LOGS,
330                      stderr_is_expected=True,
331                      stdout_level=logging.INFO,
332                      stderr_level=logging.ERROR)
333        except error.CmdError as e:
334            logging.error('Failed to log tast version: %s', str(e))
335
336    def _run_tast(self, subcommand, extra_subcommand_args, timeout_sec,
337                  log_stdout=False):
338        """Runs the tast command locally to e.g. list available tests or perform
339        testing against the DUT.
340
341        @param subcommand: Subcommand to pass to the tast executable, e.g. 'run'
342            or 'list'.
343        @param extra_subcommand_args: List of additional subcommand arguments.
344        @param timeout_sec: Integer timeout for the command in seconds.
345        @param log_stdout: If true, write stdout to log.
346
347        @returns client.common_lib.utils.CmdResult object describing the result.
348
349        @raises error.TestFail if the tast command fails or times out.
350        """
351        cmd = [
352            self._tast_path,
353            '-verbose=true',
354            '-logtime=false',
355            subcommand,
356            '-sshretries=%d' % self._SSH_CONNECT_RETRIES,
357            '-downloaddata=%s' % (
358                'lazy' if self._download_data_lazily else 'batch'),
359        ]
360        if self._build:
361            cmd.extend([
362                '-build=true',
363                '-buildbundle=%s' % self._build_bundle,
364                '-checkbuilddeps=false',
365            ])
366        else:
367            cmd.append('-build=false')
368            if self._ssp:
369                remote_test_runner_path = self._get_path(
370                    self._SSP_REMOTE_TEST_RUNNER_PATH)
371                if not os.path.exists(remote_test_runner_path):
372                    raise error.TestFail(
373                        '%s does not exist (broken SSP?)' %
374                        remote_test_runner_path)
375                cmd.extend([
376                    '-remotebundledir=%s' % self._get_path(
377                        self._SSP_REMOTE_BUNDLE_DIR),
378                    '-remotedatadir=%s' % self._get_path(
379                        self._SSP_REMOTE_DATA_DIR),
380                    '-remoterunner=%s' % remote_test_runner_path,
381                ])
382                if subcommand == 'run':
383                    cmd.append('-defaultvarsdir=%s' %
384                               self._get_path(self._SSP_DEFAULT_VARS_DIR_PATH))
385            if self._run_private_tests:
386                cmd.append('-downloadprivatebundles=true')
387        cmd.extend(self._devserver_args)
388        cmd.extend(extra_subcommand_args)
389        cmd.append('%s:%d' % (self._host.hostname, self._host.port))
390        cmd.extend(self._test_exprs)
391
392        logging.info('Running %s',
393                     ' '.join([utils.sh_quote_word(a) for a in cmd]))
394        try:
395            return utils.run(cmd,
396                             ignore_status=False,
397                             timeout=timeout_sec,
398                             stdout_tee=(utils.TEE_TO_LOGS if log_stdout
399                                         else None),
400                             stderr_tee=utils.TEE_TO_LOGS,
401                             stderr_is_expected=True,
402                             stdout_level=logging.INFO,
403                             stderr_level=logging.ERROR)
404        except error.CmdError as e:
405            # The tast command's output generally ends with a line describing
406            # the error that was encountered; include it in the first line of
407            # the TestFail exception. Fall back to stderr if stdout is empty (as
408            # is the case with the "list" subcommand, which uses stdout to print
409            # test data).
410            get_last_line = lambda s: s.strip().split('\n')[-1].strip()
411            last_line = (get_last_line(e.result_obj.stdout) or
412                         get_last_line(e.result_obj.stderr))
413            msg = (' (last line: %s)' % last_line) if last_line else ''
414            raise error.TestFail('Failed to run tast%s: %s' % (msg, str(e)))
415        except error.CmdTimeoutError as e:
416            raise error.TestFail('Got timeout while running tast: %s' % str(e))
417
418    def _get_tests_to_run(self):
419        """Runs the tast command to update the list of tests that will be run.
420
421        @returns False if no tests matched by test_exprs; True otherwise
422
423        @raises error.TestFail if the tast command fails or times out.
424        """
425        logging.info('Getting list of tests that will be run')
426        args = ['-json=true'] + self._get_cloud_storage_info()
427        result = self._run_tast('list', args, self._LIST_TIMEOUT_SEC)
428        try:
429            self._tests_to_run = _encode_utf8_json(
430                json.loads(result.stdout.strip()))
431        except ValueError as e:
432            raise error.TestFail('Failed to parse tests: %s' % str(e))
433        if len(self._tests_to_run) == 0:
434            expr = ' '.join([utils.sh_quote_word(a) for a in self._test_exprs])
435            logging.warning('No tests matched by %s', expr)
436            return False
437
438        logging.info('Expect to run %d test(s)', len(self._tests_to_run))
439        return True
440
441    def _run_tests(self):
442        """Runs the tast command to perform testing.
443
444        @raises error.TestFail if the tast command fails or times out (but not
445            if individual tests fail).
446        """
447        args = [
448            '-resultsdir=' + self.resultsdir,
449            '-waituntilready=true',
450            '-timeout=' + str(self._max_run_sec),
451            '-continueafterfailure=true',
452        ] + self._get_servo_args() + self._get_wificell_args() + self._get_cloud_storage_info()
453
454        for varsfile in self._varsfiles:
455            args.append('-varsfile=%s' % varsfile)
456
457        for var in self._varslist:
458            args.append('-var=%s' % var)
459
460        logging.info('Running tests with timeout of %d sec', self._max_run_sec)
461        self._run_tast('run', args, self._max_run_sec + tast._RUN_EXIT_SEC,
462                       log_stdout=True)
463
464    def _read_run_error(self):
465        """Reads a global run error message written by the tast command."""
466        # The file is only written if a run error occurred.
467        path = os.path.join(self.resultsdir, self._RUN_ERROR_FILENAME)
468        if os.path.exists(path):
469            with open(path, 'r') as f:
470                self._run_error = f.read().strip()
471
472    def _parse_results(self, ignore_missing_file):
473        """Parses results written by the tast command.
474
475        @param ignore_missing_file: If True, return without raising an exception
476            if the Tast results file is missing. This is used to avoid raising a
477            new error if there was already an earlier error while running the
478            tast process.
479
480        @raises error.TestFail if results file is missing and
481            ignore_missing_file is False, or one or more tests failed and
482            _ignore_test_failures is false.
483        """
484        # The file may not exist if "tast run" failed to run. Tests that were
485        # seen from the earlier "tast list" command will be reported as having
486        # missing results.
487        path = os.path.join(self.resultsdir, self._STREAMED_RESULTS_FILENAME)
488        if not os.path.exists(path):
489            if ignore_missing_file:
490                return
491            raise error.TestFail('Results file %s not found' % path)
492
493        failed = []
494        seen_test_names = set()
495        with open(path, 'r') as f:
496            for line in f:
497                line = line.strip()
498                if not line:
499                    continue
500                try:
501                    test = _encode_utf8_json(json.loads(line))
502                except ValueError as e:
503                    raise error.TestFail('Failed to parse %s: %s' % (path, e))
504                self._test_results.append(test)
505
506                name = test['name']
507                seen_test_names.add(name)
508
509                if test.get('errors'):
510                    for err in test['errors']:
511                        logging.warning('%s: %s', name, err['reason'])
512                    failed.append(name)
513                else:
514                    # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC)
515                    # end time (preceding the Unix epoch) if it didn't report
516                    # completion.
517                    if _rfc3339_time_to_timestamp(test['end']) <= 0:
518                        failed.append(name)
519
520        missing = [t['name'] for t in self._tests_to_run
521                   if t['name'] not in seen_test_names]
522
523        if missing:
524            self._record_missing_tests(missing)
525
526        failure_msg = self._get_failure_message(failed, missing)
527        if failure_msg:
528            raise error.TestFail(failure_msg)
529
530    def _get_failure_message(self, failed, missing):
531        """Returns an error message describing failed and/or missing tests.
532
533        @param failed: List of string names of Tast tests that failed.
534        @param missing: List of string names of Tast tests with missing results.
535
536        @returns String to be used as error.TestFail message.
537        """
538        def list_tests(names):
539            """Returns a string listing tests.
540
541            @param names: List of string test names.
542
543            @returns String listing tests.
544            """
545            s = ' '.join(sorted(names)[:self._MAX_TEST_NAMES_IN_ERROR])
546            if len(names) > self._MAX_TEST_NAMES_IN_ERROR:
547                s += ' ...'
548            return s
549
550        msg = ''
551        if failed and not self._ignore_test_failures:
552            msg = '%d failed: %s' % (len(failed), list_tests(failed))
553        if missing:
554            if msg:
555                msg += '; '
556            msg += '%d missing: %s' % (len(missing), list_tests(missing))
557        return msg
558
559    def _log_all_tests(self):
560        """Writes entries to the TKO status.log file describing the results of
561        all tests.
562        """
563        seen_test_names = set()
564        for test in self._test_results:
565            self._log_test(test)
566            seen_test_names.add(test['name'])
567
568    def _log_test(self, test):
569        """Writes events to the TKO status.log file describing the results from
570        a Tast test.
571
572        @param test: A JSON object corresponding to a single test from a Tast
573            results.json file. See TestResult in
574            src/platform/tast/src/chromiumos/cmd/tast/run/results.go for
575            details.
576        """
577        name = test['name']
578        start_time = _rfc3339_time_to_timestamp(test['start'])
579        end_time = _rfc3339_time_to_timestamp(test['end'])
580
581        test_reported_errors = bool(test.get('errors'))
582        test_skipped = bool(test.get('skipReason'))
583        # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC) end time
584        # (preceding the Unix epoch) if it didn't report completion.
585        test_finished = end_time > 0
586
587        # Avoid reporting tests that were skipped.
588        if test_skipped and not test_reported_errors:
589            return
590
591        self._log_test_event(self._JOB_STATUS_START, name, start_time)
592
593        if test_finished and not test_reported_errors:
594            self._log_test_event(self._JOB_STATUS_GOOD, name, end_time)
595            end_status = self._JOB_STATUS_END_GOOD
596        else:
597            # The previous START event automatically increases the log
598            # indentation level until the following END event.
599            if test_reported_errors:
600                for err in test['errors']:
601                    error_time = _rfc3339_time_to_timestamp(err['time'])
602                    self._log_test_event(self._JOB_STATUS_FAIL, name,
603                                         error_time, err['reason'])
604            if not test_finished:
605                # If a run-level error was encountered (e.g. the SSH connection
606                # to the DUT was lost), report it here to make it easier to see
607                # the reason why the test didn't finish.
608                if self._run_error:
609                    self._log_test_event(self._JOB_STATUS_FAIL, name,
610                                         start_time, self._run_error)
611                self._log_test_event(self._JOB_STATUS_FAIL, name, start_time,
612                                     self._TEST_DID_NOT_FINISH_MSG)
613                end_time = start_time
614
615            end_status = self._JOB_STATUS_END_FAIL
616
617        self._log_test_event(end_status, name, end_time)
618
619    def _log_test_event(self, status_code, test_name, timestamp, message=''):
620        """Logs a single event to the TKO status.log file.
621
622        @param status_code: Event status code, e.g. 'END GOOD'. See
623            client/common_lib/log.py for accepted values.
624        @param test_name: Tast test name, e.g. 'ui.ChromeLogin'.
625        @param timestamp: Event timestamp (as seconds since Unix epoch).
626        @param message: Optional human-readable message.
627        """
628        full_name = self._TEST_NAME_PREFIX + test_name
629        # The TKO parser code chokes on floating-point timestamps.
630        entry = base_job.status_log_entry(status_code, None, full_name, message,
631                                          None, timestamp=int(timestamp))
632        self.job.record_entry(entry, False)
633
634    def _record_missing_tests(self, missing):
635        """Records tests with missing results in job keyval file.
636
637        @param missing: List of string names of Tast tests with missing results.
638        """
639        keyvals = {}
640        for i, name in enumerate(sorted(missing)):
641            keyvals['%s%d' % (self._MISSING_TEST_KEYVAL_PREFIX, i)] = name
642        utils.write_keyval(self.job.resultdir, keyvals)
643
644
645class _LessBrokenParserInfo(dateutil.parser.parserinfo):
646    """dateutil.parser.parserinfo that interprets years before 100 correctly.
647
648    Our version of dateutil.parser.parse misinteprets an unambiguous string like
649    '0001-01-01T00:00:00Z' as having a two-digit year, which it then converts to
650    2001. This appears to have been fixed by
651    https://github.com/dateutil/dateutil/commit/fc696254. This parserinfo
652    implementation always honors the provided year to prevent this from
653    happening.
654    """
655    def convertyear(self, year, century_specified=False):
656        """Overrides convertyear in dateutil.parser.parserinfo."""
657        return int(year)
658
659
660def _rfc3339_time_to_timestamp(time_str):
661    """Converts an RFC3339 time into a Unix timestamp.
662
663    @param time_str: RFC3339-compatible time, e.g.
664        '2018-02-25T07:45:35.916929332-07:00'.
665
666    @returns Float number of seconds since the Unix epoch. Negative if the time
667        precedes the epoch.
668    """
669    dt = dateutil.parser.parse(time_str, parserinfo=_LessBrokenParserInfo())
670    return (dt - _UNIX_EPOCH).total_seconds()
671