• 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.server import test
15from autotest_lib.server import utils
16
17
18# A datetime.DateTime representing the Unix epoch in UTC.
19_UNIX_EPOCH = dateutil.parser.parse('1970-01-01T00:00:00Z')
20
21
22class tast(test.test):
23    """Autotest server test that runs a Tast test suite.
24
25    Tast is an integration-testing framework analagous to the test-running
26    portion of Autotest. See
27    https://chromium.googlesource.com/chromiumos/platform/tast/ for more
28    information.
29
30    This class runs the "tast" command locally to execute a Tast test suite on a
31    remote DUT.
32    """
33    version = 1
34
35    # Maximum time to wait for various tast commands to complete, in seconds.
36    # Note that _LIST_TIMEOUT_SEC includes time to download private test bundles
37    # if run_private_tests=True.
38    _VERSION_TIMEOUT_SEC = 10
39    _LIST_TIMEOUT_SEC = 60
40
41    # Additional time to add to the run timeout (e.g. for collecting crashes and
42    # logs).
43    _RUN_OVERHEAD_SEC = 20
44    # Additional time given to the run command to exit before it's killed.
45    _RUN_EXIT_SEC = 5
46
47    # Number of times to retry SSH connection attempts to the DUT.
48    _SSH_CONNECT_RETRIES = 2
49
50    # File written by the tast command containing test results, as
51    # newline-terminated JSON TestResult objects.
52    _STREAMED_RESULTS_FILENAME = 'streamed_results.jsonl'
53
54    # Text file written by the tast command if a global error caused the test
55    # run to fail (e.g. SSH connection to DUT was lost).
56    _RUN_ERROR_FILENAME = 'run_error.txt'
57
58    # Maximum number of failing and missing tests to include in error messages.
59    _MAX_TEST_NAMES_IN_ERROR = 3
60
61    # Default paths where Tast files are installed by Portage packages.
62    _TAST_PATH = '/usr/bin/tast'
63    _REMOTE_BUNDLE_DIR = '/usr/libexec/tast/bundles/remote'
64    _REMOTE_DATA_DIR = '/usr/share/tast/data'
65    _REMOTE_TEST_RUNNER_PATH = '/usr/bin/remote_test_runner'
66
67    # Alternate locations for Tast files when using Server-Side Packaging.
68    # These files are installed from autotest_server_package.tar.bz2.
69    _SSP_ROOT = '/usr/local/tast'
70    _SSP_TAST_PATH = os.path.join(_SSP_ROOT, 'tast')
71    _SSP_REMOTE_BUNDLE_DIR = os.path.join(_SSP_ROOT, 'bundles/remote')
72    _SSP_REMOTE_DATA_DIR = os.path.join(_SSP_ROOT, 'data')
73    _SSP_REMOTE_TEST_RUNNER_PATH = os.path.join(_SSP_ROOT, 'remote_test_runner')
74
75    # Prefix added to Tast test names when writing their results to TKO
76    # status.log files.
77    _TEST_NAME_PREFIX = 'tast.'
78
79    # Prefixes of keyval keys recorded for missing tests.
80    _MISSING_TEST_KEYVAL_PREFIX = 'tast_missing_test.'
81
82    # Job start/end TKO event status codes from base_client_job._rungroup in
83    # client/bin/job.py.
84    _JOB_STATUS_START = 'START'
85    _JOB_STATUS_END_GOOD = 'END GOOD'
86    _JOB_STATUS_END_FAIL = 'END FAIL'
87    _JOB_STATUS_END_ABORT = 'END ABORT'
88
89    # In-job TKO event status codes from base_client_job._run_test_base in
90    # client/bin/job.py and client/common_lib/error.py.
91    _JOB_STATUS_GOOD = 'GOOD'
92    _JOB_STATUS_FAIL = 'FAIL'
93
94    # Status reason used when an individual Tast test doesn't finish running.
95    _TEST_DID_NOT_FINISH_MSG = 'Test did not finish'
96
97    def initialize(self, host, test_exprs, ignore_test_failures=False,
98                   max_run_sec=3600, install_root='/', run_private_tests=True):
99        """
100        @param host: remote.RemoteHost instance representing DUT.
101        @param test_exprs: Array of strings describing tests to run.
102        @param ignore_test_failures: If False, this test will fail if individual
103            Tast tests report failure. If True, this test will only fail in
104            response to the tast command failing to run successfully. This
105            should generally be False when the test is running inline and True
106            when it's running asynchronously.
107        @param max_run_sec: Integer maximum running time for the "tast run"
108            command in seconds.
109        @param install_root: Root directory under which Tast binaries are
110            installed. Alternate values may be passed by unit tests.
111        @param run_private_tests: Download and run private tests.
112
113        @raises error.TestFail if the Tast installation couldn't be found.
114        """
115        self._host = host
116        self._test_exprs = test_exprs
117        self._ignore_test_failures = ignore_test_failures
118        self._max_run_sec = max_run_sec
119        self._install_root = install_root
120        self._run_private_tests = run_private_tests
121        self._fake_now = None
122
123        # List of JSON objects describing tests that will be run. See Test in
124        # src/platform/tast/src/chromiumos/tast/testing/test.go for details.
125        self._tests_to_run = []
126
127        # List of JSON objects corresponding to tests from
128        # _STREAMED_RESULTS_FILENAME. See TestResult in
129        # src/platform/tast/src/chromiumos/cmd/tast/run/results.go for details.
130        self._test_results = []
131
132        # Error message read from _RUN_ERROR_FILENAME, if any.
133        self._run_error = None
134
135        # The data dir can be missing if no remote tests registered data files,
136        # but all other files must exist.
137        self._tast_path = self._get_path((self._TAST_PATH, self._SSP_TAST_PATH))
138        self._remote_bundle_dir = self._get_path((self._REMOTE_BUNDLE_DIR,
139                                                  self._SSP_REMOTE_BUNDLE_DIR))
140        self._remote_data_dir = self._get_path((self._REMOTE_DATA_DIR,
141                                                self._SSP_REMOTE_DATA_DIR),
142                                               allow_missing=True)
143        self._remote_test_runner_path = self._get_path(
144                (self._REMOTE_TEST_RUNNER_PATH,
145                 self._SSP_REMOTE_TEST_RUNNER_PATH))
146
147        # Register a hook to write the results of individual Tast tests as
148        # top-level entries in the TKO status.log file.
149        self.job.add_post_run_hook(self._log_all_tests)
150
151    def run_once(self):
152        """Runs a single iteration of the test."""
153        self._log_version()
154        self._find_devservers()
155        self._get_tests_to_run()
156
157        run_failed = False
158        try:
159            self._run_tests()
160        except:
161            run_failed = True
162            raise
163        finally:
164            self._read_run_error()
165            # Parse partial results even if the tast command didn't finish.
166            self._parse_results(run_failed)
167
168    def set_fake_now_for_testing(self, now):
169        """Sets a fake timestamp to use in place of time.time() for unit tests.
170
171        @param now Numeric timestamp as would be returned by time.time().
172        """
173        self._fake_now = now
174
175    def _get_path(self, paths, allow_missing=False):
176        """Returns the path to an installed Tast-related file or directory.
177
178        @param paths: Tuple or list of absolute paths in root filesystem, e.g.
179            ("/usr/bin/tast", "/usr/local/tast/tast").
180        @param allow_missing: True if it's okay for the path to be missing.
181
182        @returns Absolute path within install root, e.g. "/usr/bin/tast", or an
183            empty string if the path wasn't found and allow_missing is True.
184
185        @raises error.TestFail if the path couldn't be found and allow_missing
186            is False.
187        """
188        for path in paths:
189            abs_path = os.path.join(self._install_root,
190                                    os.path.relpath(path, '/'))
191            if os.path.exists(abs_path):
192                return abs_path
193
194        if allow_missing:
195            return ''
196        raise error.TestFail('None of %s exist' % list(paths))
197
198    def _find_devservers(self):
199        """Finds available devservers.
200
201        The result is saved as self._devserver_args.
202        """
203        devservers, _ = dev_server.ImageServer.get_available_devservers(
204            self._host.hostname)
205        logging.info('Using devservers: %s', ', '.join(devservers))
206        self._devserver_args = ['-devservers=%s' % ','.join(devservers)]
207
208    def _log_version(self):
209        """Runs the tast command locally to log its version."""
210        try:
211            utils.run([self._tast_path, '-version'],
212                      timeout=self._VERSION_TIMEOUT_SEC,
213                      stdout_tee=utils.TEE_TO_LOGS,
214                      stderr_tee=utils.TEE_TO_LOGS,
215                      stderr_is_expected=True,
216                      stdout_level=logging.INFO,
217                      stderr_level=logging.ERROR)
218        except error.CmdError as e:
219            logging.error('Failed to log tast version: %s', str(e))
220
221    def _run_tast(self, subcommand, extra_subcommand_args, timeout_sec,
222                  log_stdout=False):
223        """Runs the tast command locally to e.g. list available tests or perform
224        testing against the DUT.
225
226        @param subcommand: Subcommand to pass to the tast executable, e.g. 'run'
227            or 'list'.
228        @param extra_subcommand_args: List of additional subcommand arguments.
229        @param timeout_sec: Integer timeout for the command in seconds.
230        @param log_stdout: If true, write stdout to log.
231
232        @returns client.common_lib.utils.CmdResult object describing the result.
233
234        @raises error.TestFail if the tast command fails or times out.
235        """
236        cmd = [
237            self._tast_path,
238            '-verbose=true',
239            '-logtime=false',
240            subcommand,
241            '-build=false',
242            '-remotebundledir=' + self._remote_bundle_dir,
243            '-remotedatadir=' + self._remote_data_dir,
244            '-remoterunner=' + self._remote_test_runner_path,
245            '-sshretries=%d' % self._SSH_CONNECT_RETRIES,
246        ]
247        cmd.extend(extra_subcommand_args)
248        cmd.append('%s:%d' % (self._host.hostname, self._host.port))
249        cmd.extend(self._test_exprs)
250
251        logging.info('Running ' +
252                     ' '.join([utils.sh_quote_word(a) for a in cmd]))
253        try:
254            return utils.run(cmd,
255                             ignore_status=False,
256                             timeout=timeout_sec,
257                             stdout_tee=(utils.TEE_TO_LOGS if log_stdout
258                                         else None),
259                             stderr_tee=utils.TEE_TO_LOGS,
260                             stderr_is_expected=True,
261                             stdout_level=logging.INFO,
262                             stderr_level=logging.ERROR)
263        except error.CmdError as e:
264            # The tast command's output generally ends with a line describing
265            # the error that was encountered; include it in the first line of
266            # the TestFail exception. Fall back to stderr if stdout is empty (as
267            # is the case with the "list" subcommand, which uses stdout to print
268            # test data).
269            get_last_line = lambda s: s.strip().split('\n')[-1].strip()
270            last_line = (get_last_line(e.result_obj.stdout) or
271                         get_last_line(e.result_obj.stderr))
272            msg = (' (last line: %s)' % last_line) if last_line else ''
273            raise error.TestFail('Failed to run tast%s: %s' % (msg, str(e)))
274        except error.CmdTimeoutError as e:
275            raise error.TestFail('Got timeout while running tast: %s' % str(e))
276
277    def _get_tests_to_run(self):
278        """Runs the tast command to update the list of tests that will be run.
279
280        @raises error.TestFail if the tast command fails or times out.
281        """
282        logging.info('Getting list of tests that will be run')
283        args = ['-json=true'] + self._devserver_args
284        if self._run_private_tests:
285            args.append('-downloadprivatebundles=true')
286        result = self._run_tast('list', args, self._LIST_TIMEOUT_SEC)
287        try:
288            self._tests_to_run = json.loads(result.stdout.strip())
289        except ValueError as e:
290            raise error.TestFail('Failed to parse tests: %s' % str(e))
291        if len(self._tests_to_run) == 0:
292            expr = ' '.join([utils.sh_quote_word(a) for a in self._test_exprs])
293            raise error.TestFail('No tests matched by %s' % expr)
294
295        logging.info('Expect to run %d test(s)', len(self._tests_to_run))
296
297    def _run_tests(self):
298        """Runs the tast command to perform testing.
299
300        @raises error.TestFail if the tast command fails or times out (but not
301            if individual tests fail).
302        """
303        timeout_sec = self._get_run_tests_timeout_sec()
304        logging.info('Running tests with timeout of %d sec', timeout_sec)
305        args = [
306            '-resultsdir=' + self.resultsdir,
307            '-waituntilready=true',
308            '-timeout=' + str(timeout_sec),
309        ] + self._devserver_args
310        if self._run_private_tests:
311            args.append('-downloadprivatebundles=true')
312        self._run_tast('run', args, timeout_sec + tast._RUN_EXIT_SEC,
313                       log_stdout=True)
314
315    def _get_run_tests_timeout_sec(self):
316        """Computes the timeout for the 'tast run' command.
317
318        @returns Integer timeout in seconds.
319        """
320        # Go time.Duration values are serialized to nanoseconds.
321        total_ns = sum([int(t['timeout']) for t in self._tests_to_run])
322        return min(total_ns / 1000000000 + tast._RUN_OVERHEAD_SEC,
323                   self._max_run_sec)
324
325    def _read_run_error(self):
326        """Reads a global run error message written by the tast command."""
327        # The file is only written if a run error occurred.
328        path = os.path.join(self.resultsdir, self._RUN_ERROR_FILENAME)
329        if os.path.exists(path):
330            with open(path, 'r') as f:
331                self._run_error = f.read().strip()
332
333    def _parse_results(self, ignore_missing_file):
334        """Parses results written by the tast command.
335
336        @param ignore_missing_file: If True, return without raising an exception
337            if the Tast results file is missing. This is used to avoid raising a
338            new error if there was already an earlier error while running the
339            tast process.
340
341        @raises error.TestFail if results file is missing and
342            ignore_missing_file is False, or one or more tests failed and
343            _ignore_test_failures is false.
344        """
345        # The file may not exist if "tast run" failed to run. Tests that were
346        # seen from the earlier "tast list" command will be reported as having
347        # missing results.
348        path = os.path.join(self.resultsdir, self._STREAMED_RESULTS_FILENAME)
349        if not os.path.exists(path):
350            if ignore_missing_file:
351                return
352            raise error.TestFail('Results file %s not found' % path)
353
354        failed = []
355        seen_test_names = set()
356        with open(path, 'r') as f:
357            for line in f:
358                line = line.strip()
359                if not line:
360                    continue
361                try:
362                    test = json.loads(line)
363                except ValueError as e:
364                    raise error.TestFail('Failed to parse %s: %s' % (path, e))
365                self._test_results.append(test)
366
367                name = test['name']
368                seen_test_names.add(name)
369
370                if test.get('errors'):
371                    for err in test['errors']:
372                        logging.warning('%s: %s', name, err['reason'])
373                    failed.append(name)
374                else:
375                    # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC)
376                    # end time (preceding the Unix epoch) if it didn't report
377                    # completion.
378                    if _rfc3339_time_to_timestamp(test['end']) <= 0:
379                        failed.append(name)
380
381        missing = [t['name'] for t in self._tests_to_run
382                   if t['name'] not in seen_test_names]
383
384        if missing:
385            self._record_missing_tests(missing)
386
387        failure_msg = self._get_failure_message(failed, missing)
388        if failure_msg:
389            raise error.TestFail(failure_msg)
390
391    def _get_failure_message(self, failed, missing):
392        """Returns an error message describing failed and/or missing tests.
393
394        @param failed: List of string names of Tast tests that failed.
395        @param missing: List of string names of Tast tests with missing results.
396
397        @returns String to be used as error.TestFail message.
398        """
399        def list_tests(names):
400            """Returns a string listing tests.
401
402            @param names: List of string test names.
403
404            @returns String listing tests.
405            """
406            s = ' '.join(sorted(names)[:self._MAX_TEST_NAMES_IN_ERROR])
407            if len(names) > self._MAX_TEST_NAMES_IN_ERROR:
408                s += ' ...'
409            return s
410
411        msg = ''
412        if failed and not self._ignore_test_failures:
413            msg = '%d failed: %s' % (len(failed), list_tests(failed))
414        if missing:
415            if msg:
416                msg += '; '
417            msg += '%d missing: %s' % (len(missing), list_tests(missing))
418        return msg
419
420    def _log_all_tests(self):
421        """Writes entries to the TKO status.log file describing the results of
422        all tests.
423        """
424        seen_test_names = set()
425        for test in self._test_results:
426            self._log_test(test)
427            seen_test_names.add(test['name'])
428
429    def _log_test(self, test):
430        """Writes events to the TKO status.log file describing the results from
431        a Tast test.
432
433        @param test: A JSON object corresponding to a single test from a Tast
434            results.json file. See TestResult in
435            src/platform/tast/src/chromiumos/cmd/tast/run/results.go for
436            details.
437        """
438        name = test['name']
439        start_time = _rfc3339_time_to_timestamp(test['start'])
440        end_time = _rfc3339_time_to_timestamp(test['end'])
441
442        test_reported_errors = bool(test.get('errors'))
443        test_skipped = bool(test.get('skipReason'))
444        # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC) end time
445        # (preceding the Unix epoch) if it didn't report completion.
446        test_finished = end_time > 0
447
448        # Avoid reporting tests that were skipped.
449        if test_skipped and not test_reported_errors:
450            return
451
452        self._log_test_event(self._JOB_STATUS_START, name, start_time)
453
454        if test_finished and not test_reported_errors:
455            self._log_test_event(self._JOB_STATUS_GOOD, name, end_time)
456            end_status = self._JOB_STATUS_END_GOOD
457        else:
458            # The previous START event automatically increases the log
459            # indentation level until the following END event.
460            if test_reported_errors:
461                for err in test['errors']:
462                    error_time = _rfc3339_time_to_timestamp(err['time'])
463                    self._log_test_event(self._JOB_STATUS_FAIL, name,
464                                         error_time, err['reason'])
465            if not test_finished:
466                # If a run-level error was encountered (e.g. the SSH connection
467                # to the DUT was lost), report it here to make it easier to see
468                # the reason why the test didn't finish.
469                if self._run_error:
470                    self._log_test_event(self._JOB_STATUS_FAIL, name,
471                                         start_time, self._run_error)
472                self._log_test_event(self._JOB_STATUS_FAIL, name, start_time,
473                                     self._TEST_DID_NOT_FINISH_MSG)
474                end_time = start_time
475
476            end_status = self._JOB_STATUS_END_FAIL
477
478        self._log_test_event(end_status, name, end_time)
479
480    def _log_test_event(self, status_code, test_name, timestamp, message=''):
481        """Logs a single event to the TKO status.log file.
482
483        @param status_code: Event status code, e.g. 'END GOOD'. See
484            client/common_lib/log.py for accepted values.
485        @param test_name: Tast test name, e.g. 'ui.ChromeLogin'.
486        @param timestamp: Event timestamp (as seconds since Unix epoch).
487        @param message: Optional human-readable message.
488        """
489        full_name = self._TEST_NAME_PREFIX + test_name
490        # The TKO parser code chokes on floating-point timestamps.
491        entry = base_job.status_log_entry(status_code, None, full_name, message,
492                                          None, timestamp=int(timestamp))
493        self.job.record_entry(entry, False)
494
495    def _record_missing_tests(self, missing):
496        """Records tests with missing results in job keyval file.
497
498        @param missing: List of string names of Tast tests with missing results.
499        """
500        keyvals = {}
501        for i, name in enumerate(sorted(missing)):
502            keyvals['%s%d' % (self._MISSING_TEST_KEYVAL_PREFIX, i)] = name
503        utils.write_keyval(self.job.resultdir, keyvals)
504
505
506class _LessBrokenParserInfo(dateutil.parser.parserinfo):
507    """dateutil.parser.parserinfo that interprets years before 100 correctly.
508
509    Our version of dateutil.parser.parse misinteprets an unambiguous string like
510    '0001-01-01T00:00:00Z' as having a two-digit year, which it then converts to
511    2001. This appears to have been fixed by
512    https://github.com/dateutil/dateutil/commit/fc696254. This parserinfo
513    implementation always honors the provided year to prevent this from
514    happening.
515    """
516    def convertyear(self, year, century_specified=False):
517        """Overrides convertyear in dateutil.parser.parserinfo."""
518        return int(year)
519
520
521def _rfc3339_time_to_timestamp(time_str):
522    """Converts an RFC3339 time into a Unix timestamp.
523
524    @param time_str: RFC3339-compatible time, e.g.
525        '2018-02-25T07:45:35.916929332-07:00'.
526
527    @returns Float number of seconds since the Unix epoch. Negative if the time
528        precedes the epoch.
529    """
530    dt = dateutil.parser.parse(time_str, parserinfo=_LessBrokenParserInfo())
531    return (dt - _UNIX_EPOCH).total_seconds()
532