From 5b9a693fda1ab8ae74f55d0825d826d705edaf0a Mon Sep 17 00:00:00 2001 From: laily Date: Fri, 16 Oct 2026 12:38:11 +0800 Subject: [PATCH] fix(tester): route mobile checks by platform binding --- skills/tester/references/execution.md | 10 ++ skills/tester/references/test-bundle.md | 10 +- skills/tester/scripts/mobile_bundle.py | 181 ++++++++++++++++++++-- skills/tester/scripts/resolve_agent.py | 11 +- skills/tester/tests/test_mobile_bundle.py | 66 ++++++++ 5 files changed, 258 insertions(+), 20 deletions(-) diff --git a/skills/tester/references/execution.md b/skills/tester/references/execution.md index f0173b2..0b87adc 100644 --- a/skills/tester/references/execution.md +++ b/skills/tester/references/execution.md @@ -25,6 +25,16 @@ bindings: 上执行测试。`tester.test` 只能用于明确的单平台或非移动测试;同一轮包含 Android 和 iOS 时必须分别解析两个 tester 绑定。 +移动 bundle 入口必须把平台绑定显式传入: + +```bash +--binding android=tester.android --binding ios=tester.ios +``` + +入口会校验 preflight matrix 的每个平台 binding、node 和 runner。远程 iOS 必须是 +`contract` runner;多平台运行不会读取旧的单一 `local.yaml.agent` 来决定执行节点, +也不会在协调节点直接执行 Android/iOS 平台命令。 + `locations` 和 `profiles` 的定义见共享 `agent-runtime`。tester adapter 只把它 materialize 到本轮兼容配置;每轮 terminal 仍然新建并在结束时关闭,不能因为 binding 是 persistent 就复用测试 terminal。 diff --git a/skills/tester/references/test-bundle.md b/skills/tester/references/test-bundle.md index dc66a53..38142f7 100644 --- a/skills/tester/references/test-bundle.md +++ b/skills/tester/references/test-bundle.md @@ -7,11 +7,15 @@ CI release、builder 绑定或本机旧文件猜测产物。 /tester run \ --artifact android= \ --artifact ios= \ - --platform android,ios + --platform android,ios \ + --binding android=tester.android \ + --binding ios=tester.ios ``` -URL 就下载,本地文件就直接用。每个平台都在自己的 `tester.` 节点校验身份并安装, -随后由现有 tester catalog 驱动真实移动 session。默认先完成 Android+iOS 双平台 +URL 就下载,本地文件就直接用。每个平台都通过自己的 `tester.` binding +校验、探测和安装;`agent-runtime` 负责把 Android 请求送到 Android 节点、把 iOS +请求送到 Mac 节点。旧的单一 `local.yaml.agent` 只能作为兼容配置,不能作为双平台 +运行目标。随后由现有 tester catalog 驱动真实移动 session。默认先完成 Android+iOS 双平台 preflight;任一平台无法准备时整轮停止。只有显式加 `--partial` 才允许运行可用平台。 每轮另外创建隔离 Git worktree,不切用户当前分支。`--commit` 缺省为当前 HEAD。 diff --git a/skills/tester/scripts/mobile_bundle.py b/skills/tester/scripts/mobile_bundle.py index 3f689fa..ddcfa92 100755 --- a/skills/tester/scripts/mobile_bundle.py +++ b/skills/tester/scripts/mobile_bundle.py @@ -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. 或 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 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.", + ) 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 证据输出路径") diff --git a/skills/tester/scripts/resolve_agent.py b/skills/tester/scripts/resolve_agent.py index 14a7339..96655d3 100755 --- a/skills/tester/scripts/resolve_agent.py +++ b/skills/tester/scripts/resolve_agent.py @@ -180,9 +180,14 @@ def clean_config(config: dict[str, Any]) -> dict[str, Any]: def config_from_binding(root: Path, selector: str) -> dict[str, Any]: """Materialize a common agent-runtime tester binding for legacy local.yaml.""" - runtime_path = Path(__file__).resolve().parents[3] / "skills" / "agent-runtime" / "scripts" / "agent.py" - if not runtime_path.is_file(): - raise RuntimeError(f"找不到共享 agent-runtime: {runtime_path}") + candidates = [ + Path.home() / ".codex" / "skills" / "agent-runtime" / "scripts" / "agent.py", + Path.home() / ".pouch" / "skills" / "agent-runtime" / "scripts" / "agent.py", + Path(__file__).resolve().parents[3] / "skills" / "agent-runtime" / "scripts" / "agent.py", + ] + runtime_path = next((item for item in candidates if item.is_file()), None) + if runtime_path is None: + raise RuntimeError("找不到共享 agent-runtime: " + ", ".join(str(item) for item in candidates)) spec = importlib.util.spec_from_file_location("pouch_agent_runtime", runtime_path) if spec is None or spec.loader is None: raise RuntimeError(f"无法加载共享 agent-runtime: {runtime_path}") diff --git a/skills/tester/tests/test_mobile_bundle.py b/skills/tester/tests/test_mobile_bundle.py index df18111..68f0631 100755 --- a/skills/tester/tests/test_mobile_bundle.py +++ b/skills/tester/tests/test_mobile_bundle.py @@ -50,6 +50,72 @@ class MobileBundleTests(unittest.TestCase): with self.assertRaises(bundle.ArtifactFailure): bundle.parse_artifacts(["android=/tmp/a.apk", "android=/tmp/b.apk"]) + def test_parse_bindings_keeps_platforms_separate(self): + self.assertEqual( + bundle.parse_bindings(["android=tester.android", "ios=tester.ios"], ["android", "ios"]), + {"android": "tester.android", "ios": "tester.ios"}, + ) + self.assertEqual( + bundle.parse_bindings([], ["android"]), + {"android": "tester.android"}, + ) + with self.assertRaises(bundle.ArtifactFailure): + bundle.parse_bindings(["android=tester.test", "ios=tester.test"], ["android", "ios"]) + + def test_preflight_matrix_rejects_remote_platform_using_local_runner(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + agent_dir = root / ".pouch" + agent_dir.mkdir() + (agent_dir / "agent.yaml").write_text( + """ +schemaVersion: 1 +locations: + local-app: + environment: local + host: local + project: test + path: /tmp/test + remote-ios: + environment: macmini + host: runtime:12345678-1234-1234-1234-123456789012 + project: test + path: /tmp/test-ios +profiles: + test-agent: + role: test + cli: codex + tier: standard + model: gpt-5 + reasoningEffort: medium + permissionMode: workspace-write +bindings: + tester.android: {adapter: tester, location: local-app, profile: test-agent} + tester.ios: {adapter: tester, location: remote-ios, profile: test-agent} +""".strip() + + "\n", + encoding="utf-8", + ) + config = root / "matrix.yaml" + config.write_text( + """ +matrix: + - platform: android + tester: {binding: tester.android, node: local, runner: {kind: local}} + - platform: ios + tester: {binding: tester.ios, node: local, runner: {kind: local}} +""".strip() + + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(bundle.EnvironmentFailure, "ios.*contract runner"): + bundle.validate_preflight_matrix( + root, + config, + {"android": "tester.android", "ios": "tester.ios"}, + ["android", "ios"], + ) + def test_new_worktree_each_run(self): with tempfile.TemporaryDirectory() as temp: repo, worktrees = Path(temp) / "repo", Path(temp) / "worktrees"