• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2
3# Copyright (C) 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"""Script for running Android Gerrit-based Mobly tests locally.
18
19Example:
20    - Run a test module.
21    local_mobly_runner.py -m my_test_module
22
23    - Run a test module. Build the module and install test APKs before running the test.
24    local_mobly_runner.py -m my_test_module -b -i
25
26    - Run a test module with specific Android devices.
27    local_mobly_runner.py -m my_test_module -s DEV00001,DEV00002
28
29    - Run a list of zipped Mobly packages (built from `python_test_host`)
30    local_mobly_runner.py -p test_pkg1,test_pkg2,test_pkg3
31
32Please run `local_mobly_runner.py -h` for a full list of options.
33"""
34
35import argparse
36import json
37import os
38import shutil
39import subprocess
40import sys
41import tempfile
42from typing import List, Optional, Tuple
43import zipfile
44
45_LOCAL_SETUP_INSTRUCTIONS = (
46    '\n\tcd <repo_root>; set -a; source build/envsetup.sh; set +a; lunch'
47    ' <target>'
48)
49
50_tempdirs = []
51_tempfiles = []
52
53
54def _padded_print(line: str) -> None:
55    print(f'\n-----{line}-----\n')
56
57
58def _parse_args() -> argparse.Namespace:
59    """Parses command line args."""
60    parser = argparse.ArgumentParser(
61        formatter_class=argparse.RawDescriptionHelpFormatter,
62        description=__doc__)
63    group1 = parser.add_mutually_exclusive_group(required=True)
64    group1.add_argument(
65        '-m', '--module', help='The Android build module of the test to run.'
66    )
67    group1.add_argument(
68        '-p', '--packages',
69        help='A comma-delimited list of test packages to run.'
70    )
71    group1.add_argument(
72        '-t',
73        '--test_paths',
74        help=(
75            'A comma-delimited list of test paths to run directly. Implies '
76            'the --novenv option.'
77        ),
78    )
79    parser.add_argument(
80        '-b',
81        '--build',
82        action='store_true',
83        help='Build/rebuild the specified module. Requires the -m option.',
84    )
85    parser.add_argument(
86        '-i',
87        '--install_apks',
88        action='store_true',
89        help=(
90            'Install all APKs associated with the module to all specified'
91            ' devices. Requires the -m or -p options.'
92        ),
93    )
94    group2 = parser.add_mutually_exclusive_group()
95    group2.add_argument(
96        '-s',
97        '--serials',
98        help=(
99            'Specify the devices to test with a comma-delimited list of device '
100            'serials.'
101        ),
102    )
103    group2.add_argument(
104        '-c', '--config', help='Provide a custom Mobly config for the test.'
105    )
106    parser.add_argument('-lp', '--log_path',
107                        help='Specify a path to store logs.')
108    parser.add_argument(
109        '--novenv',
110        action='store_true',
111        help=(
112            "Run directly in the host's system Python, without setting up a "
113            'virtualenv.'
114        ),
115    )
116    args = parser.parse_args()
117    if args.build and not args.module:
118        parser.error('Option --build requires --module to be specified.')
119    if args.install_apks and not (args.module or args.packages):
120        parser.error('Option --install_apks requires --module or --packages.')
121
122    args.novenv = args.novenv or (args.test_paths is not None)
123    return args
124
125
126def _build_module(module: str) -> None:
127    """Builds the specified module."""
128    _padded_print(f'Building test module {module}.')
129    try:
130        subprocess.check_call(f'm -j {module}', shell=True,
131                              executable='/bin/bash')
132    except subprocess.CalledProcessError as e:
133        if e.returncode == 127:
134            # `m` command not found
135            print(
136                '`m` command not found. Please set up your local environment '
137                f'with {_LOCAL_SETUP_INSTRUCTIONS}.'
138            )
139        else:
140            print(f'Failed to build module {module}.')
141        exit(1)
142
143
144def _get_module_artifacts(module: str) -> List[str]:
145    """Return the list of artifacts generated from a module."""
146    try:
147        outmod_paths = (
148            subprocess.check_output(
149                f'outmod {module}', shell=True, executable='/bin/bash'
150            )
151            .decode('utf-8')
152            .splitlines()
153        )
154    except subprocess.CalledProcessError as e:
155        if e.returncode == 127:
156            # `outmod` command not found
157            print(
158                '`outmod` command not found. Please set up your local '
159                f'environment with {_LOCAL_SETUP_INSTRUCTIONS}.'
160            )
161        if str(e.output).startswith('Could not find module'):
162            print(
163                f'Cannot find the build output of module {module}. Ensure that '
164                'the module list is up-to-date with `refreshmod`.'
165            )
166        exit(1)
167
168    for path in outmod_paths:
169        if not os.path.isfile(path):
170            print(
171                f'Declared file {path} does not exist. Please build your '
172                'module with the -b option.'
173            )
174            exit(1)
175
176    return outmod_paths
177
178
179def _resolve_test_resources(
180        args: argparse.Namespace,
181) -> Tuple[List[str], List[str], List[str]]:
182    """Resolve test resources from the given test module or package.
183
184    Args:
185      args: Parsed command-line args.
186
187    Returns:
188      Tuple of (mobly_bins, requirement_files, test_apks).
189    """
190    mobly_bins = []
191    requirements_files = []
192    test_apks = []
193    if args.test_paths:
194        mobly_bins.extend(args.test_paths.split(','))
195    elif args.module:
196        for path in _get_module_artifacts(args.module):
197            if path.endswith(args.module):
198                mobly_bins.append(path)
199            if path.endswith('requirements.txt'):
200                requirements_files.append(path)
201            if path.endswith('.apk'):
202                test_apks.append(path)
203    elif args.packages:
204        unzip_root = tempfile.mkdtemp(prefix='mobly_unzip_')
205        _tempdirs.append(unzip_root)
206        for package in args.packages.split(','):
207            mobly_bins.append(package)
208            unzip_dir = os.path.join(unzip_root, os.path.basename(package))
209            print(f'Unzipping test package {package} to {unzip_dir}.')
210            os.makedirs(unzip_dir)
211            with zipfile.ZipFile(package) as zf:
212                zf.extractall(unzip_dir)
213            for path in os.listdir(unzip_dir):
214                path = os.path.join(unzip_dir, path)
215                if path.endswith('requirements.txt'):
216                    requirements_files.append(path)
217                if path.endswith('.apk'):
218                    test_apks.append(path)
219    else:
220        print('No tests specified. Aborting.')
221        exit(1)
222    return mobly_bins, requirements_files, test_apks
223
224
225def _setup_virtualenv(requirements_files: List[str]) -> str:
226    """Creates a virtualenv and install dependencies into it.
227
228    Args:
229      requirements_files: List of paths of requirements.txt files.
230
231    Returns:
232      Path to the virtualenv's Python interpreter.
233    """
234    if not requirements_files:
235        print('No requirements.txt file found. Aborting.')
236        exit(1)
237
238    venv_dir = tempfile.mkdtemp(prefix='venv_')
239    _padded_print(f'Creating virtualenv at {venv_dir}.')
240    subprocess.check_call([sys.executable, '-m', 'venv', venv_dir])
241    _tempdirs.append(venv_dir)
242    venv_executable = os.path.join(venv_dir, 'bin/python3')
243
244    # Install requirements
245    for requirements_file in requirements_files:
246        print(f'Installing dependencies from {requirements_file}.')
247        subprocess.check_call(
248            [venv_executable, '-m', 'pip', 'install', '-r', requirements_file]
249        )
250    return venv_executable
251
252
253def _install_apks(
254        apks: List[str],
255        serials: Optional[List[str]] = None,
256) -> None:
257    """Installs given APKS to specified devices.
258
259    If no serials specified, installs APKs on all attached devices.
260
261    Args:
262      apks: List of paths to APKs.
263      serials: List of device serials.
264    """
265    _padded_print('Installing test APKs.')
266    if not serials:
267        serials = (
268            subprocess.check_output(
269                'adb devices | tail -n +2 | cut -f 1', shell=True
270            )
271            .decode('utf-8')
272            .strip()
273            .splitlines()
274        )
275    for apk in apks:
276        for serial in serials:
277            print(f'Installing {apk} on device {serial}.')
278            subprocess.check_call(
279                ['adb', '-s', serial, 'install', '-r', '-g', apk]
280            )
281
282
283def _generate_mobly_config(serials: Optional[List[str]] = None) -> str:
284    """Generates a Mobly config for the provided device serials.
285
286    If no serials specified, generate a wildcard config (test loads all attached
287    devices).
288
289    Args:
290      serials: List of device serials.
291
292    Returns:
293      Path to the generated config.
294    """
295    config = {
296        'TestBeds': [{
297            'Name': 'LocalTestBed',
298            'Controllers': {
299                'AndroidDevice': serials if serials else '*',
300            },
301        }]
302    }
303    _, config_path = tempfile.mkstemp(prefix='mobly_config_')
304    _padded_print(f'Generating Mobly config at {config_path}.')
305    with open(config_path, 'w') as f:
306        json.dump(config, f)
307    _tempfiles.append(config_path)
308    return config_path
309
310
311def _run_mobly_tests(
312        python_executable: str,
313        mobly_bins: List[str],
314        config: str,
315        log_path: Optional[str] = None,
316) -> None:
317    """Runs the Mobly tests with the specified binary and config."""
318    env = os.environ.copy()
319    for mobly_bin in mobly_bins:
320        bin_name = os.path.basename(mobly_bin)
321        if log_path:
322            env['MOBLY_LOGPATH'] = os.path.join(log_path, bin_name)
323        cmd = [python_executable, mobly_bin, '-c', config]
324        _padded_print(f'Running Mobly test {bin_name}.')
325        print(f'Command: {cmd}\n')
326        subprocess.run(cmd, env=env)
327
328
329def _clean_up() -> None:
330    """Cleans up temporary directories and files."""
331    _padded_print('Cleaning up temporary directories/files.')
332    for td in _tempdirs:
333        shutil.rmtree(td, ignore_errors=True)
334    _tempdirs.clear()
335    for tf in _tempfiles:
336        os.remove(tf)
337    _tempfiles.clear()
338
339
340def main() -> None:
341    args = _parse_args()
342
343    # Build the test module if requested by user
344    if args.build:
345        _build_module(args.module)
346
347    serials = args.serials.split(',') if args.serials else None
348
349    # Resolve test resources
350    mobly_bins, requirements_files, test_apks = _resolve_test_resources(args)
351
352    # Install test APKs, if necessary
353    if args.install_apks:
354        _install_apks(test_apks, serials)
355
356    # Set up the Python virtualenv, if necessary
357    python_executable = (
358        sys.executable if args.novenv else _setup_virtualenv(requirements_files)
359    )
360
361    # Generate the Mobly config, if necessary
362    config = args.config or _generate_mobly_config(serials)
363
364    # Run the tests
365    _run_mobly_tests(python_executable, mobly_bins, config, args.log_path)
366
367    # Clean up temporary dirs/files
368    _clean_up()
369
370
371if __name__ == '__main__':
372    main()
373