Improve controller behavior and update project requirements

This commit is contained in:
2026-09-30 15:42:36 +08:00
parent 55374356ed
commit 071f06d365
52 changed files with 2205 additions and 335 deletions
@@ -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())