fix(tester): route mobile checks by platform binding
This commit is contained in:
@@ -54,6 +54,7 @@ class CommandResult:
|
||||
@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)
|
||||
|
||||
@@ -126,6 +127,98 @@ def parse_artifacts(values: list[str]) -> dict[str, str]:
|
||||
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.<name> 或 ios=tester.<name>: {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 local_path(locator: str) -> Path | None:
|
||||
parsed = urllib.parse.urlsplit(locator)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
@@ -440,13 +533,15 @@ def run_test_preflight(
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
requested = platforms(args.platform)
|
||||
context = RuntimeContext(Path(args.root).resolve())
|
||||
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),
|
||||
"binding": args.binding,
|
||||
"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 替代移动测试"},
|
||||
@@ -496,6 +591,7 @@ def execute(args: argparse.Namespace) -> int:
|
||||
"error": "缺少 --preflight-config;不能跳过 test-preflight",
|
||||
}
|
||||
else:
|
||||
validate_preflight_matrix(context.root, preflight_config, bindings, requested)
|
||||
preflight_result = run_test_preflight(
|
||||
preflight_config,
|
||||
mode=args.preflight_mode,
|
||||
@@ -515,10 +611,28 @@ def execute(args: argparse.Namespace) -> int:
|
||||
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)
|
||||
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)})
|
||||
@@ -532,13 +646,45 @@ def execute(args: argparse.Namespace) -> int:
|
||||
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)
|
||||
)
|
||||
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 = Path(result["artifact"]["path"])
|
||||
if platform == "ios" and node_name != "local":
|
||||
raise EnvironmentFailure("远程 iOS 安装必须在 matrix.artifact.remote_path 声明 Mac 可读产物;不能把协调机路径当成 Mac 路径")
|
||||
installed = agent_runner(context.root, {
|
||||
"operation": "install",
|
||||
"platform": platform,
|
||||
"node": node_name,
|
||||
"binding": binding_id,
|
||||
"artifact": str(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)
|
||||
@@ -580,7 +726,14 @@ def parser() -> argparse.ArgumentParser:
|
||||
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(
|
||||
"--binding",
|
||||
action="append",
|
||||
dest="bindings",
|
||||
default=[],
|
||||
metavar="PLATFORM=TESTER_BINDING",
|
||||
help="每个平台的 tester binding,可重复;默认 tester.<platform>",
|
||||
)
|
||||
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 证据输出路径")
|
||||
|
||||
Reference in New Issue
Block a user