fix(tester): route mobile checks by platform binding
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -7,11 +7,15 @@ CI release、builder 绑定或本机旧文件猜测产物。
|
||||
<skill-dir>/tester run \
|
||||
--artifact android=<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>` 节点校验身份并安装,
|
||||
随后由现有 tester catalog 驱动真实移动 session。默认先完成 Android+iOS 双平台
|
||||
URL 就下载,本地文件就直接用。每个平台都通过自己的 `tester.<platform>` binding
|
||||
校验、探测和安装;`agent-runtime` 负责把 Android 请求送到 Android 节点、把 iOS
|
||||
请求送到 Mac 节点。旧的单一 `local.yaml.agent` 只能作为兼容配置,不能作为双平台
|
||||
运行目标。随后由现有 tester catalog 驱动真实移动 session。默认先完成 Android+iOS 双平台
|
||||
preflight;任一平台无法准备时整轮停止。只有显式加 `--partial` 才允许运行可用平台。
|
||||
|
||||
每轮另外创建隔离 Git worktree,不切用户当前分支。`--commit` 缺省为当前 HEAD。
|
||||
|
||||
@@ -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 证据输出路径")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user