fix(tester): route mobile checks by platform binding

This commit is contained in:
2026-10-16 12:38:11 +08:00
parent 9b2d0a28d8
commit 5b9a693fda
5 changed files with 258 additions and 20 deletions
+10
View File
@@ -25,6 +25,16 @@ bindings:
上执行测试。`tester.test` 只能用于明确的单平台或非移动测试;同一轮包含 Android 上执行测试。`tester.test` 只能用于明确的单平台或非移动测试;同一轮包含 Android
和 iOS 时必须分别解析两个 tester 绑定。 和 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 `locations` 和 `profiles` 的定义见共享 `agent-runtime`。tester adapter 只把它 materialize
到本轮兼容配置;每轮 terminal 仍然新建并在结束时关闭,不能因为 binding 是 persistent 到本轮兼容配置;每轮 terminal 仍然新建并在结束时关闭,不能因为 binding 是 persistent
就复用测试 terminal。 就复用测试 terminal。
+7 -3
View File
@@ -7,11 +7,15 @@ CI release、builder 绑定或本机旧文件猜测产物。
<skill-dir>/tester run \ <skill-dir>/tester run \
--artifact android=<path-or-url> \ --artifact android=<path-or-url> \
--artifact ios=<path-or-url> \ --artifact ios=<path-or-url> \
--platform android,ios --platform android,ios \
--binding android=tester.android \
--binding ios=tester.ios
``` ```
URL 就下载,本地文件就直接用。每个平台都在自己的 `tester.<platform>` 节点校验身份并安装, URL 就下载,本地文件就直接用。每个平台都通过自己的 `tester.<platform>` binding
随后由现有 tester catalog 驱动真实移动 session。默认先完成 Android+iOS 双平台 校验、探测和安装;`agent-runtime` 负责把 Android 请求送到 Android 节点、把 iOS
请求送到 Mac 节点。旧的单一 `local.yaml.agent` 只能作为兼容配置,不能作为双平台
运行目标。随后由现有 tester catalog 驱动真实移动 session。默认先完成 Android+iOS 双平台
preflight;任一平台无法准备时整轮停止。只有显式加 `--partial` 才允许运行可用平台。 preflight;任一平台无法准备时整轮停止。只有显式加 `--partial` 才允许运行可用平台。
每轮另外创建隔离 Git worktree,不切用户当前分支。`--commit` 缺省为当前 HEAD。 每轮另外创建隔离 Git worktree,不切用户当前分支。`--commit` 缺省为当前 HEAD。
+167 -14
View File
@@ -54,6 +54,7 @@ class CommandResult:
@dataclass @dataclass
class RuntimeContext: class RuntimeContext:
root: Path root: Path
bindings: dict[str, str] = field(default_factory=dict)
retries: dict[str, int] = field(default_factory=lambda: {"download": 3, "start": 3, "install": 3}) retries: dict[str, int] = field(default_factory=lambda: {"download": 3, "start": 3, "install": 3})
evidence: list[dict[str, Any]] = field(default_factory=list) evidence: list[dict[str, Any]] = field(default_factory=list)
@@ -126,6 +127,98 @@ def parse_artifacts(values: list[str]) -> dict[str, str]:
return pairs 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: def local_path(locator: str) -> Path | None:
parsed = urllib.parse.urlsplit(locator) parsed = urllib.parse.urlsplit(locator)
if parsed.scheme in ("http", "https"): if parsed.scheme in ("http", "https"):
@@ -440,13 +533,15 @@ def run_test_preflight(
def execute(args: argparse.Namespace) -> int: def execute(args: argparse.Namespace) -> int:
requested = platforms(args.platform) 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] = { record: dict[str, Any] = {
"schema_version": 1, "schema_version": 1,
"started_at": datetime.now(timezone.utc).isoformat(), "started_at": datetime.now(timezone.utc).isoformat(),
"platforms_requested": requested, "platforms_requested": requested,
"partial": bool(args.partial), "partial": bool(args.partial),
"binding": args.binding, "bindings": bindings,
"node": os.environ.get("TESTER_NODE", "local"), "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")}, "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 替代移动测试"}, "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", "error": "缺少 --preflight-config;不能跳过 test-preflight",
} }
else: else:
validate_preflight_matrix(context.root, preflight_config, bindings, requested)
preflight_result = run_test_preflight( preflight_result = run_test_preflight(
preflight_config, preflight_config,
mode=args.preflight_mode, mode=args.preflight_mode,
@@ -515,10 +611,28 @@ def execute(args: argparse.Namespace) -> int:
for platform in requested: for platform in requested:
result, _, identity = prepared[platform] result, _, identity = prepared[platform]
try: try:
node = android_preflight(context) if platform == "android" else ios_preflight(context) binding_id = bindings[platform]
check_network(context, node) node_name = platform_node(context.root, binding_id)
result.update({"node_preflight": node, "status": "preflight-passed"}) probe = agent_runner(context.root, {
prepared[platform] = (result, node, identity) "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: except EnvironmentFailure as exc:
failures[platform] = str(exc) failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": 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: else:
for platform, (result, node, identity) in prepared.items(): for platform, (result, node, identity) in prepared.items():
try: try:
installed = ( if args.preflight_mode == "prepare":
{"delegated": "test-preflight.prepare"} # test-preflight already ran platform_probe, network_probe,
if args.preflight_mode == "prepare" # install and start through each matrix binding.
else install_android(node, Path(result["artifact"]["path"]), identity, context) installed = {"delegated": "test-preflight.prepare", "binding": bindings[platform]}
if platform == "android" else:
else install_ios(node, Path(result["artifact"]["path"]), identity, context) 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}) result.update({"status": "ready", "install": installed, "failure_class": None})
except EnvironmentFailure as exc: except EnvironmentFailure as exc:
failures[platform] = str(exc) failures[platform] = str(exc)
@@ -580,7 +726,14 @@ def parser() -> argparse.ArgumentParser:
run.add_argument("--partial", action="store_true") run.add_argument("--partial", action="store_true")
run.add_argument("--root", default=".") run.add_argument("--root", default=".")
run.add_argument("--worktree-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-config", default="", help="test-preflight matrix YAML(必填,不得跳过)")
run.add_argument("--preflight-mode", choices=("verify", "prepare"), default="verify") run.add_argument("--preflight-mode", choices=("verify", "prepare"), default="verify")
run.add_argument("--preflight-yaml", default="", help="preflight YAML 证据输出路径") run.add_argument("--preflight-yaml", default="", help="preflight YAML 证据输出路径")
+8 -3
View File
@@ -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]: def config_from_binding(root: Path, selector: str) -> dict[str, Any]:
"""Materialize a common agent-runtime tester binding for legacy local.yaml.""" """Materialize a common agent-runtime tester binding for legacy local.yaml."""
runtime_path = Path(__file__).resolve().parents[3] / "skills" / "agent-runtime" / "scripts" / "agent.py" candidates = [
if not runtime_path.is_file(): Path.home() / ".codex" / "skills" / "agent-runtime" / "scripts" / "agent.py",
raise RuntimeError(f"找不到共享 agent-runtime: {runtime_path}") 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) spec = importlib.util.spec_from_file_location("pouch_agent_runtime", runtime_path)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载共享 agent-runtime: {runtime_path}") raise RuntimeError(f"无法加载共享 agent-runtime: {runtime_path}")
+66
View File
@@ -50,6 +50,72 @@ class MobileBundleTests(unittest.TestCase):
with self.assertRaises(bundle.ArtifactFailure): with self.assertRaises(bundle.ArtifactFailure):
bundle.parse_artifacts(["android=/tmp/a.apk", "android=/tmp/b.apk"]) 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): def test_new_worktree_each_run(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
repo, worktrees = Path(temp) / "repo", Path(temp) / "worktrees" repo, worktrees = Path(temp) / "repo", Path(temp) / "worktrees"