Files
laily adaa8e4edd feat: add test-preflight runner and deployer prepare adapters
agent-runtime executes fixed tester node operations over JSON stdin.
deployer prepare brings up the local test Compose service the same way.
test-preflight now passes the tester binding; tester select skips
unauthorized cases before creating a runner.
2026-10-19 09:28:50 +08:00

565 lines
28 KiB
Python
Executable File

#!/usr/bin/env python3
"""Provision caller-provided mobile artifacts for tester.
This is shared tester infrastructure. It never replaces real Android/iOS
execution with HTTP calls: it only obtains, verifies and installs the
artifact the caller supplied, then the catalog runner drives the mobile
session.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import os
import plistlib
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from platform import system as platform_system
from typing import Any
COMMIT = re.compile(r"^[0-9a-fA-F]{40}$")
PLATFORMS = {"android", "ios"}
PREFLIGHT_ROOT = Path(__file__).resolve().parents[2] / "test-preflight" / "scripts" / "preflight.py"
class EnvironmentFailure(RuntimeError):
"""An external environment or recoverable provisioning failure."""
class ArtifactFailure(RuntimeError):
"""A missing or unusable caller-provided artifact locator."""
@dataclass
class CommandResult:
code: int
output: str
@dataclass
class RuntimeContext:
root: Path
retries: dict[str, int] = field(default_factory=lambda: {"download": 3, "start": 3, "install": 3})
evidence: list[dict[str, Any]] = field(default_factory=list)
def record(self, command: str, result: CommandResult) -> None:
self.evidence.append({
"command": command,
"status": "ok" if result.code == 0 else "error",
"output": result.output[-800:],
})
def redact_url(value: str) -> str:
parsed = urllib.parse.urlsplit(value)
if not parsed.query:
return value
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "<redacted>", ""))
def run_command(args: list[str], *, context: RuntimeContext | None = None, cwd: Path | None = None) -> CommandResult:
try:
proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
result = CommandResult(proc.returncode, proc.stdout.strip())
except OSError as exc:
result = CommandResult(127, str(exc))
if context:
context.record(" ".join(args), result)
return result
def fetch_bytes(url: str, *, attempts: int = 3, token: str = "") -> bytes:
if url.startswith("file://"):
return Path(urllib.parse.urlsplit(url).path).read_bytes()
if not urllib.parse.urlsplit(url).scheme:
return Path(url).read_bytes()
last: Exception | None = None
for attempt in range(1, attempts + 1):
try:
headers = {"User-Agent": "pouch-tester/1"}
if token:
headers["Authorization"] = f"token {token}"
request = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(request, timeout=30) as response:
return response.read()
except (OSError, urllib.error.URLError) as exc:
last = exc
if attempt < attempts:
time.sleep(min(attempt, 2))
raise EnvironmentFailure(f"下载失败({attempts} 次): {redact_url(url)}: {last}")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def parse_artifacts(values: list[str]) -> dict[str, str]:
pairs: dict[str, str] = {}
for raw in values:
platform, sep, value = raw.partition("=")
platform = platform.strip().lower()
value = value.strip()
if not sep or platform not in PLATFORMS or not value:
raise ArtifactFailure(f"--artifact 格式必须是 android=<路径或 URL> 或 ios=<路径或 URL>: {raw}")
if platform in pairs:
raise ArtifactFailure(f"--artifact 重复平台: {platform}")
pairs[platform] = value
return pairs
def local_path(locator: str) -> Path | None:
parsed = urllib.parse.urlsplit(locator)
if parsed.scheme in ("http", "https"):
return None
if parsed.scheme == "file":
return Path(parsed.path)
if parsed.scheme:
raise ArtifactFailure(f"不支持的产物地址 scheme: {parsed.scheme}")
return Path(locator).expanduser()
def obtain_artifact(platform: str, locator: str, destination: Path, context: RuntimeContext) -> dict[str, Any]:
path = local_path(locator)
if path is not None:
if not path.is_file():
raise ArtifactFailure(f"{platform} 产物不是现有文件: {path}")
resolved = path.resolve()
return {
"path": str(resolved),
"locator": str(resolved),
"sha256": sha256_file(resolved),
"attempts": 1,
"source": "file",
"verified": True,
}
last: Exception | None = None
for attempt in range(1, context.retries["download"] + 1):
try:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(fetch_bytes(locator, attempts=1, token=os.environ.get("GITEA_TOKEN", "")))
return {
"path": str(destination),
"locator": redact_url(locator),
"sha256": sha256_file(destination),
"attempts": attempt,
"source": "url",
"verified": True,
}
except (OSError, EnvironmentFailure) as exc:
last = exc
if attempt < context.retries["download"]:
time.sleep(min(attempt, 2))
raise EnvironmentFailure(f"{platform} 下载失败({context.retries['download']} 次): {last}")
def catalog_identities(root: Path) -> dict[str, str]:
path = root / ".pouch" / "tester" / "catalog.yaml"
if not path.is_file():
return {}
try:
import yaml
except ImportError:
return {}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
return {}
artifacts = data.get("artifacts")
if not isinstance(artifacts, dict):
return {}
identities: dict[str, str] = {}
android = artifacts.get("android")
ios = artifacts.get("ios")
if isinstance(android, dict) and isinstance(android.get("package"), str) and android["package"]:
identities["android"] = android["package"]
if isinstance(ios, dict) and isinstance(ios.get("bundle_id"), str) and ios["bundle_id"]:
identities["ios"] = ios["bundle_id"]
return identities
def android_identity(path: Path, context: RuntimeContext) -> str:
commands: list[list[str]] = []
if shutil.which("apkanalyzer"):
commands.append(["apkanalyzer", "manifest", "application-id", str(path)])
if shutil.which("aapt2"):
commands.append(["aapt2", "dump", "badging", str(path)])
if shutil.which("aapt"):
commands.append(["aapt", "dump", "badging", str(path)])
if not commands:
raise EnvironmentFailure("缺少 apkanalyzer/aapt2/aapt;请在 Android tester 节点配置 Android SDK build-tools")
for command in commands:
result = run_command(command, context=context)
if result.code == 0:
plain = result.output.strip()
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_.-]*", plain):
return plain
match = re.search(r"(?:name|application-id)[=:]['\"]?([A-Za-z0-9_.]+)", result.output)
if match:
return match.group(1)
raise EnvironmentFailure("APK 身份读取失败;请检查 Android SDK build-tools")
def ios_identity(path: Path) -> str:
try:
with zipfile.ZipFile(path) as archive:
candidates = [name for name in archive.namelist() if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)]
if len(candidates) != 1:
raise EnvironmentFailure("IPA 中没有唯一 Payload/*.app/Info.plist")
plist = plistlib.loads(archive.read(candidates[0]))
except (OSError, zipfile.BadZipFile, plistlib.InvalidFileException) as exc:
raise EnvironmentFailure(f"IPA 解包/读取 Info.plist 失败: {exc}") from exc
bundle_id = plist.get("CFBundleIdentifier")
if not isinstance(bundle_id, str) or not bundle_id:
raise EnvironmentFailure("IPA Info.plist 缺少 CFBundleIdentifier")
return bundle_id
def retry_command(args: list[str], *, context: RuntimeContext, action: str) -> CommandResult:
last = CommandResult(1, "")
for attempt in range(1, context.retries[action] + 1):
last = run_command(args, context=context)
if last.code == 0:
return last
if attempt < context.retries[action]:
time.sleep(min(attempt, 2))
raise EnvironmentFailure(f"{action} 失败({context.retries[action]} 次): {' '.join(args)}\n{last.output[-500:]}")
def android_preflight(context: RuntimeContext) -> dict[str, str]:
adb = os.environ.get("ADB_BIN") or shutil.which("adb")
if not adb:
raise EnvironmentFailure("Android 节点缺少 adb;请安装/配置 Android SDK platform-tools")
retry_command([adb, "start-server"], context=context, action="start")
devices = run_command([adb, "devices", "-l"], context=context)
available = [line.split()[0] for line in devices.output.splitlines()[1:] if re.search(r"\sdevice\s", line)]
serial = os.environ.get("ANDROID_SERIAL", "").strip()
if serial and serial not in available:
raise EnvironmentFailure(f"ANDROID_SERIAL={serial} 不在 device 状态;请解锁/授权设备后检查 adb devices -l")
if not serial and len(available) != 1:
if not available:
raise EnvironmentFailure("没有 Android device;请连接、解锁、开启 USB 调试并接受 RSA 授权")
raise EnvironmentFailure(f"检测到多个 Android device ({', '.join(available)});请设置 ANDROID_SERIAL")
return {"adb": adb, "serial": serial or available[0], "session_lifecycle": "reused-or-started"}
def ios_preflight(context: RuntimeContext) -> dict[str, str]:
if platform_system() != "Darwin":
raise EnvironmentFailure("iOS 必须由 tester.ios 的 macOS 节点执行;Linux 不能伪装 Simulator")
xcrun = shutil.which("xcrun")
if not xcrun:
raise EnvironmentFailure("iOS 节点缺少 xcrun;请安装 Xcode Command Line Tools")
devices = run_command([xcrun, "simctl", "list", "devices", "available"], context=context)
if devices.code != 0:
raise EnvironmentFailure("simctl 不可用;请安装 Xcode 并确认已接受许可")
wanted = os.environ.get("IOS_SIMULATOR_UDID", "").strip()
matches: list[tuple[str, str, str]] = []
for line in devices.output.splitlines():
match = re.search(r"^\s*(.+?)\s+\(([0-9A-Fa-f-]{36})\)\s+\((Booted|Shutdown)\)", line)
if match:
matches.append((match.group(2), match.group(1), match.group(3)))
selected = next((item for item in matches if item[0] == wanted), None) if wanted else None
selected = selected or next((item for item in matches if item[2] == "Booted"), None) or (matches[0] if matches else None)
if not selected:
raise EnvironmentFailure("没有可用 iOS Simulator;请在 Mac 上安装一个可用 runtime/device")
udid, name, state = selected
if state != "Booted":
retry_command([xcrun, "simctl", "boot", udid], context=context, action="start")
retry_command([xcrun, "simctl", "bootstatus", udid, "-b"], context=context, action="start")
return {"xcrun": xcrun, "udid": udid, "name": name, "session_lifecycle": "reused" if state == "Booted" else "started"}
def check_network(context: RuntimeContext, node: dict[str, str] | None = None) -> None:
host = os.environ.get("HOST", "").strip()
api = os.environ.get("API_BASE", "").strip() or (f"http://{host}" if host else "")
if not api:
raise EnvironmentFailure("缺少 HOST/API_BASE;请在 tester.local.yaml 配置测试服务地址")
for attempt in range(1, context.retries["start"] + 1):
try:
with urllib.request.urlopen(urllib.request.Request(api.rstrip("/") + "/v1/me"), timeout=5) as response:
if response.status < 500:
break
except urllib.error.HTTPError as exc:
if exc.code < 500:
break
except (OSError, urllib.error.URLError):
pass
if attempt == context.retries["start"]:
raise EnvironmentFailure(f"测试服务不可达: {api};请检查 HOST、VPN、DNS 和局域网后重跑")
time.sleep(min(attempt, 2))
if node and "adb" in node:
if shutil.which("ping"):
result = run_command([node["adb"], "-s", node["serial"], "shell", "ping", "-c", "1", "-W", "3", host], context=context)
if result.code != 0:
raise EnvironmentFailure(f"Android device 无法访问 HOST={host};请检查设备 Wi-Fi/DNS/VPN")
elif node:
result = run_command([node["xcrun"], "simctl", "spawn", node["udid"], "/usr/bin/curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", api.rstrip("/") + "/v1/me"], context=context)
if result.code != 0 or result.output.strip() in {"", "000"} or result.output.strip().startswith("5"):
raise EnvironmentFailure(f"iOS Simulator 无法访问 {api};请检查 Mac 的 VPN/DNS/局域网权限")
def install_android(node: dict[str, str], path: Path, package: str, context: RuntimeContext) -> dict[str, Any]:
retry_command([node["adb"], "-s", node["serial"], "install", "-r", str(path)], context=context, action="install")
result = run_command([node["adb"], "-s", node["serial"], "shell", "pm", "path", package], context=context)
if result.code != 0 or "package:" not in result.output:
raise EnvironmentFailure(f"安装后未找到 Android package {package};请检查签名/设备空间")
return {"serial": node["serial"], "package": package, "installed": True, "verified": True}
def install_ios(node: dict[str, str], path: Path, bundle_id: str, context: RuntimeContext) -> dict[str, Any]:
retry_command([node["xcrun"], "simctl", "install", node["udid"], str(path)], context=context, action="install")
result = run_command([node["xcrun"], "simctl", "get_app_container", node["udid"], bundle_id], context=context)
if result.code != 0 or not result.output:
raise EnvironmentFailure(f"安装后未找到 iOS bundle {bundle_id};请检查签名/Simulator 架构")
return {"udid": node["udid"], "simulator": node["name"], "bundle_id": bundle_id, "installed": True, "verified": True}
def sanitize(value: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", value)
def resolve_commit(explicit: str, context: RuntimeContext) -> str:
if explicit:
if not COMMIT.fullmatch(explicit):
raise ArtifactFailure("--commit 必须是 40 位 commit SHA")
return explicit
git = shutil.which("git") or "git"
result = run_command([git, "rev-parse", "HEAD"], context=context, cwd=context.root)
if result.code != 0 or not COMMIT.fullmatch(result.output):
raise EnvironmentFailure(f"无法读取当前 commit: {context.root}")
return result.output
def ensure_worktree(commit: str, context: RuntimeContext, parent: Path | None = None) -> tuple[Path, str]:
git = shutil.which("git") or "git"
check = run_command([git, "rev-parse", "--show-toplevel"], context=context, cwd=context.root)
if check.code != 0:
raise EnvironmentFailure(f"目标目录不是 Git worktree: {context.root}")
repo = Path(check.output).resolve()
verify = run_command([git, "rev-parse", f"{commit}^{{commit}}"], context=context, cwd=repo)
if verify.code != 0:
fetched = run_command([git, "fetch", "--no-tags", "origin", commit], context=context, cwd=repo)
if fetched.code != 0:
raise EnvironmentFailure(f"目标 commit 不在本地且无法从 origin 获取: {commit}")
base = parent or Path(os.environ.get("TESTER_WORKTREE_ROOT", Path(tempfile.gettempdir()) / "pouch-tester" / "worktrees"))
base.mkdir(parents=True, exist_ok=True)
token = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + os.urandom(3).hex()
path = (base / f"{sanitize(commit[:12])}-{token}").resolve()
if path.exists():
raise EnvironmentFailure(f"隔离 worktree 路径已存在,拒绝覆盖: {path}")
added = run_command([git, "worktree", "add", "--detach", str(path), commit], context=context, cwd=repo)
if added.code != 0:
raise EnvironmentFailure(f"创建精确 commit worktree 失败: {path}\n{added.output[-500:]}")
actual = run_command([git, "-C", str(path), "rev-parse", "HEAD"], context=context)
if actual.code != 0 or actual.output.lower() != commit.lower():
raise EnvironmentFailure(f"worktree commit 不匹配: expected={commit} actual={actual.output}")
return path, actual.output
def platforms(raw: str) -> list[str]:
values = [item.strip().lower() for item in raw.split(",") if item.strip()]
if not values:
return ["android", "ios"]
if any(item not in PLATFORMS for item in values) or len(values) != len(set(values)):
raise ArtifactFailure("--platform 只能是 android,ios 且不能重复")
return values
def write_run(root: Path, record: dict[str, Any]) -> Path:
runs = root / ".pouch" / "tester" / "runs"
runs.mkdir(parents=True, exist_ok=True)
run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-bundle-" + os.urandom(3).hex()
path = runs / f"{run_id}.yaml"
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def run_test_preflight(
config_path: Path,
*,
mode: str = "verify",
yaml_path: Path | None = None,
csv_path: Path | None = None,
) -> dict[str, Any]:
"""Run the shared preflight contract before any client session work."""
if mode not in {"verify", "prepare"}:
raise ArtifactFailure("preflight mode 只能是 verify 或 prepare")
if not PREFLIGHT_ROOT.is_file():
raise EnvironmentFailure(f"找不到 test-preflight skill: {PREFLIGHT_ROOT}")
spec = importlib.util.spec_from_file_location("pouch_test_preflight", PREFLIGHT_ROOT)
if spec is None or spec.loader is None:
raise EnvironmentFailure("无法加载 test-preflight skill")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
try:
config = module.load_config(config_path)
evidence = module.run(config, mode)
yaml_path = yaml_path or config_path.with_name("preflight.yaml")
csv_path = csv_path or config_path.with_name("preflight.csv")
return module.write_evidence(evidence, yaml_path, csv_path)
except (OSError, ValueError, module.yaml.YAMLError) as exc:
return {"status": "blocked", "error": str(exc), "yaml": str(yaml_path or ""), "csv": str(csv_path or "")}
def execute(args: argparse.Namespace) -> int:
requested = platforms(args.platform)
context = RuntimeContext(Path(args.root).resolve())
record: dict[str, Any] = {
"schema_version": 1,
"started_at": datetime.now(timezone.utc).isoformat(),
"platforms_requested": requested,
"partial": bool(args.partial),
"binding": args.binding,
"node": os.environ.get("TESTER_NODE", "local"),
"terminal": {"handle": os.environ.get("ORCA_TERMINAL_HANDLE"), "created": os.environ.get("TESTER_TERMINAL_CREATED", "false").lower() == "true", "closed": os.environ.get("TESTER_TERMINAL_CLOSED", "unknown")},
"product_test": {"status": "not-run", "failure_class": "automatic-repair-failed", "note": "准备完成后由 tester catalog 驱动真实移动 session;本工具不以 API 替代移动测试"},
"platforms": {},
}
try:
locators = parse_artifacts(args.artifacts)
missing = [platform for platform in requested if platform not in locators]
extra = [platform for platform in locators if platform not in requested]
if missing:
raise ArtifactFailure("缺少调用方产物位置: " + ", ".join(f"--artifact {item}=<路径或 URL>" for item in missing))
if extra:
raise ArtifactFailure("产物平台超出 --platform: " + ", ".join(extra))
record["artifacts_requested"] = {platform: redact_url(locator) if local_path(locator) is None else locator for platform, locator in locators.items()}
commit = resolve_commit(args.commit, context)
worktree, actual_commit = ensure_worktree(commit, context, Path(args.worktree_root).resolve() if args.worktree_root else None)
record["worktree"] = {"path": str(worktree), "expected_commit": commit, "actual_commit": actual_commit, "isolated": True}
artifact_dir = Path(tempfile.mkdtemp(prefix="pouch-tester-artifacts-"))
record["artifact_dir"] = str(artifact_dir)
expected = catalog_identities(context.root)
prepared: dict[str, tuple[dict[str, Any], dict[str, str] | None, str]] = {}
failures: dict[str, str] = {}
for platform in requested:
result: dict[str, Any] = {"node": "tester.android" if platform == "android" else "tester.ios", "status": "preparing"}
record["platforms"][platform] = result
try:
path = artifact_dir / ("app.apk" if platform == "android" else "app.ipa")
result["artifact"] = obtain_artifact(platform, locators[platform], path, context)
identity = android_identity(Path(result["artifact"]["path"]), context) if platform == "android" else ios_identity(Path(result["artifact"]["path"]))
wanted = expected.get(platform)
if wanted and identity != wanted:
raise EnvironmentFailure(f"{platform} 身份不匹配: expected={wanted} actual={identity}")
result.update({"identity": identity, "status": "artifact-ready"})
prepared[platform] = (result, None, identity)
except (EnvironmentFailure, ArtifactFailure) as exc:
failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
# This is the hard gate shared by integration and journey callers.
# There is no safe implicit matrix: without a caller-supplied config we
# cannot prove HOST, fixtures, tester bindings, or node reachability.
preflight_config = Path(args.preflight_config).expanduser() if args.preflight_config else None
if not failures:
if preflight_config is None:
preflight_result = {
"status": "blocked",
"error": "缺少 --preflight-config;不能跳过 test-preflight",
}
else:
preflight_result = run_test_preflight(
preflight_config,
mode=args.preflight_mode,
yaml_path=Path(args.preflight_yaml).expanduser() if args.preflight_yaml else None,
csv_path=Path(args.preflight_csv).expanduser() if args.preflight_csv else None,
)
record["preflight"] = preflight_result
if preflight_result.get("status") != "ready":
failures["preflight"] = str(preflight_result.get("error") or preflight_result.get("status"))
if not failures and args.preflight_mode == "prepare":
# test-preflight prepare owns platform probing, installation and
# launch; do not install/start the same artifact a second time.
for result, _, _ in prepared.values():
result.update({"status": "preflight-prepared"})
elif not failures:
for platform in requested:
result, _, identity = prepared[platform]
try:
node = android_preflight(context) if platform == "android" else ios_preflight(context)
check_network(context, node)
result.update({"node_preflight": node, "status": "preflight-passed"})
prepared[platform] = (result, node, identity)
except EnvironmentFailure as exc:
failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
if failures and not args.partial:
for platform in requested:
if platform not in failures:
record["platforms"][platform].update({"status": "not-run", "reason": "全量 preflight 闸门被另一平台阻断"})
overall = record.get("preflight", {}).get("status") if "preflight" in record else "environment-fail"
record.update({"status": overall if overall in {"blocked", "environment-fail"} else "environment-fail", "failure_class": "test-preflight" if "preflight" in record else "automatic-repair-failed", "next_action": "修复列出的平台环境后重跑;默认不会静默降级"})
else:
for platform, (result, node, identity) in prepared.items():
try:
installed = (
{"delegated": "test-preflight.prepare"}
if args.preflight_mode == "prepare"
else install_android(node, Path(result["artifact"]["path"]), identity, context)
if platform == "android"
else install_ios(node, Path(result["artifact"]["path"]), identity, context)
)
result.update({"status": "ready", "install": installed, "failure_class": None})
except EnvironmentFailure as exc:
failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
record.update({"status": "environment-fail" if failures else "ready", "failure_class": "automatic-repair-failed" if failures else None})
if not failures:
record["product_test"].update({"status": "pending", "failure_class": None})
record["evidence"] = context.evidence
except (EnvironmentFailure, ArtifactFailure) as exc:
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc), "evidence": context.evidence})
path = write_run(context.root, record)
output = {"status": record["status"], "run": str(path), "platforms": record["platforms"], "worktree": record.get("worktree")}
if record.get("error"):
output["error"] = record["error"]
if record.get("next_action"):
output["next_action"] = record["next_action"]
print(json.dumps(output, ensure_ascii=False, indent=2), file=sys.stderr if record["status"] != "ready" else sys.stdout)
return 0 if record["status"] == "ready" else 1
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(prog="tester", description="Obtain and provision caller-provided mobile artifacts")
sub = root.add_subparsers(dest="command", required=True)
run = sub.add_parser("run")
run.add_argument("--artifact", action="append", dest="artifacts", default=[], metavar="PLATFORM=PATH_OR_URL", help="调用方产物,可重复,例如 android=dist/app.apk 或 ios=https://...")
run.add_argument("--platform", default="android,ios")
run.add_argument("--commit", default="", help="本轮隔离 worktree 的 40 位 commit;缺省使用当前 HEAD")
run.add_argument("--partial", action="store_true")
run.add_argument("--root", default=".")
run.add_argument("--worktree-root", default="")
run.add_argument("--binding", default="")
run.add_argument("--preflight-config", default="", help="test-preflight matrix YAML(必填,不得跳过)")
run.add_argument("--preflight-mode", choices=("verify", "prepare"), default="verify")
run.add_argument("--preflight-yaml", default="", help="preflight YAML 证据输出路径")
run.add_argument("--preflight-csv", default="", help="preflight CSV 证据输出路径")
run.set_defaults(func=execute)
return root
if __name__ == "__main__":
parsed = parser().parse_args()
raise SystemExit(parsed.func(parsed))