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
+8 -2
View File
@@ -16,6 +16,8 @@
- 目录在目标测试项目根的 `.pouch/tester/`。父仓库和 `app` 等 Git submodule 可以各自有
catalog;先运行 `catalog.py roots --root <repo-root>` 发现它们,不要把两个项目的 cases 合并。
没有任何 catalog 才说「初始化 tester」。
- 从父仓库直接执行 tester 命令时,只有一个子 catalog 会自动选中;多个子 catalog 会自动
fan-out,逐个执行且不合并 cases。无需在根 `.pouch` 里增加未定义的 `include`。
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认
`HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量
`HX_TEST_HOST` 覆盖。
@@ -56,8 +58,12 @@
1. 用脚本读 `.pouch/tester/`,按分类统计或筛出你点名的用例,不会把整库塞进对话。
2. 改用例前先给你看将要写入的内容,你确认再保存。
3. 执行时:若用户要求准备环境,先按目标环境走 deployer 路由;再解析并更新 Agent 配置,输出生效列表,再准备 Orca 项目并创建临时 terminal;脚本类、
浏览器和移动模拟器都在该 Agent 环境中执行。
3. 执行时先建立“产物 × 平台 × tester 绑定 × 执行环境”矩阵,并把平台、产物和 binding
传给 `catalog.py select` 做硬性校验,再对矩阵全部行做
preflight。任一产物、平台或环境不可用,都先返回完整阻塞清单,不创建测试 session、
不执行可用平台;只有全部可用才按目标环境走 deployer 路由、解析 Agent、输出生效
列表、准备 Orca 项目并创建临时 terminal。脚本类、浏览器和移动模拟器都在对应
Agent 环境中执行。`builder.<platform>` 不能替代 `tester.<platform>`。
4. 超出你允许的风险级别会跳过,不会偷偷执行。
5. 无论测试成功、失败或异常,都会关闭本轮创建的 terminal;跑完给出按分类计数的
结果,以及 `pass / fail / environment-fail / skipped / not-run`。
+14 -2
View File
@@ -30,12 +30,17 @@ python3 <skill-dir>/scripts/catalog.py roots --root <repo-root>
- `<repo-root>/.pouch/tester/catalog.yaml` 存在时,它是一个独立测试根;
- 同时检查 Git submodule 和一层普通子项目目录;
- 只有一个嵌套 catalog 时,可直接用该路径继续 `list/select/run`;
- 有多个 catalog 时按用户指定的组件分别执行,例如 `--root app`,不要猜测或合并;
- 有多个 catalog 时由 tester 自动 fan-out,逐个项目分别执行,不等待用户选择,也不合并 cases;
- 每个测试根单独生成 `.pouch/tester/runs/*.yaml`/`.csv`,交付记录可分别引用这些快照。
如果父仓库没有 catalog 但子项目有 catalog,不能把“父目录无 catalog”报告为环境失败;先用
`roots` 找到子项目并继续。只有发现不到任何 catalog,才按缺少 tester 目录处理。
CLI 也有同样的保护:从父仓库执行 `list/select/validate/run-script` 时,若只发现一个
子 tester 根会自动进入该根;发现多个候选会自动 fan-out,每个根独立输出、独立校验、
独立生成运行记录。不需要、也不能通过在根 `.pouch` 里写一个未定义的 `include` 来拼接
子项目配置。
Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>` binding,由共享
`agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml`
仍是兼容覆盖层:解析 binding 后可 materialize 为旧 `agent` mapping,但不应再维护两套
@@ -132,4 +137,11 @@ python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
## 运行
读 [run.md](references/run.md)、[execution.md](references/execution.md) 和 [deployment.md](references/deployment.md)。若本轮要求准备环境,先按目标环境完成部署,再 `select` 得到命中列表;测试环境由本机 deployer 触发,正式环境由 app00 Agent terminal 委派。随后按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表,创建测试用临时 terminal 并按 driver 分流。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。移动端还要先识别目标平台并完成 Android 或 iOS 环境闸门;闸门未通过时输出对应的用户引导,不继续猜测或代跑。无论部署或测试成功、失败、超时还是中断,都要清理各自本轮创建的 terminal。
读 [run.md](references/run.md)、[execution.md](references/execution.md) 和 [deployment.md](references/deployment.md)。运行必须按以下顺序执行:
1. 先建立本轮测试矩阵,列出实际要测的产物、平台、commit、包/Bundle 身份、产物路径或下载地址,以及对应的 `tester.<platform>` 执行绑定。用户要求 Android+iOS 时,两行都必须出现;不能因为只拿到 Android 产物就缩小范围。
2. 对所有 tester 根的矩阵行做完整 preflight:先用 `catalog.py select` 校验平台、产物定位和 tester binding,再检查目标 Agent/环境/项目路径、Android/iOS 设备闸门和 HOST 可达。所有根的检查完成前不得创建测试 session 或开始任何测试用例。
3. 有任一根或任一行不可用时,先完成其它根的 preflight,再一次性输出完整不可用清单、观测结果、修复动作和可复制的验证命令,然后停止测试执行;不能先执行已通过 preflight 的根,也不能把缺失平台记成测试通过。
4. 只有矩阵所有行都可用,才创建本轮测试 terminal/session,按 `select` 结果执行并记录成绩。
测试环境由本机 deployer 触发,正式环境由 app00 Agent terminal 委派。随后按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。无论部署或测试成功、失败、超时还是中断,都要清理各自本轮创建的 terminal。
+12 -2
View File
@@ -10,12 +10,21 @@
```yaml
bindings:
tester.test:
tester.android:
adapter: tester
location: local-app
profile: test-agent
tester.ios:
adapter: tester
location: remote-ios
profile: test-agent
```
`tester.android` / `tester.ios` 是推荐的平台绑定名。`builder.android`、`builder.ios`
只负责构建,不能被 tester 自动继承;这条边界用于防止 iOS 构建成功却仍在 Linux
上执行测试。`tester.test` 只能用于明确的单平台或非移动测试;同一轮包含 Android
和 iOS 时必须分别解析两个 tester 绑定。
`locations` 和 `profiles` 的定义见共享 `agent-runtime`。tester adapter 只把它 materialize
到本轮兼容配置;每轮 terminal 仍然新建并在结束时关闭,不能因为 binding 是 persistent
就复用测试 terminal。
@@ -54,7 +63,8 @@ terminal。
- Orca terminal id,例如 `term_...`;
- 不提供定位信息,使用当前项目。
有定位信息时优先使用解析脚本,避免手工拼接 Orca JSON:
有定位信息时优先使用解析脚本,避免手工拼接 Orca JSON。多平台运行要对每个
`tester.<platform>` 绑定分别解析,不能只解析一次 `tester.test` 后复用到所有平台:
```bash
python3 <skill-dir>/scripts/resolve_agent.py \
+68 -9
View File
@@ -7,12 +7,60 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi
已有 terminal。
测试结束、失败、超时或中断时,都要关闭本轮创建的 terminal。
## 0. 准备执行目标
## 0. 建立测试矩阵并完成全量 preflight
测试不是“找到一个能跑的设备就开始”。每轮先建立一份测试矩阵;移动端每个目标产物
必须单独占一行:
```text
平台 | 产物路径/下载地址 | commit | 包名或 Bundle ID | tester 绑定 | 执行环境
Android | … | … | page.writes | tester.android | local-app
iOS | … | … | page.writes | tester.ios | remote-ios
```
矩阵来源按优先级使用:用户明确指定的产物、builder/CI 已成功并已 obtain 的产物、
项目本地明确标记的候选产物。不能根据“曾经构建成功过”或 `builder.<platform>`
绑定猜测本轮产物。缺少路径、下载地址、commit 或平台身份时,先报告“待确认”并停止。
平台绑定是测试执行目标,不是构建目标:`builder.ios` 只能证明构建配置,不能替代
`tester.ios`;`builder.android` 同理。多平台运行不得使用无法表达平台的通用
`tester.test` 绑定。每一行都必须解析出独立的 tester Agent、Orca 环境、项目和路径。
完成矩阵后,按以下顺序检查**所有行**,但不执行测试用例:
1. 产物:文件或下载地址存在,SHA/commit(若有)与本轮目标一致;Android APK 的
package、iOS IPA 的 Bundle ID 与矩阵一致。
2. 执行目标:按 [execution.md](execution.md) 精确解析每个平台的 `tester` 绑定;
不允许把本机 worktree 当成远程 Mac 的 worktree,也不允许用旧 commit 的远程目录
代替本轮产物对应的项目目录。
3. 平台环境:Android 执行 adb、设备、APK、HOST 检查;iOS 在 macOS 执行
`uname`、`xcrun`/`simctl`、模拟器或真机、App 安装、HOST 检查。
4. 依赖环境:浏览器、权限、网络、测试账号或 catalog 明确要求的其它 session 前置。
所有检查完成后才能进入下方的 `select` 和 session 初始化。任一行不可用时,必须返回
完整清单并停止:
```text
## tester preflight:阻塞
目标产物: Android, iOS
可用: <平台 + 产物 + 环境>
不可用:
- <平台>: <具体检查项和观测结果>
修复: <用户可执行动作>
验证: <可复制命令>
本轮未创建测试 session,未执行测试用例。
完成修复后重跑原 tester 命令。
```
不能因为 Android 可用就先测 Android,也不能用 API、另一平台或旧产物替代不可用行。
先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标测试项目必须包含
可读取的 `.pouch/tester/catalog.yaml`。父仓库可能只是包含多个测试子项目;运行前用
`catalog.py roots --root <repo-root>` 发现并选择目标根,不能把父仓库无 catalog 当成失败,
也不能偷偷回退到另一个子项目或复制测试文件。每个目标根单独生成运行快照。
可读取的 `.pouch/tester/catalog.yaml`。父仓库可能包含多个测试子项目;运行前用
`catalog.py roots --root <repo-root>` 发现全部目标根,不能把父仓库无 catalog 当成失败,
也不能偷偷回退到某一个子项目或复制测试文件。所有目标根都要被 fan-out 调度,每个根独立
做产物/环境 preflight,并单独生成运行快照。
`catalog.py select` 输出的 `agent` 是本轮使用的配置快照。记录中要保留 Agent、目标
环境、主机、项目、路径、worktree id、terminal handle,以及 terminal 是否成功关闭。
@@ -27,12 +75,21 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi
python3 <skill-dir>/scripts/catalog.py select --root <root> \
[--category CAT] [--id ID]... [--tag smoke|full|slow] \
[--max-risk <risk>] [--driver script|browser|mobile] \
[--automation automated|assisted|manual|any]
[--automation automated|assisted|manual|any] \
[--platform android|ios]... \
[--artifact android=<path-or-url>] [--artifact ios=<path-or-url>] \
[--binding android=tester.android] [--binding ios=tester.ios]
```
选中任一 `driver=mobile` 用例时,`--platform`、每个平台的 `--artifact` 和对应
`tester` binding 是硬性参数;`catalog.py select` 会检查产物文件/下载地址和
`.pouch/agent.yaml` 中的 binding。缺任何一项直接返回非零并打印 `preflight: blocked`,
不能拿到 cases 后绕过闸门开始测试。默认 binding 是 `tester.<platform>`,所以 iOS
会强制检查 `tester.ios`,不会回退到 `builder.ios` 或通用 `tester.test`。
`--automation` 默认 `automated`(需人工的条目会被跳过,并在 JSON `skipped` 里说明);用户明确要求现场配合时用 `assisted`,`any` 表示不按人工参与度筛。
`select` 的 JSON 字段:`max_risk` / `automation` / `count` / `vars`(已展开的变量,用来替换 `how` 里的 `${…}`)/ `skipped` / `run_together` / `status_probe`(按分类,`url` 已展开)/ `cases`。
单根 `select` 的 JSON 字段:`max_risk` / `automation` / `count` / `vars`(已展开的变量,用来替换 `how` 里的 `${…}`)/ `artifacts` / `preflight` / `skipped` / `run_together` / `status_probe`(按分类,`url` 已展开)/ `cases`。从父仓库 fan-out 时返回 `mode: fan-out` 和每个根的独立结果,不能把多个根的 cases 拼成一个列表。
`select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明,不执行)。
@@ -54,8 +111,8 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
| mapping | 整体覆盖顶层,不合并(`source: category`)|
- 同一生效探测一次运行只请求一次,结果复用。
- 探测不可达:用它的分类标 environment-fail,其余分类照跑。
- 命中 `stop_if`:把用它的分类标 environment-fail 并记 `observed` 值,其余分类照跑;所有选中分类都被挡住时,整轮结论才是 environment-fail。
- 探测不可达或命中 `stop_if`:在 preflight 阶段记录对应分类/平台的不可用项并停止整轮;
先返回用户处理,不创建后续测试 session。只有所有本轮目标环境通过,才进入用例执行。
## 2.1 移动端平台环境闸门与用户引导
@@ -79,7 +136,9 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。
任一项不满足,都要在执行记录中把受影响的移动用例标为 `environment-fail`,不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化后续移动 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。
任一项不满足,都要在 preflight 汇总中列出平台、具体检查项、实际探测命令和简短输出,
然后停止整轮;不能先执行其它平台,不能改成 API 测试,也不能把它们标成产品 `fail`。
只有 preflight 全部通过后,才把对应移动用例的结果写入运行快照。
### 环境缺失时的用户引导
+1
View File
@@ -22,6 +22,7 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
| `risks` | 从低到高,只能是 `readonly` → `mutating` → `destructive` 的一段,且以 `readonly` 开头。含义见 [levels.md](levels.md) |
| `default_max_risk` | 未指定时的运行上限,必须是 `risks` 里的值 |
| `vars` | 可替换变量,如 `API_BASE: http://${HOST}:8095` |
| `artifacts` | 可选的本轮移动产物契约。按 `android` / `ios` 声明 `required` 和 `package` / `bundle_id`;具体文件路径、下载地址、commit 和 SHA 由每轮测试矩阵提供,不提交临时产物路径。 |
| `authorize` | 风险名 → 环境变量名。该档执行前变量必须为 `true`(`readonly` 不需要) |
| `gates` | 可选。项目细分闸名 → 环境变量。用在用例的 `gate` 上,不要拿它代替 `risk` |
| `sessions` | 可复用会话。`kind`: `browser` / `mobile`;`reuse`: `run`(本轮结束关掉)或 `persist`(probe 活着就附着) |
+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":