• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env vpython3
2#
3# Copyright 2018 The Chromium Authors
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6
7import argparse
8import collections
9import json
10import logging
11import os
12import re
13import shutil
14import signal
15import socket
16import sys
17import tempfile
18
19# The following non-std imports are fetched via vpython. See the list at
20# //.vpython3
21import dateutil.parser  # pylint: disable=import-error
22import jsonlines  # pylint: disable=import-error
23import psutil  # pylint: disable=import-error
24
25CHROMIUM_SRC_PATH = os.path.abspath(
26    os.path.join(os.path.dirname(__file__), '..', '..'))
27
28# Use the android test-runner's gtest results support library for generating
29# output json ourselves.
30sys.path.insert(0, os.path.join(CHROMIUM_SRC_PATH, 'build', 'android'))
31from pylib.base import base_test_result  # pylint: disable=import-error
32from pylib.results import json_results  # pylint: disable=import-error
33
34sys.path.insert(0, os.path.join(CHROMIUM_SRC_PATH, 'build', 'util'))
35# TODO(crbug.com/1421441): Re-enable the 'no-name-in-module' check.
36from lib.results import result_sink  # pylint: disable=import-error,no-name-in-module
37
38import subprocess  # pylint: disable=import-error,wrong-import-order
39
40DEFAULT_CROS_CACHE = os.path.abspath(
41    os.path.join(CHROMIUM_SRC_PATH, 'build', 'cros_cache'))
42CHROMITE_PATH = os.path.abspath(
43    os.path.join(CHROMIUM_SRC_PATH, 'third_party', 'chromite'))
44CROS_RUN_TEST_PATH = os.path.abspath(
45    os.path.join(CHROMITE_PATH, 'bin', 'cros_run_test'))
46
47LACROS_LAUNCHER_SCRIPT_PATH = os.path.abspath(
48    os.path.join(CHROMIUM_SRC_PATH, 'build', 'lacros',
49                 'mojo_connection_lacros_launcher.py'))
50
51# This is a special hostname that resolves to a different DUT in the lab
52# depending on which lab machine you're on.
53LAB_DUT_HOSTNAME = 'variable_chromeos_device_hostname'
54
55SYSTEM_LOG_LOCATIONS = [
56    '/home/chronos/crash/',
57    '/var/log/chrome/',
58    '/var/log/messages',
59    '/var/log/ui/',
60]
61
62TAST_DEBUG_DOC = 'https://bit.ly/2LgvIXz'
63
64
65class TestFormatError(Exception):
66  pass
67
68
69class RemoteTest:
70
71  # This is a basic shell script that can be appended to in order to invoke the
72  # test on the device.
73  BASIC_SHELL_SCRIPT = [
74      '#!/bin/sh',
75
76      # /home and /tmp are mounted with "noexec" in the device, but some of our
77      # tools and tests use those dirs as a workspace (eg: vpython downloads
78      # python binaries to ~/.vpython-root and /tmp/vpython_bootstrap).
79      # /usr/local/tmp doesn't have this restriction, so change the location of
80      # the home and temp dirs for the duration of the test.
81      'export HOME=/usr/local/tmp',
82      'export TMPDIR=/usr/local/tmp',
83  ]
84
85  def __init__(self, args, unknown_args):
86    self._additional_args = unknown_args
87    self._path_to_outdir = args.path_to_outdir
88    self._test_launcher_summary_output = args.test_launcher_summary_output
89    self._logs_dir = args.logs_dir
90    self._use_vm = args.use_vm
91    self._rdb_client = result_sink.TryInitClient()
92
93    self._retries = 0
94    self._timeout = None
95    self._test_launcher_shard_index = args.test_launcher_shard_index
96    self._test_launcher_total_shards = args.test_launcher_total_shards
97
98    # The location on disk of a shell script that can be optionally used to
99    # invoke the test on the device. If it's not set, we assume self._test_cmd
100    # contains the test invocation.
101    self._on_device_script = None
102
103    self._test_cmd = [
104        CROS_RUN_TEST_PATH,
105        '--board',
106        args.board,
107        '--cache-dir',
108        args.cros_cache,
109    ]
110    if args.use_vm:
111      self._test_cmd += [
112          '--start',
113          # Don't persist any filesystem changes after the VM shutsdown.
114          '--copy-on-write',
115      ]
116    else:
117      self._test_cmd += [
118          '--device', args.device if args.device else LAB_DUT_HOSTNAME
119      ]
120    if args.logs_dir:
121      for log in SYSTEM_LOG_LOCATIONS:
122        self._test_cmd += ['--results-src', log]
123      self._test_cmd += [
124          '--results-dest-dir',
125          os.path.join(args.logs_dir, 'system_logs')
126      ]
127    if args.flash:
128      self._test_cmd += ['--flash']
129      if args.public_image:
130        self._test_cmd += ['--public-image']
131
132    self._test_env = setup_env()
133
134  @property
135  def suite_name(self):
136    raise NotImplementedError('Child classes need to define suite name.')
137
138  @property
139  def test_cmd(self):
140    return self._test_cmd
141
142  def write_test_script_to_disk(self, script_contents):
143    # Since we're using an on_device_script to invoke the test, we'll need to
144    # set cwd.
145    self._test_cmd += [
146        '--remote-cmd',
147        '--cwd',
148        os.path.relpath(self._path_to_outdir, CHROMIUM_SRC_PATH),
149    ]
150    logging.info('Running the following command on the device:')
151    logging.info('\n%s', '\n'.join(script_contents))
152    fd, tmp_path = tempfile.mkstemp(suffix='.sh', dir=self._path_to_outdir)
153    os.fchmod(fd, 0o755)
154    with os.fdopen(fd, 'w') as f:
155      f.write('\n'.join(script_contents) + '\n')
156    return tmp_path
157
158  def run_test(self):
159    # Traps SIGTERM and kills all child processes of cros_run_test when it's
160    # caught. This will allow us to capture logs from the device if a test hangs
161    # and gets timeout-killed by swarming. See also:
162    # https://chromium.googlesource.com/infra/luci/luci-py/+/main/appengine/swarming/doc/Bot.md#graceful-termination_aka-the-sigterm-and-sigkill-dance
163    test_proc = None
164
165    def _kill_child_procs(trapped_signal, _):
166      logging.warning('Received signal %d. Killing child processes of test.',
167                      trapped_signal)
168      if not test_proc or not test_proc.pid:
169        # This shouldn't happen?
170        logging.error('Test process not running.')
171        return
172      for child in psutil.Process(test_proc.pid).children():
173        logging.warning('Killing process %s', child)
174        child.kill()
175
176    signal.signal(signal.SIGTERM, _kill_child_procs)
177
178    for i in range(self._retries + 1):
179      logging.info('########################################')
180      logging.info('Test attempt #%d', i)
181      logging.info('########################################')
182      test_proc = subprocess.Popen(
183          self._test_cmd,
184          stdout=sys.stdout,
185          stderr=sys.stderr,
186          env=self._test_env)
187      try:
188        test_proc.wait(timeout=self._timeout)
189      except subprocess.TimeoutExpired:  # pylint: disable=no-member
190        logging.error('Test timed out. Sending SIGTERM.')
191        # SIGTERM the proc and wait 10s for it to close.
192        test_proc.terminate()
193        try:
194          test_proc.wait(timeout=10)
195        except subprocess.TimeoutExpired:  # pylint: disable=no-member
196          # If it hasn't closed in 10s, SIGKILL it.
197          logging.error('Test did not exit in time. Sending SIGKILL.')
198          test_proc.kill()
199          test_proc.wait()
200      logging.info('Test exitted with %d.', test_proc.returncode)
201      if test_proc.returncode == 0:
202        break
203
204    self.post_run(test_proc.returncode)
205    # Allow post_run to override test proc return code. (Useful when the host
206    # side Tast bin returns 0 even for failed tests.)
207    return test_proc.returncode
208
209  def post_run(self, _):
210    if self._on_device_script:
211      os.remove(self._on_device_script)
212
213  @staticmethod
214  def get_artifacts(path):
215    """Crawls a given directory for file artifacts to attach to a test.
216
217    Args:
218      path: Path to a directory to search for artifacts.
219    Returns:
220      A dict mapping name of the artifact to its absolute filepath.
221    """
222    artifacts = {}
223    for dirpath, _, filenames in os.walk(path):
224      for f in filenames:
225        artifact_path = os.path.join(dirpath, f)
226        artifact_id = os.path.relpath(artifact_path, path)
227        # Some artifacts will have non-Latin characters in the filename, eg:
228        # 'ui_tree_Chinese Pinyin-你好.txt'. ResultDB's API rejects such
229        # characters as an artifact ID, so force the file name down into ascii.
230        # For more info, see:
231        # https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/resultdb/proto/v1/artifact.proto;drc=3bff13b8037ca76ec19f9810033d914af7ec67cb;l=46
232        artifact_id = artifact_id.encode('ascii', 'replace').decode()
233        artifact_id = artifact_id.replace('\\', '?')
234        artifacts[artifact_id] = {
235            'filePath': artifact_path,
236        }
237    return artifacts
238
239
240class TastTest(RemoteTest):
241
242  def __init__(self, args, unknown_args):
243    super().__init__(args, unknown_args)
244
245    self._suite_name = args.suite_name
246    self._tast_vars = args.tast_vars
247    self._tast_retries = args.tast_retries
248    self._tests = args.tests
249    # The CQ passes in '--gtest_filter' when specifying tests to skip. Store it
250    # here and parse it later to integrate it into Tast executions.
251    self._gtest_style_filter = args.gtest_filter
252    self._attr_expr = args.attr_expr
253    self._should_strip = args.strip_chrome
254    self._deploy_lacros = args.deploy_lacros
255    self._deploy_chrome = args.deploy_chrome
256
257    if not self._logs_dir:
258      # The host-side Tast bin returns 0 when tests fail, so we need to capture
259      # and parse its json results to reliably determine if tests fail.
260      raise TestFormatError(
261          'When using the host-side Tast bin, "--logs-dir" must be passed in '
262          'order to parse its results.')
263
264    # If the first test filter is negative, it should be safe to assume all of
265    # them are, so just test the first filter.
266    if self._gtest_style_filter and self._gtest_style_filter[0] == '-':
267      raise TestFormatError('Negative test filters not supported for Tast.')
268
269  @property
270  def suite_name(self):
271    return self._suite_name
272
273  def build_test_command(self):
274    unsupported_args = [
275        '--test-launcher-retry-limit',
276        '--test-launcher-batch-limit',
277        '--gtest_repeat',
278    ]
279    for unsupported_arg in unsupported_args:
280      if any(arg.startswith(unsupported_arg) for arg in self._additional_args):
281        logging.info(
282            '%s not supported for Tast tests. The arg will be ignored.',
283            unsupported_arg)
284        self._additional_args = [
285            arg for arg in self._additional_args
286            if not arg.startswith(unsupported_arg)
287        ]
288
289    # Lacros deployment mounts itself by default.
290    if self._deploy_lacros:
291      self._test_cmd.extend([
292          '--deploy-lacros', '--lacros-launcher-script',
293          LACROS_LAUNCHER_SCRIPT_PATH
294      ])
295      if self._deploy_chrome:
296        self._test_cmd.extend(['--deploy', '--mount'])
297    else:
298      self._test_cmd.extend(['--deploy', '--mount'])
299    self._test_cmd += [
300        '--build-dir',
301        os.path.relpath(self._path_to_outdir, CHROMIUM_SRC_PATH)
302    ] + self._additional_args
303
304    # Capture tast's results in the logs dir as well.
305    if self._logs_dir:
306      self._test_cmd += [
307          '--results-dir',
308          self._logs_dir,
309      ]
310    self._test_cmd += [
311        '--tast-total-shards=%d' % self._test_launcher_total_shards,
312        '--tast-shard-index=%d' % self._test_launcher_shard_index,
313    ]
314    # If we're using a test filter, replace the contents of the Tast
315    # conditional with a long list of "name:test" expressions, one for each
316    # test in the filter.
317    if self._gtest_style_filter:
318      if self._attr_expr or self._tests:
319        logging.warning(
320            'Presence of --gtest_filter will cause the specified Tast expr'
321            ' or test list to be ignored.')
322      names = []
323      for test in self._gtest_style_filter.split(':'):
324        names.append('"name:%s"' % test)
325      self._attr_expr = '(' + ' || '.join(names) + ')'
326
327    if self._attr_expr:
328      # Don't use shlex.quote() here. Something funky happens with the arg
329      # as it gets passed down from cros_run_test to tast. (Tast picks up the
330      # escaping single quotes and complains that the attribute expression
331      # "must be within parentheses".)
332      self._test_cmd.append('--tast=%s' % self._attr_expr)
333    else:
334      self._test_cmd.append('--tast')
335      self._test_cmd.extend(self._tests)
336
337    for v in self._tast_vars or []:
338      self._test_cmd.extend(['--tast-var', v])
339
340    if self._tast_retries:
341      self._test_cmd.append('--tast-retries=%d' % self._tast_retries)
342
343    # Mounting ash-chrome gives it enough disk space to not need stripping,
344    # but only for one not instrumented with code coverage.
345    # Lacros uses --nostrip by default, so there is no need to specify.
346    if not self._deploy_lacros and not self._should_strip:
347      self._test_cmd.append('--nostrip')
348
349  def post_run(self, return_code):
350    tast_results_path = os.path.join(self._logs_dir, 'streamed_results.jsonl')
351    if not os.path.exists(tast_results_path):
352      logging.error(
353          'Tast results not found at %s. Falling back to generic result '
354          'reporting.', tast_results_path)
355      return super().post_run(return_code)
356
357    # See the link below for the format of the results:
358    # https://godoc.org/chromium.googlesource.com/chromiumos/platform/tast.git/src/chromiumos/cmd/tast/run#TestResult
359    with jsonlines.open(tast_results_path) as reader:
360      tast_results = collections.deque(reader)
361
362    suite_results = base_test_result.TestRunResults()
363    for test in tast_results:
364      errors = test['errors']
365      start, end = test['start'], test['end']
366      # Use dateutil to parse the timestamps since datetime can't handle
367      # nanosecond precision.
368      duration = dateutil.parser.parse(end) - dateutil.parser.parse(start)
369      # If the duration is negative, Tast has likely reported an incorrect
370      # duration. See https://issuetracker.google.com/issues/187973541. Round
371      # up to 0 in that case to avoid confusing RDB.
372      duration_ms = max(duration.total_seconds() * 1000, 0)
373      if bool(test['skipReason']):
374        result = base_test_result.ResultType.SKIP
375      elif errors:
376        result = base_test_result.ResultType.FAIL
377      else:
378        result = base_test_result.ResultType.PASS
379      primary_error_message = None
380      error_log = ''
381      if errors:
382        # See the link below for the format of these errors:
383        # https://source.chromium.org/chromiumos/chromiumos/codesearch/+/main:src/platform/tast/src/chromiumos/tast/cmd/tast/internal/run/resultsjson/resultsjson.go
384        primary_error_message = errors[0]['reason']
385        for err in errors:
386          error_log += err['stack'] + '\n'
387      debug_link = ("If you're unsure why this test failed, consult the steps "
388                    'outlined <a href="%s">here</a>.' % TAST_DEBUG_DOC)
389      base_result = base_test_result.BaseTestResult(
390          test['name'], result, duration=duration_ms, log=error_log)
391      suite_results.AddResult(base_result)
392      self._maybe_handle_perf_results(test['name'])
393
394      if self._rdb_client:
395        # Walk the contents of the test's "outDir" and atttach any file found
396        # inside as an RDB 'artifact'. (This could include system logs, screen
397        # shots, etc.)
398        artifacts = self.get_artifacts(test['outDir'])
399        html_artifact = debug_link
400        if result == base_test_result.ResultType.SKIP:
401          html_artifact = 'Test was skipped because: ' + test['skipReason']
402        self._rdb_client.Post(
403            test['name'],
404            result,
405            duration_ms,
406            error_log,
407            None,
408            artifacts=artifacts,
409            failure_reason=primary_error_message,
410            html_artifact=html_artifact)
411
412    if self._rdb_client and self._logs_dir:
413      # Attach artifacts from the device that don't apply to a single test.
414      artifacts = self.get_artifacts(
415          os.path.join(self._logs_dir, 'system_logs'))
416      artifacts.update(
417          self.get_artifacts(os.path.join(self._logs_dir, 'crashes')))
418      self._rdb_client.ReportInvocationLevelArtifacts(artifacts)
419
420    if self._test_launcher_summary_output:
421      with open(self._test_launcher_summary_output, 'w') as f:
422        json.dump(json_results.GenerateResultsDict([suite_results]), f)
423
424    if not suite_results.DidRunPass():
425      return 1
426    if return_code:
427      logging.warning(
428          'No failed tests found, but exit code of %d was returned from '
429          'cros_run_test.', return_code)
430      return return_code
431    return 0
432
433  def _maybe_handle_perf_results(self, test_name):
434    """Prepares any perf results from |test_name| for process_perf_results.
435
436    - process_perf_results looks for top level directories containing a
437      perf_results.json file and a test_results.json file. The directory names
438      are used as the benchmark names.
439    - If a perf_results.json or results-chart.json file exists in the
440      |test_name| results directory, a top level directory is created and the
441      perf results file is copied to perf_results.json.
442    - A trivial test_results.json file is also created to indicate that the test
443      succeeded (this function would not be called otherwise).
444    - When process_perf_results is run, it will find the expected files in the
445      named directory and upload the benchmark results.
446    """
447
448    perf_results = os.path.join(self._logs_dir, 'tests', test_name,
449                                'perf_results.json')
450    # TODO(stevenjb): Remove check for crosbolt results-chart.json file.
451    if not os.path.exists(perf_results):
452      perf_results = os.path.join(self._logs_dir, 'tests', test_name,
453                                  'results-chart.json')
454    if os.path.exists(perf_results):
455      benchmark_dir = os.path.join(self._logs_dir, test_name)
456      if not os.path.isdir(benchmark_dir):
457        os.makedirs(benchmark_dir)
458      shutil.copyfile(perf_results,
459                      os.path.join(benchmark_dir, 'perf_results.json'))
460      # process_perf_results.py expects a test_results.json file.
461      test_results = {'valid': True, 'failures': []}
462      with open(os.path.join(benchmark_dir, 'test_results.json'), 'w') as out:
463        json.dump(test_results, out)
464
465
466class GTestTest(RemoteTest):
467
468  # The following list corresponds to paths that should not be copied over to
469  # the device during tests. In other words, these files are only ever used on
470  # the host.
471  _FILE_IGNORELIST = [
472      re.compile(r'.*build/android.*'),
473      re.compile(r'.*build/chromeos.*'),
474      re.compile(r'.*build/cros_cache.*'),
475      # The following matches anything under //testing/ that isn't under
476      # //testing/buildbot/filters/.
477      re.compile(r'.*testing/(?!buildbot/filters).*'),
478      re.compile(r'.*third_party/chromite.*'),
479  ]
480
481  def __init__(self, args, unknown_args):
482    super().__init__(args, unknown_args)
483
484    self._test_exe = args.test_exe
485    self._runtime_deps_path = args.runtime_deps_path
486    self._vpython_dir = args.vpython_dir
487
488    self._on_device_script = None
489    self._env_vars = args.env_var
490    self._stop_ui = args.stop_ui
491    self._trace_dir = args.trace_dir
492
493  @property
494  def suite_name(self):
495    return self._test_exe
496
497  def build_test_command(self):
498    # To keep things easy for us, ensure both types of output locations are
499    # the same.
500    if self._test_launcher_summary_output and self._logs_dir:
501      json_out_dir = os.path.dirname(self._test_launcher_summary_output) or '.'
502      if os.path.abspath(json_out_dir) != os.path.abspath(self._logs_dir):
503        raise TestFormatError(
504            '--test-launcher-summary-output and --logs-dir must point to '
505            'the same directory.')
506
507    if self._test_launcher_summary_output:
508      result_dir, result_file = os.path.split(
509          self._test_launcher_summary_output)
510      # If args.test_launcher_summary_output is a file in cwd, result_dir will
511      # be an empty string, so replace it with '.' when this is the case so
512      # cros_run_test can correctly handle it.
513      if not result_dir:
514        result_dir = '.'
515      device_result_file = '/tmp/%s' % result_file
516      self._test_cmd += [
517          '--results-src',
518          device_result_file,
519          '--results-dest-dir',
520          result_dir,
521      ]
522
523    if self._trace_dir and self._logs_dir:
524      trace_path = os.path.dirname(self._trace_dir) or '.'
525      if os.path.abspath(trace_path) != os.path.abspath(self._logs_dir):
526        raise TestFormatError(
527            '--trace-dir and --logs-dir must point to the same directory.')
528
529    if self._trace_dir:
530      trace_path, trace_dirname = os.path.split(self._trace_dir)
531      device_trace_dir = '/tmp/%s' % trace_dirname
532      self._test_cmd += [
533          '--results-src',
534          device_trace_dir,
535          '--results-dest-dir',
536          trace_path,
537      ]
538
539    # Build the shell script that will be used on the device to invoke the test.
540    # Stored here as a list of lines.
541    device_test_script_contents = self.BASIC_SHELL_SCRIPT[:]
542    for var_name, var_val in self._env_vars:
543      device_test_script_contents += ['export %s=%s' % (var_name, var_val)]
544
545    if self._vpython_dir:
546      vpython_path = os.path.join(self._path_to_outdir, self._vpython_dir,
547                                  'vpython3')
548      cpython_path = os.path.join(self._path_to_outdir, self._vpython_dir,
549                                  'bin', 'python3')
550      if not os.path.exists(vpython_path) or not os.path.exists(cpython_path):
551        raise TestFormatError(
552            '--vpython-dir must point to a dir with both '
553            'infra/3pp/tools/cpython3 and infra/tools/luci/vpython installed.')
554      vpython_spec_path = os.path.relpath(
555          os.path.join(CHROMIUM_SRC_PATH, '.vpython3'), self._path_to_outdir)
556      # Initialize the vpython cache. This can take 10-20s, and some tests
557      # can't afford to wait that long on the first invocation.
558      device_test_script_contents.extend([
559          'export PATH=$PWD/%s:$PWD/%s/bin/:$PATH' %
560          (self._vpython_dir, self._vpython_dir),
561          'vpython3 -vpython-spec %s -vpython-tool install' %
562          (vpython_spec_path),
563      ])
564
565    test_invocation = ('LD_LIBRARY_PATH=./ ./%s --test-launcher-shard-index=%d '
566                       '--test-launcher-total-shards=%d' %
567                       (self._test_exe, self._test_launcher_shard_index,
568                        self._test_launcher_total_shards))
569    if self._test_launcher_summary_output:
570      test_invocation += ' --test-launcher-summary-output=%s' % (
571          device_result_file)
572
573    if self._trace_dir:
574      device_test_script_contents.extend([
575          'rm -rf %s' % device_trace_dir,
576          'sudo -E -u chronos -- /bin/bash -c "mkdir -p %s"' % device_trace_dir,
577      ])
578      test_invocation += ' --trace-dir=%s' % device_trace_dir
579
580    if self._additional_args:
581      test_invocation += ' %s' % ' '.join(self._additional_args)
582
583    if self._stop_ui:
584      device_test_script_contents += [
585          'stop ui',
586      ]
587      # Send a user activity ping to powerd to ensure the display is on.
588      device_test_script_contents += [
589          'dbus-send --system --type=method_call'
590          ' --dest=org.chromium.PowerManager /org/chromium/PowerManager'
591          ' org.chromium.PowerManager.HandleUserActivity int32:0'
592      ]
593      # The UI service on the device owns the chronos user session, so shutting
594      # it down as chronos kills the entire execution of the test. So we'll have
595      # to run as root up until the test invocation.
596      test_invocation = (
597          'sudo -E -u chronos -- /bin/bash -c "%s"' % test_invocation)
598      # And we'll need to chown everything since cros_run_test's "--as-chronos"
599      # option normally does that for us.
600      device_test_script_contents.append('chown -R chronos: ../..')
601    else:
602      self._test_cmd += [
603          # Some tests fail as root, so run as the less privileged user
604          # 'chronos'.
605          '--as-chronos',
606      ]
607
608    device_test_script_contents.append(test_invocation)
609
610    # (Re)start ui after all tests are done. This is for developer convenienve.
611    # Without this, the device would remain in a black screen which looks like
612    # powered off.
613    if self._stop_ui:
614      device_test_script_contents += [
615          'start ui',
616      ]
617
618    self._on_device_script = self.write_test_script_to_disk(
619        device_test_script_contents)
620
621    runtime_files = [os.path.relpath(self._on_device_script)]
622    runtime_files += self._read_runtime_files()
623    if self._vpython_dir:
624      # --vpython-dir is relative to the out dir, but --files expects paths
625      # relative to src dir, so fix the path up a bit.
626      runtime_files.append(
627          os.path.relpath(
628              os.path.abspath(
629                  os.path.join(self._path_to_outdir, self._vpython_dir)),
630              CHROMIUM_SRC_PATH))
631
632    for f in runtime_files:
633      self._test_cmd.extend(['--files', f])
634
635    self._test_cmd += [
636        '--',
637        './' + os.path.relpath(self._on_device_script, self._path_to_outdir)
638    ]
639
640  def _read_runtime_files(self):
641    if not self._runtime_deps_path:
642      return []
643
644    abs_runtime_deps_path = os.path.abspath(
645        os.path.join(self._path_to_outdir, self._runtime_deps_path))
646    with open(abs_runtime_deps_path) as runtime_deps_file:
647      files = [l.strip() for l in runtime_deps_file if l]
648    rel_file_paths = []
649    for f in files:
650      rel_file_path = os.path.relpath(
651          os.path.abspath(os.path.join(self._path_to_outdir, f)))
652      if not any(regex.match(rel_file_path) for regex in self._FILE_IGNORELIST):
653        rel_file_paths.append(rel_file_path)
654    return rel_file_paths
655
656  def post_run(self, _):
657    if self._on_device_script:
658      os.remove(self._on_device_script)
659
660    if self._test_launcher_summary_output and self._rdb_client:
661      logging.error('Native ResultDB integration is not supported for GTests. '
662                    'Upload results via result_adapter instead. '
663                    'See crbug.com/1330441.')
664
665
666def device_test(args, unknown_args):
667  # cros_run_test has trouble with relative paths that go up directories,
668  # so cd to src/, which should be the root of all data deps.
669  os.chdir(CHROMIUM_SRC_PATH)
670
671  # TODO: Remove the above when depot_tool's pylint is updated to include the
672  # fix to https://github.com/PyCQA/pylint/issues/710.
673  if args.test_type == 'tast':
674    test = TastTest(args, unknown_args)
675  else:
676    test = GTestTest(args, unknown_args)
677
678  test.build_test_command()
679  logging.info('Running the following command on the device:')
680  logging.info(' '.join(test.test_cmd))
681
682  return test.run_test()
683
684
685def host_cmd(args, cmd_args):
686  if not cmd_args:
687    raise TestFormatError('Must specify command to run on the host.')
688  if args.deploy_chrome and not args.path_to_outdir:
689    raise TestFormatError(
690        '--path-to-outdir must be specified if --deploy-chrome is passed.')
691
692  cros_run_test_cmd = [
693      CROS_RUN_TEST_PATH,
694      '--board',
695      args.board,
696      '--cache-dir',
697      os.path.join(CHROMIUM_SRC_PATH, args.cros_cache),
698  ]
699  if args.use_vm:
700    cros_run_test_cmd += [
701        '--start',
702        # Don't persist any filesystem changes after the VM shutsdown.
703        '--copy-on-write',
704    ]
705  else:
706    cros_run_test_cmd += [
707        '--device', args.device if args.device else LAB_DUT_HOSTNAME
708    ]
709  if args.verbose:
710    cros_run_test_cmd.append('--debug')
711  if args.flash:
712    cros_run_test_cmd.append('--flash')
713    if args.public_image:
714      cros_run_test_cmd += ['--public-image']
715
716  if args.logs_dir:
717    for log in SYSTEM_LOG_LOCATIONS:
718      cros_run_test_cmd += ['--results-src', log]
719    cros_run_test_cmd += [
720        '--results-dest-dir',
721        os.path.join(args.logs_dir, 'system_logs')
722    ]
723
724  test_env = setup_env()
725  if args.deploy_chrome or args.deploy_lacros:
726    if args.deploy_lacros:
727      cros_run_test_cmd.extend([
728          '--deploy-lacros', '--lacros-launcher-script',
729          LACROS_LAUNCHER_SCRIPT_PATH
730      ])
731      if args.deploy_chrome:
732        # Mounting ash-chrome gives it enough disk space to not need stripping
733        # most of the time.
734        cros_run_test_cmd.extend(['--deploy', '--mount'])
735    else:
736      # Mounting ash-chrome gives it enough disk space to not need stripping
737      # most of the time.
738      cros_run_test_cmd.extend(['--deploy', '--mount'])
739
740    if not args.strip_chrome:
741      cros_run_test_cmd.append('--nostrip')
742
743    cros_run_test_cmd += [
744        '--build-dir',
745        os.path.join(CHROMIUM_SRC_PATH, args.path_to_outdir)
746    ]
747
748  cros_run_test_cmd += [
749      '--host-cmd',
750      '--',
751  ] + cmd_args
752
753  logging.info('Running the following command:')
754  logging.info(' '.join(cros_run_test_cmd))
755
756  return subprocess.call(
757      cros_run_test_cmd, stdout=sys.stdout, stderr=sys.stderr, env=test_env)
758
759
760def setup_env():
761  """Returns a copy of the current env with some needed vars added."""
762  env = os.environ.copy()
763  # Some chromite scripts expect chromite/bin to be on PATH.
764  env['PATH'] = env['PATH'] + ':' + os.path.join(CHROMITE_PATH, 'bin')
765  # deploy_chrome needs a set of GN args used to build chrome to determine if
766  # certain libraries need to be pushed to the device. It looks for the args via
767  # an env var. To trigger the default deploying behavior, give it a dummy set
768  # of args.
769  # TODO(crbug.com/823996): Make the GN-dependent deps controllable via cmd
770  # line args.
771  if not env.get('GN_ARGS'):
772    env['GN_ARGS'] = 'enable_nacl = true'
773  if not env.get('USE'):
774    env['USE'] = 'highdpi'
775  return env
776
777
778def add_common_args(*parsers):
779  for parser in parsers:
780    parser.add_argument('--verbose', '-v', action='store_true')
781    parser.add_argument(
782        '--board', type=str, required=True, help='Type of CrOS device.')
783    parser.add_argument(
784        '--deploy-chrome',
785        action='store_true',
786        help='Will deploy a locally built ash-chrome binary to the device '
787        'before running the host-cmd.')
788    parser.add_argument(
789        '--deploy-lacros', action='store_true', help='Deploy a lacros-chrome.')
790    parser.add_argument(
791        '--cros-cache',
792        type=str,
793        default=DEFAULT_CROS_CACHE,
794        help='Path to cros cache.')
795    parser.add_argument(
796        '--path-to-outdir',
797        type=str,
798        required=True,
799        help='Path to output directory, all of whose contents will be '
800        'deployed to the device.')
801    parser.add_argument(
802        '--runtime-deps-path',
803        type=str,
804        help='Runtime data dependency file from GN.')
805    parser.add_argument(
806        '--vpython-dir',
807        type=str,
808        help='Location on host of a directory containing a vpython binary to '
809        'deploy to the device before the test starts. The location of '
810        'this dir will be added onto PATH in the device. WARNING: The '
811        'arch of the device might not match the arch of the host, so '
812        'avoid using "${platform}" when downloading vpython via CIPD.')
813    parser.add_argument(
814        '--logs-dir',
815        type=str,
816        dest='logs_dir',
817        help='Will copy everything under /var/log/ from the device after the '
818        'test into the specified dir.')
819    # Shard args are parsed here since we might also specify them via env vars.
820    parser.add_argument(
821        '--test-launcher-shard-index',
822        type=int,
823        default=os.environ.get('GTEST_SHARD_INDEX', 0),
824        help='Index of the external shard to run.')
825    parser.add_argument(
826        '--test-launcher-total-shards',
827        type=int,
828        default=os.environ.get('GTEST_TOTAL_SHARDS', 1),
829        help='Total number of external shards.')
830    parser.add_argument(
831        '--flash',
832        action='store_true',
833        help='Will flash the device to the current SDK version before running '
834        'the test.')
835    parser.add_argument(
836        '--public-image',
837        action='store_true',
838        help='Will flash a public "full" image to the device.')
839    parser.add_argument(
840        '--magic-vm-cache',
841        help='Path to the magic CrOS VM cache dir. See the comment above '
842             '"magic_cros_vm_cache" in mixins.pyl for more info.')
843
844    vm_or_device_group = parser.add_mutually_exclusive_group()
845    vm_or_device_group.add_argument(
846        '--use-vm',
847        action='store_true',
848        help='Will run the test in the VM instead of a device.')
849    vm_or_device_group.add_argument(
850        '--device',
851        type=str,
852        help='Hostname (or IP) of device to run the test on. This arg is not '
853        'required if --use-vm is set.')
854
855
856def main():
857  parser = argparse.ArgumentParser()
858  subparsers = parser.add_subparsers(dest='test_type')
859  # Host-side test args.
860  host_cmd_parser = subparsers.add_parser(
861      'host-cmd',
862      help='Runs a host-side test. Pass the host-side command to run after '
863      '"--". If --use-vm is passed, hostname and port for the device '
864      'will be 127.0.0.1:9222.')
865  host_cmd_parser.set_defaults(func=host_cmd)
866  host_cmd_parser.add_argument(
867      '--strip-chrome',
868      action='store_true',
869      help='Strips symbols from ash-chrome or lacros-chrome before deploying '
870      ' to the device.')
871
872  gtest_parser = subparsers.add_parser(
873      'gtest', help='Runs a device-side gtest.')
874  gtest_parser.set_defaults(func=device_test)
875  gtest_parser.add_argument(
876      '--test-exe',
877      type=str,
878      required=True,
879      help='Path to test executable to run inside the device.')
880
881  # GTest args. Some are passed down to the test binary in the device. Others
882  # are parsed here since they might need tweaking or special handling.
883  gtest_parser.add_argument(
884      '--test-launcher-summary-output',
885      type=str,
886      help='When set, will pass the same option down to the test and retrieve '
887      'its result file at the specified location.')
888  gtest_parser.add_argument(
889      '--stop-ui',
890      action='store_true',
891      help='Will stop the UI service in the device before running the test. '
892      'Also start the UI service after all tests are done.')
893  gtest_parser.add_argument(
894      '--trace-dir',
895      type=str,
896      help='When set, will pass down to the test to generate the trace and '
897      'retrieve the trace files to the specified location.')
898  gtest_parser.add_argument(
899      '--env-var',
900      nargs=2,
901      action='append',
902      default=[],
903      help='Env var to set on the device for the duration of the test. '
904      'Expected format is "--env-var SOME_VAR_NAME some_var_value". Specify '
905      'multiple times for more than one var.')
906
907  # Tast test args.
908  # pylint: disable=line-too-long
909  tast_test_parser = subparsers.add_parser(
910      'tast',
911      help='Runs a device-side set of Tast tests. For more details, see: '
912      'https://chromium.googlesource.com/chromiumos/platform/tast/+/main/docs/running_tests.md'
913  )
914  tast_test_parser.set_defaults(func=device_test)
915  tast_test_parser.add_argument(
916      '--suite-name',
917      type=str,
918      required=True,
919      help='Name to apply to the set of Tast tests to run. This has no effect '
920      'on what is executed, but is used mainly for test results reporting '
921      'and tracking (eg: flakiness dashboard).')
922  tast_test_parser.add_argument(
923      '--test-launcher-summary-output',
924      type=str,
925      help='Generates a simple GTest-style JSON result file for the test run.')
926  tast_test_parser.add_argument(
927      '--attr-expr',
928      type=str,
929      help='A boolean expression whose matching tests will run '
930      '(eg: ("dep:chrome")).')
931  tast_test_parser.add_argument(
932      '--strip-chrome',
933      action='store_true',
934      help='Strips symbols from ash-chrome before deploying to the device.')
935  tast_test_parser.add_argument(
936      '--tast-var',
937      action='append',
938      dest='tast_vars',
939      help='Runtime variables for Tast tests, and the format are expected to '
940      'be "key=value" pairs.')
941  tast_test_parser.add_argument(
942      '--tast-retries',
943      type=int,
944      dest='tast_retries',
945      help='Number of retries for failed Tast tests on the same DUT.')
946  tast_test_parser.add_argument(
947      '--test',
948      '-t',
949      action='append',
950      dest='tests',
951      help='A Tast test to run in the device (eg: "login.Chrome").')
952  tast_test_parser.add_argument(
953      '--gtest_filter',
954      type=str,
955      help="Similar to GTest's arg of the same name, this will filter out the "
956      "specified tests from the Tast run. However, due to the nature of Tast's "
957      'cmd-line API, this will overwrite the value(s) of "--test" above.')
958
959  add_common_args(gtest_parser, tast_test_parser, host_cmd_parser)
960  args, unknown_args = parser.parse_known_args()
961  # Re-add N-1 -v/--verbose flags to the args we'll pass to whatever we are
962  # running. The assumption is that only one verbosity incrase would be meant
963  # for this script since it's a boolean value instead of increasing verbosity
964  # with more instances.
965  verbose_flags = [a for a in sys.argv if a in ('-v', '--verbose')]
966  if verbose_flags:
967    unknown_args += verbose_flags[1:]
968
969  logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARN)
970
971  if not args.use_vm and not args.device:
972    logging.warning(
973        'The test runner is now assuming running in the lab environment, if '
974        'this is unintentional, please re-invoke the test runner with the '
975        '"--use-vm" arg if using a VM, otherwise use the "--device=<DUT>" arg '
976        'to specify a DUT.')
977
978    # If we're not running on a VM, but haven't specified a hostname, assume
979    # we're on a lab bot and are trying to run a test on a lab DUT. See if the
980    # magic lab DUT hostname resolves to anything. (It will in the lab and will
981    # not on dev machines.)
982    try:
983      socket.getaddrinfo(LAB_DUT_HOSTNAME, None)
984    except socket.gaierror:
985      logging.error('The default lab DUT hostname of %s is unreachable.',
986                    LAB_DUT_HOSTNAME)
987      return 1
988
989  if args.flash and args.public_image:
990    # The flashing tools depend on being unauthenticated with GS when flashing
991    # public images, so make sure the env var GS uses to locate its creds is
992    # unset in that case.
993    os.environ.pop('BOTO_CONFIG', None)
994
995  if args.magic_vm_cache:
996    full_vm_cache_path = os.path.join(CHROMIUM_SRC_PATH, args.magic_vm_cache)
997    if os.path.exists(full_vm_cache_path):
998      with open(os.path.join(full_vm_cache_path, 'swarming.txt'), 'w') as f:
999        f.write('non-empty file to make swarming persist this cache')
1000
1001  return args.func(args, unknown_args)
1002
1003
1004if __name__ == '__main__':
1005  sys.exit(main())
1006