diff --git a/skills/tester/README.md b/skills/tester/README.md index 037fa35..7151796 100644 --- a/skills/tester/README.md +++ b/skills/tester/README.md @@ -16,6 +16,8 @@ - 目录在目标测试项目根的 `.pouch/tester/`。父仓库和 `app` 等 Git submodule 可以各自有 catalog;先运行 `catalog.py roots --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.` 不能替代 `tester.`。 4. 超出你允许的风险级别会跳过,不会偷偷执行。 5. 无论测试成功、失败或异常,都会关闭本轮创建的 terminal;跑完给出按分类计数的 结果,以及 `pass / fail / environment-fail / skipped / not-run`。 diff --git a/skills/tester/SKILL.md b/skills/tester/SKILL.md index fb4c361..0984cd8 100644 --- a/skills/tester/SKILL.md +++ b/skills/tester/SKILL.md @@ -30,12 +30,17 @@ python3 /scripts/catalog.py roots --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.` binding,由共享 `agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml` 仍是兼容覆盖层:解析 binding 后可 materialize 为旧 `agent` mapping,但不应再维护两套 @@ -132,4 +137,11 @@ python3 /scripts/catalog.py show --root --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.` 执行绑定。用户要求 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。 diff --git a/skills/tester/references/execution.md b/skills/tester/references/execution.md index e3df5eb..c2ff4de 100644 --- a/skills/tester/references/execution.md +++ b/skills/tester/references/execution.md @@ -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.` 绑定分别解析,不能只解析一次 `tester.test` 后复用到所有平台: ```bash python3 /scripts/resolve_agent.py \ diff --git a/skills/tester/references/run.md b/skills/tester/references/run.md index 5a02891..379ad45 100644 --- a/skills/tester/references/run.md +++ b/skills/tester/references/run.md @@ -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.` +绑定猜测本轮产物。缺少路径、下载地址、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 ` 发现并选择目标根,不能把父仓库无 catalog 当成失败, -也不能偷偷回退到另一个子项目或复制测试文件。每个目标根单独生成运行快照。 +可读取的 `.pouch/tester/catalog.yaml`。父仓库可能包含多个测试子项目;运行前用 +`catalog.py roots --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 /scripts/catalog.py select --root \ [--category CAT] [--id ID]... [--tag smoke|full|slow] \ [--max-risk ] [--driver script|browser|mobile] \ - [--automation automated|assisted|manual|any] + [--automation automated|assisted|manual|any] \ + [--platform android|ios]... \ + [--artifact android=] [--artifact ios=] \ + [--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.`,所以 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 /scripts/catalog.py select --root \ | mapping | 整体覆盖顶层,不合并(`source: category`)| - 同一生效探测一次运行只请求一次,结果复用。 -- 探测不可达:用它的分类标 environment-fail,其余分类照跑。 -- 命中 `stop_if`:把用它的分类标 environment-fail 并记 `observed` 值,其余分类照跑;所有选中分类都被挡住时,整轮结论才是 environment-fail。 +- 探测不可达或命中 `stop_if`:在 preflight 阶段记录对应分类/平台的不可用项并停止整轮; + 先返回用户处理,不创建后续测试 session。只有所有本轮目标环境通过,才进入用例执行。 ## 2.1 移动端平台环境闸门与用户引导 @@ -79,7 +136,9 @@ python3 /scripts/catalog.py select --root \ 3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container ` 验证。 4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。 -任一项不满足,都要在执行记录中把受影响的移动用例标为 `environment-fail`,不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化后续移动 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。 +任一项不满足,都要在 preflight 汇总中列出平台、具体检查项、实际探测命令和简短输出, +然后停止整轮;不能先执行其它平台,不能改成 API 测试,也不能把它们标成产品 `fail`。 +只有 preflight 全部通过后,才把对应移动用例的结果写入运行快照。 ### 环境缺失时的用户引导 diff --git a/skills/tester/references/schema.md b/skills/tester/references/schema.md index 9c504de..34411c7 100644 --- a/skills/tester/references/schema.md +++ b/skills/tester/references/schema.md @@ -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 活着就附着) | diff --git a/skills/tester/scripts/catalog.py b/skills/tester/scripts/catalog.py index 95c65e0..96a8d6d 100755 --- a/skills/tester/scripts/catalog.py +++ b/skills/tester/scripts/catalog.py @@ -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.", + ) 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":