xref: /aosp_15_r20/tools/asuite/atest/integration_tests/atest_integration_test.py (revision c2e18aaa1096c836b086f94603d04f4eb9cf37f5)
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