Move project test environments into the workspace
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user