aboutsummaryrefslogtreecommitdiff
path: root/integration_tests/csuite_test_utils.py
blob: 489f348b870de10353a49768604491ff555f054e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# Lint as: python3
#
# Copyright 2020, The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Utilities for C-Suite integration tests."""

import argparse
import contextlib
import logging
import os
import pathlib
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
from typing import Sequence, Text
import zipfile
import csuite_test

# Export symbols to reduce the number of imports tests have to list.
TestCase = csuite_test.TestCase  # pylint: disable=invalid-name
get_device_serial = csuite_test.get_device_serial

# Keep any created temporary directories for debugging test failures. The
# directories do not need explicit removal since they are created using the
# system's temporary-file facility.
_KEEP_TEMP_DIRS = False


class CSuiteHarness(contextlib.AbstractContextManager):
  """Interface class for interacting with the C-Suite harness.

  WARNING: Explicitly clean up created instances or use as a context manager.
  Not doing so will result in a ResourceWarning for the implicit cleanup which
  confuses the TradeFed Python test output parser.
  """

  def __init__(self):
    self._suite_dir = pathlib.Path(tempfile.mkdtemp(prefix='csuite'))
    logging.debug('Created harness directory: %s', self._suite_dir)

    with zipfile.ZipFile(_get_standalone_zip_path(), 'r') as f:
      f.extractall(self._suite_dir)

    # Add owner-execute permission on scripts since zip does not preserve them.
    self._launcher_binary = self._suite_dir.joinpath(
        'android-csuite/tools/csuite-tradefed')
    _add_owner_exec_permission(self._launcher_binary)

    self._testcases_dir = self._suite_dir.joinpath('android-csuite/testcases')

  def __exit__(self, unused_type, unused_value, unused_traceback):
    self.cleanup()

  def cleanup(self):
    if _KEEP_TEMP_DIRS:
      return
    shutil.rmtree(self._suite_dir, ignore_errors=True)


  def run_and_wait(self, flags: Sequence[Text]) -> subprocess.CompletedProcess:
    """Starts the Tradefed launcher and waits for it to complete."""

    env = os.environ.copy()

    # Unset environment variables that would cause the script to think it's in a
    # build tree.
    env.pop('ANDROID_BUILD_TOP', None)
    env.pop('ANDROID_HOST_OUT', None)

    # Unset environment variables that would cause TradeFed to find test configs
    # other than the ones created by the test.
    env.pop('ANDROID_HOST_OUT_TESTCASES', None)
    env.pop('ANDROID_TARGET_OUT_TESTCASES', None)

    # Unset environment variables that might cause the suite to pick up a
    # connected device that wasn't explicitly specified.
    env.pop('ANDROID_SERIAL', None)

    # Unset environment variables that might cause the TradeFed to load classes
    # that weren't included in the standalone suite zip.
    env.pop('TF_GLOBAL_CONFIG', None)

    # Set the environment variable that TradeFed requires to find test modules.
    env['ANDROID_TARGET_OUT_TESTCASES'] = self._testcases_dir
    jdk17_path = '/jdk/jdk17/linux-x86'
    if os.path.isdir(jdk17_path):
      env['JAVA_HOME'] = jdk17_path
      java_path = jdk17_path + '/bin'
      env['PATH'] = java_path + ':' + env['PATH']

    return _run_command([self._launcher_binary] + flags, env=env)


class PackageRepository(contextlib.AbstractContextManager):
  """A file-system based APK repository for use in tests.

  WARNING: Explicitly clean up created instances or use as a context manager.
  Not doing so will result in a ResourceWarning for the implicit cleanup which
  confuses the TradeFed Python test output parser.
  """

  def __init__(self):
    self._root_dir = pathlib.Path(tempfile.mkdtemp(prefix='csuite_apk_dir'))
    logging.info('Created repository directory: %s', self._root_dir)

  def __exit__(self, unused_type, unused_value, unused_traceback):
    self.cleanup()

  def cleanup(self):
    if _KEEP_TEMP_DIRS:
      return
    shutil.rmtree(self._root_dir, ignore_errors=True)

  def get_path(self) -> pathlib.Path:
    """Returns the path to the repository's root directory."""
    return self._root_dir

  def add_package_apks(self, package_name: Text,
                       apk_paths: Sequence[pathlib.Path]):
    """Adds the provided package APKs to the repository."""
    apk_dir = self._root_dir.joinpath(package_name)

    # Raises if the directory already exists.
    apk_dir.mkdir()
    for f in apk_paths:
      shutil.copy(f, apk_dir)


