Files

372 lines
19 KiB
Python

"""Rebuild project-owned Windows test environments; never connect to a device."""
from __future__ import annotations
import argparse
from contextlib import contextmanager
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
LOCAL = HERE / '.local'
CONFIG = LOCAL / 'tools.local.json'
PROFILES = ('ssh', 'image-editor', 'android')
IMAGE = HERE / 'android-image.json'
def run(command, *, env=None):
subprocess.run([str(p) for p in command], check=True, env=env)
def digest(path, algorithm='sha256'):
with path.open('rb') as stream:
return hashlib.file_digest(stream, algorithm).hexdigest()
def save_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def python_for(profile):
return LOCAL / profile / 'venv' / 'Scripts' / 'python.exe'
def installed(profile):
script = 'import importlib.metadata as m,json; print(json.dumps({d.metadata["Name"].lower().replace("_","-"):d.version for d in m.distributions()}))'
result = subprocess.run([str(python_for(profile)), '-c', script], check=True,
capture_output=True, text=True, encoding='utf-8')
return json.loads(result.stdout)
def validate_lock(profile):
source = ROOT / {
'ssh': '移动端相关内容/安卓app/如何安卓测试/requirements-host.txt',
'image-editor': '发布更新相关/镜像编辑器/编辑器源代码/requirements-build.txt',
}[profile]
lock = HERE / f'requirements-{profile}.lock.txt'
pins = {line.split(' --hash=', 1)[0].strip().lower().replace('_', '-')
for line in lock.read_text(encoding='utf-8').splitlines()
if line.strip() and not line.lstrip().startswith('#')}
for line in source.read_text(encoding='utf-8').splitlines():
requirement = line.split('#', 1)[0].strip()
if requirement and requirement.lower().replace('_', '-') not in pins:
raise RuntimeError(f'{profile}: source requirements changed; update full dependency lock and wheel hashes')
def check_python(profile):
validate_lock(profile)
python = python_for(profile)
if not python.is_file():
raise RuntimeError(f'{profile}: environment missing; run setup')
versions = installed(profile)
lock = HERE / f'requirements-{profile}.lock.txt'
for line in lock.read_text(encoding='utf-8').splitlines():
if line and not line.startswith('#'):
name, version = line.split(' --hash=', 1)[0].split('==')
if versions.get(name.lower().replace('_', '-')) != version:
raise RuntimeError(f'{profile}: dependency version mismatch: {name}')
# A copied venv can appear functional but launchers still point to the previous location.
expected = str((LOCAL / profile / 'venv').resolve())
cfg = (LOCAL / profile / 'venv' / 'pyvenv.cfg').read_text(encoding='utf-8')
if expected not in cfg:
raise RuntimeError(f'{profile}: copied or relocated venv; rename it and run setup again')
run([python, '-m', 'pip', 'check'])
print(f'{profile}: checked')
def setup_python(profile, offline):
validate_lock(profile)
folder = LOCAL / profile
venv = folder / 'venv'
lock = HERE / f'requirements-{profile}.lock.txt'
# Refuse to silently overwrite an existing broken/relocated environment.
if venv.exists():
cfg = venv / 'pyvenv.cfg'
if not python_for(profile).is_file() or not cfg.is_file() or str(venv.resolve()) not in cfg.read_text(encoding='utf-8'):
raise RuntimeError(f'{profile}: existing venv is invalid; preserve/rename it before rebuilding')
else:
run([sys.executable, '-m', 'venv', venv])
wheels = folder / 'wheels'
wheels.mkdir(parents=True, exist_ok=True)
if not offline:
print(f'{profile}: official PyPI wheels -> project-local cache; existing downloads/cache reused', flush=True)
run([sys.executable, '-m', 'pip', 'download', '--disable-pip-version-check',
'--index-url', 'https://pypi.org/simple', '--only-binary=:all:', '--require-hashes',
'--dest', wheels, '-r', lock])
run([python_for(profile), '-m', 'pip', 'install', '--disable-pip-version-check',
'--no-index', '--find-links', wheels, '--require-hashes', '-r', lock])
check_python(profile)
save_json(folder / 'wheel-sha256.json', {p.name: digest(p) for p in sorted(wheels.glob('*.whl'))})
def tools(args=None):
config = json.loads(CONFIG.read_text(encoding='utf-8')) if CONFIG.is_file() else {}
for name in ('adb', 'emulator', 'jdk', 'sdk', 'gradle'):
supplied = getattr(args, name, None) if args else None
if supplied:
config[name] = str(supplied.resolve())
# Prefer registered generic tools, then the normally installed Android Studio SDK.
home = Path.home()
sdk = Path(os.environ.get('LOCALAPPDATA', home / 'AppData/Local')) / 'Android/Sdk'
candidates = {
'adb': [home / '.codex-tools/bin/platform-tools/adb.exe', sdk / 'platform-tools/adb.exe'],
'emulator': [sdk / 'emulator/emulator.exe'],
'sdk': [sdk],
'jdk': [Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Android/Android Studio/jbr'],
'gradle': [home / '.codex-tools/bin/gradle-9.5.0/bin/gradle.bat'],
}
for name, paths in candidates.items():
if name not in config:
found = next((p for p in paths if p.exists()), None)
if found:
config[name] = str(found)
return config
def image_dir():
return LOCAL / 'android/sdk/system-images/android-35/google_apis/x86_64'
def ini_values(path):
return {key.strip(): value.strip() for key, value in
(line.split('=', 1) for line in path.read_text(encoding='utf-8').splitlines() if '=' in line)}
def android_environment():
env = os.environ.copy()
env['ANDROID_AVD_HOME'] = str(LOCAL / 'android/avd')
env['ANDROID_EMULATOR_HOME'] = str(LOCAL / 'android/emulator-home')
# The build SDK is a normally installed generic SDK; the test image is supplied via --sysdir.
config = tools()
if 'sdk' in config:
env['ANDROID_HOME'] = env['ANDROID_SDK_ROOT'] = config['sdk']
if 'jdk' in config:
env['JAVA_HOME'] = config['jdk']
return env
@contextmanager
def android_session():
"""Expose workspace-owned AVD/image through a temporary ASCII junction.
Emulator's native file access cannot read the Chinese workspace path on
this host. The junction contains no data and is removed without traversal.
"""
spec = json.loads(IMAGE.read_text(encoding='utf-8'))
name = spec['avd']
with tempfile.TemporaryDirectory(prefix='qms-local-emulator-') as temporary:
parent = Path(temporary)
if not str(parent).isascii():
raise RuntimeError('Emulator needs an ASCII temporary directory; set TEMP for this process')
alias = parent / 'environment'
environment = os.environ.copy()
environment.update(QMS_ALIAS_PATH=str(alias), QMS_ALIAS_TARGET=str(LOCAL.resolve()))
script = '$ErrorActionPreference="Stop"; New-Item -ItemType Junction -Path $env:QMS_ALIAS_PATH -Target $env:QMS_ALIAS_TARGET | Out-Null'
subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', script],
env=environment, check=True, capture_output=True)
session = LOCAL / 'android' / ('session-' + parent.name)
try:
session.mkdir()
avd = alias / 'android/avd' / (name + '.avd')
(session / (name + '.ini')).write_text(f'avd.ini.encoding=UTF-8\npath={avd}\ntarget=android-35\n', encoding='utf-8')
launch_env = android_environment()
launch_env['ANDROID_AVD_HOME'] = str(alias / 'android' / session.name)
launch_env['ANDROID_EMULATOR_HOME'] = str(alias / 'android/emulator-home')
launch_env['ANDROID_USER_HOME'] = str(alias / 'android/user-home')
(LOCAL / 'android/user-home').mkdir(exist_ok=True)
yield launch_env, alias / image_dir().relative_to(LOCAL), alias
finally:
# Emulator writes its effective --sysdir back to config.ini. Restore
# the durable workspace spelling before deleting the temporary alias.
config_file = LOCAL / 'android/avd' / (name + '.avd') / 'config.ini'
values = ini_values(config_file)
values['image.sysdir.1'] = str(image_dir().resolve())
config_file.write_text(''.join(f'{k}={v}\n' for k, v in values.items()), encoding='utf-8')
# Exact owned files only; never recursively delete via a junction.
index = session / (name + '.ini')
if index.is_file():
index.unlink()
if session.exists():
session.rmdir()
if alias.is_junction() and alias.resolve() == LOCAL.resolve():
alias.rmdir()
elif alias.exists():
raise RuntimeError('Temporary junction target changed; refusing cleanup')
def check_android():
config = tools()
for key, suffix in (('adb', ''), ('emulator', ''), ('jdk', 'bin/java.exe'), ('sdk', 'platforms')):
if key not in config or not (Path(config[key]) / suffix).exists():
raise RuntimeError(f'android: configure an installed generic {key} with setup --{key}')
directory = image_dir()
props = (directory / 'source.properties').read_text(encoding='utf-8')
for expected in ('Pkg.Revision=9', 'AndroidVersion.ApiLevel=35', 'SystemImage.Abi=x86_64', 'SystemImage.TagId=google_apis'):
if expected not in props.splitlines():
raise RuntimeError('android: image version mismatch')
for name in ('package.xml', 'system.img', 'vendor.img', 'kernel-ranchu', 'ramdisk.img'):
if not (directory / name).is_file():
raise RuntimeError(f'android: incomplete system image: {name}')
registration = ET.parse(directory / 'package.xml').getroot().find('localPackage')
spec = json.loads(IMAGE.read_text(encoding='utf-8'))
if registration is None or registration.get('path') != spec['package']:
raise RuntimeError('android: SDK package registration mismatch')
if registration.findtext('revision/major') != str(spec['revision']):
raise RuntimeError('android: SDK package revision mismatch')
name = json.loads(IMAGE.read_text(encoding='utf-8'))['avd']
avd_home = LOCAL / 'android/avd'
index = avd_home / f'{name}.ini'
avd = avd_home / f'{name}.avd'
if not index.is_file() or f'path={avd.resolve()}' not in index.read_text(encoding='utf-8').splitlines():
raise RuntimeError('android: stale AVD index; run setup android after copying the workspace')
if ini_values(avd / 'config.ini').get('image.sysdir.1') != str(directory.resolve()):
raise RuntimeError('android: stale AVD system image path; run setup android')
result = subprocess.run([config['emulator'], '-list-avds'], env=android_environment(),
check=True, capture_output=True, text=True, encoding='utf-8')
if name not in result.stdout.splitlines():
raise RuntimeError('android: Emulator cannot discover project AVD')
print('android: image package and project AVD checked; no device accessed')
def setup_android(args):
config = tools(args)
save_json(CONFIG, config)
spec = json.loads(IMAGE.read_text(encoding='utf-8'))
directory = image_dir()
if not directory.exists():
archive = args.image_archive
if archive is None:
if args.offline:
raise RuntimeError('android: missing image; provide --image-archive with the pinned official ZIP')
archive = LOCAL / 'work/downloads' / Path(spec['url']).name
archive.parent.mkdir(parents=True, exist_ok=True)
if not archive.exists():
print(f'Missing API 35 revision 9 image: downloading official Google ZIP from {spec["url"]}\n'
'Destination: project .local/work/downloads; no existing file will be overwritten.', flush=True)
partial = archive.with_suffix('.partial')
# A failed download is retried only by a subsequent explicit setup invocation.
with urllib.request.urlopen(spec['url'], timeout=60) as source, partial.open('xb') as out:
shutil.copyfileobj(source, out)
partial.rename(archive)
if digest(archive, 'sha1') != spec['sha1']:
raise RuntimeError('android: official archive checksum mismatch')
save_json(LOCAL / 'android/archive-sha256.json', {'file': archive.name, 'sha256': digest(archive)})
staging = LOCAL / 'android/image-staging'
if staging.exists():
raise RuntimeError('android: previous staging exists; inspect it before retrying')
staging.mkdir(parents=True)
with zipfile.ZipFile(archive) as bundle:
for member in bundle.infolist():
target = (staging / member.filename).resolve()
if not target.is_relative_to(staging.resolve()):
raise RuntimeError('android: unsafe archive member')
bundle.extractall(staging)
candidates = list(staging.glob('*/source.properties'))
if len(candidates) != 1:
raise RuntimeError('android: unexpected official archive layout')
extracted = candidates[0].parent
# ZIPs may omit SDK Manager's local registration. Preserve the pinned official descriptor.
if not (extracted / 'package.xml').exists():
shutil.copyfile(HERE / 'android-package.xml', extracted / 'package.xml')
directory.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(extracted), directory)
staging.rmdir()
avd_home = LOCAL / 'android/avd'
avd = avd_home / (spec['avd'] + '.avd')
index = avd_home / (spec['avd'] + '.ini')
if avd.exists() and not (avd / '.qms-managed').is_file():
raise RuntimeError('android: refusing to overwrite an unmanaged AVD')
if index.exists() and not avd.exists():
raise RuntimeError('android: orphan index exists; preserve it before setup')
avd.mkdir(parents=True, exist_ok=True)
(avd / '.qms-managed').touch()
values = {
'avd.ini.encoding': 'UTF-8', 'AvdId': spec['avd'], 'avd.ini.displayname': spec['avd'],
'hw.cpu.arch': 'x86_64', 'hw.cpu.ncore': '2', 'hw.ramSize': '2048',
'hw.lcd.width': '1080', 'hw.lcd.height': '1920', 'hw.lcd.density': '420',
'hw.gpu.enabled': 'yes', 'hw.gpu.mode': 'auto', 'hw.keyboard': 'yes',
'hw.camera.back': 'none', 'hw.camera.front': 'none', 'hw.audioInput': 'no',
'disk.dataPartition.size': '2G', 'image.sysdir.1': str(directory.resolve()),
'tag.id': 'google_apis', 'tag.display': 'Google APIs', 'abi.type': 'x86_64',
'PlayStore.enabled': 'false', 'fastboot.forceColdBoot': 'yes',
}
# On copied workspaces refresh only location keys; preserve user changes and userdata.
cfg = avd / 'config.ini'
if cfg.is_file():
values = ini_values(cfg)
values['image.sysdir.1'] = str(directory.resolve())
cfg.write_text(''.join(f'{k}={v}\n' for k, v in values.items()), encoding='utf-8')
index.write_text(f'avd.ini.encoding=UTF-8\npath={avd.resolve()}\ntarget=android-35\n', encoding='utf-8')
(LOCAL / 'android/emulator-home').mkdir(exist_ok=True)
check_android()
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=('setup', 'check', 'build-ui', 'ui-smoke'))
parser.add_argument('profile', choices=PROFILES)
parser.add_argument('--offline', action='store_true')
parser.add_argument('--image-archive', type=Path)
for name in ('adb', 'emulator', 'jdk', 'sdk', 'gradle'):
parser.add_argument('--' + name, type=Path)
parser.add_argument('--build', type=Path, help='Existing isolated build containing uiTest-debug.apk; ui-smoke only')
parser.add_argument('--report', type=Path, help='New result JSON; ui-smoke only')
args = parser.parse_args(argv)
try:
if os.name != 'nt':
raise RuntimeError('These pinned environments target Windows x64; use the platform-specific build instructions elsewhere')
if args.profile != 'android' and sys.version_info[:2] != (3, 14):
raise RuntimeError('Pinned Windows wheels require CPython 3.14 x64')
if args.action == 'build-ui':
if args.profile != 'android':
parser.error('build-ui requires android')
check_android()
config = tools()
build_root = LOCAL / 'work/android-build'
build_root.mkdir(parents=True, exist_ok=True)
before = set(build_root.glob('qms-build-*'))
builder = ROOT / '移动端相关内容/安卓app/构建与发布/build_android.py'
with android_session() as (launch_env, _, alias):
command = [sys.executable, builder, '--work-root', alias / 'work/android-build',
'--jdk', config['jdk'], '--sdk', config['sdk']]
if config.get('gradle'):
command += ['--gradle', config['gradle']]
run([*command, ':uiTest:assembleDebug'], env=launch_env)
for output in sorted(set(build_root.glob('qms-build-*')) - before):
print(f'Project-local build: {output.relative_to(ROOT)}')
elif args.action == 'ui-smoke':
if args.profile != 'android' or not args.build or not args.report:
parser.error('ui-smoke android requires --build and --report')
check_android()
config = tools()
name = json.loads(IMAGE.read_text(encoding='utf-8'))['avd']
runner = ROOT / '移动端相关内容/安卓app/如何安卓测试/run_emulator_ui_test.py'
with android_session() as (launch_env, system_image, _):
run([sys.executable, runner, '--scenario', 'settings', '--avd', name,
'--adb', config['adb'], '--emulator', config['emulator'], '--sysdir', system_image,
'--build', args.build.resolve(), '--report', args.report.resolve()], env=launch_env)
elif args.profile == 'android':
setup_android(args) if args.action == 'setup' else check_android()
elif args.action == 'setup':
setup_python(args.profile, args.offline)
else:
check_python(args.profile)
return 0
except (OSError, RuntimeError, subprocess.CalledProcessError, ValueError) as error:
print(f'Local environment failed: {error}', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())