fix(tester): discover catalogs in subprojects

This commit is contained in:
2026-10-14 09:26:39 +08:00
parent 95946d4278
commit f8e490e9b5
4 changed files with 108 additions and 7 deletions
+3 -1
View File
@@ -13,7 +13,9 @@
## 使用前准备 ## 使用前准备
- 目录在项目根的 `.pouch/tester/`。没有就说「初始化 tester」。 - 目录在目标测试项目根的 `.pouch/tester/`。父仓库和 `app` 等 Git submodule 可以各自有
catalog;先运行 `catalog.py roots --root <repo-root>` 发现它们,不要把两个项目的 cases 合并。
没有任何 catalog 才说「初始化 tester」。
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认 - 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认
`HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量 `HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量
`HX_TEST_HOST` 覆盖。 `HX_TEST_HOST` 覆盖。
+26 -3
View File
@@ -11,7 +11,30 @@ description: >-
# Tester:项目测试目录 # Tester:项目测试目录
开始时解析本 `SKILL.md` 所在目录为 `<skill-dir>`。项目根用 开始时解析本 `SKILL.md` 所在目录为 `<skill-dir>`。项目根用
`git rev-parse --show-toplevel`。目录固定为 `<root>/.pouch/tester/`。 `git rev-parse --show-toplevel`。目录固定为目标测试项目的 `<root>/.pouch/tester/`;父仓库
和 Git submodule 可以各自拥有独立目录。
## 多测试项目 / 子项目
一个仓库可能同时包含后端测试目录和 `app` 等子项目测试目录。用例属于拥有
`catalog.yaml` 的项目,不能把多个 catalog 的 cases 合并成一份清单或共用一次运行快照。
开始列出或运行前,先发现目标仓库下的独立测试根:
```bash
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`,不要猜测或合并;
- 每个测试根单独生成 `.pouch/tester/runs/*.yaml`/`.csv`,交付记录可分别引用这些快照。
如果父仓库没有 catalog 但子项目有 catalog,不能把“父目录无 catalog”报告为环境失败;先用
`roots` 找到子项目并继续。只有发现不到任何 catalog,才按缺少 tester 目录处理。
Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>` binding,由共享 Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>` binding,由共享
`agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml` `agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml`
@@ -21,7 +44,7 @@ Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>`
用脚本读写目录,不要把 `cases/` 整库灌进上下文。术语与命名理由见 [references/concepts.md](references/concepts.md): 用脚本读写目录,不要把 `cases/` 整库灌进上下文。术语与命名理由见 [references/concepts.md](references/concepts.md):
```bash ```bash
python3 <skill-dir>/scripts/catalog.py <list|select|show|validate|next-id|run-script> \ python3 <skill-dir>/scripts/catalog.py <roots|list|select|show|validate|next-id|run-script> \
--root <project-root> [参数] --root <project-root> [参数]
``` ```
@@ -34,7 +57,7 @@ tester agent 配置。
## 选择模式 ## 选择模式
- 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。 - 先用 `roots` 检查当前仓库及子项目;没有任何 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。
- 列出、有哪些测试、一共多少条:执行「列出」。 - 列出、有哪些测试、一共多少条:执行「列出」。
- 看某一条怎么测:执行「查看」。 - 看某一条怎么测:执行「查看」。
- 加一条 / 完善 / 改验证 / 退役:执行「维护」。 - 加一条 / 完善 / 改验证 / 退役:执行「维护」。
+4 -3
View File
@@ -9,9 +9,10 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi
## 0. 准备执行目标 ## 0. 准备执行目标
先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标项目必须包含 先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标测试项目必须包含
可读取的 `.pouch/tester/`;如果目标目录没有测试目录,记为 `environment-fail`,不要 可读取的 `.pouch/tester/catalog.yaml`。父仓库可能只是包含多个测试子项目;运行前用
偷偷回退到当前项目,也不要把测试文件复制过去。 `catalog.py roots --root <repo-root>` 发现并选择目标根,不能把父仓库无 catalog 当成失败,
也不能偷偷回退到另一个子项目或复制测试文件。每个目标根单独生成运行快照。
`catalog.py select` 输出的 `agent` 是本轮使用的配置快照。记录中要保留 Agent、目标 `catalog.py select` 输出的 `agent` 是本轮使用的配置快照。记录中要保留 Agent、目标
环境、主机、项目、路径、worktree id、terminal handle,以及 terminal 是否成功关闭。 环境、主机、项目、路径、worktree id、terminal handle,以及 terminal 是否成功关闭。
+75
View File
@@ -55,6 +55,55 @@ def catalog_dir(root: Path) -> Path:
return root / ".pouch" / "tester" 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: def load_yaml(path: Path) -> Any:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
data = yaml.safe_load(text) data = yaml.safe_load(text)
@@ -829,6 +878,29 @@ def cmd_export_csv(root: Path) -> int:
return run_export_csv(root) 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: def cmd_next_id(root: Path, category: str) -> int:
meta = load_catalog_meta(root) meta = load_catalog_meta(root)
cat_ids = [c.get("id") for c in meta.get("categories") or [] if isinstance(c, dict)] 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 = sub.add_parser("show", help="打印一条用例", parents=[parent])
p_show.add_argument("--id", required=True, dest="case_id") p_show.add_argument("--id", required=True, dest="case_id")
sub.add_parser("validate", help="校验目录", parents=[parent]) 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]) sub.add_parser("export-csv", help="写出给人看的 cases.csv", parents=[parent])
p_next = sub.add_parser("next-id", help="分配下一个 TC-<CAT>-NNN", parents=[parent]) p_next = sub.add_parser("next-id", help="分配下一个 TC-<CAT>-NNN", parents=[parent])
p_next.add_argument("--category", required=True) 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) return cmd_show(root, args.case_id)
if args.cmd == "validate": if args.cmd == "validate":
return cmd_validate(root) return cmd_validate(root)
if args.cmd == "roots":
return cmd_roots(root)
if args.cmd == "export-csv": if args.cmd == "export-csv":
return cmd_export_csv(root) return cmd_export_csv(root)
if args.cmd == "next-id": if args.cmd == "next-id":