diff --git a/skills/tester/README.md b/skills/tester/README.md index 85db5c9..037fa35 100644 --- a/skills/tester/README.md +++ b/skills/tester/README.md @@ -13,7 +13,9 @@ ## 使用前准备 -- 目录在项目根的 `.pouch/tester/`。没有就说「初始化 tester」。 +- 目录在目标测试项目根的 `.pouch/tester/`。父仓库和 `app` 等 Git submodule 可以各自有 + catalog;先运行 `catalog.py roots --root ` 发现它们,不要把两个项目的 cases 合并。 + 没有任何 catalog 才说「初始化 tester」。 - 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认 `HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量 `HX_TEST_HOST` 覆盖。 diff --git a/skills/tester/SKILL.md b/skills/tester/SKILL.md index 5d9afcb..fb4c361 100644 --- a/skills/tester/SKILL.md +++ b/skills/tester/SKILL.md @@ -11,7 +11,30 @@ description: >- # Tester:项目测试目录 开始时解析本 `SKILL.md` 所在目录为 ``。项目根用 -`git rev-parse --show-toplevel`。目录固定为 `/.pouch/tester/`。 +`git rev-parse --show-toplevel`。目录固定为目标测试项目的 `/.pouch/tester/`;父仓库 +和 Git submodule 可以各自拥有独立目录。 + +## 多测试项目 / 子项目 + +一个仓库可能同时包含后端测试目录和 `app` 等子项目测试目录。用例属于拥有 +`catalog.yaml` 的项目,不能把多个 catalog 的 cases 合并成一份清单或共用一次运行快照。 + +开始列出或运行前,先发现目标仓库下的独立测试根: + +```bash +python3 /scripts/catalog.py roots --root +``` + +选择规则: + +- `/.pouch/tester/catalog.yaml` 存在时,它是一个独立测试根; +- 同时检查 Git submodule 和一层普通子项目目录; +- 只有一个嵌套 catalog 时,可直接用该路径继续 `list/select/run`; +- 有多个 catalog 时按用户指定的组件分别执行,例如 `--root app`,不要猜测或合并; +- 每个测试根单独生成 `.pouch/tester/runs/*.yaml`/`.csv`,交付记录可分别引用这些快照。 + +如果父仓库没有 catalog 但子项目有 catalog,不能把“父目录无 catalog”报告为环境失败;先用 +`roots` 找到子项目并继续。只有发现不到任何 catalog,才按缺少 tester 目录处理。 Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.` binding,由共享 `agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml` @@ -21,7 +44,7 @@ Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.` 用脚本读写目录,不要把 `cases/` 整库灌进上下文。术语与命名理由见 [references/concepts.md](references/concepts.md): ```bash -python3 /scripts/catalog.py \ +python3 /scripts/catalog.py \ --root [参数] ``` @@ -34,7 +57,7 @@ tester agent 配置。 ## 选择模式 -- 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。 +- 先用 `roots` 检查当前仓库及子项目;没有任何 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。 - 列出、有哪些测试、一共多少条:执行「列出」。 - 看某一条怎么测:执行「查看」。 - 加一条 / 完善 / 改验证 / 退役:执行「维护」。 diff --git a/skills/tester/references/run.md b/skills/tester/references/run.md index 54107ae..5a02891 100644 --- a/skills/tester/references/run.md +++ b/skills/tester/references/run.md @@ -9,9 +9,10 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi ## 0. 准备执行目标 -先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标项目必须包含 -可读取的 `.pouch/tester/`;如果目标目录没有测试目录,记为 `environment-fail`,不要 -偷偷回退到当前项目,也不要把测试文件复制过去。 +先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标测试项目必须包含 +可读取的 `.pouch/tester/catalog.yaml`。父仓库可能只是包含多个测试子项目;运行前用 +`catalog.py roots --root ` 发现并选择目标根,不能把父仓库无 catalog 当成失败, +也不能偷偷回退到另一个子项目或复制测试文件。每个目标根单独生成运行快照。 `catalog.py select` 输出的 `agent` 是本轮使用的配置快照。记录中要保留 Agent、目标 环境、主机、项目、路径、worktree id、terminal handle,以及 terminal 是否成功关闭。 diff --git a/skills/tester/scripts/catalog.py b/skills/tester/scripts/catalog.py index c344de7..95c65e0 100755 --- a/skills/tester/scripts/catalog.py +++ b/skills/tester/scripts/catalog.py @@ -55,6 +55,55 @@ def catalog_dir(root: Path) -> Path: return root / ".pouch" / "tester" +def catalog_path(root: Path) -> Path: + return catalog_dir(root) / "catalog.yaml" + + +def discover_catalog_roots(root: Path) -> list[Path]: + """Find tester projects owned by a repository and its Git submodules. + + A monorepo or a parent repository may contain more than one independent + tester catalog. Cases must stay scoped to the project that owns them; this + helper only discovers roots and never merges their cases. + """ + root = root.resolve() + candidates: list[Path] = [] + if catalog_path(root).is_file(): + candidates.append(root) + + # Prefer the authoritative submodule list so generated directories and + # vendored dependencies are not accidentally treated as test projects. + try: + proc = subprocess.run( + ["git", "-C", str(root), "config", "--file", ".gitmodules", "--get-regexp", r"^submodule\..*\.path$"], + capture_output=True, + text=True, + check=False, + ) + for line in proc.stdout.splitlines(): + _, _, raw_path = line.partition(" ") + subroot = (root / raw_path.strip()).resolve() + if raw_path.strip() and catalog_path(subroot).is_file() and subroot not in candidates: + candidates.append(subroot) + except OSError: + pass + + # Also support a plain nested package that is not declared as a submodule. + # Only inspect a shallow set of project directories; callers can pass that + # directory explicitly when a deeper layout is intentional. + ignored = {".git", ".pouch", "node_modules", "vendor", "third_party", "dist", "build"} + try: + children = list(root.iterdir()) + except OSError: + children = [] + for child in children: + if not child.is_dir() or child.name in ignored or child.name.startswith("."): + continue + if catalog_path(child).is_file() and child.resolve() not in candidates: + candidates.append(child.resolve()) + return sorted(candidates) + + def load_yaml(path: Path) -> Any: text = path.read_text(encoding="utf-8") data = yaml.safe_load(text) @@ -829,6 +878,29 @@ def cmd_export_csv(root: Path) -> int: return run_export_csv(root) +def cmd_roots(root: Path) -> int: + """List independent tester catalog roots below a repository.""" + roots = discover_catalog_roots(root) + entries: list[dict[str, str]] = [] + for item in roots: + project = "" + try: + meta = load_catalog_meta(item) + project = str((meta.get("project") or {}).get("name") or "") + except (SystemExit, AttributeError): + pass + entries.append( + { + "root": str(item), + "catalog": str(catalog_path(item)), + "project": project, + "relative": str(item.relative_to(root.resolve())) if item != root.resolve() else ".", + } + ) + print(json.dumps({"base": str(root.resolve()), "count": len(entries), "roots": entries}, ensure_ascii=False, indent=2)) + return 0 + + def cmd_next_id(root: Path, category: str) -> int: meta = load_catalog_meta(root) cat_ids = [c.get("id") for c in meta.get("categories") or [] if isinstance(c, dict)] @@ -988,6 +1060,7 @@ def build_parser() -> argparse.ArgumentParser: 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]) + sub.add_parser("roots", help="发现当前仓库及子项目的 tester 根目录", parents=[parent]) sub.add_parser("export-csv", help="写出给人看的 cases.csv", parents=[parent]) p_next = sub.add_parser("next-id", help="分配下一个 TC--NNN", parents=[parent]) p_next.add_argument("--category", required=True) @@ -1008,6 +1081,8 @@ def main(argv: list[str] | None = None) -> int: return cmd_show(root, args.case_id) if args.cmd == "validate": return cmd_validate(root) + if args.cmd == "roots": + return cmd_roots(root) if args.cmd == "export-csv": return cmd_export_csv(root) if args.cmd == "next-id":