"""Run selected isolated Compose UI tests on an explicitly named Android Studio AVD.""" import argparse import json from pathlib import Path import re import subprocess import time SCENARIOS = ('connection', 'status', 'content', 'settings', 'navigation', 'layout') CONFIGS = {'baseline': (None, '1.0', '0'), 'narrow': (340, '1.0', '0'), 'large-font': (340, '1.3', '0'), 'landscape': (None, '1.3', '1')} PACKAGE = 'org.qimiaoscreen.uitest' def passed(output): return bool(re.search(r'^OK \(1 test\)\s*$', output, re.M)) and not any( marker in output for marker in ('FAILURES!!!', 'INSTRUMENTATION_FAILED', 'Process crashed')) def main(argv=None): p = argparse.ArgumentParser(description=__doc__) p.add_argument('--list-scenarios', action='store_true') p.add_argument('--dry-run', action='store_true') choice = p.add_mutually_exclusive_group() choice.add_argument('--scenario', action='append', choices=SCENARIOS) choice.add_argument('--all-ui', action='store_true') p.add_argument('--configuration', action='append', choices=CONFIGS) p.add_argument('--avd') p.add_argument('--adb') p.add_argument('--emulator') p.add_argument('--sysdir', type=Path, help='Optional system image directory for a separately stored SDK image') p.add_argument('--port', type=int, default=5580) p.add_argument('--build', type=Path) p.add_argument('--report', type=Path) args = p.parse_args(argv) if args.list_scenarios: print(json.dumps({'scenarios': SCENARIOS, 'configurations': list(CONFIGS)})); return 0 selected = list(SCENARIOS) if args.all_ui else list(dict.fromkeys(args.scenario or [])) if not selected: p.print_help(); return 2 configs = list(dict.fromkeys(args.configuration or ['baseline'])) plan = [{'scenario': s, 'configuration': c, 'result': 'not run'} for c in configs for s in selected] print(json.dumps({'selected': plan, 'physical_phone': False, 'board': False, 'root': False}), flush=True) if args.dry_run: return 0 if not args.adb or not args.avd or not args.build: p.error('--adb, --avd and --build required') if not re.fullmatch(r'[A-Za-z0-9_.-]+', args.avd): p.error('Invalid AVD name') if args.port % 2 or not 5554 <= args.port <= 5682: p.error('Expected an even emulator console port') apk = args.build / 'uiTest/build/outputs/apk/debug/uiTest-debug.apk' if not apk.is_file(): p.error('Build :uiTest:assembleDebug first') if args.report and args.report.exists(): p.error('Report already exists; choose a new path') serial = 'emulator-' + str(args.port) started = False verified_target = False original = {} cleanup_errors = [] process = None def adb(*command, timeout=30): try: result = subprocess.run([args.adb, '-s', serial, *command], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout) except subprocess.TimeoutExpired: raise RuntimeError('Emulator operation timed out') from None if result.returncode: raise RuntimeError('Emulator operation failed') return result.stdout + result.stderr def cleanup(name, action): try: action() except Exception: cleanup_errors.append(name) def restore_setting(key, value): adb('shell', 'settings', 'delete', 'system', key) if value == 'null' else adb('shell', 'settings', 'put', 'system', key, value) if adb('shell', 'settings', 'get', 'system', key).strip() != value: raise RuntimeError('Restore failed') failure = None try: # An occupied port must not cause selection of another device. state = subprocess.run([args.adb, '-s', serial, 'get-state'], capture_output=True, timeout=15) if state.returncode: if not args.emulator: raise RuntimeError('AVD is not running; supply --emulator to start it') available = subprocess.run([args.emulator, '-list-avds'], capture_output=True, text=True, timeout=20) if args.avd not in available.stdout.splitlines(): raise RuntimeError('Named AVD does not exist; create it in Android Studio Device Manager') command = [args.emulator, '-avd', args.avd, '-port', str(args.port), '-no-window', '-no-audio', '-no-snapshot', '-no-boot-anim'] if args.sysdir: command += ['-sysdir', str(args.sysdir)] process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) started = True deadline = time.monotonic() + 180 while True: if process is not None and process.poll() is not None: raise RuntimeError('Emulator exited before boot') try: if adb('shell', 'getprop', 'sys.boot_completed').strip() == '1': break except RuntimeError: pass if time.monotonic() >= deadline: raise RuntimeError('AVD boot timeout') time.sleep(2) if adb('shell', 'getprop', 'ro.kernel.qemu').strip() != '1': raise RuntimeError('Refusing a physical device') if adb('emu', 'avd', 'name').splitlines()[0].strip() != args.avd: raise RuntimeError('AVD name does not match requested target') verified_target = True original = {k: adb('shell', 'settings', 'get', 'system', k).strip() for k in ('font_scale', 'accelerometer_rotation', 'user_rotation')} density = re.search(r'Override density: (\d+)', adb('shell', 'wm', 'density')) original['density'] = density[1] if density else 'reset' dims = re.findall(r'(\d+)x(\d+)', adb('shell', 'wm', 'size'))[-1] result = adb('install', '-r', str(apk), timeout=120) if 'Success' not in result: raise RuntimeError('Test APK installation failed') for item in plan: name, config = item['scenario'], item['configuration'] width, font, rotation = CONFIGS[config] adb('shell', 'am', 'force-stop', PACKAGE) adb('shell', 'wm', 'density', str(min(map(int, dims)) * 160 // width) if width else original['density']) for key, value in [('font_scale', font), ('accelerometer_rotation', '0'), ('user_rotation', rotation)]: adb('shell', 'settings', 'put', 'system', key, value) try: output = adb('shell', 'am', 'instrument', '-w', '-r', '-e', 'class', PACKAGE + '.IsolatedUiTest#' + name, PACKAGE + '/androidx.test.runner.AndroidJUnitRunner', timeout=120) if not passed(output): # Isolated fixtures contain no private data. Keep assertion diagnostics for repair. item["diagnostic"] = output[:2500] print(output[:2500] + ("\n[remaining diagnostics omitted]" if len(output) > 2500 else ""), flush=True) raise RuntimeError('Selected Compose scenario failed') item['result'] = 'passed' except Exception: item['result'] = 'failed'; raise print(json.dumps(item), flush=True) except Exception as e: failure = str(e) finally: if original: cleanup('stop-test', lambda: adb('shell', 'am', 'force-stop', PACKAGE)) for key, value in original.items(): if key != 'density': cleanup(key, lambda k=key, v=value: restore_setting(k, v)) def density_restore(): adb('shell', 'wm', 'density', original['density']) current = re.search(r'Override density: (\d+)', adb('shell', 'wm', 'density')) if (current[1] if current else 'reset') != original['density']: raise RuntimeError('Density restore failed') cleanup('density', density_restore) if started and process is not None and process.poll() is None: if verified_target: cleanup('stop-owned-emulator', lambda: adb('emu', 'kill')) else: process.terminate() if process is not None: try: process.wait(timeout=20) except subprocess.TimeoutExpired: process.terminate(); cleanup_errors.append('emulator-exit') report = {'tests': plan, 'failure': failure, 'cleanup_errors': cleanup_errors, 'not_selected': [s for s in SCENARIOS if s not in selected]} if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) with args.report.open('x', encoding='utf-8') as f: json.dump(report, f, ensure_ascii=False, indent=2) print(json.dumps(report, ensure_ascii=False), flush=True) return 1 if failure or cleanup_errors else 0 if __name__ == '__main__': raise SystemExit(main())