fix tester multi-root fan-out execution

This commit is contained in:
2026-10-14 15:35:13 +08:00
parent fbf0ca8cd8
commit db92baf3a7
6 changed files with 370 additions and 15 deletions
+267
View File
@@ -4,11 +4,16 @@
from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
import re
import subprocess
import sys
import urllib.parse
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -59,6 +64,60 @@ def catalog_path(root: Path) -> Path:
return catalog_dir(root) / "catalog.yaml"
def discover_or_self(root: Path) -> list[Path]:
"""Return all independent tester projects below a parent repository."""
root = root.resolve()
return discover_catalog_roots(root)
def cmd_fanout(args: argparse.Namespace, roots: list[Path]) -> int:
"""Run a catalog command independently for every discovered tester root."""
results: list[dict[str, Any]] = []
for child_root in roots:
output = io.StringIO()
with contextlib.redirect_stdout(output):
if args.cmd == "list":
exit_code = cmd_list(child_root)
elif args.cmd == "select":
exit_code = cmd_select(args, child_root)
elif args.cmd == "show":
exit_code = cmd_show(child_root, args.case_id)
elif args.cmd == "validate":
exit_code = cmd_validate(child_root)
elif args.cmd == "export-csv":
exit_code = cmd_export_csv(child_root)
elif args.cmd == "run-script":
exit_code = cmd_run_script(child_root, args.case_id)
else:
exit_code = 2
print(f"不支持从父仓库 fan-out 命令: {args.cmd}")
raw = output.getvalue().strip()
try:
result: Any = json.loads(raw) if raw else None
except json.JSONDecodeError:
result = raw
results.append({
"root": str(child_root),
"exit": exit_code,
"result": result,
})
if args.cmd == "select":
print(json.dumps({"mode": "fan-out", "roots": results}, ensure_ascii=False, indent=2))
else:
for item in results:
print(f"\n## tester root: {item['root']} (exit={item['exit']})")
result = item["result"]
if isinstance(result, (dict, list)):
print(json.dumps(result, ensure_ascii=False, indent=2))
elif result:
print(result)
return 0 if all(item["exit"] == 0 for item in results) else 2
FANOUT_COMMANDS = {"list", "select", "show", "validate", "export-csv", "run-script"}
def discover_catalog_roots(root: Path) -> list[Path]:
"""Find tester projects owned by a repository and its Git submodules.
@@ -560,6 +619,142 @@ def expand_run_together(
return selected, hit_groups
def parse_platform_pairs(values: list[str], label: str) -> tuple[dict[str, str], list[str]]:
pairs: dict[str, str] = {}
errors: list[str] = []
for raw in values:
platform, sep, value = raw.partition("=")
platform = platform.strip().lower()
value = value.strip()
if not sep or platform not in ("android", "ios") or not value:
errors.append(f"{label} 格式必须是 android=<值> 或 ios=<值>: {raw}")
continue
if platform in pairs:
errors.append(f"{label} 重复平台: {platform}")
continue
pairs[platform] = value
return pairs, errors
def safe_locator(locator: str) -> str:
parsed = urllib.parse.urlsplit(locator)
if parsed.scheme in ("http", "https"):
path = parsed.path or "/"
return f"{parsed.scheme}://{parsed.netloc}{path}"
return locator
def artifact_ready(locator: str) -> tuple[bool, str]:
parsed = urllib.parse.urlsplit(locator)
if parsed.scheme in ("http", "https"):
try:
request = urllib.request.Request(locator, method="HEAD")
with urllib.request.urlopen(request, timeout=10) as response:
if 200 <= response.status < 400:
return True, f"HTTP {response.status}"
return False, f"HTTP {response.status}"
except Exception as exc:
return False, f"下载地址不可达: {type(exc).__name__}: {exc}"
path = Path(locator).expanduser()
if not path.is_file():
return False, "文件不存在"
if path.stat().st_size <= 0:
return False, "文件为空"
if path.suffix.lower() in (".apk", ".ipa") and not zipfile.is_zipfile(path):
return False, "不是有效的 APK/IPA zip 产物"
return True, f"文件 {path.stat().st_size} bytes"
def runtime_binding(root: Path, binding_id: str) -> tuple[bool, str]:
config_path = root / ".pouch" / "agent.yaml"
if not config_path.is_file():
return False, f"缺少 {config_path}"
try:
config = load_yaml(config_path) or {}
except Exception as exc:
return False, f"读取 agent.yaml 失败: {exc}"
binding = (config.get("bindings") or {}).get(binding_id)
if not isinstance(binding, dict):
return False, "binding 不存在"
if binding.get("adapter") != "tester":
return False, f"adapter={binding.get('adapter')!r},不是 tester"
location = binding.get("location")
if not location:
return False, "未配置 location"
return True, f"location={location}"
def mobile_preflight(
root: Path,
meta: dict[str, Any],
selected: list[dict[str, Any]],
platforms_raw: list[str],
artifacts_raw: list[str],
bindings_raw: list[str],
) -> dict[str, Any]:
mobile_selected = [case for case in selected if case.get("driver") == "mobile"]
if not mobile_selected:
return {"required": False, "status": "not-needed", "checks": [], "errors": []}
platforms = [item.strip().lower() for item in platforms_raw if item.strip()]
errors: list[str] = []
if not platforms:
errors.append("选中了 mobile 用例,必须显式提供 --platform android/ios")
if len(platforms) != len(set(platforms)):
errors.append("--platform 不能重复")
invalid_platforms = [item for item in platforms if item not in ("android", "ios")]
if invalid_platforms:
errors.append("非法平台: " + ", ".join(invalid_platforms))
artifacts, pair_errors = parse_platform_pairs(artifacts_raw, "--artifact")
errors.extend(pair_errors)
bindings, pair_errors = parse_platform_pairs(bindings_raw, "--binding")
errors.extend(pair_errors)
contract = meta.get("artifacts")
if not isinstance(contract, dict) or not contract:
errors.append("catalog 缺少 artifacts 产物契约")
checks: list[dict[str, Any]] = []
for platform in platforms:
if platform not in ("android", "ios"):
continue
spec = (contract or {}).get(platform) if isinstance(contract, dict) else None
if not isinstance(spec, dict):
errors.append(f"{platform}: catalog.artifacts 未声明")
if platform not in artifacts:
errors.append(f"{platform}: 缺少 --artifact {platform}=<路径或下载地址>")
else:
ok, detail = artifact_ready(artifacts[platform])
checks.append({
"platform": platform,
"artifact": safe_locator(artifacts[platform]),
"artifact_ok": ok,
"artifact_detail": detail,
})
if not ok:
errors.append(f"{platform}: 产物不可用: {detail}")
binding_id = bindings.get(platform, f"tester.{platform}")
ok, detail = runtime_binding(root, binding_id)
checks.append({
"platform": platform,
"binding": binding_id,
"binding_ok": ok,
"binding_detail": detail,
})
if not ok:
errors.append(f"{platform}: binding {binding_id} 不可用: {detail}")
return {
"required": True,
"status": "blocked" if errors else "ready",
"platforms": platforms,
"checks": checks,
"errors": errors,
}
def cmd_select(args: argparse.Namespace, root: Path) -> int:
meta = load_catalog_meta(root)
cases = load_all_cases(root)
@@ -620,6 +815,18 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
selected, together = expand_run_together(meta, cases, selected, skipped, max_i, automation_filter)
preflight = mobile_preflight(
root,
meta,
selected,
args.platform or [],
args.artifacts or [],
args.bindings or [],
)
if preflight["status"] == "blocked":
print(json.dumps({"preflight": preflight}, ensure_ascii=False, indent=2))
return 2
# 按分类解析生效的 status_probe,让 Agent 不再自己按 catalog 猜作用范围。
vars_map = resolve_vars(root, meta)
status_probes: dict[str, dict[str, Any]] = {}
@@ -636,6 +843,8 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
"automation": automation_filter,
"count": len(selected),
"vars": vars_map,
"artifacts": meta.get("artifacts") or {},
"preflight": preflight,
"skipped": skipped,
"run_together": together,
"status_probe": status_probes,
@@ -718,6 +927,28 @@ def validate(root: Path) -> list[str]:
if not isinstance(sessions, dict):
errors.append("catalog.sessions 必须是 mapping")
sessions = {}
artifacts = meta.get("artifacts")
if artifacts is not None:
if not isinstance(artifacts, dict):
errors.append("catalog.artifacts 必须是 mapping")
else:
for platform, spec in artifacts.items():
where = f"catalog.artifacts.{platform}"
if platform not in ("android", "ios"):
errors.append(f"{where}: 平台只能是 android/ios")
if not isinstance(spec, dict):
errors.append(f"{where}: 必须是 mapping")
continue
if "required" in spec and not isinstance(spec["required"], bool):
errors.append(f"{where}.required 必须是 boolean")
if platform == "android" and not spec.get("package"):
errors.append(f"{where}: Android 需要 package")
if platform == "ios" and not spec.get("bundle_id"):
errors.append(f"{where}: iOS 需要 bundle_id")
if platform == "android" and spec.get("bundle_id"):
errors.append(f"{where}: Android 不应填 bundle_id")
if platform == "ios" and spec.get("package"):
errors.append(f"{where}: iOS 不应填 package")
pattern = meta.get("id_pattern") or r"^.+$"
try:
id_re = re.compile(pattern)
@@ -1057,6 +1288,29 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
help="默认 automated;any 表示不按人工参与度筛选",
)
p_sel.add_argument(
"--platform",
action="append",
choices=["android", "ios"],
default=[],
help="mobile 运行平台,可重复;选中 mobile 用例时必填",
)
p_sel.add_argument(
"--artifact",
action="append",
dest="artifacts",
default=[],
metavar="PLATFORM=PATH_OR_URL",
help="本轮移动产物,可重复,例如 android=dist/app.apk",
)
p_sel.add_argument(
"--binding",
action="append",
dest="bindings",
default=[],
metavar="PLATFORM=TESTER_BINDING",
help="平台 tester binding,可重复;默认 tester.<platform>",
)
p_show = sub.add_parser("show", help="打印一条用例", parents=[parent])
p_show.add_argument("--id", required=True, dest="case_id")
sub.add_parser("validate", help="校验目录", parents=[parent])
@@ -1073,6 +1327,19 @@ def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
root = Path(args.root).resolve()
if args.cmd != "roots":
roots = discover_or_self(root)
if len(roots) > 1:
if args.cmd in FANOUT_COMMANDS:
return cmd_fanout(args, roots)
print(
f"发现多个 tester 根目录,命令 {args.cmd} 不是可 fan-out 的执行命令;"
"请对具体项目根目录执行该维护命令。",
file=sys.stderr,
)
return 2
if len(roots) == 1:
root = roots[0]
if args.cmd == "list":
return cmd_list(root)
if args.cmd == "select":