1#!/usr/bin/env python3 2# 3# Copyright 2023, The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17"""Base test module for Atest integration tests.""" 18import concurrent.futures 19import json 20import logging 21import os 22import pathlib 23import re 24import shutil 25import subprocess 26import sys 27import time 28from typing import Any 29 30import split_build_test_script 31 32# Exporting for test modules' typing reference 33SplitBuildTestScript = split_build_test_script.SplitBuildTestScript 34StepInput = split_build_test_script.StepInput 35StepOutput = split_build_test_script.StepOutput 36run_in_parallel = split_build_test_script.ParallelTestRunner.run_in_parallel 37setup_parallel_in_build_env = ( 38 split_build_test_script.ParallelTestRunner.setup_parallel_in_build_env 39) 40 41# Note: The following constants should ideally be imported from their 42# corresponding prod source code, but this makes local execution of the 43# integration test harder due to some special dependencies in the prod 44# code. Therefore we copy the definition here for now in favor of easier 45# local integration test execution. If value changes in the source code 46# breaking the integration test becomes a problem in the future, we can 47# reconsider importing these constants. 48# Stdout print prefix for results directory. Defined in atest/atest_main.py 49RESULTS_DIR_PRINT_PREFIX = 'Atest results and logs directory: ' 50DRY_RUN_COMMAND_LOG_PREFIX = 'Internal run command from dry-run: ' 51 52 53class LogEntry: 54 """Represents a single log entry.""" 55 56 def __init__( 57 self, 58 timestamp_str, 59 src_file_name, 60 src_file_line_number, 61 log_level, 62 content_lines, 63 ): 64 """Initializes a LogEntry object from a logging line. 65 66 Args: 67 timestamp_str: The timestamp header string in each log entry. 68 src_file_name: The source file name in the log entry. 69 src_file_line_number: The source file line number in the log entry. 70 log_level: The log level string in the log entry. 71 content_lines: A list of log entry content lines. 72 """ 73 self._timestamp_string = timestamp_str 74 self._source_file_name = src_file_name 75 self._source_file_line_number = src_file_line_number 76 self._log_level = log_level 77 self._content_lines = content_lines 78 79 def get_timestamp(self) -> float: 80 """Returns the timestamp of the log entry as an epoch time.""" 81 return time.mktime( 82 time.strptime(self._timestamp_string, '%Y-%m-%d %H:%M:%S') 83 ) 84 85 def get_timestamp_string(self) -> str: 86 """Returns the timestamp of the log entry as a string.""" 87 return self._timestamp_string 88 89 def get_source_file_name(self) -> str: 90 """Returns the source file name of the log entry.""" 91 return self._source_file_name 92 93 def get_source_file_line_number(self) -> int: 94 """Returns the source file line number of the log entry.""" 95 return self._source_file_line_number 96 97 def get_log_level(self) -> str: 98 """Returns the log level of the log entry.""" 99 return self._log_level 100 101 def get_content(self) -> str: 102 """Returns the content of the log entry.""" 103 return '\n'.join(self._content_lines) 104 105 106class AtestRunResult: 107 """A class to store Atest run result and get detailed run information.""" 108 109 def __init__( 110 self, 111 completed_process: subprocess.CompletedProcess[str], 112 env: dict[str, str], 113 repo_root: str, 114 config: split_build_test_script.IntegrationTestConfiguration, 115 elapsed_time: float, 116 ): 117 self._completed_process = completed_process 118 self._env = env 119 self._repo_root = repo_root 120 self._config = config 121 self._elapsed_time = elapsed_time 122 123 def get_elapsed_time(self) -> float: 124 """Returns the elapsed time of the atest command execution.""" 125 return self._elapsed_time 126 127 def get_returncode(self) -> int: 128 """Returns the return code of the completed process.""" 129 return self._completed_process.returncode 130 131 def get_stdout(self) -> str: 132 """Returns the standard output of the completed process.""" 133 return self._completed_process.stdout 134 135 def get_stderr(self) -> str: 136 """Returns the standard error of the completed process.""" 137 return self._completed_process.stderr 138 139 def get_cmd_list(self) -> list[str]: 140 """Returns the command list used in the process run.""" 141 return self._completed_process.args 142 143 def get_results_dir_path(self, snapshot_ready=False) -> pathlib.Path: 144 """Returns the atest results directory path. 145 146 Args: 147 snapshot_ready: Whether to make the result root directory snapshot 148 ready. When set to True and called in build environment, this method 149 will copy the path into <repo_root>/out with dereferencing so that the 150 directory can be safely added to snapshot. 151 152 Raises: 153 RuntimeError: Failed to parse the result dir path. 154 155 Returns: 156 The Atest result directory path. 157 """ 158 results_dir = None 159 for line in self.get_stdout().splitlines(keepends=False): 160 if line.startswith(RESULTS_DIR_PRINT_PREFIX): 161 results_dir = pathlib.Path(line[len(RESULTS_DIR_PRINT_PREFIX) :]) 162 if not results_dir: 163 raise RuntimeError('Failed to parse the result directory from stdout.') 164 165 if self._config.is_test_env or not snapshot_ready: 166 return results_dir 167 168 result_dir_copy_path = pathlib.Path(self._env['OUT_DIR']).joinpath( 169 'atest_integration_tests', results_dir.name 170 ) 171 if not result_dir_copy_path.exists(): 172 shutil.copytree(results_dir, result_dir_copy_path, symlinks=False) 173 174 return result_dir_copy_path 175 176 def get_test_result_dict(self) -> dict[str, Any]: 177 """Gets the atest results loaded from the test_result json. 178 179 Returns: 180 Atest result information loaded from the test_result json file. The test 181 result usually contains information about test runners and test 182 pass/fail results. 183 """ 184 json_path = self.get_results_dir_path() / 'test_result' 185 with open(json_path, 'r', encoding='utf-8') as f: 186 return json.load(f) 187 188 def get_passed_count(self) -> int: 189 """Gets the total number of passed tests from atest summary.""" 190 return self.get_test_result_dict()['total_summary']['PASSED'] 191 192 def get_failed_count(self) -> int: 193 """Gets the total number of failed tests from atest summary.""" 194 return self.get_test_result_dict()['total_summary']['FAILED'] 195 196 def get_ignored_count(self) -> int: 197 """Gets the total number of ignored tests from atest summary.""" 198 return self.get_test_result_dict()['total_summary']['IGNORED'] 199 200 def get_atest_log(self) -> str: 201 """Gets the log content read from the atest log file.""" 202 log_path = self.get_results_dir_path() / 'atest.log' 203 return log_path.read_text(encoding='utf-8') 204 205 def get_atest_log_entries(self) -> list[LogEntry]: 206 """Gets the parsed atest log entries list from the atest log file. 207 208 This method parse the atest log file and construct a new entry when a line 209 starts with a time string, source file name, line number, and log level. 210 211 Returns: 212 A list of parsed log entries. 213 """ 214 entries = [] 215 last_content_lines = [] 216 for line in self.get_atest_log().splitlines(): 217 regex = r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?):(\d+):(\w+): (.*)' 218 match = re.match(regex, line) 219 if match: 220 last_content_lines = [match.group(5)] 221 entries.append( 222 LogEntry( 223 match.group(1), 224 match.group(2), 225 int(match.group(3)), 226 match.group(4), 227 last_content_lines, 228 ) 229 ) 230 else: 231 if last_content_lines: 232 last_content_lines.append(line) 233 234 return entries 235 236 def get_atest_log_values_from_prefix(self, prefix: str) -> list[str]: 237 """Gets log values from lines starting with the given log prefix.""" 238 res = [] 239 for entry in self.get_atest_log_entries(): 240 content = entry.get_content() 241 if content.startswith(prefix): 242 res.append(content[len(prefix) :]) 243 return res 244 245 def check_returncode(self) -> None: 246 """Checks the return code and raises an exception if non-zero.""" 247 248 def add_line_prefix(msg: str): 249 return ( 250 ''.join(('> %s' % line for line in msg.splitlines(keepends=True))) 251 if msg 252 else msg 253 ) 254 255 stderr = ( 256 f'stderr:\n{add_line_prefix(self.get_stderr())}\n' 257 if self.get_stderr() and self.get_stderr().strip() 258 else '' 259 ) 260 261 if self.get_returncode() != 0: 262 raise RuntimeError( 263 f'Atest command {self.get_cmd_list()} finished with exit code' 264 f' {self.get_returncode()}.\n' 265 f'stdout:\n{add_line_prefix(self.get_stdout())}\n{stderr}' 266 ) 267 268 def get_local_reproduce_debug_cmd(self) -> str: 269 """Returns a full reproduce command for local debugging purpose. 270 271 Returns: 272 A command that can be executed directly in command line to 273 reproduce the atest command. 274 """ 275 return '(cd {dir} && {env} {cmd})'.format( 276 dir=self._repo_root, 277 env=' '.join((k + '=' + v for k, v in self._env.items())), 278 cmd=' '.join(self.get_cmd_list()), 279 ) 280 281 282class AtestTestCase(split_build_test_script.SplitBuildTestTestCase): 283 """Base test case for build-test environment split integration tests.""" 284 285 def setUp(self): 286 super().setUp() 287 # Default include list of repo paths for snapshot 288 self._default_snapshot_include_paths = [ 289 '$OUT_DIR/host/linux-x86', 290 '$OUT_DIR/target/product/*/module-info*', 291 '$OUT_DIR/target/product/*/testcases', 292 '$OUT_DIR/target/product/*/data', 293 '$OUT_DIR/target/product/*/all_modules.txt', 294 '$OUT_DIR/soong/module_bp*', 295 'tools/asuite/atest/test_runners/roboleaf_launched.txt', 296 '.repo/manifest.xml', 297 'build/soong/soong_ui.bash', 298 'prebuilts/build-tools/path/linux-x86/python3', 299 'prebuilts/build-tools/linux-x86/bin/py3-cmd', 300 'prebuilts/build-tools', 301 'prebuilts/asuite/atest/linux-x86', 302 ] 303 304 # Default exclude list of repo paths for snapshot 305 self._default_snapshot_exclude_paths = [ 306 '$OUT_DIR/host/linux-x86/bin/go', 307 '$OUT_DIR/host/linux-x86/bin/soong_build', 308 '$OUT_DIR/host/linux-x86/obj', 309 ] 310 311 # Default list of environment variables to take and restore in snapshots 312 self._default_snapshot_env_keys = [ 313 split_build_test_script.ANDROID_BUILD_TOP_KEY, 314 'ANDROID_HOST_OUT', 315 'ANDROID_PRODUCT_OUT', 316 'ANDROID_HOST_OUT_TESTCASES', 317 'ANDROID_TARGET_OUT_TESTCASES', 318 'OUT', 319 'OUT_DIR', 320 'PATH', 321 'HOST_OUT_TESTCASES', 322 'ANDROID_JAVA_HOME', 323 'JAVA_HOME', 324 ] 325 326 def create_atest_script(self, name: str = None) -> SplitBuildTestScript: 327 """Create an instance of atest integration test utility.""" 328 return self.create_split_build_test_script(name) 329 330 def create_step_output(self) -> StepOutput: 331 """Create a step output object with default values.""" 332 out = StepOutput() 333 out.add_snapshot_include_paths(self._default_snapshot_include_paths) 334 out.add_snapshot_exclude_paths(self._default_snapshot_exclude_paths) 335 out.add_snapshot_env_keys(self._default_snapshot_env_keys) 336 out.add_snapshot_include_paths(self._get_jdk_path_list()) 337 return out 338 339 @classmethod 340 def run_atest_command( 341 cls, 342 cmd: str, 343 step_in: split_build_test_script.StepInput, 344 include_device_serial: bool, 345 print_output: bool = True, 346 use_prebuilt_atest_binary=None, 347 pipe_to_stdin: str = None, 348 ) -> AtestRunResult: 349 """Run either `atest-dev` or `atest` command through subprocess. 350 351 Args: 352 cmd: command string for Atest. Do not add 'atest-dev' or 'atest' in the 353 beginning of the command. 354 step_in: The step input object from build or test step. 355 include_device_serial: Whether a device is required for the atest 356 command. This argument is only used to determine whether to include 357 device serial in the command. It does not add device/deviceless 358 arguments such as '--host'. 359 print_output: Whether to print the stdout and stderr while the command 360 is running. 361 use_prebuilt_atest_binary: Whether to run the command using the prebuilt 362 atest binary instead of the atest-dev binary. 363 pipe_to_stdin: A string value to pipe continuously to the stdin of the 364 command subprocess. 365 366 Returns: 367 An AtestRunResult object containing the run information. 368 """ 369 if use_prebuilt_atest_binary is None: 370 use_prebuilt_atest_binary = step_in.get_config().use_prebuilt_atest_binary 371 atest_binary = 'atest' if use_prebuilt_atest_binary else 'atest-dev' 372 373 # TODO: b/336839543 - Throw error here when serial is required but not set 374 # instead of from step_in.get_device_serial_args_or_empty() 375 serial_arg = ( 376 step_in.get_device_serial_args_or_empty() 377 if include_device_serial 378 else '' 379 ) 380 complete_cmd = f'{atest_binary}{serial_arg} {cmd}' 381 382 indentation = ' ' 383 logging.debug('Executing atest command: %s', complete_cmd) 384 logging.debug( 385 '%sCommand environment variables: %s', indentation, step_in.get_env() 386 ) 387 start_time = time.time() 388 shell_result = cls._run_shell_command( 389 complete_cmd.split(), 390 env=step_in.get_env(), 391 cwd=step_in.get_repo_root(), 392 print_output=print_output, 393 pipe_to_stdin=pipe_to_stdin, 394 ) 395 elapsed_time = time.time() - start_time 396 result = AtestRunResult( 397 shell_result, 398 step_in.get_env(), 399 step_in.get_repo_root(), 400 step_in.get_config(), 401 elapsed_time, 402 ) 403 404 wrap_output_lines = lambda output_str: ''.join(( 405 f'{indentation * 2}> %s' % line for line in output_str.splitlines(True) 406 )) 407 logging.debug( 408 '%sCommand stdout:\n%s', 409 indentation, 410 wrap_output_lines(result.get_stdout()), 411 ) 412 logging.debug( 413 '%sAtest log:\n%s', 414 indentation, 415 wrap_output_lines(result.get_atest_log()), 416 ) 417 418 return result 419 420 @staticmethod 421 def _run_shell_command( 422 cmd: list[str], 423 env: dict[str, str], 424 cwd: str, 425 print_output: bool = True, 426 pipe_to_stdin: str = None, 427 ) -> subprocess.CompletedProcess[str]: 428 """Execute shell command with real time output printing and capture.""" 429 430 def read_output(process, read_src, print_dst, capture_dst): 431 while (output := read_src.readline()) or process.poll() is None: 432 if output: 433 if print_output: 434 print(output, end='', file=print_dst) 435 capture_dst.append(output) 436 437 # Disable log uploading when running locally. 438 env['ENABLE_ATEST_LOG_UPLOADING'] = 'false' 439 440 def run_popen(stdin=None): 441 with subprocess.Popen( 442 cmd, 443 stdout=subprocess.PIPE, 444 stderr=subprocess.PIPE, 445 stdin=stdin, 446 text=True, 447 env=env, 448 cwd=cwd, 449 ) as process: 450 stdout = [] 451 stderr = [] 452 with concurrent.futures.ThreadPoolExecutor() as executor: 453 stdout_future = executor.submit( 454 read_output, process, process.stdout, sys.stdout, stdout 455 ) 456 stderr_future = executor.submit( 457 read_output, process, process.stderr, sys.stderr, stderr 458 ) 459 stdout_future.result() 460 stderr_future.result() 461 462 return subprocess.CompletedProcess( 463 cmd, process.poll(), ''.join(stdout), ''.join(stderr) 464 ) 465 466 if pipe_to_stdin: 467 with subprocess.Popen( 468 ['yes', pipe_to_stdin], stdout=subprocess.PIPE 469 ) as yes_process: 470 return run_popen(yes_process.stdout) 471 472 return run_popen() 473 474 @staticmethod 475 def _get_jdk_path_list() -> str: 476 """Get the relative jdk directory in build environment.""" 477 if split_build_test_script.ANDROID_BUILD_TOP_KEY not in os.environ: 478 return [] 479 absolute_path = pathlib.Path(os.environ['ANDROID_JAVA_HOME']) 480 while not absolute_path.name.startswith('jdk'): 481 absolute_path = absolute_path.parent 482 if not absolute_path.name.startswith('jdk'): 483 raise ValueError( 484 'Unrecognized jdk directory ' + os.environ['ANDROID_JAVA_HOME'] 485 ) 486 repo_root = pathlib.Path( 487 os.environ[split_build_test_script.ANDROID_BUILD_TOP_KEY] 488 ) 489 return [absolute_path.relative_to(repo_root).as_posix()] 490 491 492def sanitize_runner_command(cmd: str) -> str: 493 """Sanitize an atest runner command by removing non-essential args.""" 494 remove_args_starting_with = [ 495 '--skip-all-system-status-check', 496 '--atest-log-file-path', 497 'LD_LIBRARY_PATH=', 498 '--proto-output-file=', 499 '--log-root-path', 500 ] 501 remove_args_with_values = ['-s', '--serial'] 502 build_command = 'build/soong/soong_ui.bash' 503 original_args = cmd.split() 504 result_args = [] 505 for arg in original_args: 506 if arg == build_command: 507 result_args.append(f'./{build_command}') 508 continue 509 if not any( 510 (arg.startswith(prefix) for prefix in remove_args_starting_with) 511 ): 512 result_args.append(arg) 513 for arg in remove_args_with_values: 514 while arg in result_args: 515 idx = result_args.index(arg) 516 # Delete value index first. 517 del result_args[idx + 1] 518 del result_args[idx] 519 520 return ' '.join(result_args) 521 522 523def main(): 524 """Main method to run the integration tests.""" 525 additional_args = [ 526 split_build_test_script.AddArgument( 527 'use_prebuilt_atest_binary', 528 '--use-prebuilt-atest-binary', 529 action='store_true', 530 default=False, 531 help=( 532 'Set the default atest binary to the prebuilt `atest` instead' 533 ' of `atest-dev`.' 534 ), 535 ), 536 split_build_test_script.AddArgument( 537 'dry_run_diff_test_cmd_input_file', 538 '--dry-run-diff-test-cmd-input-file', 539 help=( 540 'The path of file containing the list of atest commands to test' 541 ' in the dry run diff tests relative to the repo root.' 542 ), 543 ), 544 ] 545 split_build_test_script.main( 546 argv=sys.argv, 547 make_before_build=['atest'], 548 additional_args=additional_args, 549 ) 550