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