xref: /aosp_15_r20/external/autotest/server/site_tests/tast/tast.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1*9c5db199SXin Li# Copyright 2018 The Chromium OS Authors. All rights reserved.
2*9c5db199SXin Li# Use of this source code is governed by a BSD-style license that can be
3*9c5db199SXin Li# found in the LICENSE file.
4*9c5db199SXin Li
5*9c5db199SXin Lifrom __future__ import absolute_import, division, print_function
6*9c5db199SXin Li
7*9c5db199SXin Liimport datetime
8*9c5db199SXin Liimport json
9*9c5db199SXin Liimport logging
10*9c5db199SXin Liimport os
11*9c5db199SXin Liimport shutil
12*9c5db199SXin Liimport socket
13*9c5db199SXin Liimport tempfile
14*9c5db199SXin Lifrom collections import OrderedDict
15*9c5db199SXin Li
16*9c5db199SXin Liimport dateutil.parser
17*9c5db199SXin Liimport six
18*9c5db199SXin Liimport yaml
19*9c5db199SXin Lifrom autotest_lib.client.common_lib import (base_job, config_vars, error)
20*9c5db199SXin Lifrom autotest_lib.client.common_lib.cros import dev_server, tpm_utils
21*9c5db199SXin Lifrom autotest_lib.server import test, utils
22*9c5db199SXin Lifrom autotest_lib.server.cros.network import wifi_test_context_manager
23*9c5db199SXin Lifrom autotest_lib.server.hosts import cros_host, servo_constants, servo_host
24*9c5db199SXin Lifrom autotest_lib.site_utils.rpm_control_system import rpm_constants
25*9c5db199SXin Lifrom autotest_lib.utils import labellib
26*9c5db199SXin Lifrom six.moves import urllib
27*9c5db199SXin Li
28*9c5db199SXin Li# A datetime.DateTime representing the Unix epoch in UTC.
29*9c5db199SXin Li_UNIX_EPOCH = dateutil.parser.parse('1970-01-01T00:00:00Z')
30*9c5db199SXin Li
31*9c5db199SXin Li# Keywords that are used in result json file.
32*9c5db199SXin Li_KEY_NAME = 'name'
33*9c5db199SXin Li_KEY_START = 'start'
34*9c5db199SXin Li_KEY_END = 'end'
35*9c5db199SXin Li_KEY_ERRORS = 'errors'
36*9c5db199SXin Li_KEY_SKIP_REASON = 'skipReason'
37*9c5db199SXin Li_KEY_REASON = 'reason'
38*9c5db199SXin Li_KEY_TIME = 'time'
39*9c5db199SXin Li_KEY_MISSING_REASON = 'missingReason'
40*9c5db199SXin Li
41*9c5db199SXin Li
42*9c5db199SXin Lidef split_arguments(args):
43*9c5db199SXin Li    """Splits arguments into the autotest and tast variable assignments.
44*9c5db199SXin Li    Use the results as command_args and varslist respectively.
45*9c5db199SXin Li
46*9c5db199SXin Li    @param args: List of strings passed to test_that --args
47*9c5db199SXin Li
48*9c5db199SXin Li    @returns Array of Tauto args, Array of TAST variable assignments.
49*9c5db199SXin Li    """
50*9c5db199SXin Li
51*9c5db199SXin Li    auto_args = []
52*9c5db199SXin Li    tast_vars = []
53*9c5db199SXin Li    for a in args:
54*9c5db199SXin Li        if a.startswith("tast."):
55*9c5db199SXin Li            tast_vars.append(a[5:])
56*9c5db199SXin Li        else:
57*9c5db199SXin Li            auto_args.append(a)
58*9c5db199SXin Li    return auto_args, tast_vars
59*9c5db199SXin Li
60*9c5db199SXin Li
61*9c5db199SXin Lidef _encode_text(text):
62*9c5db199SXin Li    """Takes an unicode string into utf-8 string
63*9c5db199SXin Li    (bytes for python 2 and text for python 3).
64*9c5db199SXin Li    """
65*9c5db199SXin Li    if six.PY2:
66*9c5db199SXin Li        return text.encode('utf-8')
67*9c5db199SXin Li    return text
68*9c5db199SXin Li
69*9c5db199SXin Li
70*9c5db199SXin Lidef _encode_json(j):
71*9c5db199SXin Li    """Takes JSON object parsed by json.load() family, and encode each unicode
72*9c5db199SXin Li    strings into str.
73*9c5db199SXin Li    """
74*9c5db199SXin Li    if isinstance(j, six.text_type):
75*9c5db199SXin Li        return _encode_text(j)
76*9c5db199SXin Li    if isinstance(j, list):
77*9c5db199SXin Li        return [_encode_json(x) for x in j]
78*9c5db199SXin Li    if isinstance(j, dict):
79*9c5db199SXin Li        return dict((_encode_json(k), _encode_json(v))
80*9c5db199SXin Li                    for k, v in six.iteritems(j))
81*9c5db199SXin Li    return j
82*9c5db199SXin Li
83*9c5db199SXin Li
84*9c5db199SXin Lidef _dut_server_arg(command_args):
85*9c5db199SXin Li    """Return dut_server arg out of command_args if found."""
86*9c5db199SXin Li    dut_server_arg = None
87*9c5db199SXin Li    dut_server = None
88*9c5db199SXin Li    for arg in command_args:
89*9c5db199SXin Li        if 'dut_servers=' in arg:
90*9c5db199SXin Li            dut_server_arg = arg
91*9c5db199SXin Li            break
92*9c5db199SXin Li    if not dut_server_arg:
93*9c5db199SXin Li        return
94*9c5db199SXin Li    dut_server = dut_server_arg.split('=')[1]
95*9c5db199SXin Li
96*9c5db199SXin Li    # In case multiple dutservers are passed...
97*9c5db199SXin Li    # e.g.: "dut_servers=localhost:1343,localhost:5678"
98*9c5db199SXin Li    if ',' in dut_server:
99*9c5db199SXin Li        # Currently only support the first, until we map dut:dut_server
100*9c5db199SXin Li        dut_server = dut_server.split(',')[0]
101*9c5db199SXin Li
102*9c5db199SXin Li    return dut_server
103*9c5db199SXin Li
104*9c5db199SXin Li
105*9c5db199SXin Liclass TastConfigError(error.AutotestError):
106*9c5db199SXin Li    """Indicates a problem with configuration files."""
107*9c5db199SXin Li
108*9c5db199SXin Li
109*9c5db199SXin Liclass tast(test.test):
110*9c5db199SXin Li    """Autotest server test that runs a Tast test suite.
111*9c5db199SXin Li
112*9c5db199SXin Li    Tast is an integration-testing framework analagous to the test-running
113*9c5db199SXin Li    portion of Autotest. See
114*9c5db199SXin Li    https://chromium.googlesource.com/chromiumos/platform/tast/ for more
115*9c5db199SXin Li    information.
116*9c5db199SXin Li
117*9c5db199SXin Li    This class runs the "tast" command locally to execute a Tast test suite on a
118*9c5db199SXin Li    remote DUT.
119*9c5db199SXin Li    """
120*9c5db199SXin Li    version = 1
121*9c5db199SXin Li
122*9c5db199SXin Li    # Maximum time to wait for various tast commands to complete, in seconds.
123*9c5db199SXin Li    _VERSION_TIMEOUT_SEC = 10
124*9c5db199SXin Li    _DOWNLOAD_TIMEOUT_SEC = 120
125*9c5db199SXin Li    _LIST_TIMEOUT_SEC = 30
126*9c5db199SXin Li    _LIST_TRIES = 2
127*9c5db199SXin Li
128*9c5db199SXin Li    # Additional time to add to the run timeout (e.g. for collecting crashes and
129*9c5db199SXin Li    # logs).
130*9c5db199SXin Li    _RUN_OVERHEAD_SEC = 20
131*9c5db199SXin Li    # Additional time given to the run command to exit before it's killed.
132*9c5db199SXin Li    _RUN_EXIT_SEC = 5
133*9c5db199SXin Li
134*9c5db199SXin Li    # Number of times to retry SSH connection attempts to the DUT.
135*9c5db199SXin Li    _SSH_CONNECT_RETRIES = 2
136*9c5db199SXin Li
137*9c5db199SXin Li    # File written by the tast command containing test results, as
138*9c5db199SXin Li    # newline-terminated JSON TestResult objects.
139*9c5db199SXin Li    _STREAMED_RESULTS_FILENAME = 'streamed_results.jsonl'
140*9c5db199SXin Li
141*9c5db199SXin Li    # Text file written by the tast command if a global error caused the test
142*9c5db199SXin Li    # run to fail (e.g. SSH connection to DUT was lost).
143*9c5db199SXin Li    _RUN_ERROR_FILENAME = 'run_error.txt'
144*9c5db199SXin Li
145*9c5db199SXin Li    # Maximum number of failing and missing tests to include in error messages.
146*9c5db199SXin Li    _MAX_TEST_NAMES_IN_ERROR = 3
147*9c5db199SXin Li
148*9c5db199SXin Li    # Default paths where Tast files are installed by Portage packages.
149*9c5db199SXin Li    _PORTAGE_TAST_PATH = '/usr/bin/tast'
150*9c5db199SXin Li
151*9c5db199SXin Li    # Alternate locations for Tast files when using Server-Side Packaging.
152*9c5db199SXin Li    # These files are installed from autotest_server_package.tar.bz2.
153*9c5db199SXin Li    _SSP_ROOT = '/usr/local/tast'
154*9c5db199SXin Li    _SSP_TAST_PATH = os.path.join(_SSP_ROOT, 'tast')
155*9c5db199SXin Li    _SSP_REMOTE_BUNDLE_DIR = os.path.join(_SSP_ROOT, 'bundles/remote')
156*9c5db199SXin Li    _SSP_REMOTE_DATA_DIR = os.path.join(_SSP_ROOT, 'data')
157*9c5db199SXin Li    _SSP_REMOTE_TEST_RUNNER_PATH = os.path.join(_SSP_ROOT, 'remote_test_runner')
158*9c5db199SXin Li    _SSP_DEFAULT_VARS_DIR_PATH = os.path.join(_SSP_ROOT, 'vars')
159*9c5db199SXin Li
160*9c5db199SXin Li    _F20_CONTAINER_BREADCRUMB = '/usr/local/f20container'
161*9c5db199SXin Li    # Prefix added to Tast test names when writing their results to TKO
162*9c5db199SXin Li    # status.log files.
163*9c5db199SXin Li    _TEST_NAME_PREFIX = 'tast.'
164*9c5db199SXin Li
165*9c5db199SXin Li    # Prefixes of keyval keys recorded for missing tests.
166*9c5db199SXin Li    _MISSING_TEST_KEYVAL_PREFIX = 'tast_missing_test.'
167*9c5db199SXin Li
168*9c5db199SXin Li    # Job start/end TKO event status codes from base_client_job._rungroup in
169*9c5db199SXin Li    # client/bin/job.py.
170*9c5db199SXin Li    _JOB_STATUS_START = 'START'
171*9c5db199SXin Li    _JOB_STATUS_END_GOOD = 'END GOOD'
172*9c5db199SXin Li    _JOB_STATUS_END_FAIL = 'END FAIL'
173*9c5db199SXin Li    _JOB_STATUS_END_NOSTATUS = 'END NOSTATUS'
174*9c5db199SXin Li    _JOB_STATUS_END_SKIP = 'END TEST_NA'
175*9c5db199SXin Li
176*9c5db199SXin Li    # In-job TKO event status codes from base_client_job._run_test_base in
177*9c5db199SXin Li    # client/bin/job.py and client/common_lib/error.py.
178*9c5db199SXin Li    _JOB_STATUS_GOOD = 'GOOD'
179*9c5db199SXin Li    _JOB_STATUS_FAIL = 'FAIL'
180*9c5db199SXin Li    _JOB_STATUS_NOSTATUS = 'NOSTATUS'
181*9c5db199SXin Li    _JOB_STATUS_SKIP = 'TEST_NA'
182*9c5db199SXin Li
183*9c5db199SXin Li    # Status reason used when an individual Tast test doesn't finish running.
184*9c5db199SXin Li    _TEST_DID_NOT_FINISH_MSG = 'Test did not finish'
185*9c5db199SXin Li    # Status reason used when an individual Tast test doesn't start running.
186*9c5db199SXin Li    _TEST_DID_NOT_RUN_MSG = 'Test did not run'
187*9c5db199SXin Li
188*9c5db199SXin Li    def initialize(self,
189*9c5db199SXin Li                   host,
190*9c5db199SXin Li                   test_exprs,
191*9c5db199SXin Li                   ignore_test_failures=False,
192*9c5db199SXin Li                   max_run_sec=3600,
193*9c5db199SXin Li                   command_args=[],
194*9c5db199SXin Li                   install_root='/',
195*9c5db199SXin Li                   ssp=None,
196*9c5db199SXin Li                   build=None,
197*9c5db199SXin Li                   build_bundle='cros',
198*9c5db199SXin Li                   run_private_tests=True,
199*9c5db199SXin Li                   varsfiles=[],
200*9c5db199SXin Li                   download_data_lazily=True,
201*9c5db199SXin Li                   clear_tpm=True,
202*9c5db199SXin Li                   totalshards=1,
203*9c5db199SXin Li                   shardindex=0,
204*9c5db199SXin Li                   companion_duts={},
205*9c5db199SXin Li                   varslist=[],
206*9c5db199SXin Li                   maybemissingvars='',
207*9c5db199SXin Li                   use_camera_box=False,
208*9c5db199SXin Li                   vars_gs_path='',
209*9c5db199SXin Li                   retries=0,
210*9c5db199SXin Li                   ephemeraldevserver=None,
211*9c5db199SXin Li                   is_cft=False,
212*9c5db199SXin Li                   exclude_missing=False,
213*9c5db199SXin Li                   test_filter_files=[],
214*9c5db199SXin Li                   report_skipped=False):
215*9c5db199SXin Li        """
216*9c5db199SXin Li        @param host: remote.RemoteHost instance representing DUT.
217*9c5db199SXin Li        @param test_exprs: Array of strings describing tests to run.
218*9c5db199SXin Li        @param ignore_test_failures: If False, this test will fail if individual
219*9c5db199SXin Li            Tast tests report failure. If True, this test will only fail in
220*9c5db199SXin Li            response to the tast command failing to run successfully. This
221*9c5db199SXin Li            should generally be False when the test is running inline and True
222*9c5db199SXin Li            when it's running asynchronously.
223*9c5db199SXin Li        @param max_run_sec: Integer maximum running time for the "tast run"
224*9c5db199SXin Li            command in seconds.
225*9c5db199SXin Li        @param command_args: List of arguments passed on the command line via
226*9c5db199SXin Li            test_that's --args flag, i.e. |args| in control file.
227*9c5db199SXin Li        @param install_root: Root directory under which Tast binaries are
228*9c5db199SXin Li            installed. Alternate values may be passed by unit tests.
229*9c5db199SXin Li        @param ssp: Whether to use SSP files. Default is to auto-detect.
230*9c5db199SXin Li        @param build: Whether to build test runners and test bundles.
231*9c5db199SXin Li            Default is to build if and only if SSP is unavailable
232*9c5db199SXin Li            (i.e. build = not ssp).
233*9c5db199SXin Li        @param build_bundle: Test bundle name to build. Effective only when
234*9c5db199SXin Li            build=True.
235*9c5db199SXin Li        @param run_private_tests: Download and run private tests. Effective
236*9c5db199SXin Li            only when build=False. When build=True, build_bundle can be
237*9c5db199SXin Li            specified to build and run a private bundle.
238*9c5db199SXin Li        @param varsfiles: list of names of yaml files containing variables set
239*9c5db199SXin Li            in |-varsfile| arguments.
240*9c5db199SXin Li        @param download_data_lazily: If True, external data files are downloaded
241*9c5db199SXin Li            lazily between tests. If false, external data files are downloaded
242*9c5db199SXin Li            in a batch before running tests.
243*9c5db199SXin Li        @param clear_tpm: clear the TPM first before running the tast tests.
244*9c5db199SXin Li        @param totalshards: Total number of shards.
245*9c5db199SXin Li        @param shardindex: The shard index to be run.
246*9c5db199SXin Li        @param companion_duts: A map of role to DUT name to tast run command as
247*9c5db199SXin Li            |-companiondut| arguments. Each entry in the map will be formatted
248*9c5db199SXin Li            as "role:dut" for each -companiondut argument.
249*9c5db199SXin Li        @param varslist: list of strings to pass to tast run command as |-vars|
250*9c5db199SXin Li            arguments. Each string should be formatted as "name=value".
251*9c5db199SXin Li        @param maybemissingvars: a regex to pass to tast run command as
252*9c5db199SXin Li            |-maybemissingvars| arguments.
253*9c5db199SXin Li        @param vars_gs_path: gs path to load vars from. The vars are loaded
254*9c5db199SXin Li            from gs in json format (key = value), then stored in a local
255*9c5db199SXin Li            yaml file. The local file name is then appended to |-varsfiles|.
256*9c5db199SXin Li        @param use_camera_box: Bring the IP address of chart device in CameraBox
257*9c5db199SXin Li            to tast tests.
258*9c5db199SXin Li        @param ephemeraldevserver: A value to pass to -ephemeraldevserver
259*9c5db199SXin Li        @param exclude_missing: This option will exclude tests that are requested, but not found in
260*9c5db199SXin Li        `tast list` command
261*9c5db199SXin Li        @param test_filter_files: This option includes a list of files containing names
262*9c5db199SXin Li        of test to be disabled.
263*9c5db199SXin Li        @param report_skipped: If true then skipped tests will be reported in
264*9c5db199SXin Li        the status.log
265*9c5db199SXin Li
266*9c5db199SXin Li        When the F20 breadcrumb is detected, it is assumed we are running in
267*9c5db199SXin Li            the F20 container, meaning we will force disable SSP (though the
268*9c5db199SXin Li            SSP flag should be false in this case). The F20 container is fully
269*9c5db199SXin Li            build versioned and matches the chroot paths, so we do not want to
270*9c5db199SXin Li            take the SSP logic.
271*9c5db199SXin Li
272*9c5db199SXin Li        @raises error.TestFail if the Tast installation couldn't be found.
273*9c5db199SXin Li        """
274*9c5db199SXin Li        f20_container = False
275*9c5db199SXin Li        if os.path.exists(self._F20_CONTAINER_BREADCRUMB):
276*9c5db199SXin Li            ssp = False
277*9c5db199SXin Li            f20_container = True
278*9c5db199SXin Li        if ssp is None:
279*9c5db199SXin Li            ssp = os.path.exists(self._SSP_TAST_PATH)
280*9c5db199SXin Li        if build is None:
281*9c5db199SXin Li            build = not ssp
282*9c5db199SXin Li
283*9c5db199SXin Li        self._host = host
284*9c5db199SXin Li        self._test_exprs = test_exprs
285*9c5db199SXin Li        self._ignore_test_failures = ignore_test_failures
286*9c5db199SXin Li        self._max_run_sec = max_run_sec
287*9c5db199SXin Li        self._command_args = command_args
288*9c5db199SXin Li        self._install_root = install_root
289*9c5db199SXin Li        self._ssp = ssp
290*9c5db199SXin Li        self._build = build
291*9c5db199SXin Li        self._build_bundle = build_bundle
292*9c5db199SXin Li        self._run_private_tests = run_private_tests
293*9c5db199SXin Li        self._fake_now = None
294*9c5db199SXin Li        self._varsfiles = varsfiles
295*9c5db199SXin Li        self._varslist = varslist
296*9c5db199SXin Li        self._download_data_lazily = download_data_lazily
297*9c5db199SXin Li        self._clear_tpm = clear_tpm
298*9c5db199SXin Li        self._totalshards = totalshards
299*9c5db199SXin Li        self._shardindex = shardindex
300*9c5db199SXin Li        self._companion_duts = companion_duts
301*9c5db199SXin Li        self._maybemissingvars = maybemissingvars
302*9c5db199SXin Li        self._vars_gs_path = vars_gs_path
303*9c5db199SXin Li        self._use_camera_box = use_camera_box
304*9c5db199SXin Li        self._retries = retries
305*9c5db199SXin Li        self._f20_container = f20_container or is_cft
306*9c5db199SXin Li        self._ephemeraldevserver = ephemeraldevserver
307*9c5db199SXin Li        self._exclude_missing = exclude_missing
308*9c5db199SXin Li        self._test_filter_files = test_filter_files
309*9c5db199SXin Li        self._report_skipped = report_skipped
310*9c5db199SXin Li
311*9c5db199SXin Li        # Need to pass in dut_servers for every test in CFT.
312*9c5db199SXin Li        # But only add it if not already in varslist.
313*9c5db199SXin Li        if not any(
314*9c5db199SXin Li                [True if 'dut.servers' in arg else False for arg in varslist]):
315*9c5db199SXin Li            dut_server = _dut_server_arg(command_args)
316*9c5db199SXin Li            if dut_server:
317*9c5db199SXin Li                self._varslist.append('servers.dut=:%s' % dut_server)
318*9c5db199SXin Li
319*9c5db199SXin Li        # List of JSON objects describing tests that will be run. See Test in
320*9c5db199SXin Li        # src/platform/tast/src/chromiumos/tast/testing/test.go for details.
321*9c5db199SXin Li        self._tests_to_run = []
322*9c5db199SXin Li
323*9c5db199SXin Li        # List of JSON objects corresponding to tests from
324*9c5db199SXin Li        # _STREAMED_RESULTS_FILENAME. See TestResult in
325*9c5db199SXin Li        # src/platform/tast/src/chromiumos/cmd/tast/run/results.go for details.
326*9c5db199SXin Li        self._test_results = []
327*9c5db199SXin Li
328*9c5db199SXin Li        # Error message read from _RUN_ERROR_FILENAME, if any.
329*9c5db199SXin Li        self._run_error = None
330*9c5db199SXin Li
331*9c5db199SXin Li        self._tast_path = self._get_path(
332*9c5db199SXin Li            self._SSP_TAST_PATH if ssp else self._PORTAGE_TAST_PATH)
333*9c5db199SXin Li
334*9c5db199SXin Li        # Register a hook to write the results of individual Tast tests as
335*9c5db199SXin Li        # top-level entries in the TKO status.log file.
336*9c5db199SXin Li        self.job.add_post_run_hook(self._log_all_unique_tests)
337*9c5db199SXin Li
338*9c5db199SXin Li    def run_once(self):
339*9c5db199SXin Li        """Runs a single iteration of the test."""
340*9c5db199SXin Li
341*9c5db199SXin Li        if self._clear_tpm:
342*9c5db199SXin Li            tpm_utils.ClearTPMOwnerRequest(self._host, wait_for_ready=True)
343*9c5db199SXin Li
344*9c5db199SXin Li        self._log_version()
345*9c5db199SXin Li        if not self._f20_container:
346*9c5db199SXin Li            self._find_devservers()
347*9c5db199SXin Li
348*9c5db199SXin Li        self._ensure_bundles()
349*9c5db199SXin Li
350*9c5db199SXin Li        # Shortcut if no test belongs to the specified test_exprs.
351*9c5db199SXin Li        list_test_exception = None
352*9c5db199SXin Li        has_tests_to_run = False
353*9c5db199SXin Li        for i in range(self._LIST_TRIES):
354*9c5db199SXin Li            try:
355*9c5db199SXin Li                if i > 0:
356*9c5db199SXin Li                    logging.info('Retrying to get which tests to run')
357*9c5db199SXin Li                has_tests_to_run = bool(self._get_tests_to_run())
358*9c5db199SXin Li                list_test_exception = None
359*9c5db199SXin Li                break
360*9c5db199SXin Li            except Exception as e:
361*9c5db199SXin Li                list_test_exception = e
362*9c5db199SXin Li        if list_test_exception:
363*9c5db199SXin Li            raise error.TestFail('Failed to get list of tests to run: %s' %
364*9c5db199SXin Li                                 str(list_test_exception))
365*9c5db199SXin Li        if not has_tests_to_run:
366*9c5db199SXin Li            return
367*9c5db199SXin Li
368*9c5db199SXin Li        # TODO(b/221333999): There are no devservers in CFT (F20), so this
369*9c5db199SXin Li        # would likely error. Once full CFT is done tast.py will be deprecated
370*9c5db199SXin Li        # and this won't be needed.
371*9c5db199SXin Li        if not self._f20_container:
372*9c5db199SXin Li            self._pull_varsfile_from_gs()
373*9c5db199SXin Li
374*9c5db199SXin Li        run_failed = False
375*9c5db199SXin Li        run_failed_msg = None
376*9c5db199SXin Li        try:
377*9c5db199SXin Li            self._run_tests()
378*9c5db199SXin Li        except Exception as e:
379*9c5db199SXin Li            run_failed = True
380*9c5db199SXin Li            run_failed_msg = str(e).split('\n', 1)[0]
381*9c5db199SXin Li            raise
382*9c5db199SXin Li        finally:
383*9c5db199SXin Li            self._read_run_error()
384*9c5db199SXin Li            # Parse partial results even if the tast command didn't finish.
385*9c5db199SXin Li            self._parse_results(run_failed, run_failed_msg)
386*9c5db199SXin Li
387*9c5db199SXin Li    def set_fake_now_for_testing(self, now):
388*9c5db199SXin Li        """Sets a fake timestamp to use in place of time.time() for unit tests.
389*9c5db199SXin Li
390*9c5db199SXin Li        @param now Numeric timestamp as would be returned by time.time().
391*9c5db199SXin Li        """
392*9c5db199SXin Li        self._fake_now = now
393*9c5db199SXin Li
394*9c5db199SXin Li    def _pull_varsfile_from_gs(self):
395*9c5db199SXin Li        """Pulls varsfiles from GS, does dynamic values transformation, stores
396*9c5db199SXin Li        it as a local file and appends the file name to varsfiles.
397*9c5db199SXin Li
398*9c5db199SXin Li        Has to be called after _get_tests_to_run since it's using _tests_to_run.
399*9c5db199SXin Li
400*9c5db199SXin Li        @param varsgspath Path to varsfiles in GS e.g.
401*9c5db199SXin Li            'config/perf_cuj/perf_cuj.config'.
402*9c5db199SXin Li
403*9c5db199SXin Li        @raises TastConfigError for config errors.
404*9c5db199SXin Li        """
405*9c5db199SXin Li        if not self._vars_gs_path:
406*9c5db199SXin Li            return
407*9c5db199SXin Li
408*9c5db199SXin Li        devservers = dev_server.ImageServer.get_available_devservers()
409*9c5db199SXin Li        devserver_url = devservers[0][0]
410*9c5db199SXin Li        if not devserver_url:
411*9c5db199SXin Li            raise TastConfigError('No devserver_url')
412*9c5db199SXin Li
413*9c5db199SXin Li        logging.info('Using devserver: %s', devserver_url)
414*9c5db199SXin Li        labels = self._host.host_info_store.get().labels
415*9c5db199SXin Li        build = labellib.LabelsMapping(labels).get(labellib.Key.CROS_VERSION)
416*9c5db199SXin Li        if not build:
417*9c5db199SXin Li            raise TastConfigError(
418*9c5db199SXin Li                    'Not able to detect build, means not running on Moblab.')
419*9c5db199SXin Li
420*9c5db199SXin Li        ds = dev_server.ImageServer(devserver_url)
421*9c5db199SXin Li        gs_bucket = dev_server._get_image_storage_server()
422*9c5db199SXin Li        if not gs_bucket:
423*9c5db199SXin Li            raise TastConfigError('No image storage server gs bucket')
424*9c5db199SXin Li
425*9c5db199SXin Li        config_path, config_file = os.path.split(self._vars_gs_path)
426*9c5db199SXin Li        archive_url = os.path.join(gs_bucket, config_path.strip('/'))
427*9c5db199SXin Li        logging.info('Staging configuration from %s.', gs_bucket)
428*9c5db199SXin Li        try:
429*9c5db199SXin Li            ds.stage_artifacts(build,
430*9c5db199SXin Li                               archive_url=archive_url,
431*9c5db199SXin Li                               files=[config_file])
432*9c5db199SXin Li        except Exception as e:
433*9c5db199SXin Li            raise TastConfigError('Staging artifacts failed: %s', str(e))
434*9c5db199SXin Li
435*9c5db199SXin Li        logging.info('Parsing configuration from %s.', archive_url)
436*9c5db199SXin Li        config_url = os.path.join(devserver_url, 'static',
437*9c5db199SXin Li                                  self._vars_gs_path.strip('/'))
438*9c5db199SXin Li        response = urllib.request.urlopen(config_url)
439*9c5db199SXin Li        vars = json.loads(response.read())
440*9c5db199SXin Li        test_args = dict()
441*9c5db199SXin Li        for key in vars:
442*9c5db199SXin Li            test_args[key] = vars[key]
443*9c5db199SXin Li        logging.info('Read %d values from remote configuration.', len(vars))
444*9c5db199SXin Li
445*9c5db199SXin Li        extvars = self._fill_config_extvars()
446*9c5db199SXin Li        test_args = config_vars.TransformConfig(test_args, extvars)
447*9c5db199SXin Li
448*9c5db199SXin Li        with tempfile.NamedTemporaryFile(suffix='.yaml',
449*9c5db199SXin Li                                         delete=False) as temp_file:
450*9c5db199SXin Li            yaml.dump(test_args, stream=temp_file, default_flow_style=False)
451*9c5db199SXin Li            self._varsfiles.append(temp_file.name)
452*9c5db199SXin Li
453*9c5db199SXin Li    def _fill_config_extvars(self):
454*9c5db199SXin Li        """Fill in external variables map for conditional config processing.
455*9c5db199SXin Li
456*9c5db199SXin Li        The sources used (in order of precedence low to high):
457*9c5db199SXin Li          * --varsfiles.
458*9c5db199SXin Li          * --varslist.
459*9c5db199SXin Li          * list of tests to run.
460*9c5db199SXin Li          * command_args: List of arguments passed on the command line via
461*9c5db199SXin Li            test_that's --args flag, i.e. |args| in control file.
462*9c5db199SXin Li          * DUT labels (with and without a value).
463*9c5db199SXin Li
464*9c5db199SXin Li        @returns external variables map.
465*9c5db199SXin Li        """
466*9c5db199SXin Li        # The latter overwrites the former.
467*9c5db199SXin Li        extvars = {}
468*9c5db199SXin Li
469*9c5db199SXin Li        # Load varsfiles
470*9c5db199SXin Li        for varsfile in self._varsfiles:
471*9c5db199SXin Li            with open(varsfile, 'r') as f:
472*9c5db199SXin Li                for key, val in yaml.safe_load(f).items():
473*9c5db199SXin Li                    if 'var:' + key in extvars:
474*9c5db199SXin Li                        logging.info('var:%s overwritten', key)
475*9c5db199SXin Li                    extvars['var:' + key] = val
476*9c5db199SXin Li
477*9c5db199SXin Li        # Load vars
478*9c5db199SXin Li        for var in self._varslist:
479*9c5db199SXin Li            key, val = var.split('=', 1)
480*9c5db199SXin Li            if 'var:' + key in extvars:
481*9c5db199SXin Li                logging.info('var:%s overwritten', key)
482*9c5db199SXin Li            extvars['var:' + key] = val
483*9c5db199SXin Li
484*9c5db199SXin Li        # Load tests_to_run
485*9c5db199SXin Li        extvars['tests:'] = '\n'.join(self._tests_to_run)
486*9c5db199SXin Li        for test_to_run in self._tests_to_run:
487*9c5db199SXin Li            extvars['test:' + test_to_run] = ''
488*9c5db199SXin Li
489*9c5db199SXin Li        # Load command_args
490*9c5db199SXin Li        extvars['args:'] = '\n'.join(self._command_args)
491*9c5db199SXin Li        for key, val in utils.args_to_dict(self._command_args).items():
492*9c5db199SXin Li            extvars['arg:' + key] = val
493*9c5db199SXin Li        for command_arg in self._command_args:
494*9c5db199SXin Li            if '=' not in command_arg and ':' not in command_arg:
495*9c5db199SXin Li                extvars['arg:' + command_arg] = ''
496*9c5db199SXin Li
497*9c5db199SXin Li        # Load labels
498*9c5db199SXin Li        labels = self._host.host_info_store.get().labels
499*9c5db199SXin Li        extvars['labels:'] = '\n'.join(labels)
500*9c5db199SXin Li        for label in labels:
501*9c5db199SXin Li            key, val = (label.split(':', 1) + [''])[0:2]
502*9c5db199SXin Li            extvars['label:' + key] = val
503*9c5db199SXin Li
504*9c5db199SXin Li        return extvars
505*9c5db199SXin Li
506*9c5db199SXin Li    def _get_path(self, path):
507*9c5db199SXin Li        """Returns the path to an installed Tast-related file or directory.
508*9c5db199SXin Li
509*9c5db199SXin Li        @param path: Absolute paths in root filesystem, e.g. "/usr/bin/tast".
510*9c5db199SXin Li
511*9c5db199SXin Li        @returns Absolute path within install root, e.g.
512*9c5db199SXin Li            "/usr/local/tast/usr/bin/tast".
513*9c5db199SXin Li        """
514*9c5db199SXin Li        return os.path.join(self._install_root, os.path.relpath(path, '/'))
515*9c5db199SXin Li
516*9c5db199SXin Li    def _get_camerabox_args(self):
517*9c5db199SXin Li        """Gets camerabox-related arguments to pass to "tast run".
518*9c5db199SXin Li
519*9c5db199SXin Li        @returns List of command-line flag strings that should be inserted in
520*9c5db199SXin Li            the command line after "tast run".
521*9c5db199SXin Li        """
522*9c5db199SXin Li        args = []
523*9c5db199SXin Li        if self._use_camera_box:
524*9c5db199SXin Li            host_name = self._host.hostname
525*9c5db199SXin Li
526*9c5db199SXin Li            # If host name is "FOO.BAR.BAR2", the chart host name should be
527*9c5db199SXin Li            # "FOO-tablet.BAR.BAR2"
528*9c5db199SXin Li            domains = host_name.split('.', 1)
529*9c5db199SXin Li            domains[0] += '-tablet'
530*9c5db199SXin Li            chart_host_name = '.'.join(domains)
531*9c5db199SXin Li            try:
532*9c5db199SXin Li                chart_ip = socket.gethostbyname(chart_host_name)
533*9c5db199SXin Li
534*9c5db199SXin Li                # Check if the IP is pingable.
535*9c5db199SXin Li                if os.system("ping -c 1 " + chart_ip) != 0:
536*9c5db199SXin Li                    logging.error('Failed to ping IP: %s.', chart_ip)
537*9c5db199SXin Li
538*9c5db199SXin Li                args += ['-var=chart=' + chart_ip]
539*9c5db199SXin Li            except socket.gaierror:
540*9c5db199SXin Li                logging.exception('Failed to get IP: %s.', chart_host_name)
541*9c5db199SXin Li        logging.info('Camerabox args: %s', args)
542*9c5db199SXin Li        return args
543*9c5db199SXin Li
544*9c5db199SXin Li    def _get_servo_args(self):
545*9c5db199SXin Li        """Gets servo-related arguments to pass to "tast run".
546*9c5db199SXin Li
547*9c5db199SXin Li        @returns List of command-line flag strings that should be inserted in
548*9c5db199SXin Li            the command line after "tast run".
549*9c5db199SXin Li        """
550*9c5db199SXin Li        # Start with information provided by the Autotest database.
551*9c5db199SXin Li        merged_args = {}
552*9c5db199SXin Li        host_args = servo_host.get_servo_args_for_host(self._host)
553*9c5db199SXin Li        if host_args:
554*9c5db199SXin Li            merged_args.update(host_args)
555*9c5db199SXin Li
556*9c5db199SXin Li        # Incorporate information that was passed manually.
557*9c5db199SXin Li        args_dict = utils.args_to_dict(self._command_args)
558*9c5db199SXin Li        merged_args.update(cros_host.CrosHost.get_servo_arguments(args_dict))
559*9c5db199SXin Li
560*9c5db199SXin Li        logging.info('Autotest servo-related args: %s', merged_args)
561*9c5db199SXin Li        host_arg = merged_args.get(servo_constants.SERVO_HOST_ATTR)
562*9c5db199SXin Li        port_arg = merged_args.get(servo_constants.SERVO_PORT_ATTR)
563*9c5db199SXin Li        if not host_arg or not port_arg:
564*9c5db199SXin Li            return []
565*9c5db199SXin Li        return ['-var=servo=%s:%s' % (host_arg, port_arg)]
566*9c5db199SXin Li
567*9c5db199SXin Li    def _get_firmware_args(self):
568*9c5db199SXin Li        """Gets firmware-related arguments to pass to "tast run".
569*9c5db199SXin Li
570*9c5db199SXin Li        @returns List of command-line flag strings that should be inserted in
571*9c5db199SXin Li            the command line after "tast run".
572*9c5db199SXin Li        """
573*9c5db199SXin Li        # Incorporate information that was passed manually.
574*9c5db199SXin Li        args_dict = utils.args_to_dict(self._command_args)
575*9c5db199SXin Li
576*9c5db199SXin Li        args = []
577*9c5db199SXin Li        no_ec_sync = args_dict.get("no_ec_sync")
578*9c5db199SXin Li        if no_ec_sync:
579*9c5db199SXin Li            args += ['-var=firmware.no_ec_sync=' + no_ec_sync]
580*9c5db199SXin Li        logging.info('Firmware args: %s', args)
581*9c5db199SXin Li        return args
582*9c5db199SXin Li
583*9c5db199SXin Li    def _get_rpm_args(self):
584*9c5db199SXin Li        """Gets rpm-related arguments to pass to "tast run".
585*9c5db199SXin Li
586*9c5db199SXin Li        @returns List of command-line flag strings that should be inserted in
587*9c5db199SXin Li            the command line after "tast run".
588*9c5db199SXin Li        """
589*9c5db199SXin Li        info = self._host.host_info_store.get()
590*9c5db199SXin Li        args = []
591*9c5db199SXin Li        forward_args = [
592*9c5db199SXin Li                (rpm_constants.POWERUNIT_HOSTNAME_KEY, 'powerunitHostname=%s'),
593*9c5db199SXin Li                (rpm_constants.POWERUNIT_OUTLET_KEY, 'powerunitOutlet=%s'),
594*9c5db199SXin Li                (rpm_constants.HYDRA_HOSTNAME_KEY, 'hydraHostname=%s'),
595*9c5db199SXin Li        ]
596*9c5db199SXin Li        for key, var_arg in forward_args:
597*9c5db199SXin Li            if key in info.attributes:
598*9c5db199SXin Li                args += ['-var=' + var_arg % info.attributes[key]]
599*9c5db199SXin Li        logging.info('RPM args: %s', args)
600*9c5db199SXin Li        return args
601*9c5db199SXin Li
602*9c5db199SXin Li    def _get_wificell_args(self):
603*9c5db199SXin Li        """Gets wificell-related (router, pcap) arguments to pass to "tast run".
604*9c5db199SXin Li
605*9c5db199SXin Li        @returns List of command-line flag strings that should be inserted in
606*9c5db199SXin Li            the command line after "tast run".
607*9c5db199SXin Li        """
608*9c5db199SXin Li        # Incorporate information that was passed manually.
609*9c5db199SXin Li        args_dict = utils.args_to_dict(self._command_args)
610*9c5db199SXin Li        args = []
611*9c5db199SXin Li        # Alias of WiFiTestContextManager.
612*9c5db199SXin Li        WiFiManager = wifi_test_context_manager.WiFiTestContextManager
613*9c5db199SXin Li        # TODO(crbug.com/1065601): plumb other WiFi test specific arguments,
614*9c5db199SXin Li        #     e.g. pcap address. See: WiFiTestContextManager's constants.
615*9c5db199SXin Li        forward_args = [
616*9c5db199SXin Li            (WiFiManager.CMDLINE_ROUTER_ADDR, 'router=%s'),
617*9c5db199SXin Li            (WiFiManager.CMDLINE_PCAP_ADDR, 'pcap=%s'),
618*9c5db199SXin Li        ]
619*9c5db199SXin Li        for key, var_arg in forward_args:
620*9c5db199SXin Li            if key in args_dict:
621*9c5db199SXin Li                args += ['-var=' + var_arg % args_dict[key]]
622*9c5db199SXin Li        # Append "routers" var for supporting multi-router tests with current
623*9c5db199SXin Li        # two-AP fixture setup (with specified router_addr and pcap_addr args).
624*9c5db199SXin Li        # TODO(b/171949862): remove this when a new multi-router fixture is
625*9c5db199SXin Li        # defined and rolled out to the lab.
626*9c5db199SXin Li        if (WiFiManager.CMDLINE_ROUTER_ADDR in args_dict
627*9c5db199SXin Li                    and WiFiManager.CMDLINE_PCAP_ADDR in args_dict):
628*9c5db199SXin Li            args += [
629*9c5db199SXin Li                    '-var=routers=%s,%s' %
630*9c5db199SXin Li                    (args_dict[WiFiManager.CMDLINE_ROUTER_ADDR],
631*9c5db199SXin Li                     args_dict[WiFiManager.CMDLINE_PCAP_ADDR])
632*9c5db199SXin Li            ]
633*9c5db199SXin Li        logging.info('Autotest wificell-related args: %s', args)
634*9c5db199SXin Li        return args
635*9c5db199SXin Li
636*9c5db199SXin Li    def _get_cloud_storage_info(self):
637*9c5db199SXin Li        """Gets the cloud storage bucket URL to pass to tast.
638*9c5db199SXin Li
639*9c5db199SXin Li        @returns Cloud storage bucket URL that should be inserted in
640*9c5db199SXin Li            the command line after "tast run".
641*9c5db199SXin Li        """
642*9c5db199SXin Li        gs_bucket = dev_server._get_image_storage_server()
643*9c5db199SXin Li        args_dict = utils.args_to_dict(self._command_args)
644*9c5db199SXin Li        build = args_dict.get('build')
645*9c5db199SXin Li        if not build:
646*9c5db199SXin Li            labels = self._host.host_info_store.get().labels
647*9c5db199SXin Li            build = labellib.LabelsMapping(labels).get(
648*9c5db199SXin Li                labellib.Key.CROS_VERSION)
649*9c5db199SXin Li
650*9c5db199SXin Li        if not gs_bucket or not build:
651*9c5db199SXin Li            return []
652*9c5db199SXin Li        gs_path = os.path.join(gs_bucket, build)
653*9c5db199SXin Li        if not gs_path.endswith('/'):
654*9c5db199SXin Li            gs_path += '/'
655*9c5db199SXin Li        logging.info('Cloud storage bucket: %s', gs_path)
656*9c5db199SXin Li        return ['-buildartifactsurl=%s' % gs_path]
657*9c5db199SXin Li
658*9c5db199SXin Li    def _find_devservers(self):
659*9c5db199SXin Li        """Finds available devservers.
660*9c5db199SXin Li
661*9c5db199SXin Li        The result is saved as self._devserver_args.
662*9c5db199SXin Li        """
663*9c5db199SXin Li        logging.info('All devservers: %s',
664*9c5db199SXin Li                     ', '.join(dev_server.ImageServer.servers()))
665*9c5db199SXin Li        devservers, can_retry = dev_server.ImageServer.get_available_devservers(
666*9c5db199SXin Li                self._host.hostname, prefer_local_devserver=True)
667*9c5db199SXin Li        if not devservers and can_retry and (self._host.is_satlab()
668*9c5db199SXin Li                                             or 'MOBLAB' in os.environ):
669*9c5db199SXin Li            devservers, can_retry = dev_server.ImageServer.get_available_devservers(
670*9c5db199SXin Li                    self._host.hostname, prefer_local_devserver=False)
671*9c5db199SXin Li        logging.info('Using devservers: %s', ', '.join(devservers))
672*9c5db199SXin Li        self._devserver_args = ['-devservers=%s' % ','.join(devservers)]
673*9c5db199SXin Li        if self._ephemeraldevserver is not None:
674*9c5db199SXin Li            self._devserver_args.append('-ephemeraldevserver=%s' %
675*9c5db199SXin Li                                        self._ephemeraldevserver)
676*9c5db199SXin Li
677*9c5db199SXin Li    def _log_version(self):
678*9c5db199SXin Li        """Runs the tast command locally to log its version."""
679*9c5db199SXin Li        try:
680*9c5db199SXin Li            utils.run([self._tast_path, '-version'],
681*9c5db199SXin Li                      timeout=self._VERSION_TIMEOUT_SEC,
682*9c5db199SXin Li                      stdout_tee=utils.TEE_TO_LOGS,
683*9c5db199SXin Li                      stderr_tee=utils.TEE_TO_LOGS,
684*9c5db199SXin Li                      stderr_is_expected=True,
685*9c5db199SXin Li                      stdout_level=logging.INFO,
686*9c5db199SXin Li                      stderr_level=logging.ERROR)
687*9c5db199SXin Li        except error.CmdError as e:
688*9c5db199SXin Li            logging.error('Failed to log tast version: %s', str(e))
689*9c5db199SXin Li
690*9c5db199SXin Li    def _run_tast(self,
691*9c5db199SXin Li                  subcommand,
692*9c5db199SXin Li                  extra_subcommand_args,
693*9c5db199SXin Li                  test_exprs,
694*9c5db199SXin Li                  timeout_sec,
695*9c5db199SXin Li                  log_stdout=False,
696*9c5db199SXin Li                  ignore_status=False):
697*9c5db199SXin Li        """Runs the tast command locally to e.g. list available tests or perform
698*9c5db199SXin Li        testing against the DUT.
699*9c5db199SXin Li
700*9c5db199SXin Li        @param subcommand: Subcommand to pass to the tast executable, e.g. 'run'
701*9c5db199SXin Li            or 'list'.
702*9c5db199SXin Li        @param extra_subcommand_args: List of additional subcommand arguments.
703*9c5db199SXin Li        @param test_exprs: Array of strings describing tests to run.
704*9c5db199SXin Li        @param timeout_sec: Integer timeout for the command in seconds.
705*9c5db199SXin Li        @param log_stdout: If true, write stdout to log.
706*9c5db199SXin Li        @param ignore_status: If true, command execution errors are ignored.
707*9c5db199SXin Li
708*9c5db199SXin Li        @returns client.common_lib.utils.CmdResult object describing the result.
709*9c5db199SXin Li
710*9c5db199SXin Li        @raises error.TestFail if the tast command fails or times out.
711*9c5db199SXin Li        """
712*9c5db199SXin Li        cmd = [
713*9c5db199SXin Li            self._tast_path,
714*9c5db199SXin Li            '-verbose=true',
715*9c5db199SXin Li            '-logtime=false',
716*9c5db199SXin Li            subcommand,
717*9c5db199SXin Li            '-sshretries=%d' % self._SSH_CONNECT_RETRIES,
718*9c5db199SXin Li            '-downloaddata=%s' % (
719*9c5db199SXin Li                'lazy' if self._download_data_lazily else 'batch'),
720*9c5db199SXin Li            '-totalshards=%s' % self._totalshards,
721*9c5db199SXin Li            '-shardindex=%s' % self._shardindex,
722*9c5db199SXin Li        ]
723*9c5db199SXin Li        if self._f20_container:
724*9c5db199SXin Li            cmd.extend(['-build=false'])
725*9c5db199SXin Li            if self._run_private_tests:
726*9c5db199SXin Li                cmd.append('-downloadprivatebundles=true')
727*9c5db199SXin Li        elif self._build:
728*9c5db199SXin Li            cmd.extend([
729*9c5db199SXin Li                '-build=true',
730*9c5db199SXin Li                '-buildbundle=%s' % self._build_bundle,
731*9c5db199SXin Li                '-checkbuilddeps=false',
732*9c5db199SXin Li            ])
733*9c5db199SXin Li        else:
734*9c5db199SXin Li            cmd.append('-build=false')
735*9c5db199SXin Li            if self._ssp:
736*9c5db199SXin Li                remote_test_runner_path = self._get_path(
737*9c5db199SXin Li                    self._SSP_REMOTE_TEST_RUNNER_PATH)
738*9c5db199SXin Li                if not os.path.exists(remote_test_runner_path):
739*9c5db199SXin Li                    raise error.TestFail(
740*9c5db199SXin Li                        '%s does not exist (broken SSP?)' %
741*9c5db199SXin Li                        remote_test_runner_path)
742*9c5db199SXin Li                cmd.extend([
743*9c5db199SXin Li                    '-remotebundledir=%s' % self._get_path(
744*9c5db199SXin Li                        self._SSP_REMOTE_BUNDLE_DIR),
745*9c5db199SXin Li                    '-remotedatadir=%s' % self._get_path(
746*9c5db199SXin Li                        self._SSP_REMOTE_DATA_DIR),
747*9c5db199SXin Li                    '-remoterunner=%s' % remote_test_runner_path,
748*9c5db199SXin Li                ])
749*9c5db199SXin Li                if subcommand == 'run':
750*9c5db199SXin Li                    cmd.append('-defaultvarsdir=%s' %
751*9c5db199SXin Li                               self._get_path(self._SSP_DEFAULT_VARS_DIR_PATH))
752*9c5db199SXin Li            if self._run_private_tests:
753*9c5db199SXin Li                cmd.append('-downloadprivatebundles=true')
754*9c5db199SXin Li        if not self._f20_container:
755*9c5db199SXin Li            cmd.extend(self._devserver_args)
756*9c5db199SXin Li        cmd.extend(extra_subcommand_args)
757*9c5db199SXin Li        cmd.append('%s%s' % (self._host.hostname, ':%d' %
758*9c5db199SXin Li                             self._host.port if self._host.port else ''))
759*9c5db199SXin Li        cmd.extend(test_exprs)
760*9c5db199SXin Li
761*9c5db199SXin Li        logging.info('Running %s',
762*9c5db199SXin Li                     ' '.join([utils.sh_quote_word(a) for a in cmd]))
763*9c5db199SXin Li        try:
764*9c5db199SXin Li            return utils.run(
765*9c5db199SXin Li                    cmd,
766*9c5db199SXin Li                    ignore_status=ignore_status,
767*9c5db199SXin Li                    timeout=timeout_sec,
768*9c5db199SXin Li                    stdout_tee=(utils.TEE_TO_LOGS if log_stdout else None),
769*9c5db199SXin Li                    stderr_tee=utils.TEE_TO_LOGS,
770*9c5db199SXin Li                    stderr_is_expected=True,
771*9c5db199SXin Li                    stdout_level=logging.INFO,
772*9c5db199SXin Li                    stderr_level=logging.ERROR)
773*9c5db199SXin Li        except error.CmdError as e:
774*9c5db199SXin Li            # Run several commands to debug possible network issues.
775*9c5db199SXin Li            # TODO(b/189332919): Remove this logic once we finish debugging.
776*9c5db199SXin Li            logging.info('Tast exited abnormally. Running several commands to '
777*9c5db199SXin Li                         'diagnose possible network issues...')
778*9c5db199SXin Li            utils.run('time getent ahosts %s' % self._host.hostname,
779*9c5db199SXin Li                      timeout=60,
780*9c5db199SXin Li                      ignore_status=True,
781*9c5db199SXin Li                      stdout_tee=utils.TEE_TO_LOGS,
782*9c5db199SXin Li                      stderr_tee=utils.TEE_TO_LOGS,
783*9c5db199SXin Li                      stderr_is_expected=True,
784*9c5db199SXin Li                      stdout_level=logging.INFO,
785*9c5db199SXin Li                      stderr_level=logging.ERROR)
786*9c5db199SXin Li            utils.run(
787*9c5db199SXin Li                    'ssh '
788*9c5db199SXin Li                    # Enable maximum debug logging.
789*9c5db199SXin Li                    '-vvv '
790*9c5db199SXin Li                    # Disable connection sharing to debug connection issues.
791*9c5db199SXin Li                    '-o ControlPath=none '
792*9c5db199SXin Li                    # Following arguments were copied from Autotest logs.
793*9c5db199SXin Li                    '-a -x '
794*9c5db199SXin Li                    '-o StrictHostKeyChecking=no '
795*9c5db199SXin Li                    '-o UserKnownHostsFile=/dev/null '
796*9c5db199SXin Li                    '-o BatchMode=yes '
797*9c5db199SXin Li                    '-o ConnectTimeout=10 '
798*9c5db199SXin Li                    '-o ConnectionAttempts=3 '
799*9c5db199SXin Li                    '-l root %s%s true' %
800*9c5db199SXin Li                    ('-p %d ' % self._host.port if self._host.port else '',
801*9c5db199SXin Li                     self._host.hostname),
802*9c5db199SXin Li                    timeout=60,
803*9c5db199SXin Li                    ignore_status=True,
804*9c5db199SXin Li                    stdout_tee=utils.TEE_TO_LOGS,
805*9c5db199SXin Li                    stderr_tee=utils.TEE_TO_LOGS,
806*9c5db199SXin Li                    stderr_is_expected=True,
807*9c5db199SXin Li                    stdout_level=logging.INFO,
808*9c5db199SXin Li                    stderr_level=logging.ERROR)
809*9c5db199SXin Li            # The tast command's output generally ends with a line describing
810*9c5db199SXin Li            # the error that was encountered; include it in the first line of
811*9c5db199SXin Li            # the TestFail exception. Fall back to stderr if stdout is empty (as
812*9c5db199SXin Li            # is the case with the "list" subcommand, which uses stdout to print
813*9c5db199SXin Li            # test data).
814*9c5db199SXin Li            get_last_line = lambda s: s.strip().split('\n')[-1].strip()
815*9c5db199SXin Li            last_line = (get_last_line(e.result_obj.stdout) or
816*9c5db199SXin Li                         get_last_line(e.result_obj.stderr))
817*9c5db199SXin Li            msg = (' (last line: %s)' % last_line) if last_line else ''
818*9c5db199SXin Li            raise error.TestFail('Failed to run tast%s: %s' % (msg, str(e)))
819*9c5db199SXin Li        except error.CmdTimeoutError as e:
820*9c5db199SXin Li            raise error.TestFail('Got timeout while running tast: %s' % str(e))
821*9c5db199SXin Li
822*9c5db199SXin Li    def _ensure_bundles(self):
823*9c5db199SXin Li        """Runs the tast command to ensure all test bundles are available.
824*9c5db199SXin Li
825*9c5db199SXin Li        If private test bundles are available, they are downloaded from cloud
826*9c5db199SXin Li        storage and installed to the DUT. Otherwise it is no-nop.
827*9c5db199SXin Li
828*9c5db199SXin Li        Note that "tast list" also downloads private test bundles if they are
829*9c5db199SXin Li        missing. Nevertheless we attempt to download them in advance because
830*9c5db199SXin Li        "tast list" cannot emit detailed logs due to technical limitations and
831*9c5db199SXin Li        often make it difficult to debug issues related to private test bundle
832*9c5db199SXin Li        installation.
833*9c5db199SXin Li        """
834*9c5db199SXin Li        logging.info('Downloading private test bundles (if any)')
835*9c5db199SXin Li        temp_dir = tempfile.mkdtemp()
836*9c5db199SXin Li        try:
837*9c5db199SXin Li            args = ['-resultsdir=' + temp_dir] + self._get_cloud_storage_info()
838*9c5db199SXin Li            for role, dut in sorted(self._companion_duts.items()):
839*9c5db199SXin Li                args.append('-companiondut=%s:%s' % (role, dut))
840*9c5db199SXin Li
841*9c5db199SXin Li            for var in self._varslist:
842*9c5db199SXin Li                args.append('-var=%s' % var)
843*9c5db199SXin Li
844*9c5db199SXin Li            for file_name in self._test_filter_files:
845*9c5db199SXin Li                args.append('-testfilterfile=%s' % file_name)
846*9c5db199SXin Li
847*9c5db199SXin Li            # Start "tast run" with an attribute expression matching no test
848*9c5db199SXin Li            # to trigger a private test bundle download.
849*9c5db199SXin Li            # Note that Tast CLI will exit abnormally when no test matches,
850*9c5db199SXin Li            # so we set ignore_status=True to avoid throwing TestFail.
851*9c5db199SXin Li            self._run_tast('run',
852*9c5db199SXin Li                           args, ['("group:none")'],
853*9c5db199SXin Li                           tast._DOWNLOAD_TIMEOUT_SEC,
854*9c5db199SXin Li                           log_stdout=True,
855*9c5db199SXin Li                           ignore_status=True)
856*9c5db199SXin Li        finally:
857*9c5db199SXin Li            shutil.rmtree(temp_dir, ignore_errors=True)
858*9c5db199SXin Li
859*9c5db199SXin Li    def _get_tests_to_run(self):
860*9c5db199SXin Li        """Runs the tast command to update the list of tests that will be run.
861*9c5db199SXin Li
862*9c5db199SXin Li        @returns False if no tests matched by test_exprs; True otherwise
863*9c5db199SXin Li
864*9c5db199SXin Li        @raises error.TestFail if the tast command fails or times out.
865*9c5db199SXin Li        """
866*9c5db199SXin Li        logging.info('Getting list of tests that will be run')
867*9c5db199SXin Li        args = ['-json=true'] + self._get_cloud_storage_info()
868*9c5db199SXin Li        result = self._run_tast('list', args, self._test_exprs,
869*9c5db199SXin Li                                self._LIST_TIMEOUT_SEC)
870*9c5db199SXin Li        try:
871*9c5db199SXin Li            self._tests_to_run = _encode_json(json.loads(
872*9c5db199SXin Li                    result.stdout.strip()))
873*9c5db199SXin Li        except ValueError as e:
874*9c5db199SXin Li            raise error.TestFail('Failed to parse tests: %s' % str(e))
875*9c5db199SXin Li        if len(self._tests_to_run) == 0:
876*9c5db199SXin Li            expr = ' '.join([utils.sh_quote_word(a) for a in self._test_exprs])
877*9c5db199SXin Li            logging.warning('No tests matched by %s', expr)
878*9c5db199SXin Li            return False
879*9c5db199SXin Li
880*9c5db199SXin Li        logging.info('Expect to run %d test(s)', len(self._tests_to_run))
881*9c5db199SXin Li
882*9c5db199SXin Li        logging.info('Tests in scope:')
883*9c5db199SXin Li        for test in self._tests_to_run:
884*9c5db199SXin Li            logging.info('Test: %s', test['name'])
885*9c5db199SXin Li
886*9c5db199SXin Li        return True
887*9c5db199SXin Li
888*9c5db199SXin Li    def _run_tests(self):
889*9c5db199SXin Li        """Runs the tast command to perform testing.
890*9c5db199SXin Li
891*9c5db199SXin Li        @raises error.TestFail if the tast command fails or times out (but not
892*9c5db199SXin Li            if individual tests fail).
893*9c5db199SXin Li        """
894*9c5db199SXin Li        args = [
895*9c5db199SXin Li                '-resultsdir=' + self.resultsdir,
896*9c5db199SXin Li                '-waituntilready=true',
897*9c5db199SXin Li                '-timeout=' + str(self._max_run_sec),
898*9c5db199SXin Li                '-continueafterfailure=true',
899*9c5db199SXin Li        ]
900*9c5db199SXin Li        args.extend(self._get_servo_args())
901*9c5db199SXin Li        args.extend(self._get_rpm_args())
902*9c5db199SXin Li        args.extend(self._get_wificell_args())
903*9c5db199SXin Li        args.extend(self._get_cloud_storage_info())
904*9c5db199SXin Li        args.extend(self._get_firmware_args())
905*9c5db199SXin Li        args.extend(self._get_camerabox_args())
906*9c5db199SXin Li        if self._retries:
907*9c5db199SXin Li            args.append('-retries=%d' % self._retries)
908*9c5db199SXin Li
909*9c5db199SXin Li        for varsfile in self._varsfiles:
910*9c5db199SXin Li            args.append('-varsfile=%s' % varsfile)
911*9c5db199SXin Li
912*9c5db199SXin Li        for var in self._varslist:
913*9c5db199SXin Li            args.append('-var=%s' % var)
914*9c5db199SXin Li
915*9c5db199SXin Li        if self._maybemissingvars:
916*9c5db199SXin Li            args.append('-maybemissingvars=%s' % self._maybemissingvars)
917*9c5db199SXin Li
918*9c5db199SXin Li        for role, dut in sorted(self._companion_duts.items()):
919*9c5db199SXin Li            args.append(
920*9c5db199SXin Li                    '-companiondut=%s:%s%s' %
921*9c5db199SXin Li                    (role, dut.hostname, ':%d' % dut.port if dut.port else ''))
922*9c5db199SXin Li
923*9c5db199SXin Li        for file in self._test_filter_files:
924*9c5db199SXin Li            args.append('-testfilterfile=%s' % file)
925*9c5db199SXin Li
926*9c5db199SXin Li        logging.info('Running tests with timeout of %d sec', self._max_run_sec)
927*9c5db199SXin Li        # This option will exclude tests that are requested, but not found in
928*9c5db199SXin Li        # `tast list` command
929*9c5db199SXin Li        if self._exclude_missing:
930*9c5db199SXin Li            tests_to_run_list = [test["name"] for test in self._tests_to_run]
931*9c5db199SXin Li            self._run_tast('run',
932*9c5db199SXin Li                           args,
933*9c5db199SXin Li                           tests_to_run_list,
934*9c5db199SXin Li                           self._max_run_sec + tast._RUN_EXIT_SEC,
935*9c5db199SXin Li                           log_stdout=True)
936*9c5db199SXin Li        else:
937*9c5db199SXin Li            self._run_tast('run',
938*9c5db199SXin Li                           args,
939*9c5db199SXin Li                           self._test_exprs,
940*9c5db199SXin Li                           self._max_run_sec + tast._RUN_EXIT_SEC,
941*9c5db199SXin Li                           log_stdout=True)
942*9c5db199SXin Li
943*9c5db199SXin Li    def _read_run_error(self):
944*9c5db199SXin Li        """Reads a global run error message written by the tast command."""
945*9c5db199SXin Li        # The file is only written if a run error occurred.
946*9c5db199SXin Li        path = os.path.join(self.resultsdir, self._RUN_ERROR_FILENAME)
947*9c5db199SXin Li        if os.path.exists(path):
948*9c5db199SXin Li            with open(path, 'r') as f:
949*9c5db199SXin Li                self._run_error = f.read().strip()
950*9c5db199SXin Li
951*9c5db199SXin Li    def maybe_replace(self, test, failed):
952*9c5db199SXin Li        """ Removes a test from the list of failed results
953*9c5db199SXin Li
954*9c5db199SXin Li        @param test: Name of test to remove from failed list
955*9c5db199SXin Li        @param failed: List of failed tests
956*9c5db199SXin Li        """
957*9c5db199SXin Li        # Remove the result, will take & only count the second result.
958*9c5db199SXin Li        if test[_KEY_NAME] in failed:
959*9c5db199SXin Li            failed.remove(test[_KEY_NAME])
960*9c5db199SXin Li
961*9c5db199SXin Li    def _parse_results(self, ignore_missing_file, run_error_msg):
962*9c5db199SXin Li        """Parses results written by the tast command.
963*9c5db199SXin Li
964*9c5db199SXin Li        @param ignore_missing_file: If True, return without raising an exception
965*9c5db199SXin Li            if the Tast results file is missing. This is used to avoid raising a
966*9c5db199SXin Li            new error if there was already an earlier error while running the
967*9c5db199SXin Li            tast process.
968*9c5db199SXin Li        @param run_error_msg: The error message from Tast when there is an
969*9c5db199SXin Li            error. It will be None if Tast encounters no errors.
970*9c5db199SXin Li
971*9c5db199SXin Li        @raises error.TestFail if results file is missing and
972*9c5db199SXin Li            ignore_missing_file is False, or one or more tests failed and
973*9c5db199SXin Li            _ignore_test_failures is false.
974*9c5db199SXin Li        """
975*9c5db199SXin Li        # The file may not exist if "tast run" failed to run. Tests that were
976*9c5db199SXin Li        # seen from the earlier "tast list" command will be reported as having
977*9c5db199SXin Li        # missing results.
978*9c5db199SXin Li        path = os.path.join(self.resultsdir, self._STREAMED_RESULTS_FILENAME)
979*9c5db199SXin Li        if not os.path.exists(path):
980*9c5db199SXin Li            if ignore_missing_file:
981*9c5db199SXin Li                return
982*9c5db199SXin Li            raise error.TestFail('Results file %s not found' % path)
983*9c5db199SXin Li
984*9c5db199SXin Li        failed = set()
985*9c5db199SXin Li        seen_test_names = set()
986*9c5db199SXin Li        with open(path, 'r') as f:
987*9c5db199SXin Li            for line in f:
988*9c5db199SXin Li                line = line.strip()
989*9c5db199SXin Li                if not line:
990*9c5db199SXin Li                    continue
991*9c5db199SXin Li                try:
992*9c5db199SXin Li                    test = _encode_json(json.loads(line))
993*9c5db199SXin Li                except ValueError as e:
994*9c5db199SXin Li                    raise error.TestFail('Failed to parse %s: %s' % (path, e))
995*9c5db199SXin Li                self._test_results.append(test)
996*9c5db199SXin Li                if test[_KEY_NAME] in seen_test_names:
997*9c5db199SXin Li                    self.maybe_replace(test, failed)
998*9c5db199SXin Li
999*9c5db199SXin Li                name = test[_KEY_NAME]
1000*9c5db199SXin Li                seen_test_names.add(name)
1001*9c5db199SXin Li
1002*9c5db199SXin Li                if test.get(_KEY_ERRORS):
1003*9c5db199SXin Li                    for err in test[_KEY_ERRORS]:
1004*9c5db199SXin Li                        logging.warning('%s: %s', name, err[_KEY_REASON])
1005*9c5db199SXin Li                    failed.add(name)
1006*9c5db199SXin Li                else:
1007*9c5db199SXin Li                    # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC)
1008*9c5db199SXin Li                    # end time (preceding the Unix epoch) if it didn't report
1009*9c5db199SXin Li                    # completion.
1010*9c5db199SXin Li                    if _rfc3339_time_to_timestamp(test[_KEY_END]) <= 0:
1011*9c5db199SXin Li                        failed.add(name)
1012*9c5db199SXin Li
1013*9c5db199SXin Li        missing = [
1014*9c5db199SXin Li                t[_KEY_NAME] for t in self._tests_to_run
1015*9c5db199SXin Li                if t[_KEY_NAME] not in seen_test_names
1016*9c5db199SXin Li        ]
1017*9c5db199SXin Li
1018*9c5db199SXin Li        if missing:
1019*9c5db199SXin Li            self._record_missing_tests(missing)
1020*9c5db199SXin Li            time_str = '%sZ' % datetime.datetime.utcnow().isoformat()
1021*9c5db199SXin Li            for name in missing:
1022*9c5db199SXin Li                t = {}
1023*9c5db199SXin Li                t[_KEY_NAME] = name
1024*9c5db199SXin Li                t[_KEY_START] = time_str
1025*9c5db199SXin Li                t[_KEY_END] = time_str
1026*9c5db199SXin Li                if self._run_error:
1027*9c5db199SXin Li                    t[_KEY_MISSING_REASON] = '%s due to global error: %s' % (
1028*9c5db199SXin Li                            self._TEST_DID_NOT_RUN_MSG, self._run_error)
1029*9c5db199SXin Li                elif run_error_msg:
1030*9c5db199SXin Li                    t[_KEY_MISSING_REASON] = run_error_msg
1031*9c5db199SXin Li                else:
1032*9c5db199SXin Li                    t[_KEY_MISSING_REASON] = self._TEST_DID_NOT_RUN_MSG
1033*9c5db199SXin Li                self._test_results.append(t)
1034*9c5db199SXin Li
1035*9c5db199SXin Li        failure_msg = self._get_failure_message(failed, missing)
1036*9c5db199SXin Li        if failure_msg:
1037*9c5db199SXin Li            raise error.TestFail(failure_msg)
1038*9c5db199SXin Li
1039*9c5db199SXin Li    def _get_failure_message(self, failed, missing):
1040*9c5db199SXin Li        """Returns an error message describing failed and/or missing tests.
1041*9c5db199SXin Li
1042*9c5db199SXin Li        @param failed: List of string names of Tast tests that failed.
1043*9c5db199SXin Li        @param missing: List of string names of Tast tests with missing results.
1044*9c5db199SXin Li
1045*9c5db199SXin Li        @returns String to be used as error.TestFail message.
1046*9c5db199SXin Li        """
1047*9c5db199SXin Li        def list_tests(names):
1048*9c5db199SXin Li            """Returns a string listing tests.
1049*9c5db199SXin Li
1050*9c5db199SXin Li            @param names: List of string test names.
1051*9c5db199SXin Li
1052*9c5db199SXin Li            @returns String listing tests.
1053*9c5db199SXin Li            """
1054*9c5db199SXin Li            s = ' '.join(sorted(names)[:self._MAX_TEST_NAMES_IN_ERROR])
1055*9c5db199SXin Li            if len(names) > self._MAX_TEST_NAMES_IN_ERROR:
1056*9c5db199SXin Li                s += ' ...'
1057*9c5db199SXin Li            return s
1058*9c5db199SXin Li
1059*9c5db199SXin Li        msg = ''
1060*9c5db199SXin Li        if failed and not self._ignore_test_failures:
1061*9c5db199SXin Li            msg = '%d failed: %s' % (len(failed), list_tests(failed))
1062*9c5db199SXin Li        if missing:
1063*9c5db199SXin Li            if msg:
1064*9c5db199SXin Li                msg += '; '
1065*9c5db199SXin Li            msg += '%d missing: %s' % (len(missing), list_tests(missing))
1066*9c5db199SXin Li        return msg
1067*9c5db199SXin Li
1068*9c5db199SXin Li    def _log_all_unique_tests(self):
1069*9c5db199SXin Li        """Writes entries to the TKO status.log file describing the results of
1070*9c5db199SXin Li        all tests.
1071*9c5db199SXin Li
1072*9c5db199SXin Li        If there are 2 tests with the same name, AND it has an error (failure)
1073*9c5db199SXin Li            replace the result.
1074*9c5db199SXin Li        Because: if it has an err AND a second result, its either:
1075*9c5db199SXin Li            The first attempt is logged and failed and we want to use the
1076*9c5db199SXin Li                retry result
1077*9c5db199SXin Li            Or the attempts are out of order, and the already logged attempt is
1078*9c5db199SXin Li                the second attempt which failed, meaning the first ALSO failed.
1079*9c5db199SXin Li                So in this case, its still safe to override because we just
1080*9c5db199SXin Li                need to mark the failure.
1081*9c5db199SXin Li        The benefit of this is, if the first result is logged and failed, the
1082*9c5db199SXin Li            retry might have passed, so we want to log that.
1083*9c5db199SXin Li
1084*9c5db199SXin Li        """
1085*9c5db199SXin Li        seen_test_names = set()
1086*9c5db199SXin Li        tests_to_log = OrderedDict()
1087*9c5db199SXin Li        for test_res in self._test_results:
1088*9c5db199SXin Li            test_name = test_res[_KEY_NAME]
1089*9c5db199SXin Li
1090*9c5db199SXin Li            dup_res = tests_to_log.get(test_name)
1091*9c5db199SXin Li            if not dup_res or dup_res.get(_KEY_ERRORS):
1092*9c5db199SXin Li                tests_to_log[test_name] = test_res
1093*9c5db199SXin Li        for test in tests_to_log.values():
1094*9c5db199SXin Li            self._log_test(test)
1095*9c5db199SXin Li            seen_test_names.add(test[_KEY_NAME])
1096*9c5db199SXin Li
1097*9c5db199SXin Li    def _log_test(self, test):
1098*9c5db199SXin Li        """Writes events to the TKO status.log file describing the results from
1099*9c5db199SXin Li        a Tast test.
1100*9c5db199SXin Li
1101*9c5db199SXin Li        @param test: A JSON object corresponding to a single test from a Tast
1102*9c5db199SXin Li            results.json file. See TestResult in
1103*9c5db199SXin Li            src/platform/tast/src/chromiumos/cmd/tast/run/results.go for
1104*9c5db199SXin Li            details.
1105*9c5db199SXin Li        """
1106*9c5db199SXin Li        name = test[_KEY_NAME]
1107*9c5db199SXin Li        start_time = _rfc3339_time_to_timestamp(test[_KEY_START])
1108*9c5db199SXin Li        end_time = _rfc3339_time_to_timestamp(test[_KEY_END])
1109*9c5db199SXin Li
1110*9c5db199SXin Li        test_reported_errors = bool(test.get(_KEY_ERRORS))
1111*9c5db199SXin Li        test_skipped = bool(test.get(_KEY_SKIP_REASON))
1112*9c5db199SXin Li        test_not_run = bool(test.get(_KEY_MISSING_REASON))
1113*9c5db199SXin Li        # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC) end time
1114*9c5db199SXin Li        # (preceding the Unix epoch) if it didn't report completion.
1115*9c5db199SXin Li        test_finished = end_time > 0
1116*9c5db199SXin Li
1117*9c5db199SXin Li        # Avoid reporting tests that were skipped.
1118*9c5db199SXin Li        if test_skipped and not test_reported_errors and not self._report_skipped:
1119*9c5db199SXin Li            return
1120*9c5db199SXin Li
1121*9c5db199SXin Li        # Look for magic error _TEST_DID_NOT_RUN_MSG and mark test as not run.
1122*9c5db199SXin Li        for err in test.get(_KEY_ERRORS) or []:
1123*9c5db199SXin Li            if err[_KEY_REASON] == self._TEST_DID_NOT_RUN_MSG:
1124*9c5db199SXin Li                test_not_run = True
1125*9c5db199SXin Li                test[_KEY_MISSING_REASON] = self._TEST_DID_NOT_RUN_MSG
1126*9c5db199SXin Li
1127*9c5db199SXin Li        self._log_test_event(self._JOB_STATUS_START, name, start_time)
1128*9c5db199SXin Li
1129*9c5db199SXin Li        if test_not_run:
1130*9c5db199SXin Li            self._log_test_event(self._JOB_STATUS_NOSTATUS, name, end_time,
1131*9c5db199SXin Li                                 test[_KEY_MISSING_REASON])
1132*9c5db199SXin Li            end_status = self._JOB_STATUS_END_NOSTATUS
1133*9c5db199SXin Li        elif test_skipped and not test_reported_errors and self._report_skipped:
1134*9c5db199SXin Li            self._log_test_event(self._JOB_STATUS_SKIP, name, end_time,
1135*9c5db199SXin Li                                 test.get(_KEY_SKIP_REASON))
1136*9c5db199SXin Li            end_status = self._JOB_STATUS_END_SKIP
1137*9c5db199SXin Li        elif test_finished and not test_reported_errors:
1138*9c5db199SXin Li            self._log_test_event(self._JOB_STATUS_GOOD, name, end_time)
1139*9c5db199SXin Li            end_status = self._JOB_STATUS_END_GOOD
1140*9c5db199SXin Li        else:
1141*9c5db199SXin Li            # The previous START event automatically increases the log
1142*9c5db199SXin Li            # indentation level until the following END event.
1143*9c5db199SXin Li            if test_reported_errors:
1144*9c5db199SXin Li                for err in test[_KEY_ERRORS]:
1145*9c5db199SXin Li                    error_time = _rfc3339_time_to_timestamp(err[_KEY_TIME])
1146*9c5db199SXin Li                    self._log_test_event(self._JOB_STATUS_FAIL, name,
1147*9c5db199SXin Li                                         error_time, err[_KEY_REASON])
1148*9c5db199SXin Li            if not test_finished:
1149*9c5db199SXin Li                # If a run-level error was encountered (e.g. the SSH connection
1150*9c5db199SXin Li                # to the DUT was lost), report it here to make it easier to see
1151*9c5db199SXin Li                # the reason why the test didn't finish.
1152*9c5db199SXin Li                if self._run_error:
1153*9c5db199SXin Li                    self._log_test_event(self._JOB_STATUS_FAIL, name,
1154*9c5db199SXin Li                                         start_time, self._run_error)
1155*9c5db199SXin Li                self._log_test_event(self._JOB_STATUS_FAIL, name, start_time,
1156*9c5db199SXin Li                                     self._TEST_DID_NOT_FINISH_MSG)
1157*9c5db199SXin Li                end_time = start_time
1158*9c5db199SXin Li
1159*9c5db199SXin Li            end_status = self._JOB_STATUS_END_FAIL
1160*9c5db199SXin Li
1161*9c5db199SXin Li        self._log_test_event(end_status, name, end_time)
1162*9c5db199SXin Li
1163*9c5db199SXin Li    def _log_test_event(self, status_code, test_name, timestamp, message=''):
1164*9c5db199SXin Li        """Logs a single event to the TKO status.log file.
1165*9c5db199SXin Li
1166*9c5db199SXin Li        @param status_code: Event status code, e.g. 'END GOOD'. See
1167*9c5db199SXin Li            client/common_lib/log.py for accepted values.
1168*9c5db199SXin Li        @param test_name: Tast test name, e.g. 'ui.ChromeLogin'.
1169*9c5db199SXin Li        @param timestamp: Event timestamp (as seconds since Unix epoch).
1170*9c5db199SXin Li        @param message: Optional human-readable message.
1171*9c5db199SXin Li        """
1172*9c5db199SXin Li        full_name = self._TEST_NAME_PREFIX + test_name
1173*9c5db199SXin Li        # The TKO parser code chokes on floating-point timestamps.
1174*9c5db199SXin Li        entry = base_job.status_log_entry(status_code,
1175*9c5db199SXin Li                                          None,
1176*9c5db199SXin Li                                          full_name,
1177*9c5db199SXin Li                                          message,
1178*9c5db199SXin Li                                          None,
1179*9c5db199SXin Li                                          timestamp=int(timestamp))
1180*9c5db199SXin Li        self.job.record_entry(entry, False)
1181*9c5db199SXin Li
1182*9c5db199SXin Li    def _record_missing_tests(self, missing):
1183*9c5db199SXin Li        """Records tests with missing results in job keyval file.
1184*9c5db199SXin Li
1185*9c5db199SXin Li        @param missing: List of string names of Tast tests with missing results.
1186*9c5db199SXin Li        """
1187*9c5db199SXin Li        keyvals = {}
1188*9c5db199SXin Li        for i, name in enumerate(sorted(missing)):
1189*9c5db199SXin Li            keyvals['%s%d' % (self._MISSING_TEST_KEYVAL_PREFIX, i)] = name
1190*9c5db199SXin Li        utils.write_keyval(self.job.resultdir, keyvals)
1191*9c5db199SXin Li
1192*9c5db199SXin Li
1193*9c5db199SXin Liclass _LessBrokenParserInfo(dateutil.parser.parserinfo):
1194*9c5db199SXin Li    """dateutil.parser.parserinfo that interprets years before 100 correctly.
1195*9c5db199SXin Li
1196*9c5db199SXin Li    Our version of dateutil.parser.parse misinteprets an unambiguous string like
1197*9c5db199SXin Li    '0001-01-01T00:00:00Z' as having a two-digit year, which it then converts to
1198*9c5db199SXin Li    2001. This appears to have been fixed by
1199*9c5db199SXin Li    https://github.com/dateutil/dateutil/commit/fc696254. This parserinfo
1200*9c5db199SXin Li    implementation always honors the provided year to prevent this from
1201*9c5db199SXin Li    happening.
1202*9c5db199SXin Li    """
1203*9c5db199SXin Li    def convertyear(self, year, century_specified=False):
1204*9c5db199SXin Li        """Overrides convertyear in dateutil.parser.parserinfo."""
1205*9c5db199SXin Li        return int(year)
1206*9c5db199SXin Li
1207*9c5db199SXin Li
1208*9c5db199SXin Lidef _rfc3339_time_to_timestamp(time_str):
1209*9c5db199SXin Li    """Converts an RFC3339 time into a Unix timestamp.
1210*9c5db199SXin Li
1211*9c5db199SXin Li    @param time_str: RFC3339-compatible time, e.g.
1212*9c5db199SXin Li        '2018-02-25T07:45:35.916929332-07:00'.
1213*9c5db199SXin Li
1214*9c5db199SXin Li    @returns Float number of seconds since the Unix epoch. Negative if the time
1215*9c5db199SXin Li        precedes the epoch.
1216*9c5db199SXin Li    """
1217*9c5db199SXin Li    dt = dateutil.parser.parse(time_str, parserinfo=_LessBrokenParserInfo())
1218*9c5db199SXin Li    return (dt - _UNIX_EPOCH).total_seconds()
1219