#!/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 bindings: dict[str, str] = field(default_factory=dict) 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, "", "")) 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 parse_bindings(values: list[str], requested: list[str]) -> dict[str, str]: """Resolve one tester binding per requested platform. A single legacy ``agent`` mapping cannot safely represent a multi-platform run. Keep the platform distinction in the command contract all the way to agent-runtime instead of treating binding as report metadata. """ 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 or not re.fullmatch(r"[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+", value): raise ArtifactFailure(f"--binding 格式必须是 android=tester. 或 ios=tester.: {raw}") if platform in pairs: raise ArtifactFailure(f"--binding 重复平台: {platform}") pairs[platform] = value resolved = {platform: pairs.get(platform, f"tester.{platform}") for platform in requested} if len(set(resolved.values())) != len(resolved) and len(resolved) > 1: raise ArtifactFailure("多平台运行不能复用同一个 tester binding;请分别配置 tester.android/tester.ios") return resolved def load_agent_runtime(): candidates = [ Path.home() / ".codex" / "skills" / "agent-runtime" / "scripts" / "agent.py", Path.home() / ".pouch" / "skills" / "agent-runtime" / "scripts" / "agent.py", Path(__file__).resolve().parents[2] / "agent-runtime" / "scripts" / "agent.py", ] runtime_path = next((item for item in candidates if item.is_file()), None) if runtime_path is None: raise EnvironmentFailure("找不到共享 agent-runtime: " + ", ".join(str(item) for item in candidates)) spec = importlib.util.spec_from_file_location("pouch_agent_runtime_for_mobile_bundle", runtime_path) if spec is None or spec.loader is None: raise EnvironmentFailure(f"无法加载共享 agent-runtime: {runtime_path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def binding_location(root: Path, binding_id: str) -> dict[str, Any]: runtime = load_agent_runtime() try: value = runtime.binding(runtime.load_config(root), binding_id) except (ValueError, OSError) as exc: raise EnvironmentFailure(f"无法解析 {binding_id}: {exc}") from exc if value.get("adapter") != "tester": raise EnvironmentFailure(f"{binding_id} 不是 tester binding") return value def agent_runner(root: Path, request: dict[str, Any]) -> dict[str, Any]: """Dispatch a platform operation through the resolved tester binding.""" runtime = load_agent_runtime() return runtime.execute_runner_request(root, request) def platform_node(root: Path, binding_id: str) -> str: location = binding_location(root, binding_id)["location"] return "local" if location.get("environment") == "local" else str(location.get("environment")) def validate_preflight_matrix(root: Path, config_path: Path, bindings: dict[str, str], requested: list[str]) -> None: """Fail closed when a matrix routes a platform to the wrong node/runner.""" try: import yaml raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except (OSError, ValueError, ImportError) as exc: raise EnvironmentFailure(f"无法读取 preflight matrix: {config_path}: {exc}") from exc rows = raw.get("matrix") if isinstance(raw, dict) else None if not isinstance(rows, list): raise EnvironmentFailure("preflight matrix 缺少 matrix;不能推断平台执行节点") by_platform = {str(row.get("platform", "")).lower(): row for row in rows if isinstance(row, dict)} for platform in requested: expected_binding = bindings[platform] row = by_platform.get(platform) if not row: raise EnvironmentFailure(f"preflight matrix 缺少 {platform} 行;不能创建该平台 session") tester = row.get("tester") if isinstance(row, dict) else None actual_binding = tester.get("binding") if isinstance(tester, dict) else None if actual_binding != expected_binding: raise EnvironmentFailure(f"{platform} preflight binding={actual_binding!r},期望 {expected_binding!r}") expected_node = platform_node(root, expected_binding) actual_node = tester.get("node") if isinstance(tester, dict) else None if expected_node == "local" and actual_node not in {"local", "localhost", "127.0.0.1"}: raise EnvironmentFailure(f"{platform} binding 是 local,但 preflight node={actual_node!r}") runner = tester.get("runner") if isinstance(tester, dict) else None if expected_node != "local" and (not isinstance(runner, dict) or runner.get("kind") != "contract"): raise EnvironmentFailure(f"{platform} 绑定到 {expected_node},必须使用 contract runner;禁止在协调节点执行") def preflight_remote_artifacts(config_path: Path, requested: list[str]) -> dict[str, str]: """Return caller-declared artifact paths readable by remote tester nodes.""" try: import yaml raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except (OSError, ValueError, ImportError) as exc: raise EnvironmentFailure(f"无法读取 preflight matrix: {config_path}: {exc}") from exc rows = raw.get("matrix") if isinstance(raw, dict) else None if not isinstance(rows, list): raise EnvironmentFailure("preflight matrix 缺少 matrix;不能解析远程产物路径") by_platform = {str(row.get("platform", "")).lower(): row for row in rows if isinstance(row, dict)} paths: dict[str, str] = {} for platform in requested: row = by_platform.get(platform) or {} artifact = row.get("artifact") if isinstance(row, dict) else None remote_path = artifact.get("remote_path") if isinstance(artifact, dict) else None if isinstance(remote_path, str) and remote_path.strip(): paths[platform] = remote_path.strip() return paths 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: match = re.search(r"(?:name|application-id)[=:]['\"]?([A-Za-z0-9_.]+)", result.output) if not match: # apkanalyzer manifest application-id prints the bare package # name, while aapt/aapt2 print a package: name=... record. match = re.fullmatch(r"\s*([A-Za-z0-9_.]+)\s*", 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 all_active_result_rows(root: Path, *, blocked_by: str) -> list[dict[str, Any]]: """Keep a testcase row for every active catalog entry when preflight blocks.""" try: from generate_cases_csv import load_cases cases = load_cases(root) except (ImportError, OSError, ValueError, SystemExit): cases = [] rows: list[dict[str, Any]] = [] for case in cases: if case.get("lifecycle", "active") != "active": continue rows.append({ "id": str(case.get("id") or ""), "result": "not-run", "notes": "test-preflight 未通过,未创建移动 session", "blocked_by": [blocked_by], }) return rows 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) root = Path(args.root).resolve() bindings = parse_bindings(args.bindings, requested) context = RuntimeContext(root, bindings=bindings) record: dict[str, Any] = { "schema_version": 1, "started_at": datetime.now(timezone.utc).isoformat(), "platforms_requested": requested, "partial": bool(args.partial), "bindings": bindings, "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": {}, } remote_artifacts: dict[str, str] = {} 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: validate_preflight_matrix(context.root, preflight_config, bindings, requested) remote_artifacts = preflight_remote_artifacts(preflight_config, requested) 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: binding_id = bindings[platform] node_name = platform_node(context.root, binding_id) probe = agent_runner(context.root, { "operation": "platform_probe", "platform": platform, "node": node_name, "binding": binding_id, }) if probe.get("status") != "ready": raise EnvironmentFailure(str(probe.get("observed") or probe)) host = os.environ.get("HOST", "").strip() network = agent_runner(context.root, { "operation": "network_probe", "platform": platform, "node": node_name, "binding": binding_id, "url": f"http://{host}/v1/me", }) if host else {"status": "blocked", "observed": "HOST 未设置"} if network.get("status") != "ready": raise EnvironmentFailure(str(network.get("observed") or network)) result.update({"node_preflight": probe, "network_preflight": network, "status": "preflight-passed"}) prepared[platform] = (result, None, 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": "修复列出的平台环境后重跑;默认不会静默降级"}) record["results"] = all_active_result_rows(context.root, blocked_by="test-preflight") else: for platform, (result, node, identity) in prepared.items(): try: if args.preflight_mode == "prepare": # test-preflight already ran platform_probe, network_probe, # install and start through each matrix binding. installed = {"delegated": "test-preflight.prepare", "binding": bindings[platform]} else: binding_id = bindings[platform] node_name = platform_node(context.root, binding_id) probe = agent_runner(context.root, { "operation": "platform_probe", "platform": platform, "node": node_name, "binding": binding_id, }) if probe.get("status") != "ready": raise EnvironmentFailure(str(probe.get("observed") or probe)) host = os.environ.get("HOST", "").strip() if host: network = agent_runner(context.root, { "operation": "network_probe", "platform": platform, "node": node_name, "binding": binding_id, "url": f"http://{host}/v1/me", }) if network.get("status") != "ready": raise EnvironmentFailure(str(network.get("observed") or network)) artifact_path = result["artifact"]["path"] if node_name != "local": artifact_path = remote_artifacts.get(platform, "") if not artifact_path: raise EnvironmentFailure(f"远程 {platform} 安装必须在 matrix.artifact.remote_path 声明节点可读产物") installed = agent_runner(context.root, { "operation": "install", "platform": platform, "node": node_name, "binding": binding_id, "artifact": artifact_path, "identity": identity, }) if installed.get("status") != "ready": raise EnvironmentFailure(str(installed.get("observed") or installed)) 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}) if record.get("status") != "ready" and "results" not in record: record["results"] = all_active_result_rows(context.root, blocked_by="test-preflight") path = write_run(context.root, record) try: from generate_run_csv import export_run_csv record["csv"] = str(export_run_csv(context.root, run_path=path)) except (OSError, SystemExit, ValueError): # The YAML run remains the source of truth when a partial/provisioning # root has no catalog cases yet. record["csv"] = "" path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") 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", action="append", dest="bindings", default=[], metavar="PLATFORM=TESTER_BINDING", help="每个平台的 tester binding,可重复;默认 tester.", ) 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))