class Adb:
  """Encapsulates adb functionality to simplify usage in tests.

  Most methods in this class raise an exception if they fail to execute. This
  behavior can be overridden by using the check parameter.
  """

  def __init__(self,
               adb_binary_path: pathlib.Path = None,
               device_serial: Text = None):
    self._args = [adb_binary_path or 'adb']

    device_serial = device_serial or get_device_serial()
    if device_serial:
      self._args.extend(['-s', device_serial])

  def shell(self,
            args: Sequence[Text],
            check: bool = None) -> subprocess.CompletedProcess:
    """Runs an adb shell command and waits for it to complete.

    Note that the exit code of the returned object corresponds to that of
    the adb command and not the command executed in the shell.

    Args:
      args: a sequence of program arguments to pass to the shell.
      check: whether to raise if the process terminates with a non-zero exit
        code.

    Returns:
      An object representing a process that has finished and that can be
      queried.
    """
    return self.run(['shell'] + args, check)

  def run(self,
          args: Sequence[Text],
          check: bool = None) -> subprocess.CompletedProcess:
    """Runs an adb command and waits for it to complete."""
    return _run_command(self._args + args, check=check)

  def uninstall(self, package_name: Text, check: bool = None):
    """Uninstalls the specified package."""
    self.run(['uninstall', package_name], check=check)

  def list_packages(self) -> Sequence[Text]:
    """Lists packages installed on the device."""
    p = self.shell(['pm', 'list', 'packages'])
    return [l.split(':')[1] for l in p.stdout.splitlines()]


def _run_command(args, check=False, **kwargs) -> subprocess.CompletedProcess:
  """A wrapper for subprocess.run that overrides defaults and adds logging."""
  env = kwargs.get('env', {})

  # Log the command-line for debugging failed tests. Note that we convert
  # tokens to strings for _shlex_join.
  env_str = ['env', '-i'] + [f'{k}={v}' for k, v in env.items()]
  args_str = [str(t) for t in args]

  # Override some defaults. Note that 'check' deviates from this pattern to
  # avoid getting warnings about using subprocess.run without an explicitly set
  # `check` parameter.
  kwargs.setdefault('capture_output', True)
  kwargs.setdefault('universal_newlines', True)

  logging.debug('Running command: %s', _shlex_join(env_str + args_str))

  return subprocess.run(args, check=check, **kwargs)


def _add_owner_exec_permission(path: pathlib.Path):
  path.chmod(path.stat().st_mode | stat.S_IEXEC)


def get_test_app_apks(app_module_name: Text) -> Sequence[pathlib.Path]:
  """Returns a test app's apk file paths."""
  return [_get_test_file(app_module_name + '.apk')]


def _get_standalone_zip_path():
  """Returns the suite standalone zip file's path."""
  return _get_test_file('csuite-standalone.zip')


def _get_test_file(name: Text) -> pathlib.Path:
  test_dir = _get_test_dir()
  test_file = test_dir.joinpath(name)

  if not test_file.exists():
    raise RuntimeError(f'Unable to find the file `{name}` in the test '
                       'execution dir `{test_dir}`; are you missing a data '
                       'dependency in the build module?')

  return test_file


def _shlex_join(split_command: Sequence[Text]) -> Text:
  """Concatenate tokens and return a shell-escaped string."""
  # This is an alternative to shlex.join that doesn't exist in Python versions
  # < 3.8.
  return ' '.join(shlex.quote(t) for t in split_command)


def _get_test_dir() -> pathlib.Path:
  return pathlib.Path(__file__).parent


def main():
  global _KEEP_TEMP_DIRS

  parser = argparse.ArgumentParser(parents=[csuite_test.create_arg_parser()])
  parser.add_argument(
      '--log-level',
      choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
      default='WARNING',
      help='sets the logging level threshold')
  parser.add_argument(
      '--keep-temp-dirs',
      type=bool,
      help='keeps any created temporary directories for debugging failures')
  args, unittest_argv = parser.parse_known_args(sys.argv)

  _KEEP_TEMP_DIRS = args.keep_temp_dirs
  logging.basicConfig(level=getattr(logging, args.log_level))

  csuite_test.run_tests(args, unittest_argv)