Improve controller behavior and update project requirements
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user