595 lines
29 KiB
Python
Executable File
595 lines
29 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:
|
|
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 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)
|
|
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": "修复列出的平台环境后重跑;默认不会静默降级"})
|
|
record["results"] = all_active_result_rows(context.root, blocked_by="test-preflight")
|
|
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})
|
|
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", 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))
|