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