372 lines
19 KiB
Python
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())
|