feat(tester): add catalog-driven black-box test skill
Maintain and run project test catalogs under .pouch/tester, filtered by category, id, and risk (safe/act/harm). Scripts generate the human CSV from cases YAML.
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
# tester
|
||||||
|
|
||||||
|
维护并执行项目里的黑盒测试目录。用例按分类放在 `.pouch/tester/`,可以列出、增改,也可以按分类或编号去跑。
|
||||||
|
|
||||||
|
## 什么时候使用
|
||||||
|
|
||||||
|
- 「列出测试」「一共有哪些用例」
|
||||||
|
- 「加一条相机开盖的测试」「完善 TC-CAM-003 的验证」
|
||||||
|
- 「跑 smoke」「测 CAM」「跑 TC-INF-001 和 TC-CAM-001」
|
||||||
|
- 「现场复测」
|
||||||
|
|
||||||
|
不管 ACK。这是项目自己的测试目录。
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
- 目录在项目根的 `.pouch/tester/`。没有就说「初始化 tester」。
|
||||||
|
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`,填 `HOST`。也可以用环境变量 `HX_TEST_HOST`。
|
||||||
|
- 会动硬件、会改数据的用例带风险级别。默认只跑最低档(只读)。要跑更高风险,需要你明确说,例如「风险到 hardware」。
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
列出测试用例
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
加一条测试:分类 CAM,风险 hardware,开盖再关盖,用脚本打 /api/camera_3d
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
跑 INF 分类的 smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
跑 TC-CAM-001,TC-CAM-005,风险保持默认
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent 会做什么
|
||||||
|
|
||||||
|
1. 用脚本读 `.pouch/tester/`,按分类统计或筛出你点名的用例,不会把整库塞进对话。
|
||||||
|
2. 改用例前先给你看将要写入的内容,你确认再保存。
|
||||||
|
3. 执行时:脚本类直接跑命令;浏览器和移动模拟器先初始化一次,同一轮里复用。
|
||||||
|
4. 超出你允许的风险级别会跳过,不会偷偷执行。
|
||||||
|
5. 跑完给出按分类计数的结果,以及 `pass / fail / environment-fail / skipped`。
|
||||||
|
|
||||||
|
## 如何判断完成
|
||||||
|
|
||||||
|
- **列出**:能看到分类表和总条数;`.pouch/tester/cases.csv` 已由 skill 里的 `generate_cases_csv.py` 从 `cases/` 重新生成。
|
||||||
|
- **维护**:目标 id 能 `查看` 到,且检查通过。
|
||||||
|
- **运行**:有本次 `runs/` 快照,每条都有判定和证据;没填 HOST 时会停,而不是编造失败。
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: tester
|
||||||
|
description: >-
|
||||||
|
维护并执行项目黑盒测试目录(.pouch/tester):列出/增改用例,按分类或
|
||||||
|
id 跑脚本、浏览器或移动模拟器。触发词:/tester、列出测试、完善测试用例、
|
||||||
|
按分类测、跑 smoke、现场复测。默认风险最低档,不猜 HOST。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tester:项目测试目录
|
||||||
|
|
||||||
|
开始时解析本 `SKILL.md` 所在目录为 `<skill-dir>`。项目根用
|
||||||
|
`git rev-parse --show-toplevel`。目录固定为 `<root>/.pouch/tester/`。
|
||||||
|
|
||||||
|
用脚本读写目录,不要把 `cases/` 整库灌进上下文:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/catalog.py <list|select|show|validate|next-id|run-script> \
|
||||||
|
--root <project-root> [参数]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 选择模式
|
||||||
|
|
||||||
|
- 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。
|
||||||
|
- 列出、有哪些测试、一共多少条:执行「列出」。
|
||||||
|
- 看某一条怎么测:执行「查看」。
|
||||||
|
- 加一条 / 完善 / 改验证 / 退役:执行「维护」。
|
||||||
|
- 检查目录是否完整:执行「检查」。
|
||||||
|
- 跑测试、按分类测、按 id 测、跑 smoke:执行「运行」。
|
||||||
|
|
||||||
|
## 边界(所有模式)
|
||||||
|
|
||||||
|
- 不猜 HOST / Base URL / 是否已授权。缺 HOST 则停,记 environment-fail,不用 localhost。
|
||||||
|
- 默认风险上限是 catalog 里的 `default_max_risk`。超出上限的条目标 `skipped`,禁止执行。
|
||||||
|
- `risk` 只能是 `safe` / `act` / `harm`。`suite=smoke` 只能配 `risk=safe`。规则见 [levels.md](references/levels.md)。
|
||||||
|
- `surface=browser` 必须开真实页面;`surface=mobile` 必须挂模拟器 session。二者都不得改成只打 API。
|
||||||
|
- 浏览器、移动模拟器按 catalog `sessions` 初始化一次,同一次运行内复用。
|
||||||
|
- 环境失败(连不上、缺浏览器、下游超时、session 挂了)不是产品失败。
|
||||||
|
- 不改产品代码,不提交,不把成绩单当操作手册。
|
||||||
|
- 禁止把完整 `cases/` 贴进 prompt。筛选用 `select`,单条用 `show`。
|
||||||
|
|
||||||
|
## 初始化
|
||||||
|
|
||||||
|
用户点名才跑。已有 `catalog.yaml` 则不覆盖,转入「检查」。
|
||||||
|
|
||||||
|
1. 建 `.pouch/tester/{cases,runs}/`。
|
||||||
|
2. 若不存在,从 `<skill-dir>/templates/` 拷 `catalog.yaml` 和 `local.yaml.example`。
|
||||||
|
3. 不写 `local.yaml` 的 HOST。不猜地址。
|
||||||
|
4. 跑 `catalog.py validate --root <root>`。无用例是「部分完成」,不是失败。
|
||||||
|
|
||||||
|
```text
|
||||||
|
## tester 初始化:完成 | 部分完成 | 阻塞
|
||||||
|
|
||||||
|
已具备: …
|
||||||
|
待配置: 路径 + 字段 + 缺了会挡住哪步
|
||||||
|
下一步: 一句话
|
||||||
|
```
|
||||||
|
|
||||||
|
## 列出
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/catalog.py list --root <project-root>
|
||||||
|
```
|
||||||
|
|
||||||
|
把脚本打印的分类表原样给用户。不要手算条数。清单 CSV 由独立脚本从 `cases/*.yaml` 生成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/generate_cases_csv.py --root <project-root>
|
||||||
|
```
|
||||||
|
|
||||||
|
`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。
|
||||||
|
|
||||||
|
## 查看
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
只展示这一条的 how / expected / risk / surface。字段对不上时再读 [schema.md](references/schema.md)。
|
||||||
|
|
||||||
|
## 检查
|
||||||
|
|
||||||
|
只读。跑 `validate`。HOST 是否已配置只报「已设 / 未设」,不要把 `local.yaml` 全文贴出来。用初始化同一套报告,标题改为 `## tester 检查:…`。
|
||||||
|
|
||||||
|
## 维护
|
||||||
|
|
||||||
|
读 [maintain.md](references/maintain.md)。写 `suite`/`risk` 时读 [levels.md](references/levels.md)。先 `show` 或 `next-id`,拟好条目给用户确认再写文件,然后 `validate`。
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
读 [run.md](references/run.md)。先 `select` 得到命中列表,再按 surface 分流。未指定范围时等同「suite=smoke 且风险上限=默认」。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# suite 与 risk
|
||||||
|
|
||||||
|
维护或用户问套件/安全级别时读本文件。两套词对所有项目相同,不要自造名字。
|
||||||
|
|
||||||
|
## suite:跑哪一批
|
||||||
|
|
||||||
|
只表示耗时和依赖,不表示危不危险。危不危险看 `risk`。
|
||||||
|
|
||||||
|
业界常见还有 sanity(窄回归,和 smoke 重叠)、regression(≈本 skill 的 full)、acceptance、nightly。本 skill **不采用** sanity/acceptance:和 smoke/full 分不清,且已有 category、id、risk 三层筛选。
|
||||||
|
|
||||||
|
固定三个词:
|
||||||
|
|
||||||
|
| 值 | 何时用 |
|
||||||
|
|----|--------|
|
||||||
|
| `smoke` | 最短路径证明系统还活着。必须快、少依赖、**`risk` 只能是 `safe`**。 |
|
||||||
|
| `full` | 日常回归里其余自动化用例。说「跑 full」仍受风险上限约束。 |
|
||||||
|
| `slow` | 太慢或环境太重,不进日常 full:真实焊接、付费 API、要工件/账号、跑很久。 |
|
||||||
|
|
||||||
|
口诀:
|
||||||
|
|
||||||
|
1. 只回答「活着吗、主入口能不能用」→ `smoke`。
|
||||||
|
2. 要特殊现场/很久才能跑完 → `slow`。
|
||||||
|
3. 其余自动化 → `full`。
|
||||||
|
4. 拿不准 → `full`。不要把会动真机或会删数据的放进 `smoke`。
|
||||||
|
|
||||||
|
未指定套件时只跑 `smoke`。`跑 full` 不含 `slow`。
|
||||||
|
|
||||||
|
## risk:副作用有多大
|
||||||
|
|
||||||
|
三个短词,网站 / App / 机器人共用。`catalog.yaml` 的 `risks` 必须按此顺序,且以 `safe` 开头:
|
||||||
|
|
||||||
|
| 值 | 含义 | 网站 | App | 机器人 |
|
||||||
|
|----|------|------|-----|--------|
|
||||||
|
| `safe` | 只看,不留下变化 | 打开页、只读 API | 打开界面 | 读位姿、查状态 |
|
||||||
|
| `act` | 会动手,一般可补救 | 提交表单、改配置 | 发消息、保存 | 点动、使能、开盖扫描 |
|
||||||
|
| `harm` | 难恢复或伤人/伤钱/伤环境 | 删库、真实扣费 | 清数据、内购 | 急停、断电、真焊 |
|
||||||
|
|
||||||
|
默认只跑 `safe`。项目细分闸(点动 vs 焊接)用可选 `gate`,不要再发明 risk 名。
|
||||||
|
|
||||||
|
`suite=smoke` 且 `risk!=safe` 为非法。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# tester 维护用例
|
||||||
|
|
||||||
|
只在用户要加、改、退役时读本文件。写之前必须把完整条目亮给用户,确认后再落盘。
|
||||||
|
|
||||||
|
## 加一条
|
||||||
|
|
||||||
|
1. 分类必须已在 `catalog.yaml` 的 `categories` 里。没有就先改 catalog(仍要确认)。
|
||||||
|
2. 取编号:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/catalog.py next-id --root <root> --category <CAT>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 拟一条符合 [schema.md](schema.md) 的用例。`suite`/`risk` 按 [levels.md](levels.md):`smoke` 只能 `safe`;会动手用 `act`,难恢复用 `harm`。细分授权用 `gate`,不要发明新的 risk 名。
|
||||||
|
4. `surface=browser|mobile` 必须填 `session`,且 catalog 里已声明。
|
||||||
|
5. `what` 和 `expected` 写成可观察信号,禁止「应该正常」。
|
||||||
|
6. 给用户看拟写内容。确认后写入 `cases/<category小写>.yaml` 的 `cases` 列表末尾。
|
||||||
|
7. `validate`。失败则修好再结束。通过后跑 `generate_cases_csv.py --root <root>` 刷新 `.pouch/tester/cases.csv`。不要手改这份 CSV。
|
||||||
|
|
||||||
|
## 完善 / 更新
|
||||||
|
|
||||||
|
1. `show --id <id>`。
|
||||||
|
2. 只改用户点名的字段(常见:`how`、`expected`、`risk`、`notes`)。
|
||||||
|
3. 展示 diff 或更新后的整条。确认后写回。
|
||||||
|
4. `validate`。通过后跑 `generate_cases_csv.py` 刷新 `cases.csv`。
|
||||||
|
|
||||||
|
## 退役
|
||||||
|
|
||||||
|
把 `status` 改为 `retired`,不要删文件、不要复用 id。`notes` 写原因。确认后写回,再跑 `generate_cases_csv.py`。
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- 未确认就写
|
||||||
|
- 把 browser/mobile 改成 script 来「省事」
|
||||||
|
- 为了能在默认风险下跑而把 `risk` 改低
|
||||||
|
- 一次塞进无关的一批用例
|
||||||
|
- 手改 `runs/` 冒充执行记录
|
||||||
|
- 手改 `cases.csv`(必须从 `cases/*.yaml` 生成)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# tester 运行
|
||||||
|
|
||||||
|
只在用户要跑测试时读本文件。
|
||||||
|
|
||||||
|
## 1. 选范围
|
||||||
|
|
||||||
|
未指定范围 → `--suite smoke`,风险上限 = `default_max_risk`。
|
||||||
|
|
||||||
|
用户说分类、id、suite、风险上限时,原样传给 select:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
||||||
|
[--category CAT] [--id ID]... [--suite smoke|full|slow] \
|
||||||
|
[--max-risk <risk>] [--surface script|browser|mobile]
|
||||||
|
```
|
||||||
|
|
||||||
|
`select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明,不执行)。
|
||||||
|
|
||||||
|
无命中:停下说明筛选条件,不要扩大范围。
|
||||||
|
|
||||||
|
## 2. 运行前闸
|
||||||
|
|
||||||
|
- `HOST` 未设(环境变量 / `HX_TEST_HOST` / `local.yaml` 都没有):全部停,environment-fail。
|
||||||
|
- 命中条的 `risk` 在 catalog `auth_env` 里有对应变量:变量不是 `true` 则该条 `skipped`。有 `gate` 时还要 `catalog.gates` 对应变量为 `true`。即使用户点了 id 也不执行。
|
||||||
|
- 若 catalog 声明了 `status_probe`:先请求它。命中 `stop_if` → 整轮停,environment-fail。
|
||||||
|
|
||||||
|
## 3. Session
|
||||||
|
|
||||||
|
看命中条用到的 `session` 键:
|
||||||
|
|
||||||
|
- 尚未初始化:按 catalog `sessions.<name>.init` 做。`reuse: persist` 的先 `probe`,活着就附着。
|
||||||
|
- 本轮已初始化:直接用,不要重开浏览器或模拟器。
|
||||||
|
- `reuse: run`:整轮结束再关。`persist`:不关。
|
||||||
|
- 中途 session 挂了:后续同 surface 标 environment-fail,不要标产品失败。
|
||||||
|
|
||||||
|
## 4. 执行
|
||||||
|
|
||||||
|
按 select 返回的顺序,一条一条来。默认失败即停(产品 `fail` 或探测到的不安全状态)。用户说继续 / keep-going 才往后跑。
|
||||||
|
|
||||||
|
**script**
|
||||||
|
|
||||||
|
有 `how.run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
`run-script` 只跑命令,返回 `executed` + stdout/exit(或 skipped / environment-fail)。**pass/fail 仍由 Agent 对照 `expected` 判定**,不要把 curl 退出码 0 当成产品通过。
|
||||||
|
|
||||||
|
没有 `run`:按 `how.steps` 做,证据对照 `expected`。不要发明 steps 里没有的调用。
|
||||||
|
|
||||||
|
**browser / mobile**
|
||||||
|
|
||||||
|
挂到已有 session,执行 `how.steps`,对照 `expected`。必须留下可见文本、URL 或交互证据。
|
||||||
|
|
||||||
|
## 5. 记录与报告
|
||||||
|
|
||||||
|
每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped`。
|
||||||
|
|
||||||
|
写入 `.pouch/tester/runs/<UTC时间>-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、逐条结果。
|
||||||
|
|
||||||
|
对话报告:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## tester 运行:完成 | 部分完成 | 阻塞
|
||||||
|
|
||||||
|
范围: …
|
||||||
|
风险上限: …
|
||||||
|
session: browser=复用|新开|未用;mobile=…
|
||||||
|
计数: pass=N fail=N environment-fail=N skipped=N
|
||||||
|
失败: id + 一句证据
|
||||||
|
跳过: id + 原因
|
||||||
|
快照: .pouch/tester/runs/<file>
|
||||||
|
下一步: 一句话
|
||||||
|
```
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# tester 目录字段
|
||||||
|
|
||||||
|
项目根 `.pouch/tester/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
catalog.yaml 分类、风险阶梯、session、变量
|
||||||
|
local.yaml.example HOST 与授权开关模板
|
||||||
|
local.yaml 本机覆盖,不提交
|
||||||
|
cases/<category>.yaml 一个分类一个文件(执行源)
|
||||||
|
cases.csv 给人看的扁平清单;skill 的 generate_cases_csv.py 读 cases/*.yaml 生成
|
||||||
|
runs/ 每次运行快照
|
||||||
|
```
|
||||||
|
|
||||||
|
## catalog.yaml
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `categories` | `{id, title}` 列表,id 与 `cases/<id小写>.yaml` 对应 |
|
||||||
|
| `risks` | 从低到高,只能是 `safe` → `act` → `harm` 的一段,且以 `safe` 开头。含义见 [levels.md](levels.md) |
|
||||||
|
| `default_max_risk` | 未指定时的运行上限,必须是 `risks` 里的值 |
|
||||||
|
| `vars` | 可替换变量,如 `API_BASE: http://${HOST}:8095` |
|
||||||
|
| `auth_env` | 风险名 → 环境变量名。该档执行前变量必须为 `true` |
|
||||||
|
| `gates` | 可选。项目细分闸名 → 环境变量。用在用例的 `gate` 上,不要拿它代替 `risk` |
|
||||||
|
| `sessions` | 可复用会话。`kind`: `browser` / `mobile`;`reuse`: `run`(本轮结束关掉)或 `persist`(probe 活着就附着) |
|
||||||
|
| `id_pattern` | 用例 id 正则 |
|
||||||
|
|
||||||
|
`local.yaml` 与环境变量覆盖 `HOST` 和授权开关。查找顺序:环境变量 > `local.yaml` > `catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。
|
||||||
|
|
||||||
|
## 一条用例
|
||||||
|
|
||||||
|
写在对应分类文件的 `cases:` 列表里。
|
||||||
|
|
||||||
|
| 字段 | 必填 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | 是 | 匹配 `id_pattern` |
|
||||||
|
| `category` | 是 | 必须等于文件声明的 category |
|
||||||
|
| `title` | 是 | 短名 |
|
||||||
|
| `what` | 是 | 一句可观察的「测什么」 |
|
||||||
|
| `status` | 是 | `active` / `retired` |
|
||||||
|
| `suite` | 是 | `smoke` / `full` / `slow`,判定见 [levels.md](levels.md) |
|
||||||
|
| `risk` | 是 | `safe` / `act` / `harm` |
|
||||||
|
| `gate` | 否 | 项目细分闸,必须在 catalog `gates` 里 |
|
||||||
|
| `surface` | 是 | `script` / `browser` / `mobile` |
|
||||||
|
| `session` | browser/mobile 必填 | 指向 catalog `sessions` 的键 |
|
||||||
|
| `how` | 是 | 怎么测,见下 |
|
||||||
|
| `expected` | 是 | 怎么过,至少一条 `{kind, value}` |
|
||||||
|
| `maps_to` | 否 | 对人文档的功能项 id |
|
||||||
|
| `requires` | 否 | 前置标签 |
|
||||||
|
| `skip_unless` | 否 | 额外跳过条件(环境变量表达式) |
|
||||||
|
| `setup` | 否 | 人话前置 |
|
||||||
|
| `cleanup` | 否 | 跑完回退 |
|
||||||
|
| `notes` | 否 | 备注 |
|
||||||
|
|
||||||
|
## how
|
||||||
|
|
||||||
|
- `script`:`how.run` 是一条可在项目根执行的命令(可多行脚本)。没有 `run` 时 Agent 按 `how.steps` 做,仍不得发明未写明的请求。
|
||||||
|
- `browser`:`how.url` + `how.steps`。`expected.kind` 用 `visible-text` / `url` / `interaction`。
|
||||||
|
- `mobile`:`how.steps`,挂到 `session` 指定的模拟器。`expected` 同样是可观察信号。
|
||||||
|
|
||||||
|
可选 `how.timeout`,秒,默认 60。
|
||||||
|
|
||||||
|
## expected.kind
|
||||||
|
|
||||||
|
`api-status` / `api-field` / `script-exit` / `visible-text` / `url` / `interaction`
|
||||||
|
|
||||||
|
## 判定
|
||||||
|
|
||||||
|
`pass` / `fail` / `environment-fail` / `skipped`
|
||||||
Executable
+634
@@ -0,0 +1,634 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""tester catalog CLI: list / select / show / validate / next-id / run-script."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
SURFACES = ("script", "browser", "mobile")
|
||||||
|
STATUSES = ("active", "retired")
|
||||||
|
SUITES = ("smoke", "full", "slow")
|
||||||
|
CANONICAL_RISKS = ("safe", "act", "harm")
|
||||||
|
RESULTS = ("pass", "fail", "environment-fail", "skipped")
|
||||||
|
EXPECTED_KINDS = (
|
||||||
|
"api-status",
|
||||||
|
"api-field",
|
||||||
|
"script-exit",
|
||||||
|
"visible-text",
|
||||||
|
"url",
|
||||||
|
"interaction",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_dir(root: Path) -> Path:
|
||||||
|
return root / ".pouch" / "tester"
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path: Path) -> Any:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
data = yaml.safe_load(text)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def dump_yaml(data: Any) -> str:
|
||||||
|
return yaml.safe_dump(
|
||||||
|
data,
|
||||||
|
allow_unicode=True,
|
||||||
|
sort_keys=False,
|
||||||
|
default_flow_style=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def truthy(value: Any) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog_meta(root: Path) -> dict[str, Any]:
|
||||||
|
path = catalog_dir(root) / "catalog.yaml"
|
||||||
|
if not path.is_file():
|
||||||
|
raise SystemExit(f"缺少 {path}")
|
||||||
|
data = load_yaml(path)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"{path} 必须是 mapping")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def load_local(root: Path) -> dict[str, Any]:
|
||||||
|
path = catalog_dir(root) / "local.yaml"
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
data = load_yaml(path) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"{path} 必须是 mapping")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def case_files(root: Path) -> list[Path]:
|
||||||
|
d = catalog_dir(root) / "cases"
|
||||||
|
if not d.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(p for p in d.glob("*.yaml") if p.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_cases(root: Path) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for path in case_files(root):
|
||||||
|
data = load_yaml(path) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"{path} 必须是 mapping")
|
||||||
|
cat = data.get("category")
|
||||||
|
title = data.get("title", "")
|
||||||
|
cases = data.get("cases") or []
|
||||||
|
if not isinstance(cases, list):
|
||||||
|
raise SystemExit(f"{path}: cases 必须是列表")
|
||||||
|
for i, case in enumerate(cases):
|
||||||
|
if not isinstance(case, dict):
|
||||||
|
raise SystemExit(f"{path}: cases[{i}] 必须是 mapping")
|
||||||
|
item = dict(case)
|
||||||
|
item["_file"] = str(path.relative_to(root))
|
||||||
|
if "category" not in item and cat:
|
||||||
|
item["category"] = cat
|
||||||
|
if "_file_category" not in item:
|
||||||
|
item["_file_category"] = cat
|
||||||
|
item["_file_title"] = title
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_vars(root: Path, meta: dict[str, Any]) -> dict[str, str]:
|
||||||
|
merged: dict[str, str] = {}
|
||||||
|
for key, value in (meta.get("vars") or {}).items():
|
||||||
|
merged[str(key)] = "" if value is None else str(value)
|
||||||
|
local = load_local(root)
|
||||||
|
for key, value in local.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
merged[str(key)] = str(value).lower() if isinstance(value, bool) else str(value)
|
||||||
|
auth_keys = set()
|
||||||
|
for mapping in (meta.get("auth_env") or {}, meta.get("gates") or {}):
|
||||||
|
if isinstance(mapping, dict):
|
||||||
|
auth_keys.update(str(value) for value in mapping.values() if value)
|
||||||
|
for key, value in os.environ.items():
|
||||||
|
if key in merged or key in auth_keys or key.startswith("AUTHORIZE_") or key in {
|
||||||
|
"HOST",
|
||||||
|
"HX_TEST_HOST",
|
||||||
|
"WEB_BASE",
|
||||||
|
"API_BASE",
|
||||||
|
"WS_URL",
|
||||||
|
"STATUS_BASE",
|
||||||
|
}:
|
||||||
|
merged[key] = value
|
||||||
|
if not merged.get("HOST") and merged.get("HX_TEST_HOST"):
|
||||||
|
merged["HOST"] = merged["HX_TEST_HOST"]
|
||||||
|
if os.environ.get("HX_TEST_HOST") and not os.environ.get("HOST"):
|
||||||
|
merged["HOST"] = os.environ["HX_TEST_HOST"]
|
||||||
|
for _ in range(10):
|
||||||
|
changed = False
|
||||||
|
for key, value in list(merged.items()):
|
||||||
|
new = substitute(value, merged)
|
||||||
|
if new != value:
|
||||||
|
merged[key] = new
|
||||||
|
changed = True
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def substitute(text: str, vars_map: dict[str, str]) -> str:
|
||||||
|
def repl(match: re.Match[str]) -> str:
|
||||||
|
name = match.group(1)
|
||||||
|
return vars_map.get(name, match.group(0))
|
||||||
|
|
||||||
|
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", repl, text)
|
||||||
|
|
||||||
|
|
||||||
|
def risk_index(meta: dict[str, Any], name: str) -> int:
|
||||||
|
risks = list(meta.get("risks") or [])
|
||||||
|
if name not in risks:
|
||||||
|
raise SystemExit(f"未知风险: {name}(catalog risks={risks})")
|
||||||
|
return risks.index(name)
|
||||||
|
|
||||||
|
|
||||||
|
def host_set(vars_map: dict[str, str]) -> bool:
|
||||||
|
host = (vars_map.get("HOST") or "").strip()
|
||||||
|
return bool(host) and "${HOST}" not in host
|
||||||
|
|
||||||
|
|
||||||
|
def export_csv_script() -> Path:
|
||||||
|
return Path(__file__).resolve().parent / "generate_cases_csv.py"
|
||||||
|
|
||||||
|
|
||||||
|
def run_export_csv(root: Path) -> int:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(export_csv_script()), "--root", str(root)],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return int(proc.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(root: Path) -> int:
|
||||||
|
meta = load_catalog_meta(root)
|
||||||
|
cases = load_all_cases(root)
|
||||||
|
risks = list(meta.get("risks") or [])
|
||||||
|
cats_meta = {c["id"]: c for c in meta.get("categories") or [] if isinstance(c, dict) and "id" in c}
|
||||||
|
active = [c for c in cases if c.get("status", "active") == "active"]
|
||||||
|
retired = [c for c in cases if c.get("status") == "retired"]
|
||||||
|
print(f"## 测试目录 active {len(active)} / retired {len(retired)}")
|
||||||
|
print()
|
||||||
|
headers = ["分类", "标题", "条数", "smoke"] + risks + list(SURFACES)
|
||||||
|
print("| " + " | ".join(headers) + " |")
|
||||||
|
print("| " + " | ".join("---" for _ in headers) + " |")
|
||||||
|
|
||||||
|
cat_ids: list[str] = []
|
||||||
|
for c in meta.get("categories") or []:
|
||||||
|
if isinstance(c, dict) and c.get("id"):
|
||||||
|
cat_ids.append(str(c["id"]))
|
||||||
|
for c in cases:
|
||||||
|
cid = str(c.get("category") or "")
|
||||||
|
if cid and cid not in cat_ids:
|
||||||
|
cat_ids.append(cid)
|
||||||
|
|
||||||
|
for cat in cat_ids:
|
||||||
|
group = [c for c in active if c.get("category") == cat]
|
||||||
|
title = (cats_meta.get(cat) or {}).get("title") or (
|
||||||
|
group[0].get("_file_title") if group else ""
|
||||||
|
)
|
||||||
|
row = [cat, str(title), str(len(group)), str(sum(1 for c in group if c.get("suite") == "smoke"))]
|
||||||
|
for risk in risks:
|
||||||
|
row.append(str(sum(1 for c in group if c.get("risk") == risk)))
|
||||||
|
for surface in SURFACES:
|
||||||
|
row.append(str(sum(1 for c in group if c.get("surface") == surface)))
|
||||||
|
print("| " + " | ".join(row) + " |")
|
||||||
|
print()
|
||||||
|
print("跑法: 跑 <分类> | 跑 <id,id> | 跑 smoke | 跑 <分类> 风险到 <risk>")
|
||||||
|
vars_map = resolve_vars(root, meta)
|
||||||
|
print(f"HOST: {'已设' if host_set(vars_map) else '未设'}")
|
||||||
|
code = run_export_csv(root)
|
||||||
|
if code == 0:
|
||||||
|
print(f"清单: {catalog_dir(root).relative_to(root) / 'cases.csv'}")
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ids(values: list[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for raw in values:
|
||||||
|
for part in str(raw).split(","):
|
||||||
|
item = part.strip()
|
||||||
|
if item:
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||||
|
meta = load_catalog_meta(root)
|
||||||
|
cases = load_all_cases(root)
|
||||||
|
max_risk = args.max_risk or meta.get("default_max_risk") or "safe"
|
||||||
|
max_i = risk_index(meta, max_risk)
|
||||||
|
wanted = set(parse_ids(args.ids or []))
|
||||||
|
selected: list[dict[str, Any]] = []
|
||||||
|
skipped: list[dict[str, str]] = []
|
||||||
|
missing = set(wanted)
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
cid = str(case.get("id") or "")
|
||||||
|
if case.get("status", "active") != "active":
|
||||||
|
if cid in wanted:
|
||||||
|
skipped.append({"id": cid, "reason": "retired"})
|
||||||
|
missing.discard(cid)
|
||||||
|
continue
|
||||||
|
if wanted:
|
||||||
|
if cid not in wanted:
|
||||||
|
continue
|
||||||
|
missing.discard(cid)
|
||||||
|
else:
|
||||||
|
if args.category and case.get("category") != args.category:
|
||||||
|
continue
|
||||||
|
if args.suite and case.get("suite") != args.suite:
|
||||||
|
continue
|
||||||
|
if args.surface and case.get("surface") != args.surface:
|
||||||
|
continue
|
||||||
|
risk = str(case.get("risk") or "safe")
|
||||||
|
try:
|
||||||
|
r_i = risk_index(meta, risk)
|
||||||
|
except SystemExit:
|
||||||
|
skipped.append({"id": cid, "reason": f"未知风险 {risk}"})
|
||||||
|
continue
|
||||||
|
if r_i > max_i:
|
||||||
|
skipped.append({"id": cid, "reason": f"risk={risk} 超过上限 {max_risk}"})
|
||||||
|
continue
|
||||||
|
public = {k: v for k, v in case.items() if not str(k).startswith("_")}
|
||||||
|
selected.append(public)
|
||||||
|
|
||||||
|
if wanted and missing:
|
||||||
|
print(
|
||||||
|
json.dumps({"error": "找不到 active 用例: " + ", ".join(sorted(missing))}, ensure_ascii=False),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"max_risk": max_risk,
|
||||||
|
"count": len(selected),
|
||||||
|
"skipped": skipped,
|
||||||
|
"cases": selected,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_show(root: Path, case_id: str) -> int:
|
||||||
|
for case in load_all_cases(root):
|
||||||
|
if case.get("id") == case_id:
|
||||||
|
public = {k: v for k, v in case.items() if not str(k).startswith("_")}
|
||||||
|
print(dump_yaml(public), end="")
|
||||||
|
return 0
|
||||||
|
print(f"找不到用例: {case_id}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def validate(root: Path) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
meta_path = catalog_dir(root) / "catalog.yaml"
|
||||||
|
if not meta_path.is_file():
|
||||||
|
return [f"缺少 {meta_path}"]
|
||||||
|
try:
|
||||||
|
meta = load_catalog_meta(root)
|
||||||
|
except SystemExit as exc:
|
||||||
|
return [str(exc)]
|
||||||
|
|
||||||
|
cats = meta.get("categories")
|
||||||
|
if not isinstance(cats, list):
|
||||||
|
errors.append("catalog.categories 必须是列表")
|
||||||
|
cats = []
|
||||||
|
cat_ids = []
|
||||||
|
for i, item in enumerate(cats):
|
||||||
|
if not isinstance(item, dict) or not item.get("id"):
|
||||||
|
errors.append(f"catalog.categories[{i}] 需要 id")
|
||||||
|
else:
|
||||||
|
cat_ids.append(str(item["id"]))
|
||||||
|
risks = meta.get("risks")
|
||||||
|
if not isinstance(risks, list) or not risks:
|
||||||
|
errors.append("catalog.risks 必须是非空列表")
|
||||||
|
risks = []
|
||||||
|
if risks and risks[0] != "safe":
|
||||||
|
errors.append("catalog.risks 必须以 safe 开头")
|
||||||
|
last_pos = -1
|
||||||
|
for name in risks:
|
||||||
|
if name not in CANONICAL_RISKS:
|
||||||
|
errors.append(
|
||||||
|
f"非法 risk 名 {name!r},只能是 safe/act/harm"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
pos = CANONICAL_RISKS.index(name)
|
||||||
|
if pos <= last_pos:
|
||||||
|
errors.append("catalog.risks 必须按 safe < act < harm 升序且不重复")
|
||||||
|
last_pos = pos
|
||||||
|
default_max = meta.get("default_max_risk")
|
||||||
|
if default_max not in risks:
|
||||||
|
errors.append(f"default_max_risk={default_max!r} 不在 risks 中")
|
||||||
|
gates = meta.get("gates") or {}
|
||||||
|
if gates and not isinstance(gates, dict):
|
||||||
|
errors.append("catalog.gates 必须是 mapping")
|
||||||
|
gates = {}
|
||||||
|
auth_env = meta.get("auth_env") or {}
|
||||||
|
if auth_env and not isinstance(auth_env, dict):
|
||||||
|
errors.append("catalog.auth_env 必须是 mapping")
|
||||||
|
auth_env = {}
|
||||||
|
sessions = meta.get("sessions") or {}
|
||||||
|
if not isinstance(sessions, dict):
|
||||||
|
errors.append("catalog.sessions 必须是 mapping")
|
||||||
|
sessions = {}
|
||||||
|
pattern = meta.get("id_pattern") or r"^.+$"
|
||||||
|
try:
|
||||||
|
id_re = re.compile(pattern)
|
||||||
|
except re.error as exc:
|
||||||
|
errors.append(f"id_pattern 无效: {exc}")
|
||||||
|
id_re = re.compile(r"^.+$")
|
||||||
|
|
||||||
|
seen: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
cases = load_all_cases(root)
|
||||||
|
except SystemExit as exc:
|
||||||
|
return errors + [str(exc)]
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
cid = str(case.get("id") or "")
|
||||||
|
where = f"{case.get('_file')}:{cid or '?'}"
|
||||||
|
if not cid:
|
||||||
|
errors.append(f"{where}: 缺少 id")
|
||||||
|
continue
|
||||||
|
if not id_re.match(cid):
|
||||||
|
errors.append(f"{where}: id 不匹配 {pattern}")
|
||||||
|
if cid in seen:
|
||||||
|
errors.append(f"{where}: id 重复(也出现在 {seen[cid]})")
|
||||||
|
seen[cid] = str(case.get("_file"))
|
||||||
|
cat = str(case.get("category") or "")
|
||||||
|
file_cat = str(case.get("_file_category") or "")
|
||||||
|
if file_cat and cat and cat != file_cat:
|
||||||
|
errors.append(f"{where}: category={cat} 与文件 category={file_cat} 不一致")
|
||||||
|
if cat and cat_ids and cat not in cat_ids:
|
||||||
|
errors.append(f"{where}: category={cat} 不在 catalog.categories")
|
||||||
|
if case.get("status", "active") not in STATUSES:
|
||||||
|
errors.append(f"{where}: 非法 status")
|
||||||
|
if case.get("suite") not in SUITES:
|
||||||
|
errors.append(f"{where}: 非法 suite")
|
||||||
|
if case.get("risk") not in risks:
|
||||||
|
errors.append(f"{where}: 非法 risk={case.get('risk')}")
|
||||||
|
if case.get("suite") == "smoke" and case.get("risk") != "safe":
|
||||||
|
errors.append(f"{where}: smoke 只能 risk=safe")
|
||||||
|
gate = case.get("gate")
|
||||||
|
if gate:
|
||||||
|
if gate not in gates:
|
||||||
|
errors.append(f"{where}: gate={gate} 未在 catalog.gates 声明")
|
||||||
|
if case.get("risk") in (None, "safe"):
|
||||||
|
errors.append(f"{where}: risk=safe 不要填 gate")
|
||||||
|
risk = case.get("risk")
|
||||||
|
if risk and risk != "safe" and risk not in auth_env and not gate:
|
||||||
|
errors.append(f"{where}: {risk} 需要 auth_env.{risk} 或 gate")
|
||||||
|
surface = case.get("surface")
|
||||||
|
if surface not in SURFACES:
|
||||||
|
errors.append(f"{where}: 非法 surface={surface}")
|
||||||
|
if not str(case.get("title") or "").strip():
|
||||||
|
errors.append(f"{where}: 缺少 title")
|
||||||
|
if not str(case.get("what") or "").strip():
|
||||||
|
errors.append(f"{where}: 缺少 what")
|
||||||
|
how = case.get("how")
|
||||||
|
if not isinstance(how, dict):
|
||||||
|
errors.append(f"{where}: how 必须是 mapping")
|
||||||
|
how = {}
|
||||||
|
if surface == "script" and not how.get("run") and not how.get("steps"):
|
||||||
|
errors.append(f"{where}: script 需要 how.run 或 how.steps")
|
||||||
|
if surface in ("browser", "mobile"):
|
||||||
|
session = case.get("session")
|
||||||
|
if not session:
|
||||||
|
errors.append(f"{where}: {surface} 需要 session")
|
||||||
|
elif session not in sessions:
|
||||||
|
errors.append(f"{where}: session={session} 未在 catalog.sessions 声明")
|
||||||
|
if surface == "browser" and not how.get("url") and not how.get("steps"):
|
||||||
|
errors.append(f"{where}: browser 需要 how.url 或 how.steps")
|
||||||
|
if surface == "mobile" and not how.get("steps"):
|
||||||
|
errors.append(f"{where}: mobile 需要 how.steps")
|
||||||
|
expected = case.get("expected")
|
||||||
|
if not isinstance(expected, list) or not expected:
|
||||||
|
errors.append(f"{where}: expected 必须是非空列表")
|
||||||
|
else:
|
||||||
|
for j, sig in enumerate(expected):
|
||||||
|
if not isinstance(sig, dict) or not sig.get("kind") or not sig.get("value"):
|
||||||
|
errors.append(f"{where}: expected[{j}] 需要 kind 与 value")
|
||||||
|
elif sig.get("kind") not in EXPECTED_KINDS:
|
||||||
|
errors.append(f"{where}: expected[{j}].kind 非法")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_validate(root: Path) -> int:
|
||||||
|
errors = validate(root)
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
print(f"✗ {err}", file=sys.stderr)
|
||||||
|
print(f"validate: {len(errors)} 个问题", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
cases = load_all_cases(root)
|
||||||
|
active = sum(1 for c in cases if c.get("status", "active") == "active")
|
||||||
|
print(f"✓ validate 通过 active={active} total={len(cases)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_export_csv(root: Path) -> int:
|
||||||
|
return run_export_csv(root)
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
|
if category not in cat_ids:
|
||||||
|
print(f"未知分类: {category} 可选: {cat_ids}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
prefix = f"TC-{category}-"
|
||||||
|
max_n = 0
|
||||||
|
for case in load_all_cases(root):
|
||||||
|
cid = str(case.get("id") or "")
|
||||||
|
if case.get("category") != category:
|
||||||
|
continue
|
||||||
|
match = re.match(rf"TC-{re.escape(category)}-(\d+)", cid)
|
||||||
|
if match:
|
||||||
|
max_n = max(max_n, int(match.group(1)))
|
||||||
|
print(f"{prefix}{max_n + 1:03d}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def authorized(vars_map: dict[str, str], meta: dict[str, Any], case: dict[str, Any]) -> bool:
|
||||||
|
risk = str(case.get("risk") or "safe")
|
||||||
|
risks = list(meta.get("risks") or ["safe"])
|
||||||
|
lowest = risks[0] if risks else "safe"
|
||||||
|
if risk != lowest:
|
||||||
|
env_name = (meta.get("auth_env") or {}).get(risk)
|
||||||
|
if env_name and not truthy(vars_map.get(str(env_name), "")):
|
||||||
|
return False
|
||||||
|
if not env_name and not case.get("gate"):
|
||||||
|
return False
|
||||||
|
gate = case.get("gate")
|
||||||
|
if gate:
|
||||||
|
gate_env = (meta.get("gates") or {}).get(gate)
|
||||||
|
if not gate_env or not truthy(vars_map.get(str(gate_env), "")):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run_script(root: Path, case_id: str) -> int:
|
||||||
|
meta = load_catalog_meta(root)
|
||||||
|
vars_map = resolve_vars(root, meta)
|
||||||
|
case = next((c for c in load_all_cases(root) if c.get("id") == case_id), None)
|
||||||
|
if not case:
|
||||||
|
print(json.dumps({"id": case_id, "result": "environment-fail", "evidence": "找不到用例"}, ensure_ascii=False))
|
||||||
|
return 2
|
||||||
|
public_id = case.get("id")
|
||||||
|
if case.get("status") != "active":
|
||||||
|
print(json.dumps({"id": public_id, "result": "skipped", "evidence": "retired"}, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
if case.get("surface") != "script":
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"id": public_id, "result": "environment-fail", "evidence": "run-script 只能跑 surface=script"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
if not host_set(vars_map):
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"id": public_id, "result": "environment-fail", "evidence": "HOST 未设"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
risk = str(case.get("risk") or "safe")
|
||||||
|
if not authorized(vars_map, meta, case):
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"id": public_id, "result": "skipped", "evidence": f"风险 {risk} 未授权"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
how = case.get("how") or {}
|
||||||
|
run = how.get("run")
|
||||||
|
if not run:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"id": public_id, "result": "environment-fail", "evidence": "没有 how.run,改由 Agent 按 steps 执行"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
script = substitute(str(run), vars_map)
|
||||||
|
timeout = float(how.get("timeout") or 60)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update({k: v for k, v in vars_map.items() if v is not None})
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
script,
|
||||||
|
shell=True,
|
||||||
|
cwd=str(root),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": public_id,
|
||||||
|
"result": "environment-fail",
|
||||||
|
"evidence": f"timeout {timeout}s",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
output = ((proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "")).strip()
|
||||||
|
evidence = output[-1200:]
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": public_id,
|
||||||
|
"result": "executed",
|
||||||
|
"exit": proc.returncode,
|
||||||
|
"evidence": evidence,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parent = argparse.ArgumentParser(add_help=False)
|
||||||
|
parent.add_argument("--root", default=".", help="项目根")
|
||||||
|
parser = argparse.ArgumentParser(description="tester catalog")
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("list", help="按分类统计", parents=[parent])
|
||||||
|
p_sel = sub.add_parser("select", help="筛选用例 JSON", parents=[parent])
|
||||||
|
p_sel.add_argument("--category")
|
||||||
|
p_sel.add_argument("--id", action="append", dest="ids", default=[])
|
||||||
|
p_sel.add_argument("--suite", choices=SUITES)
|
||||||
|
p_sel.add_argument("--max-risk")
|
||||||
|
p_sel.add_argument("--surface", choices=SURFACES)
|
||||||
|
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("export-csv", help="写出给人看的 cases.csv", parents=[parent])
|
||||||
|
p_next = sub.add_parser("next-id", help="分配下一个 TC-<CAT>-NNN", parents=[parent])
|
||||||
|
p_next.add_argument("--category", required=True)
|
||||||
|
p_run = sub.add_parser("run-script", help="执行一条 how.run", parents=[parent])
|
||||||
|
p_run.add_argument("--id", required=True, dest="case_id")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
if args.cmd == "list":
|
||||||
|
return cmd_list(root)
|
||||||
|
if args.cmd == "select":
|
||||||
|
return cmd_select(args, root)
|
||||||
|
if args.cmd == "show":
|
||||||
|
return cmd_show(root, args.case_id)
|
||||||
|
if args.cmd == "validate":
|
||||||
|
return cmd_validate(root)
|
||||||
|
if args.cmd == "export-csv":
|
||||||
|
return cmd_export_csv(root)
|
||||||
|
if args.cmd == "next-id":
|
||||||
|
return cmd_next_id(root, args.category)
|
||||||
|
if args.cmd == "run-script":
|
||||||
|
return cmd_run_script(root, args.case_id)
|
||||||
|
parser.error(f"未知命令 {args.cmd}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+194
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""从 .pouch/tester/cases/*.yaml 生成给人看的 cases.csv。不要手改 CSV。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
FIELDS = (
|
||||||
|
"category",
|
||||||
|
"id",
|
||||||
|
"maps_to",
|
||||||
|
"title",
|
||||||
|
"what",
|
||||||
|
"suite",
|
||||||
|
"risk",
|
||||||
|
"gate",
|
||||||
|
"surface",
|
||||||
|
"session",
|
||||||
|
"status",
|
||||||
|
"requires",
|
||||||
|
"setup",
|
||||||
|
"how",
|
||||||
|
"expected",
|
||||||
|
"notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tester_dir(root: Path) -> Path:
|
||||||
|
return root / ".pouch" / "tester"
|
||||||
|
|
||||||
|
|
||||||
|
def git_root(start: Path) -> Path | None:
|
||||||
|
cur = start.resolve()
|
||||||
|
if cur.is_file():
|
||||||
|
cur = cur.parent
|
||||||
|
for path in [cur, *cur.parents]:
|
||||||
|
if (path / ".git").exists() and (path / ".pouch" / "tester" / "cases").is_dir():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_root(explicit: str) -> Path:
|
||||||
|
path = Path(explicit).resolve()
|
||||||
|
if (path / ".pouch" / "tester" / "cases").is_dir():
|
||||||
|
return path
|
||||||
|
found = git_root(Path.cwd())
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(path: Path) -> Any:
|
||||||
|
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def one_line(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, list):
|
||||||
|
return " | ".join(part for part in (one_line(item) for item in value) if part)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if "kind" in value and "value" in value:
|
||||||
|
return f"{value['kind']}: {value['value']}"
|
||||||
|
return " | ".join(f"{key}={one_line(item)}" for key, item in value.items())
|
||||||
|
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
return " | ".join(line.strip() for line in text.split("\n") if line.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def how_brief(how: Any) -> str:
|
||||||
|
if not isinstance(how, dict):
|
||||||
|
return one_line(how)
|
||||||
|
bits: list[str] = []
|
||||||
|
if how.get("url"):
|
||||||
|
bits.append(f"url={how['url']}")
|
||||||
|
if how.get("run"):
|
||||||
|
bits.append("run: " + one_line(how["run"]))
|
||||||
|
if how.get("steps"):
|
||||||
|
bits.append(one_line(how["steps"]))
|
||||||
|
return " | ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
|
def category_order(root: Path) -> list[str]:
|
||||||
|
catalog = tester_dir(root) / "catalog.yaml"
|
||||||
|
if not catalog.is_file():
|
||||||
|
return []
|
||||||
|
data = load_yaml(catalog) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
order: list[str] = []
|
||||||
|
for item in data.get("categories") or []:
|
||||||
|
if isinstance(item, dict) and item.get("id"):
|
||||||
|
order.append(str(item["id"]))
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def load_cases(root: Path) -> list[dict[str, Any]]:
|
||||||
|
cases_dir = tester_dir(root) / "cases"
|
||||||
|
if not cases_dir.is_dir():
|
||||||
|
raise SystemExit(f"找不到用例目录: {cases_dir}")
|
||||||
|
files = sorted(path for path in cases_dir.glob("*.yaml") if path.is_file())
|
||||||
|
if not files:
|
||||||
|
raise SystemExit(f"{cases_dir} 里没有 yaml")
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for path in files:
|
||||||
|
data = load_yaml(path)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"{path} 必须是 mapping")
|
||||||
|
file_cat = data.get("category")
|
||||||
|
rows = data.get("cases") or []
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise SystemExit(f"{path}: cases 必须是列表")
|
||||||
|
for index, case in enumerate(rows):
|
||||||
|
if not isinstance(case, dict):
|
||||||
|
raise SystemExit(f"{path}: cases[{index}] 必须是 mapping")
|
||||||
|
item = dict(case)
|
||||||
|
if not item.get("category") and file_cat:
|
||||||
|
item["category"] = file_cat
|
||||||
|
item["_file"] = str(path)
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sort_cases(root: Path, cases: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
order = category_order(root)
|
||||||
|
for case in cases:
|
||||||
|
cat = str(case.get("category") or "")
|
||||||
|
if cat and cat not in order:
|
||||||
|
order.append(cat)
|
||||||
|
rank = {name: index for index, name in enumerate(order)}
|
||||||
|
return sorted(
|
||||||
|
cases,
|
||||||
|
key=lambda case: (
|
||||||
|
rank.get(str(case.get("category") or ""), 999),
|
||||||
|
str(case.get("id") or ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def export_cases_csv(root: Path, out: Path | None = None) -> Path:
|
||||||
|
cases = sort_cases(root, load_cases(root))
|
||||||
|
path = out or (tester_dir(root) / "cases.csv")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8-sig", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=list(FIELDS), extrasaction="ignore")
|
||||||
|
writer.writeheader()
|
||||||
|
for case in cases:
|
||||||
|
writer.writerow(
|
||||||
|
{
|
||||||
|
"category": case.get("category") or "",
|
||||||
|
"id": case.get("id") or "",
|
||||||
|
"maps_to": case.get("maps_to") or "",
|
||||||
|
"title": case.get("title") or "",
|
||||||
|
"what": one_line(case.get("what")),
|
||||||
|
"suite": case.get("suite") or "",
|
||||||
|
"risk": case.get("risk") or "",
|
||||||
|
"gate": case.get("gate") or "",
|
||||||
|
"surface": case.get("surface") or "",
|
||||||
|
"session": case.get("session") or "",
|
||||||
|
"status": case.get("status") or "active",
|
||||||
|
"requires": one_line(case.get("requires")),
|
||||||
|
"setup": one_line(case.get("setup")),
|
||||||
|
"how": how_brief(case.get("how")),
|
||||||
|
"expected": one_line(case.get("expected")),
|
||||||
|
"notes": one_line(case.get("notes")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="从 cases/*.yaml 生成 cases.csv")
|
||||||
|
parser.add_argument("--root", default=".", help="项目根")
|
||||||
|
parser.add_argument("--out", help="输出路径,默认 <root>/.pouch/tester/cases.csv")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
root = resolve_root(args.root)
|
||||||
|
out = Path(args.out).resolve() if args.out else None
|
||||||
|
path = export_cases_csv(root, out)
|
||||||
|
n = sum(1 for _ in path.open(encoding="utf-8-sig")) - 1
|
||||||
|
print(f"wrote {path} rows={n}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
version: 1
|
||||||
|
updatedAt: "1970-01-01T00:00:00+00:00"
|
||||||
|
project:
|
||||||
|
name: ""
|
||||||
|
id_pattern: "^[A-Z]+-[A-Za-z0-9-]+$"
|
||||||
|
categories: []
|
||||||
|
risks:
|
||||||
|
- safe
|
||||||
|
- act
|
||||||
|
- harm
|
||||||
|
default_max_risk: safe
|
||||||
|
vars: {}
|
||||||
|
auth_env:
|
||||||
|
act: AUTHORIZE_ACT
|
||||||
|
harm: AUTHORIZE_HARM
|
||||||
|
gates: {}
|
||||||
|
sessions:
|
||||||
|
browser:
|
||||||
|
kind: browser
|
||||||
|
reuse: run
|
||||||
|
init: "打开目标页面"
|
||||||
|
mobile:
|
||||||
|
kind: mobile
|
||||||
|
reuse: persist
|
||||||
|
probe: ""
|
||||||
|
init: ""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 复制为 .pouch/tester/local.yaml 后填写。不要提交 local.yaml。
|
||||||
|
HOST: ""
|
||||||
|
AUTHORIZE_ACT: false
|
||||||
|
AUTHORIZE_HARM: false
|
||||||
Reference in New Issue
Block a user