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