xref: /aosp_15_r20/tools/asuite/atest/test_runners/test_runner_base.py (revision c2e18aaa1096c836b086f94603d04f4eb9cf37f5)
1# Copyright 2017, The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Base test runner class.
16
17Class that other test runners will instantiate for test runners.
18"""
19
20from __future__ import print_function
21
22from collections import namedtuple
23import errno
24import logging
25import os
26import signal
27import subprocess
28import tempfile
29from typing import Any, Dict, List, Set
30
31from atest import atest_error
32from atest import atest_utils
33from atest import device_update
34from atest.test_finders import test_info
35from atest.test_runner_invocation import TestRunnerInvocation
36
37OLD_OUTPUT_ENV_VAR = 'ATEST_OLD_OUTPUT'
38
39# TestResult contains information of individual tests during a test run.
40TestResult = namedtuple(
41    'TestResult',
42    [
43        'runner_name',
44        'group_name',
45        'test_name',
46        'status',
47        'details',
48        'test_count',
49        'test_time',
50        'runner_total',
51        'group_total',
52        'additional_info',
53        'test_run_name',
54    ],
55)
56ASSUMPTION_FAILED = 'ASSUMPTION_FAILED'
57FAILED_STATUS = 'FAILED'
58PASSED_STATUS = 'PASSED'
59IGNORED_STATUS = 'IGNORED'
60ERROR_STATUS = 'ERROR'
61
62# Code for RunnerFinishEvent.
63RESULT_CODE = {
64    PASSED_STATUS: 0,
65    FAILED_STATUS: 1,
66    IGNORED_STATUS: 2,
67    ASSUMPTION_FAILED: 3,
68    ERROR_STATUS: 4,
69}
70
71
72class TestRunnerBase:
73  """Base Test Runner class."""
74
75  NAME = ''
76  EXECUTABLE = ''
77
78  def __init__(self, results_dir, **kwargs):
79    """Init stuff for base class."""
80    self.results_dir = results_dir
81    self.test_log_file = None
82    self._subprocess_stdout = None
83    if not self.NAME:
84      raise atest_error.NoTestRunnerName('Class var NAME is not defined.')
85    if not self.EXECUTABLE:
86      raise atest_error.NoTestRunnerExecutable(
87          'Class var EXECUTABLE is not defined.'
88      )
89    if kwargs:
90      for key, value in kwargs.items():
91        if not 'test_infos' in key:
92          logging.debug('Found auxiliary args: %s=%s', key, value)
93
94  def create_invocations(
95      self,
96      extra_args: Dict[str, Any],
97      test_infos: List[test_info.TestInfo],
98  ) -> List[TestRunnerInvocation]:
99    """Creates test runner invocations.
100
101    Args:
102        extra_args: A dict of arguments.
103        test_infos: A list of instances of TestInfo.
104
105    Returns:
106        A list of TestRunnerInvocation instances.
107    """
108    return [
109        TestRunnerInvocation(
110            test_runner=self, extra_args=extra_args, test_infos=test_infos
111        )
112    ]
113
114  def requires_device_update(
115      self, test_infos: List[test_info.TestInfo]
116  ) -> bool:
117    """Checks whether this runner requires device update."""
118    return False
119
120  def run(
121      self,
122      cmd,
123      output_to_stdout=False,
124      env_vars=None,
125      rolling_output_lines=False,
126  ):
127    """Shell out and execute command.
128
129    Args:
130        cmd: A string of the command to execute.
131        output_to_stdout: A boolean. If False, the raw output of the run command
132          will not be seen in the terminal. This is the default behavior, since
133          the test_runner's run_tests() method should use atest's result
134          reporter to print the test results.  Set to True to see the output of
135          the cmd. This would be appropriate for verbose runs.
136        env_vars: Environment variables passed to the subprocess.
137        rolling_output_lines: If True, the subprocess output will be streamed
138          with rolling lines when output_to_stdout is False.
139    """
140    logging.debug('Executing command: %s', cmd)
141    if rolling_output_lines:
142      proc = subprocess.Popen(
143          cmd,
144          start_new_session=True,
145          shell=True,
146          stderr=subprocess.STDOUT,
147          stdout=None if output_to_stdout else subprocess.PIPE,
148          env=env_vars,
149      )
150      self._subprocess_stdout = proc.stdout
151      return proc
152    else:
153      if not output_to_stdout:
154        self.test_log_file = tempfile.NamedTemporaryFile(
155            mode='w', dir=self.results_dir, delete=True
156        )
157      return subprocess.Popen(
158          cmd,
159          start_new_session=True,
160          shell=True,
161          stderr=subprocess.STDOUT,
162          stdout=self.test_log_file,
163          env=env_vars,
164      )
165
166  # pylint: disable=broad-except
167  def handle_subprocess(self, subproc, func):
168    """Execute the function. Interrupt the subproc when exception occurs.
169
170    Args:
171        subproc: A subprocess to be terminated.
172        func: A function to be run.
173    """
174    try:
175      signal.signal(signal.SIGINT, self._signal_passer(subproc))
176      func()
177    except Exception as error:
178      # exc_info=1 tells logging to log the stacktrace
179      logging.debug('Caught exception:', exc_info=1)
180      # If atest crashes, try to kill subproc group as well.
181      try:
182        logging.debug('Killing subproc: %s', subproc.pid)
183        os.killpg(os.getpgid(subproc.pid), signal.SIGINT)
184      except OSError:
185        # this wipes our previous stack context, which is why
186        # we have to save it above.
187        logging.debug('Subproc already terminated, skipping')
188      finally:
189        full_output = ''
190        if self._subprocess_stdout:
191          full_output = self._subprocess_stdout.read()
192        elif self.test_log_file:
193          with open(self.test_log_file.name, 'r') as f:
194            full_output = f.read()
195        if full_output:
196          print(atest_utils.mark_red('Unexpected Issue. Raw Output:'))
197          print(full_output)
198        # Ignore socket.recv() raising due to ctrl-c
199        if not error.args or error.args[0] != errno.EINTR:
200          raise error
201
202  def wait_for_subprocess(self, proc):
203    """Check the process status.
204
205    Interrupt the TF subprocess if user hits Ctrl-C.
206
207    Args:
208        proc: The tradefed subprocess.
209
210    Returns:
211        Return code of the subprocess for running tests.
212    """
213    try:
214      logging.debug('Runner Name: %s, Process ID: %s', self.NAME, proc.pid)
215      signal.signal(signal.SIGINT, self._signal_passer(proc))
216      proc.wait()
217      return proc.returncode
218    except:
219      # If atest crashes, kill TF subproc group as well.
220      os.killpg(os.getpgid(proc.pid), signal.SIGINT)
221      raise
222
223  def _signal_passer(self, proc):
224    """Return the signal_handler func bound to proc.
225
226    Args:
227        proc: The tradefed subprocess.
228
229    Returns:
230        signal_handler function.
231    """
232
233    def signal_handler(_signal_number, _frame):
234      """Pass SIGINT to proc.
235
236      If user hits ctrl-c during atest run, the TradeFed subprocess
237      won't stop unless we also send it a SIGINT. The TradeFed process
238      is started in a process group, so this SIGINT is sufficient to
239      kill all the child processes TradeFed spawns as well.
240      """
241      print('Process ID: %s', proc.pid)
242      try:
243        atest_utils.print_and_log_info(
244            'Ctrl-C received. Killing process group ID: %s',
245            os.getpgid(proc.pid),
246        )
247        os.killpg(os.getpgid(proc.pid), signal.SIGINT)
248      except ProcessLookupError as e:
249        atest_utils.print_and_log_info(e)
250
251    return signal_handler
252
253  def run_tests(self, test_infos, extra_args, reporter):
254    """Run the list of test_infos.
255
256    Should contain code for kicking off the test runs using
257    test_runner_base.run(). Results should be processed and printed
258    via the reporter passed in.
259
260    Args:
261        test_infos: List of TestInfo.
262        extra_args: Dict of extra args to add to test run.
263        reporter: An instance of result_report.ResultReporter.
264    """
265    raise NotImplementedError
266
267  def host_env_check(self):
268    """Checks that host env has met requirements."""
269    raise NotImplementedError
270
271  def get_test_runner_build_reqs(self, test_infos: List[test_info.TestInfo]):
272    """Returns a list of build targets required by the test runner."""
273    raise NotImplementedError
274
275  def generate_run_commands(self, test_infos, extra_args, port=None):
276    """Generate a list of run commands from TestInfos.
277
278    Args:
279        test_infos: A set of TestInfo instances.
280        extra_args: A Dict of extra args to append.
281        port: Optional. An int of the port number to send events to. Subprocess
282          reporter in TF won't try to connect if it's None.
283
284    Returns:
285        A list of run commands to run the tests.
286    """
287    raise NotImplementedError
288
289
290def gather_build_targets(test_infos: List[test_info.TestInfo]) -> Set[str]:
291  """Gets all build targets for the given tests.
292
293  Args:
294      test_infos: List of TestInfo.
295
296  Returns:
297      Set of build targets.
298  """
299  build_targets = set()
300
301  for t_info in test_infos:
302    build_targets |= t_info.build_targets
303
304  return build_targets
305