153 lines
8.7 KiB
Python
153 lines
8.7 KiB
Python
"""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())
|