tester v2: 术语对齐 pytest/JUnit/Robot,补齐 automation 与 not-run
字段改名(每项取舍的理由见新增的 references/concepts.md):
- 用例 status -> lifecycle, suite -> tag, surface -> driver,
cleanup -> teardown, skip_unless -> assume;删除 requires
- risk 取值 safe/act/harm -> readonly/mutating/destructive(单调三级,
与 gate 正交)
- catalog auth_env -> authorize;授权变量统一为
AUTHORIZE_MUTATING / AUTHORIZE_DESTRUCTIVE
新增用例字段 automation(automated/assisted/manual),默认 automated,
并在 validate 里机器校验:manual 禁用 driver 与 how.run 且必须有
how.steps;assisted 至少一步以 [人工] 开头。select 新增 --automation。
结果词新增 not-run,区分「主动不跑」(skipped:retired/pending/超风险
上限/未授权/assume 不成立)与「没轮到」(失败即停后、被分类前置挡住整组、
需人工而无人值守)。
status_probe 定为分类级前置条件:catalog 顶层为默认,categories[] 同名键
覆盖(缺省继承 / false 关闭 / mapping 整体替换)。select 按分类返回生效值,
并把 probe.url 用 vars 展开(solve Agent 无法自行解析 ${VAR} 的问题),
同批返回 vars 供 how 里的 ${...} 替换。
新增 references/concepts.md:术语表(每词一句唯一定义 + 业界锚点 +
反例)、v1->v2 映射、以及 risk/automation/not-run/status_probe 的设计理由。
其余文档(SKILL/README/schema/levels/run/maintain)与三个脚本(catalog、
generate_cases_csv、generate_run_csv)同步更新。
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# tester
|
# tester
|
||||||
|
|
||||||
维护并执行项目里的黑盒测试目录。用例按分类放在 `.pouch/tester/`,可以列出、增改,也可以按分类或编号去跑。
|
维护并执行项目里的黑盒测试目录。用例按分类放在 `.pouch/tester/`,可以列出、增改,也可以按分类或编号去跑。术语、命名与设计理由见 `references/concepts.md`。
|
||||||
|
|
||||||
## 什么时候使用
|
## 什么时候使用
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
- 目录在项目根的 `.pouch/tester/`。没有就说「初始化 tester」。
|
- 目录在项目根的 `.pouch/tester/`。没有就说「初始化 tester」。
|
||||||
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`,填 `HOST`。也可以用环境变量 `HX_TEST_HOST`。
|
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`,填 `HOST`。也可以用环境变量 `HX_TEST_HOST`。
|
||||||
- 会动硬件、会改数据的用例带风险级别。默认只跑最低档(只读)。要跑更高风险,需要你明确说,例如「风险到 hardware」。
|
- 会动硬件、会改数据的用例带风险级别(`readonly` / `mutating` / `destructive`)。默认只跑最低档 `readonly`。要跑更高风险,需要你明确说,例如「风险到 mutating」;碰哪个子系统另由 `gate`(如 hardware / weld)控制。
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
加一条测试:分类 CAM,风险 hardware,开盖再关盖,用脚本打 /api/camera_3d
|
加一条测试:分类 CAM,风险 mutating,开盖再关盖,用脚本打 /api/camera_3d
|
||||||
```
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
2. 改用例前先给你看将要写入的内容,你确认再保存。
|
2. 改用例前先给你看将要写入的内容,你确认再保存。
|
||||||
3. 执行时:脚本类直接跑命令;浏览器和移动模拟器先初始化一次,同一轮里复用。
|
3. 执行时:脚本类直接跑命令;浏览器和移动模拟器先初始化一次,同一轮里复用。
|
||||||
4. 超出你允许的风险级别会跳过,不会偷偷执行。
|
4. 超出你允许的风险级别会跳过,不会偷偷执行。
|
||||||
5. 跑完给出按分类计数的结果,以及 `pass / fail / environment-fail / skipped`。
|
5. 跑完给出按分类计数的结果,以及 `pass / fail / environment-fail / skipped / not-run`。
|
||||||
|
|
||||||
## 如何判断完成
|
## 如何判断完成
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ description: >-
|
|||||||
开始时解析本 `SKILL.md` 所在目录为 `<skill-dir>`。项目根用
|
开始时解析本 `SKILL.md` 所在目录为 `<skill-dir>`。项目根用
|
||||||
`git rev-parse --show-toplevel`。目录固定为 `<root>/.pouch/tester/`。
|
`git rev-parse --show-toplevel`。目录固定为 `<root>/.pouch/tester/`。
|
||||||
|
|
||||||
用脚本读写目录,不要把 `cases/` 整库灌进上下文:
|
用脚本读写目录,不要把 `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 <list|select|show|validate|next-id|run-script> \
|
||||||
@@ -31,8 +31,9 @@ python3 <skill-dir>/scripts/catalog.py <list|select|show|validate|next-id|run-sc
|
|||||||
|
|
||||||
- 不猜 HOST / Base URL / 是否已授权。缺 HOST 则停,记 environment-fail,不用 localhost。
|
- 不猜 HOST / Base URL / 是否已授权。缺 HOST 则停,记 environment-fail,不用 localhost。
|
||||||
- 默认风险上限是 catalog 里的 `default_max_risk`。超出上限的条目标 `skipped`,禁止执行。
|
- 默认风险上限是 catalog 里的 `default_max_risk`。超出上限的条目标 `skipped`,禁止执行。
|
||||||
- `risk` 只能是 `safe` / `act` / `harm`。`suite=smoke` 只能配 `risk=safe`。规则见 [levels.md](references/levels.md)。
|
- `risk` 只能是 `readonly` / `mutating` / `destructive`,默认只跑 `readonly`。`tag=smoke` 只能配 `risk=readonly`。规则见 [levels.md](references/levels.md)。
|
||||||
- `surface=browser` 必须开真实页面;`surface=mobile` 必须挂模拟器 session。二者都不得改成只打 API。
|
- `driver=browser` 必须开真实页面;`driver=mobile` 必须挂模拟器 session。二者都不得改成只打 API。
|
||||||
|
- `automation=assisted|manual` 需要人在场:无人值守时记 `not-run`,不要自己代跑人工步骤。见 [concepts.md](references/concepts.md)。
|
||||||
- 浏览器、移动模拟器按 catalog `sessions` 初始化一次,同一次运行内复用。
|
- 浏览器、移动模拟器按 catalog `sessions` 初始化一次,同一次运行内复用。
|
||||||
- 环境失败(连不上、缺浏览器、下游超时、session 挂了)不是产品失败。
|
- 环境失败(连不上、缺浏览器、下游超时、session 挂了)不是产品失败。
|
||||||
- 不改产品代码,不提交,不把成绩单当操作手册。
|
- 不改产品代码,不提交,不把成绩单当操作手册。
|
||||||
@@ -67,7 +68,7 @@ python3 <skill-dir>/scripts/catalog.py list --root <project-root>
|
|||||||
python3 <skill-dir>/scripts/generate_cases_csv.py --root <project-root>
|
python3 <skill-dir>/scripts/generate_cases_csv.py --root <project-root>
|
||||||
```
|
```
|
||||||
|
|
||||||
`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。有运行快照时,这份 CSV 末尾有 `last_result` / `last_executed`(`yes` 已跑,`no` 跳过,空=未进入最近一次运行)。
|
`list` 结束时也会跑它。告诉用户路径 `.pouch/tester/cases.csv`。不要手改 CSV,改用例走「维护」后再生成。有运行快照时,这份 CSV 末尾有 `last_result` / `last_executed`(`yes` 已跑,`no` 未跑——含 `skipped` 与 `not-run`,空=未进入最近一次运行)。
|
||||||
|
|
||||||
## 查看
|
## 查看
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ python3 <skill-dir>/scripts/generate_cases_csv.py --root <project-root>
|
|||||||
python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
|
python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
只展示这一条的 how / expected / risk / surface。字段对不上时再读 [schema.md](references/schema.md)。
|
只展示这一条的 how / expected / risk / driver / automation。字段对不上时再读 [schema.md](references/schema.md)。
|
||||||
|
|
||||||
## 检查
|
## 检查
|
||||||
|
|
||||||
@@ -83,8 +84,8 @@ python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
|
|||||||
|
|
||||||
## 维护
|
## 维护
|
||||||
|
|
||||||
读 [maintain.md](references/maintain.md)。写 `suite`/`risk` 时读 [levels.md](references/levels.md)。先 `show` 或 `next-id`,拟好条目给用户确认再写文件,然后 `validate`。
|
读 [maintain.md](references/maintain.md)。写 `tag`/`risk`/`automation` 时读 [levels.md](references/levels.md) 与 [concepts.md](references/concepts.md)。先 `show` 或 `next-id`,拟好条目给用户确认再写文件,然后 `validate`。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
读 [run.md](references/run.md)。先 `select` 得到命中列表,再按 surface 分流。未指定范围时等同「suite=smoke 且风险上限=默认」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。
|
读 [run.md](references/run.md)。先 `select` 得到命中列表,再按 driver 分流。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# tester 概念、命名与设计理由
|
||||||
|
|
||||||
|
这是 tester 的**唯一术语真相**。写用例、改脚本、看文档遇到词义冲突时,以本文件为准。
|
||||||
|
|
||||||
|
## 0. 三层结构
|
||||||
|
|
||||||
|
```
|
||||||
|
catalog(整个测试集)
|
||||||
|
└── category(= 一个 suite,一个 cases/<cat>.yaml)
|
||||||
|
└── case(一条用例)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 层 | 是什么 | 业界对应 |
|
||||||
|
|---|---|---|
|
||||||
|
| catalog | 全部用例 + 全局配置(`catalog.yaml`) | pytest 的一次 collection;整个 test suite |
|
||||||
|
| category | 一组用例 + 自己的前置条件(`status_probe`) | Robot **suite**;pytest 一个目录 + conftest.py |
|
||||||
|
| case | 一条用例 | test case |
|
||||||
|
|
||||||
|
**「suite」只作总称**(泛指整个测试集),不作 YAML 键。集合级的角色由 **category** 承担:`cases/<cat>.yaml` 就是 suite 容器,`categories[].status_probe` 就是 Suite Setup。所以框架里没有 `suite` 字段。
|
||||||
|
|
||||||
|
## 1. 术语表
|
||||||
|
|
||||||
|
每个词一句话定义 + 业界锚点 + 「它不是什么」。
|
||||||
|
|
||||||
|
### 用例字段
|
||||||
|
|
||||||
|
| 词 | 定义 | 业界锚点 | 不是什么 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `case` | 一条可独立判定的测试 | test case | 不是一次运行记录(那是 `runs/`) |
|
||||||
|
| `tag` | 成本档:`smoke` / `full` / `slow`,单选 | pytest `mark`;JUnit / Robot `tag` | 不是分类,不是「一个 suite」 |
|
||||||
|
| `risk` | 执行副作用等级:`readonly` / `mutating` / `destructive` | read-only vs mutating;destructive tests;K8s e2e `[Disruptive]` | 不是概率,不是「要不要人」(那是 `automation`) |
|
||||||
|
| `lifecycle` | 用例生命周期:`active` / `pending` / `retired` | JUnit `@Disabled`;pytest skip / deselect | 不是 HTTP 状态,不是前置探测(那是 `api-status` / `status_probe`) |
|
||||||
|
| `driver` | 谁来驱动执行:`script` / `browser` / `mobile` | Selenium / Appium **driver** | 不是「要不要人」(那是 `automation`) |
|
||||||
|
| `automation` | 人工参与度:`automated` / `assisted` / `manual` | TestRail *Automation Type*;ADO *Automation status*;Xray *Test Type* | 不是执行器(那是 `driver`) |
|
||||||
|
| `session` | 可复用会话(浏览器 / 模拟器) | pytest fixture;Robot suite variable | 不是一条用例 |
|
||||||
|
| `gate` | 项目细分闸门名 → 环境变量(`motion` / `hardware` / `weld`) | 授权开关 | 不是 `risk`,不要拿它代替风险等级 |
|
||||||
|
| `authorize` | 风险档 → 环境变量。该档执行前必须为 `true` | pytest `skipif` 条件 | 不是认证(auth),是**授权** |
|
||||||
|
| `assume` | 额外跳过条件(gate / authorize 之外的环境表达式) | JUnit `assumeTrue`;pytest `skipif` | 不是授权开关的重复写法 |
|
||||||
|
| `status_probe` | 分类级前置条件:请求一个 URL,命中即视为前置不满足 | pytest session fixture;Robot Suite Setup | 不是产品失败,不是整轮硬闸 |
|
||||||
|
| `how` | 怎么测:`steps` / `run` / `url` / `timeout` | test steps | 不是判定标准 |
|
||||||
|
| `expected` | 怎么算过:`{kind, value}` 列表 | pytest `assert`;Robot `Should …` | 不是步骤 |
|
||||||
|
| `run_together` | 必须成组执行的用例(有序、可选 continue_on_fail) | 场景组(scenario) | 不是 suite,不是 tag |
|
||||||
|
|
||||||
|
### 结果词
|
||||||
|
|
||||||
|
| 结果 | 定义 | 业界锚点 |
|
||||||
|
|---|---|---|
|
||||||
|
| `pass` | 通过 | passed |
|
||||||
|
| `fail` | 产品缺陷 | failed |
|
||||||
|
| `environment-fail` | 前置/环境导致无法执行,**永远不是产品失败** | pytest error;JUnit aborted |
|
||||||
|
| `skipped` | **主动**不跑:`retired` / `pending` / 超风险上限 / 未授权 / `assume` 不成立 | pytest skipped;JUnit disabled |
|
||||||
|
| `not-run` | **被动**没轮到:失败即停后剩下的;分类被 `status_probe` 挡住整组没执行;`automation` 需人工而无人值守 | pytest not run;deselected |
|
||||||
|
|
||||||
|
## 2. 命名迁移对照(v1 → v2)
|
||||||
|
|
||||||
|
| v1 | v2 | 为什么改 |
|
||||||
|
|---|---|---|
|
||||||
|
| 用例 `status` | `lifecycle` | v1 的 `status` 一词三义:用例状态、`api-status` 的 HTTP 状态、`status_probe`。拆开后各是各的 |
|
||||||
|
| 用例 `suite` | `tag` | Robot 里 suite 是**集合**,`cases/<cat>.yaml` 才是集合;我们却拿它当标记。改 `tag` 与 pytest mark / JUnit tag 对齐 |
|
||||||
|
| 用例 `surface` | `driver` | `surface` 是自造词;`driver` 是 Selenium / Appium 的既有概念,且正好回答「谁来驱动」 |
|
||||||
|
| 用例 `cleanup` | `teardown` | 与 pytest teardown / JUnit `@AfterEach` / Robot `[Teardown]` 对齐 |
|
||||||
|
| 用例 `skip_unless` | `assume` | 语义是「假设成立才跑」,对应 JUnit `assumeTrue`;旧名与 `gate` 含义重叠 |
|
||||||
|
| 用例 `requires` | **删除** | 只出现在导出的 CSV,不参与任何逻辑;126 条标注还不一致。前置能力改用 `tag` + `driver` + `gate`/`assume` 表达 |
|
||||||
|
| catalog `auth_env` | `authorize` | `auth` 被读成「认证」,实际是「授权放行」 |
|
||||||
|
| `risk` 值 `safe/act/harm` | `readonly/mutating/destructive` | `act` 太含糊(谁都在 act),`harm` 情绪化,且把「改了状态」与「难恢复」混成一维。新词是单调三级,见下 |
|
||||||
|
| 结果 `skipped`(含被动) | `skipped` + `not-run` | v1 把「主动不跑」和「被动没轮到」混在一个词里,成绩单看不出是哪种 |
|
||||||
|
|
||||||
|
## 3. 关键设计理由(为什么是这些值)
|
||||||
|
|
||||||
|
### 3.1 `risk`:为什么是 `readonly / mutating / destructive`
|
||||||
|
|
||||||
|
- 这一维回答的是「跑它会不会改变被测系统、有多难退回」,不是概率,也不是「要不要人」。
|
||||||
|
- `readonly` / `mutating` 是数据库、文件系统、API 测试里的通用二分,事实性描述。
|
||||||
|
- `destructive` 是硬件 / 内核 / 集群测试的标准词(Kubernetes e2e 用 `[Disruptive]` 标记会打断集群的用例)。
|
||||||
|
- 三级**单调**:`readonly ⊂ mutating ⊂ destructive`,所以只需要一个上限 `default_max_risk` 和一个 `--max-risk`,不用组合矩阵。
|
||||||
|
- 与 `gate` 正交:`risk` 说「影响多大」,`gate` 说「碰哪个子系统」。点动 vs 焊接这种细分授权用 `gate`,不要再造 risk 名。
|
||||||
|
- `tag=smoke` 只能配 `risk=readonly`:冒烟测试不许留下变化。
|
||||||
|
|
||||||
|
### 3.2 `automation`:为什么是 `automated / assisted / manual`
|
||||||
|
|
||||||
|
- 有些用例无法自动化,或需要人在现场配合(急停、断电、物理复位、目视 LED)。这需要在**选范围**阶段就能筛出来。
|
||||||
|
- 这是测试管理工具的标准字段(TestRail *Automation Type*、ADO *Automation status*、Xray *Test Type*),因此沿用 `automation` 这个名字。
|
||||||
|
- 与 `driver` 正交:`driver` 说「用什么驱动」,`automation` 说「要不要人」。把 `manual` 塞进 `driver` 就无法表达 `assisted`(它仍由 script/browser 驱动)。
|
||||||
|
- 三档的含义与约束:
|
||||||
|
- `automated`(默认):Agent 端到端跑完。
|
||||||
|
- `assisted`:Agent 驱动,但至少一步需要人在场;该步在 `how.steps` 里以 `[人工]` 开头(可被 `validate` 机器判定)。
|
||||||
|
- `manual`:只能人工执行;禁止 `driver`、禁止 `how.run`,必须有 `how.steps`。
|
||||||
|
- 默认只跑 `automated`。无人值守时 `assisted` / `manual` 记 `not-run`;有人在场时 `assisted` 暂停等人确认,`manual` 由人工回报结果。
|
||||||
|
|
||||||
|
### 3.3 结果为什么拆出 `not-run`
|
||||||
|
|
||||||
|
- `skipped` = **我们决定不跑**(retired / pending / 超上限 / 未授权 / assume 不成立)。
|
||||||
|
- `not-run` = **没轮到**(失败即停后剩下的 / 分类被前置挡住 / 需人工而无人值守)。
|
||||||
|
- 混在一起,成绩单就看不出「本轮到底跑没跑」。拆开后 `executed` 列(`yes` / `no`)与原因都清楚。
|
||||||
|
|
||||||
|
### 3.4 `status_probe` 为什么是分类级
|
||||||
|
|
||||||
|
- 不同分类的前置条件不同:相机链路走 `API_BASE`(8095) / 相机内网,独立于机械臂 FSM(8096)。机械臂没连上不应该挡住相机测试。
|
||||||
|
- 因此它是**分类级前置条件**:`catalog.status_probe` 是全局默认,`categories[].status_probe` 可覆盖(缺省继承 / `false` 关闭 / mapping 整体替换)。对应 pytest 的 session fixture、Robot 的 Suite Setup。
|
||||||
|
- 命中或不可达只影响用它的分类;只有所有选中分类都被挡住,整轮结论才是 `environment-fail`。`select` 会按分类返回解析后的生效值,Agent 不要自己读 catalog 猜。
|
||||||
|
|
||||||
|
## 4. 不采用的词
|
||||||
|
|
||||||
|
| 词 | 不用它的原因 |
|
||||||
|
|---|---|
|
||||||
|
| `suite`(作字段) | 与 Robot 的「集合」语义冲突;集合角色已由 category 承担 |
|
||||||
|
| `status`(作字段) | 与 HTTP 状态、前置探测撞词 |
|
||||||
|
| `surface` | 自造词,业界无此概念 |
|
||||||
|
| `requires` | 不参与逻辑,标注还容易失真 |
|
||||||
|
| `sanity` / `regression` / `acceptance` | 与 `smoke` / `full` 分不清,且已有 category / id / risk / automation 多层筛选 |
|
||||||
|
| `preconditions`(通用列表) | 当前只有 `status_probe` 一种前置,出现第二种再抽象 |
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# suite 与 risk
|
# tag 与 risk
|
||||||
|
|
||||||
维护或用户问套件/安全级别时读本文件。两套词对所有项目相同,不要自造名字。
|
维护或用户问套件/安全级别时读本文件。两套词对所有项目相同,不要自造名字。命名理由见 [concepts.md](concepts.md)。
|
||||||
|
|
||||||
## suite:跑哪一批
|
## tag:跑哪一批
|
||||||
|
|
||||||
只表示耗时和依赖,不表示危不危险。危不危险看 `risk`。
|
只表示耗时和依赖,不表示危不危险。危不危险看 `risk`。
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| 值 | 何时用 |
|
| 值 | 何时用 |
|
||||||
|----|--------|
|
|----|--------|
|
||||||
| `smoke` | 最短路径证明系统还活着。必须快、少依赖、**`risk` 只能是 `safe`**。 |
|
| `smoke` | 最短路径证明系统还活着。必须快、少依赖、**`risk` 只能是 `readonly`**。 |
|
||||||
| `full` | 日常回归里其余自动化用例。说「跑 full」仍受风险上限约束。 |
|
| `full` | 日常回归里其余自动化用例。说「跑 full」仍受风险上限约束。 |
|
||||||
| `slow` | 太慢或环境太重,不进日常 full:真实焊接、付费 API、要工件/账号、跑很久。 |
|
| `slow` | 太慢或环境太重,不进日常 full:真实焊接、付费 API、要工件/账号、跑很久。 |
|
||||||
|
|
||||||
@@ -23,18 +23,20 @@
|
|||||||
3. 其余自动化 → `full`。
|
3. 其余自动化 → `full`。
|
||||||
4. 拿不准 → `full`。不要把会动真机或会删数据的放进 `smoke`。
|
4. 拿不准 → `full`。不要把会动真机或会删数据的放进 `smoke`。
|
||||||
|
|
||||||
未指定套件时只跑 `smoke`。`跑 full` 不含 `slow`。
|
未指定 tag 时只跑 `smoke`。`跑 full` 不含 `slow`。
|
||||||
|
|
||||||
## risk:副作用有多大
|
## risk:副作用有多大
|
||||||
|
|
||||||
三个短词,网站 / App / 机器人共用。`catalog.yaml` 的 `risks` 必须按此顺序,且以 `safe` 开头:
|
三个词,网站 / App / 机器人共用,单调升级。`catalog.yaml` 的 `risks` 必须按此顺序,且以 `readonly` 开头:
|
||||||
|
|
||||||
| 值 | 含义 | 网站 | App | 机器人 |
|
| 值 | 含义 | 网站 | App | 机器人 |
|
||||||
|----|------|------|-----|--------|
|
|----|------|------|-----|--------|
|
||||||
| `safe` | 只看,不留下变化 | 打开页、只读 API | 打开界面 | 读位姿、查状态 |
|
| `readonly` | 只看,不留下变化 | 打开页、只读 API | 打开界面 | 读位姿、查状态 |
|
||||||
| `act` | 会动手,一般可补救 | 提交表单、改配置 | 发消息、保存 | 点动、使能、开盖扫描 |
|
| `mutating` | 会改状态,一般可补救 | 提交表单、改配置 | 发消息、保存 | 点动、使能、开盖扫描 |
|
||||||
| `harm` | 难恢复或伤人/伤钱/伤环境 | 删库、真实扣费 | 清数据、内购 | 急停、断电、真焊 |
|
| `destructive` | 难恢复或伤人/伤钱/伤环境 | 删库、真实扣费 | 清数据、内购 | 急停、断电、真焊 |
|
||||||
|
|
||||||
默认只跑 `safe`。项目细分闸(点动 vs 焊接)用可选 `gate`,不要再发明 risk 名。
|
默认只跑 `readonly`。项目细分闸(点动 vs 焊接)用可选 `gate`,不要再发明 risk 名。
|
||||||
|
|
||||||
`suite=smoke` 且 `risk!=safe` 为非法。
|
`tag=smoke` 且 `risk!=readonly` 为非法。
|
||||||
|
|
||||||
|
不想跑某一档时,`authorize.<risk>` 指向的环境变量保持 false 即可——该档用例记 `skipped`,不是 `not-run`。
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
python3 <skill-dir>/scripts/catalog.py next-id --root <root> --category <CAT>
|
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 名。
|
3. 拟一条符合 [schema.md](schema.md) 的用例。`tag`/`risk` 按 [levels.md](levels.md):`smoke` 只能 `readonly`;会改状态用 `mutating`,难恢复用 `destructive`。细分授权用 `gate`,不要发明新的 risk 名。需人工参与的填 `automation`(见 [concepts.md](concepts.md))。
|
||||||
4. `surface=browser|mobile` 必须填 `session`,且 catalog 里已声明。
|
4. `driver=browser|mobile` 必须填 `session`,且 catalog 里已声明。
|
||||||
5. `what` 和 `expected` 写成可观察信号,禁止「应该正常」。
|
5. `what` 和 `expected` 写成可观察信号,禁止「应该正常」。
|
||||||
6. 给用户看拟写内容。确认后写入 `cases/<category小写>.yaml` 的 `cases` 列表末尾。
|
6. 给用户看拟写内容。确认后写入 `cases/<category小写>.yaml` 的 `cases` 列表末尾。
|
||||||
7. `validate`。失败则修好再结束。通过后跑 `generate_cases_csv.py --root <root>` 刷新 `.pouch/tester/cases.csv`。不要手改这份 CSV。
|
7. `validate`。失败则修好再结束。通过后跑 `generate_cases_csv.py --root <root>` 刷新 `.pouch/tester/cases.csv`。不要手改这份 CSV。
|
||||||
@@ -20,13 +20,13 @@ python3 <skill-dir>/scripts/catalog.py next-id --root <root> --category <CAT>
|
|||||||
## 完善 / 更新
|
## 完善 / 更新
|
||||||
|
|
||||||
1. `show --id <id>`。
|
1. `show --id <id>`。
|
||||||
2. 只改用户点名的字段(常见:`how`、`expected`、`risk`、`notes`)。
|
2. 只改用户点名的字段(常见:`how`、`expected`、`risk`、`automation`、`notes`)。
|
||||||
3. 展示 diff 或更新后的整条。确认后写回。
|
3. 展示 diff 或更新后的整条。确认后写回。
|
||||||
4. `validate`。通过后跑 `generate_cases_csv.py` 刷新 `cases.csv`。
|
4. `validate`。通过后跑 `generate_cases_csv.py` 刷新 `cases.csv`。
|
||||||
|
|
||||||
## 退役
|
## 退役
|
||||||
|
|
||||||
把 `status` 改为 `retired`,不要删文件、不要复用 id。`notes` 写原因。确认后写回,再跑 `generate_cases_csv.py`。
|
把 `lifecycle` 改为 `retired`,不要删文件、不要复用 id。`notes` 写原因。确认后写回,再跑 `generate_cases_csv.py`。
|
||||||
|
|
||||||
## 禁止
|
## 禁止
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,21 @@
|
|||||||
|
|
||||||
## 1. 选范围
|
## 1. 选范围
|
||||||
|
|
||||||
未指定范围 → `--suite smoke`,风险上限 = `default_max_risk`。
|
未指定范围 → `--tag smoke`,风险上限 = `default_max_risk`,`automation` 默认只跑 `automated`。
|
||||||
|
|
||||||
用户说分类、id、suite、风险上限时,原样传给 select:
|
用户说分类、id、tag、风险上限时,原样传给 select:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
||||||
[--category CAT] [--id ID]... [--suite smoke|full|slow] \
|
[--category CAT] [--id ID]... [--tag smoke|full|slow] \
|
||||||
[--max-risk <risk>] [--surface script|browser|mobile]
|
[--max-risk <risk>] [--driver script|browser|mobile] \
|
||||||
|
[--automation automated|assisted|manual|any]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--automation` 默认 `automated`(需人工的条目会被跳过,并在 JSON `skipped` 里说明);用户明确要求现场配合时用 `assisted`,`any` 表示不按人工参与度筛。
|
||||||
|
|
||||||
|
`select` 的 JSON 字段:`max_risk` / `automation` / `count` / `vars`(已展开的变量,用来替换 `how` 里的 `${…}`)/ `skipped` / `run_together` / `status_probe`(按分类,`url` 已展开)/ `cases`。
|
||||||
|
|
||||||
`select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明,不执行)。
|
`select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明,不执行)。
|
||||||
|
|
||||||
若 catalog 有 `run_together`,命中组内任一条就把整组按 `ids` 顺序扩进列表(JSON 带 `run_together`)。这不是擅自扩大范围。
|
若 catalog 有 `run_together`,命中组内任一条就把整组按 `ids` 顺序扩进列表(JSON 带 `run_together`)。这不是擅自扩大范围。
|
||||||
@@ -23,8 +28,19 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
|||||||
## 2. 运行前闸
|
## 2. 运行前闸
|
||||||
|
|
||||||
- `HOST` 未设(环境变量 / `HX_TEST_HOST` / `local.yaml` 都没有):全部停,environment-fail。
|
- `HOST` 未设(环境变量 / `HX_TEST_HOST` / `local.yaml` 都没有):全部停,environment-fail。
|
||||||
- 命中条的 `risk` 在 catalog `auth_env` 里有对应变量:变量不是 `true` 则该条 `skipped`。有 `gate` 时还要 `catalog.gates` 对应变量为 `true`。即使用户点了 id 也不执行。
|
- 命中条的 `risk` 在 catalog `authorize` 里有对应变量:变量不是 `true` 则该条 `skipped`。有 `gate` 时还要 `catalog.gates` 对应变量为 `true`;`assume` 不成立同样 `skipped`。即使用户点了 id 也不执行。
|
||||||
- 若 catalog 声明了 `status_probe`:先请求它。命中 `stop_if` → 整轮停,environment-fail。
|
- 授权/假设不成立是**主动不跑**(`skipped`),不是 `not-run`:这是我们决定不跑,不是没轮到。
|
||||||
|
- `status_probe` 是**分类级前置条件**(业界对应 pytest 的 session fixture / Robot 的 Suite Setup)。用 `select` 返回的 `status_probe` 解析生效值,不要自己读 catalog 猜。返回的 `probe.url` 已用 `vars` 展开,直接请求即可;`select` 同批返回 `vars`:
|
||||||
|
|
||||||
|
| `categories[].status_probe` | 生效探测 |
|
||||||
|
|------------------------------|----------|
|
||||||
|
| 未声明 | 继承顶层 `catalog.status_probe`(`source: global`)|
|
||||||
|
| `false` | 该分类不做闸(`source: disabled`)|
|
||||||
|
| mapping | 整体覆盖顶层,不合并(`source: category`)|
|
||||||
|
|
||||||
|
- 同一生效探测一次运行只请求一次,结果复用。
|
||||||
|
- 探测不可达:用它的分类标 environment-fail,其余分类照跑。
|
||||||
|
- 命中 `stop_if`:把用它的分类标 environment-fail 并记 `observed` 值,其余分类照跑;所有选中分类都被挡住时,整轮结论才是 environment-fail。
|
||||||
|
|
||||||
## 3. Session
|
## 3. Session
|
||||||
|
|
||||||
@@ -33,7 +49,7 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
|||||||
- 尚未初始化:按 catalog `sessions.<name>.init` 做。`reuse: persist` 的先 `probe`,活着就附着。
|
- 尚未初始化:按 catalog `sessions.<name>.init` 做。`reuse: persist` 的先 `probe`,活着就附着。
|
||||||
- 本轮已初始化:直接用,不要重开浏览器或模拟器。
|
- 本轮已初始化:直接用,不要重开浏览器或模拟器。
|
||||||
- `reuse: run`:整轮结束再关。`persist`:不关。
|
- `reuse: run`:整轮结束再关。`persist`:不关。
|
||||||
- 中途 session 挂了:后续同 surface 标 environment-fail,不要标产品失败。
|
- 中途 session 挂了:后续同 driver 标 environment-fail,不要标产品失败。
|
||||||
|
|
||||||
## 4. 执行
|
## 4. 执行
|
||||||
|
|
||||||
@@ -41,6 +57,12 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
|||||||
|
|
||||||
例外:当前条和下一条同属 `run_together` 且该组 `continue_on_fail: true` 时,前一条产品 `fail` 仍跑后一条。`environment-fail` 仍整轮停。
|
例外:当前条和下一条同属 `run_together` 且该组 `continue_on_fail: true` 时,前一条产品 `fail` 仍跑后一条。`environment-fail` 仍整轮停。
|
||||||
|
|
||||||
|
**automation**
|
||||||
|
|
||||||
|
- `automated`:Agent 端到端执行。
|
||||||
|
- `assisted`:照常驱动,但遇到以 `[人工]` 开头的 `how.steps` 时停下,请现场人员执行并回报,确认后再继续。无人值守时该条记 `not-run`。
|
||||||
|
- `manual`:没有 `driver`,`run-script` 会直接返回 `not-run`。只能由人执行、回报结果,Agent 负责记录。
|
||||||
|
|
||||||
**script**
|
**script**
|
||||||
|
|
||||||
有 `how.run`:
|
有 `how.run`:
|
||||||
@@ -59,9 +81,9 @@ python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
|
|||||||
|
|
||||||
## 5. 记录与报告
|
## 5. 记录与报告
|
||||||
|
|
||||||
每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped`。
|
每条:`id, result, evidence, notes`。`result` 只能是 `pass` / `fail` / `environment-fail` / `skipped` / `not-run`。`skipped` 是主动不跑(retired / pending / 超风险上限 / 未授权 / `assume` 不成立),`not-run` 是没轮到(失败即停后剩下的 / 被前置整组挡住的 / 需人工而无人值守的)。
|
||||||
|
|
||||||
写入 `.pouch/tester/runs/<UTC时间>-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、逐条结果。立刻用同一快照生成 CSV,不要手改:
|
写入 `.pouch/tester/runs/<UTC时间>-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。立刻用同一快照生成 CSV,不要手改:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml>
|
python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml>
|
||||||
@@ -77,7 +99,7 @@ python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml>
|
|||||||
范围: …
|
范围: …
|
||||||
风险上限: …
|
风险上限: …
|
||||||
session: browser=复用|新开|未用;mobile=…
|
session: browser=复用|新开|未用;mobile=…
|
||||||
计数: pass=N fail=N environment-fail=N skipped=N
|
计数: pass=N fail=N environment-fail=N skipped=N not-run=N
|
||||||
失败: id + 一句证据
|
失败: id + 一句证据
|
||||||
跳过: id + 原因
|
跳过: id + 原因
|
||||||
快照: .pouch/tester/runs/<file>.yaml
|
快照: .pouch/tester/runs/<file>.yaml
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# tester 目录字段
|
# tester 目录字段
|
||||||
|
|
||||||
|
术语与命名理由见 [concepts.md](concepts.md);本文件只列字段与约束。
|
||||||
|
|
||||||
项目根 `.pouch/tester/`:
|
项目根 `.pouch/tester/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -16,15 +18,16 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
|
|||||||
|
|
||||||
| 字段 | 含义 |
|
| 字段 | 含义 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `categories` | `{id, title}` 列表,id 与 `cases/<id小写>.yaml` 对应;列表顺序即 `select` / 执行顺序 |
|
| `categories` | `{id, title, status_probe?}` 列表,id 与 `cases/<id小写>.yaml` 对应;列表顺序即 `select` / 执行顺序。`status_probe` 见下 |
|
||||||
| `risks` | 从低到高,只能是 `safe` → `act` → `harm` 的一段,且以 `safe` 开头。含义见 [levels.md](levels.md) |
|
| `risks` | 从低到高,只能是 `readonly` → `mutating` → `destructive` 的一段,且以 `readonly` 开头。含义见 [levels.md](levels.md) |
|
||||||
| `default_max_risk` | 未指定时的运行上限,必须是 `risks` 里的值 |
|
| `default_max_risk` | 未指定时的运行上限,必须是 `risks` 里的值 |
|
||||||
| `vars` | 可替换变量,如 `API_BASE: http://${HOST}:8095` |
|
| `vars` | 可替换变量,如 `API_BASE: http://${HOST}:8095` |
|
||||||
| `auth_env` | 风险名 → 环境变量名。该档执行前变量必须为 `true` |
|
| `authorize` | 风险名 → 环境变量名。该档执行前变量必须为 `true`(`readonly` 不需要) |
|
||||||
| `gates` | 可选。项目细分闸名 → 环境变量。用在用例的 `gate` 上,不要拿它代替 `risk` |
|
| `gates` | 可选。项目细分闸名 → 环境变量。用在用例的 `gate` 上,不要拿它代替 `risk` |
|
||||||
| `sessions` | 可复用会话。`kind`: `browser` / `mobile`;`reuse`: `run`(本轮结束关掉)或 `persist`(probe 活着就附着) |
|
| `sessions` | 可复用会话。`kind`: `browser` / `mobile`;`reuse`: `run`(本轮结束关掉)或 `persist`(probe 活着就附着) |
|
||||||
| `id_pattern` | 用例 id 正则 |
|
| `id_pattern` | 用例 id 正则 |
|
||||||
| `run_together` | 可选。成组执行:`[{ids: [TC-…, TC-…], continue_on_fail: true}]`。点到组内任一条,`select` 会按 `ids` 顺序把整组扩进命中列表。`continue_on_fail: true` 时组内前一条产品 fail 仍跑后一条(返回/收尾)。一个 id 只能属于一组。 |
|
| `run_together` | 可选。成组执行:`[{ids: [TC-…, TC-…], continue_on_fail: true}]`。点到组内任一条,`select` 会按 `ids` 顺序把整组扩进命中列表。`continue_on_fail: true` 时组内前一条产品 fail 仍跑后一条(返回/收尾)。一个 id 只能属于一组。 |
|
||||||
|
| `status_probe` | 可选。顶层默认值,一个**分类级前置条件**:`{url, stop_if: {字段: [命中值]}}`。运行前请求 `url`,响应命中任一 `stop_if` 条件即视为前置不满足。分类项可用同名键覆盖:缺省继承顶层、`false` 关闭、mapping 整体替换。命中或不可达只影响用它的分类,不是产品失败。业界对应 pytest 的 session fixture / Robot 的 Suite Setup。`select` 会按分类返回解析后的生效值,`url` 已用 `vars` 展开 |
|
||||||
|
|
||||||
`local.yaml` 与环境变量覆盖 `HOST` 和授权开关。查找顺序:环境变量 > `local.yaml` > `catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。
|
`local.yaml` 与环境变量覆盖 `HOST` 和授权开关。查找顺序:环境变量 > `local.yaml` > `catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。
|
||||||
|
|
||||||
@@ -38,33 +41,37 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
|
|||||||
| `category` | 是 | 必须等于文件声明的 category |
|
| `category` | 是 | 必须等于文件声明的 category |
|
||||||
| `title` | 是 | 短名 |
|
| `title` | 是 | 短名 |
|
||||||
| `what` | 是 | 一句可观察的「测什么」 |
|
| `what` | 是 | 一句可观察的「测什么」 |
|
||||||
| `status` | 是 | `active` / `pending` / `retired`。`pending` = 暂缓,仍保留在目录里但不执行,原因写在 `notes` |
|
| `lifecycle` | 是 | `active` / `pending` / `retired`。`pending` = 暂缓,仍保留在目录里但不执行,原因写在 `notes` |
|
||||||
| `suite` | 是 | `smoke` / `full` / `slow`,判定见 [levels.md](levels.md) |
|
| `tag` | 是 | `smoke` / `full` / `slow`,判定见 [levels.md](levels.md)。`tag=smoke` 只能配 `risk=readonly` |
|
||||||
| `risk` | 是 | `safe` / `act` / `harm` |
|
| `risk` | 是 | `readonly` / `mutating` / `destructive` |
|
||||||
| `gate` | 否 | 项目细分闸,必须在 catalog `gates` 里 |
|
| `gate` | 否 | 项目细分闸,必须在 catalog `gates` 里;`risk=readonly` 不要填 |
|
||||||
| `surface` | 是 | `script` / `browser` / `mobile` |
|
| `driver` | 否 | `script` / `browser` / `mobile`;`automation=manual` 时禁止填 |
|
||||||
|
| `automation` | 否 | `automated`(默认)/ `assisted` / `manual`,见 [concepts.md](concepts.md) |
|
||||||
| `session` | browser/mobile 必填 | 指向 catalog `sessions` 的键 |
|
| `session` | browser/mobile 必填 | 指向 catalog `sessions` 的键 |
|
||||||
| `how` | 是 | 怎么测,见下 |
|
| `how` | 是 | 怎么测,见下 |
|
||||||
| `expected` | 是 | 怎么过,至少一条 `{kind, value}` |
|
| `expected` | 是 | 怎么过,至少一条 `{kind, value}` |
|
||||||
| `maps_to` | 否 | 对人文档的功能项 id |
|
| `maps_to` | 否 | 对人文档的功能项 id |
|
||||||
| `requires` | 否 | 前置标签 |
|
| `assume` | 否 | 额外跳过条件(环境变量表达式)。不要重复 `risk` / `gate` 已覆盖的授权变量,否则 `validate` 报错 |
|
||||||
| `skip_unless` | 否 | 额外跳过条件(环境变量表达式) |
|
|
||||||
| `setup` | 否 | 人话前置 |
|
| `setup` | 否 | 人话前置 |
|
||||||
| `cleanup` | 否 | 跑完回退 |
|
| `teardown` | 否 | 跑完回退 |
|
||||||
| `notes` | 否 | 备注 |
|
| `notes` | 否 | 备注 |
|
||||||
|
|
||||||
## how
|
## how
|
||||||
|
|
||||||
|
按 `driver` 分派;`automation=manual` 时没有 `driver`,只有 `how.steps`(人工步骤)。
|
||||||
|
|
||||||
- `script`:`how.run` 是一条可在项目根执行的命令(可多行脚本)。没有 `run` 时 Agent 按 `how.steps` 做,仍不得发明未写明的请求。
|
- `script`:`how.run` 是一条可在项目根执行的命令(可多行脚本)。没有 `run` 时 Agent 按 `how.steps` 做,仍不得发明未写明的请求。
|
||||||
- `browser`:`how.url` + `how.steps`。`expected.kind` 用 `visible-text` / `url` / `interaction`。
|
- `browser`:`how.url` + `how.steps`。`expected.kind` 用 `visible-text` / `url` / `interaction`。
|
||||||
- `mobile`:`how.steps`,挂到 `session` 指定的模拟器。`expected` 同样是可观察信号。
|
- `mobile`:`how.steps`,挂到 `session` 指定的模拟器。`expected` 同样是可观察信号。
|
||||||
|
|
||||||
可选 `how.timeout`,秒,默认 60。
|
可选 `how.timeout`,秒,默认 60。
|
||||||
|
|
||||||
|
`automation=assisted` 时,至少一个 `how.steps` 条目要以 `[人工]` 开头(写在 YAML 里要加引号,`[` 开头会被当成流式序列)。
|
||||||
|
|
||||||
## expected.kind
|
## expected.kind
|
||||||
|
|
||||||
`api-status` / `api-field` / `script-exit` / `visible-text` / `url` / `interaction`
|
`api-status` / `api-field` / `script-exit` / `visible-text` / `url` / `interaction`
|
||||||
|
|
||||||
## 判定
|
## 判定
|
||||||
|
|
||||||
`pass` / `fail` / `environment-fail` / `skipped`
|
`pass` / `fail` / `environment-fail` / `skipped` / `not-run`
|
||||||
|
|||||||
@@ -19,15 +19,16 @@ except ImportError: # pragma: no cover
|
|||||||
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
|
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
|
||||||
raise SystemExit(2)
|
raise SystemExit(2)
|
||||||
|
|
||||||
SURFACES = ("script", "browser", "mobile")
|
DRIVERS = ("script", "browser", "mobile")
|
||||||
STATUSES = ("active", "retired", "pending")
|
LIFECYCLES = ("active", "retired", "pending")
|
||||||
STATUS_SKIP_REASON = {
|
LIFECYCLE_SKIP_REASON = {
|
||||||
"retired": "retired",
|
"retired": "retired",
|
||||||
"pending": "pending(暂缓,见 notes)",
|
"pending": "pending(暂缓,见 notes)",
|
||||||
}
|
}
|
||||||
SUITES = ("smoke", "full", "slow")
|
TAGS = ("smoke", "full", "slow")
|
||||||
CANONICAL_RISKS = ("safe", "act", "harm")
|
CANONICAL_RISKS = ("readonly", "mutating", "destructive")
|
||||||
RESULTS = ("pass", "fail", "environment-fail", "skipped")
|
AUTOMATIONS = ("automated", "assisted", "manual")
|
||||||
|
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
|
||||||
EXPECTED_KINDS = (
|
EXPECTED_KINDS = (
|
||||||
"api-status",
|
"api-status",
|
||||||
"api-field",
|
"api-field",
|
||||||
@@ -146,7 +147,7 @@ def resolve_vars(root: Path, meta: dict[str, Any]) -> dict[str, str]:
|
|||||||
continue
|
continue
|
||||||
merged[str(key)] = str(value).lower() if isinstance(value, bool) else str(value)
|
merged[str(key)] = str(value).lower() if isinstance(value, bool) else str(value)
|
||||||
auth_keys = set()
|
auth_keys = set()
|
||||||
for mapping in (meta.get("auth_env") or {}, meta.get("gates") or {}):
|
for mapping in (meta.get("authorize") or {}, meta.get("gates") or {}):
|
||||||
if isinstance(mapping, dict):
|
if isinstance(mapping, dict):
|
||||||
auth_keys.update(str(value) for value in mapping.values() if value)
|
auth_keys.update(str(value) for value in mapping.values() if value)
|
||||||
for key, value in os.environ.items():
|
for key, value in os.environ.items():
|
||||||
@@ -212,12 +213,12 @@ def cmd_list(root: Path) -> int:
|
|||||||
cases = load_all_cases(root)
|
cases = load_all_cases(root)
|
||||||
risks = list(meta.get("risks") or [])
|
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}
|
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"]
|
active = [c for c in cases if c.get("lifecycle", "active") == "active"]
|
||||||
retired = [c for c in cases if c.get("status") == "retired"]
|
retired = [c for c in cases if c.get("lifecycle") == "retired"]
|
||||||
pending = [c for c in cases if c.get("status") == "pending"]
|
pending = [c for c in cases if c.get("lifecycle") == "pending"]
|
||||||
print(f"## 测试目录 active {len(active)} / pending {len(pending)} / retired {len(retired)}")
|
print(f"## 测试目录 active {len(active)} / pending {len(pending)} / retired {len(retired)}")
|
||||||
print()
|
print()
|
||||||
headers = ["分类", "标题", "条数", "smoke"] + risks + list(SURFACES)
|
headers = ["分类", "标题", "条数", "smoke"] + risks + list(DRIVERS)
|
||||||
print("| " + " | ".join(headers) + " |")
|
print("| " + " | ".join(headers) + " |")
|
||||||
print("| " + " | ".join("---" for _ in headers) + " |")
|
print("| " + " | ".join("---" for _ in headers) + " |")
|
||||||
|
|
||||||
@@ -235,11 +236,11 @@ def cmd_list(root: Path) -> int:
|
|||||||
title = (cats_meta.get(cat) or {}).get("title") or (
|
title = (cats_meta.get(cat) or {}).get("title") or (
|
||||||
group[0].get("_file_title") if group else ""
|
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"))]
|
row = [cat, str(title), str(len(group)), str(sum(1 for c in group if c.get("tag") == "smoke"))]
|
||||||
for risk in risks:
|
for risk in risks:
|
||||||
row.append(str(sum(1 for c in group if c.get("risk") == risk)))
|
row.append(str(sum(1 for c in group if c.get("risk") == risk)))
|
||||||
for surface in SURFACES:
|
for driver in DRIVERS:
|
||||||
row.append(str(sum(1 for c in group if c.get("surface") == surface)))
|
row.append(str(sum(1 for c in group if c.get("driver") == driver)))
|
||||||
print("| " + " | ".join(row) + " |")
|
print("| " + " | ".join(row) + " |")
|
||||||
print()
|
print()
|
||||||
print("跑法: 跑 <分类> | 跑 <id,id> | 跑 smoke | 跑 <分类> 风险到 <risk>")
|
print("跑法: 跑 <分类> | 跑 <id,id> | 跑 smoke | 跑 <分类> 风险到 <risk>")
|
||||||
@@ -268,6 +269,75 @@ def public_case(case: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {k: v for k, v in case.items() if not str(k).startswith("_")}
|
return {k: v for k, v in case.items() if not str(k).startswith("_")}
|
||||||
|
|
||||||
|
|
||||||
|
PROBE_SOURCES = ("global", "category", "disabled", "none", "invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def effective_status_probe(meta: dict[str, Any], category: str) -> tuple[Any, str]:
|
||||||
|
"""分类生效的 status_probe(分类级前置条件)。
|
||||||
|
|
||||||
|
返回 (probe, source);probe 为 None 表示该分类不做探测。
|
||||||
|
source: global(继承顶层)/ category(分类覆盖)/ disabled(分类置 false)/
|
||||||
|
none(顶层和分类都没声明)/ invalid(分类值非法)。
|
||||||
|
"""
|
||||||
|
cats = {
|
||||||
|
str(c.get("id")): c
|
||||||
|
for c in meta.get("categories") or []
|
||||||
|
if isinstance(c, dict) and c.get("id")
|
||||||
|
}
|
||||||
|
cat = cats.get(category) or {}
|
||||||
|
if "status_probe" in cat:
|
||||||
|
value = cat.get("status_probe")
|
||||||
|
if value is False:
|
||||||
|
return None, "disabled"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value, "category"
|
||||||
|
return None, "invalid"
|
||||||
|
probe = meta.get("status_probe")
|
||||||
|
if isinstance(probe, dict):
|
||||||
|
return probe, "global"
|
||||||
|
return None, "none"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_probe(probe: Any, vars_map: dict[str, str]) -> Any:
|
||||||
|
"""把探测定义里的 ${VAR} 展开,Agent 拿到就能直接请求。"""
|
||||||
|
if not isinstance(probe, dict):
|
||||||
|
return probe
|
||||||
|
out = dict(probe)
|
||||||
|
url = out.get("url")
|
||||||
|
if isinstance(url, str):
|
||||||
|
out["url"] = substitute(url, vars_map)
|
||||||
|
stop_if = out.get("stop_if")
|
||||||
|
if isinstance(stop_if, dict):
|
||||||
|
out["stop_if"] = {
|
||||||
|
(substitute(str(key), vars_map) if "${" in str(key) else key): (
|
||||||
|
[
|
||||||
|
substitute(str(item), vars_map)
|
||||||
|
if isinstance(item, str) and "${" in item
|
||||||
|
else item
|
||||||
|
for item in value
|
||||||
|
]
|
||||||
|
if isinstance(value, list)
|
||||||
|
else value
|
||||||
|
)
|
||||||
|
for key, value in stop_if.items()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def probe_errors(where: str, probe: Any) -> list[str]:
|
||||||
|
"""校验一份 status_probe 定义。允许值为 mapping;false 由调用方处理。"""
|
||||||
|
if not isinstance(probe, dict):
|
||||||
|
return [f"{where}: status_probe 必须是 mapping 或 false"]
|
||||||
|
errors: list[str] = []
|
||||||
|
url = probe.get("url")
|
||||||
|
if not isinstance(url, str) or not url.strip():
|
||||||
|
errors.append(f"{where}.url 必填")
|
||||||
|
stop_if = probe.get("stop_if")
|
||||||
|
if stop_if is not None and not isinstance(stop_if, dict):
|
||||||
|
errors.append(f"{where}.stop_if 必须是 mapping(字段 -> 命中值列表)")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def run_together_groups(meta: dict[str, Any]) -> list[dict[str, Any]]:
|
def run_together_groups(meta: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
raw = meta.get("run_together") or []
|
raw = meta.get("run_together") or []
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
@@ -294,6 +364,7 @@ def expand_run_together(
|
|||||||
selected: list[dict[str, Any]],
|
selected: list[dict[str, Any]],
|
||||||
skipped: list[dict[str, str]],
|
skipped: list[dict[str, str]],
|
||||||
max_i: int,
|
max_i: int,
|
||||||
|
automation_filter: str = "automated",
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
groups = run_together_groups(meta)
|
groups = run_together_groups(meta)
|
||||||
if not groups:
|
if not groups:
|
||||||
@@ -318,10 +389,14 @@ def expand_run_together(
|
|||||||
members.append(selected_map[cid])
|
members.append(selected_map[cid])
|
||||||
continue
|
continue
|
||||||
case = by_id.get(cid)
|
case = by_id.get(cid)
|
||||||
if not case or case.get("status", "active") != "active":
|
if not case or case.get("lifecycle", "active") != "active":
|
||||||
skipped.append({"id": cid, "reason": "run_together 搭档不是 active"})
|
skipped.append({"id": cid, "reason": "run_together 搭档不是 active"})
|
||||||
continue
|
continue
|
||||||
risk = str(case.get("risk") or "safe")
|
automation = str(case.get("automation") or "automated")
|
||||||
|
if automation_filter != "any" and automation != automation_filter:
|
||||||
|
skipped.append({"id": cid, "reason": f"run_together 搭档 automation={automation}(需人工)"})
|
||||||
|
continue
|
||||||
|
risk = str(case.get("risk") or "readonly")
|
||||||
try:
|
try:
|
||||||
r_i = risk_index(meta, risk)
|
r_i = risk_index(meta, risk)
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
@@ -354,8 +429,9 @@ def expand_run_together(
|
|||||||
def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||||
meta = load_catalog_meta(root)
|
meta = load_catalog_meta(root)
|
||||||
cases = load_all_cases(root)
|
cases = load_all_cases(root)
|
||||||
max_risk = args.max_risk or meta.get("default_max_risk") or "safe"
|
max_risk = args.max_risk or meta.get("default_max_risk") or "readonly"
|
||||||
max_i = risk_index(meta, max_risk)
|
max_i = risk_index(meta, max_risk)
|
||||||
|
automation_filter = args.automation or "automated"
|
||||||
wanted = set(parse_ids(args.ids or []))
|
wanted = set(parse_ids(args.ids or []))
|
||||||
selected: list[dict[str, Any]] = []
|
selected: list[dict[str, Any]] = []
|
||||||
skipped: list[dict[str, str]] = []
|
skipped: list[dict[str, str]] = []
|
||||||
@@ -363,12 +439,12 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
|||||||
|
|
||||||
for case in cases:
|
for case in cases:
|
||||||
cid = str(case.get("id") or "")
|
cid = str(case.get("id") or "")
|
||||||
if case.get("status", "active") != "active":
|
if case.get("lifecycle", "active") != "active":
|
||||||
if cid in wanted:
|
if cid in wanted:
|
||||||
status = str(case.get("status"))
|
lifecycle = str(case.get("lifecycle"))
|
||||||
skipped.append({
|
skipped.append({
|
||||||
"id": cid,
|
"id": cid,
|
||||||
"reason": STATUS_SKIP_REASON.get(status, status),
|
"reason": LIFECYCLE_SKIP_REASON.get(lifecycle, lifecycle),
|
||||||
})
|
})
|
||||||
missing.discard(cid)
|
missing.discard(cid)
|
||||||
continue
|
continue
|
||||||
@@ -379,11 +455,18 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
|||||||
else:
|
else:
|
||||||
if args.category and case.get("category") != args.category:
|
if args.category and case.get("category") != args.category:
|
||||||
continue
|
continue
|
||||||
if args.suite and case.get("suite") != args.suite:
|
if args.tag and case.get("tag") != args.tag:
|
||||||
continue
|
continue
|
||||||
if args.surface and case.get("surface") != args.surface:
|
if args.driver and case.get("driver") != args.driver:
|
||||||
continue
|
continue
|
||||||
risk = str(case.get("risk") or "safe")
|
automation = str(case.get("automation") or "automated")
|
||||||
|
if automation_filter != "any" and automation != automation_filter:
|
||||||
|
skipped.append({
|
||||||
|
"id": cid,
|
||||||
|
"reason": f"automation={automation}(需人工),本轮只跑 automation={automation_filter}",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
risk = str(case.get("risk") or "readonly")
|
||||||
try:
|
try:
|
||||||
r_i = risk_index(meta, risk)
|
r_i = risk_index(meta, risk)
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
@@ -401,15 +484,27 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
|||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
selected, together = expand_run_together(meta, cases, selected, skipped, max_i)
|
selected, together = expand_run_together(meta, cases, selected, skipped, max_i, automation_filter)
|
||||||
|
|
||||||
|
# 按分类解析生效的 status_probe,让 Agent 不再自己按 catalog 猜作用范围。
|
||||||
|
vars_map = resolve_vars(root, meta)
|
||||||
|
status_probes: dict[str, dict[str, Any]] = {}
|
||||||
|
for case in selected:
|
||||||
|
cat = str(case.get("category") or "")
|
||||||
|
if cat and cat not in status_probes:
|
||||||
|
probe, source = effective_status_probe(meta, cat)
|
||||||
|
status_probes[cat] = {"source": source, "probe": resolve_probe(probe, vars_map)}
|
||||||
|
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"max_risk": max_risk,
|
"max_risk": max_risk,
|
||||||
|
"automation": automation_filter,
|
||||||
"count": len(selected),
|
"count": len(selected),
|
||||||
|
"vars": vars_map,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"run_together": together,
|
"run_together": together,
|
||||||
|
"status_probe": status_probes,
|
||||||
"cases": selected,
|
"cases": selected,
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
@@ -449,22 +544,28 @@ def validate(root: Path) -> list[str]:
|
|||||||
errors.append(f"catalog.categories[{i}] 需要 id")
|
errors.append(f"catalog.categories[{i}] 需要 id")
|
||||||
else:
|
else:
|
||||||
cat_ids.append(str(item["id"]))
|
cat_ids.append(str(item["id"]))
|
||||||
|
if isinstance(item, dict) and "status_probe" in item:
|
||||||
|
value = item.get("status_probe")
|
||||||
|
if value is not False:
|
||||||
|
errors.extend(probe_errors(f"catalog.categories[{i}].status_probe", value))
|
||||||
|
if "status_probe" in meta:
|
||||||
|
errors.extend(probe_errors("catalog.status_probe", meta.get("status_probe")))
|
||||||
risks = meta.get("risks")
|
risks = meta.get("risks")
|
||||||
if not isinstance(risks, list) or not risks:
|
if not isinstance(risks, list) or not risks:
|
||||||
errors.append("catalog.risks 必须是非空列表")
|
errors.append("catalog.risks 必须是非空列表")
|
||||||
risks = []
|
risks = []
|
||||||
if risks and risks[0] != "safe":
|
if risks and risks[0] != "readonly":
|
||||||
errors.append("catalog.risks 必须以 safe 开头")
|
errors.append("catalog.risks 必须以 readonly 开头")
|
||||||
last_pos = -1
|
last_pos = -1
|
||||||
for name in risks:
|
for name in risks:
|
||||||
if name not in CANONICAL_RISKS:
|
if name not in CANONICAL_RISKS:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"非法 risk 名 {name!r},只能是 safe/act/harm"
|
f"非法 risk 名 {name!r},只能是 readonly/mutating/destructive"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
pos = CANONICAL_RISKS.index(name)
|
pos = CANONICAL_RISKS.index(name)
|
||||||
if pos <= last_pos:
|
if pos <= last_pos:
|
||||||
errors.append("catalog.risks 必须按 safe < act < harm 升序且不重复")
|
errors.append("catalog.risks 必须按 readonly < mutating < destructive 升序且不重复")
|
||||||
last_pos = pos
|
last_pos = pos
|
||||||
default_max = meta.get("default_max_risk")
|
default_max = meta.get("default_max_risk")
|
||||||
if default_max not in risks:
|
if default_max not in risks:
|
||||||
@@ -473,10 +574,10 @@ def validate(root: Path) -> list[str]:
|
|||||||
if gates and not isinstance(gates, dict):
|
if gates and not isinstance(gates, dict):
|
||||||
errors.append("catalog.gates 必须是 mapping")
|
errors.append("catalog.gates 必须是 mapping")
|
||||||
gates = {}
|
gates = {}
|
||||||
auth_env = meta.get("auth_env") or {}
|
authorize = meta.get("authorize") or {}
|
||||||
if auth_env and not isinstance(auth_env, dict):
|
if authorize and not isinstance(authorize, dict):
|
||||||
errors.append("catalog.auth_env 必须是 mapping")
|
errors.append("catalog.authorize 必须是 mapping")
|
||||||
auth_env = {}
|
authorize = {}
|
||||||
sessions = meta.get("sessions") or {}
|
sessions = meta.get("sessions") or {}
|
||||||
if not isinstance(sessions, dict):
|
if not isinstance(sessions, dict):
|
||||||
errors.append("catalog.sessions 必须是 mapping")
|
errors.append("catalog.sessions 必须是 mapping")
|
||||||
@@ -535,26 +636,27 @@ def validate(root: Path) -> list[str]:
|
|||||||
errors.append(f"{where}: category={cat} 与文件 category={file_cat} 不一致")
|
errors.append(f"{where}: category={cat} 与文件 category={file_cat} 不一致")
|
||||||
if cat and cat_ids and cat not in cat_ids:
|
if cat and cat_ids and cat not in cat_ids:
|
||||||
errors.append(f"{where}: category={cat} 不在 catalog.categories")
|
errors.append(f"{where}: category={cat} 不在 catalog.categories")
|
||||||
if case.get("status", "active") not in STATUSES:
|
if case.get("lifecycle", "active") not in LIFECYCLES:
|
||||||
errors.append(f"{where}: 非法 status")
|
errors.append(f"{where}: 非法 lifecycle")
|
||||||
if case.get("suite") not in SUITES:
|
if case.get("tag") not in TAGS:
|
||||||
errors.append(f"{where}: 非法 suite")
|
errors.append(f"{where}: 非法 tag")
|
||||||
if case.get("risk") not in risks:
|
if case.get("risk") not in risks:
|
||||||
errors.append(f"{where}: 非法 risk={case.get('risk')}")
|
errors.append(f"{where}: 非法 risk={case.get('risk')}")
|
||||||
if case.get("suite") == "smoke" and case.get("risk") != "safe":
|
if case.get("tag") == "smoke" and case.get("risk") != "readonly":
|
||||||
errors.append(f"{where}: smoke 只能 risk=safe")
|
errors.append(f"{where}: tag=smoke 只能 risk=readonly")
|
||||||
gate = case.get("gate")
|
gate = case.get("gate")
|
||||||
if gate:
|
if gate:
|
||||||
if gate not in gates:
|
if gate not in gates:
|
||||||
errors.append(f"{where}: gate={gate} 未在 catalog.gates 声明")
|
errors.append(f"{where}: gate={gate} 未在 catalog.gates 声明")
|
||||||
if case.get("risk") in (None, "safe"):
|
if case.get("risk") in (None, "readonly"):
|
||||||
errors.append(f"{where}: risk=safe 不要填 gate")
|
errors.append(f"{where}: risk=readonly 不要填 gate")
|
||||||
risk = case.get("risk")
|
risk = case.get("risk")
|
||||||
if risk and risk != "safe" and risk not in auth_env and not gate:
|
if risk and risk != "readonly" and risk not in authorize and not gate:
|
||||||
errors.append(f"{where}: {risk} 需要 auth_env.{risk} 或 gate")
|
errors.append(f"{where}: {risk} 需要 authorize.{risk} 或 gate")
|
||||||
surface = case.get("surface")
|
automation = str(case.get("automation") or "automated")
|
||||||
if surface not in SURFACES:
|
if automation not in AUTOMATIONS:
|
||||||
errors.append(f"{where}: 非法 surface={surface}")
|
errors.append(f"{where}: 非法 automation={case.get('automation')}")
|
||||||
|
automation = "automated"
|
||||||
if not str(case.get("title") or "").strip():
|
if not str(case.get("title") or "").strip():
|
||||||
errors.append(f"{where}: 缺少 title")
|
errors.append(f"{where}: 缺少 title")
|
||||||
if not str(case.get("what") or "").strip():
|
if not str(case.get("what") or "").strip():
|
||||||
@@ -563,18 +665,51 @@ def validate(root: Path) -> list[str]:
|
|||||||
if not isinstance(how, dict):
|
if not isinstance(how, dict):
|
||||||
errors.append(f"{where}: how 必须是 mapping")
|
errors.append(f"{where}: how 必须是 mapping")
|
||||||
how = {}
|
how = {}
|
||||||
if surface == "script" and not how.get("run") and not how.get("steps"):
|
steps = how.get("steps") or []
|
||||||
|
if automation == "manual":
|
||||||
|
if case.get("driver"):
|
||||||
|
errors.append(f"{where}: automation=manual 不要填 driver")
|
||||||
|
if how.get("run"):
|
||||||
|
errors.append(f"{where}: automation=manual 不要填 how.run")
|
||||||
|
if not steps:
|
||||||
|
errors.append(f"{where}: automation=manual 需要 how.steps")
|
||||||
|
else:
|
||||||
|
driver = case.get("driver")
|
||||||
|
if driver not in DRIVERS:
|
||||||
|
errors.append(f"{where}: 非法 driver={driver}")
|
||||||
|
if driver == "script" and not how.get("run") and not steps:
|
||||||
errors.append(f"{where}: script 需要 how.run 或 how.steps")
|
errors.append(f"{where}: script 需要 how.run 或 how.steps")
|
||||||
if surface in ("browser", "mobile"):
|
if driver in ("browser", "mobile"):
|
||||||
session = case.get("session")
|
session = case.get("session")
|
||||||
if not session:
|
if not session:
|
||||||
errors.append(f"{where}: {surface} 需要 session")
|
errors.append(f"{where}: {driver} 需要 session")
|
||||||
elif session not in sessions:
|
elif session not in sessions:
|
||||||
errors.append(f"{where}: session={session} 未在 catalog.sessions 声明")
|
errors.append(f"{where}: session={session} 未在 catalog.sessions 声明")
|
||||||
if surface == "browser" and not how.get("url") and not how.get("steps"):
|
if driver == "browser" and not how.get("url") and not steps:
|
||||||
errors.append(f"{where}: browser 需要 how.url 或 how.steps")
|
errors.append(f"{where}: browser 需要 how.url 或 how.steps")
|
||||||
if surface == "mobile" and not how.get("steps"):
|
if driver == "mobile" and not steps:
|
||||||
errors.append(f"{where}: mobile 需要 how.steps")
|
errors.append(f"{where}: mobile 需要 how.steps")
|
||||||
|
if automation == "assisted":
|
||||||
|
if not steps:
|
||||||
|
errors.append(f"{where}: automation=assisted 需要 how.steps")
|
||||||
|
elif not any(str(step).startswith("[人工]") for step in steps):
|
||||||
|
errors.append(f"{where}: automation=assisted 的 how.steps 至少一步以 [人工] 开头")
|
||||||
|
assume = case.get("assume")
|
||||||
|
if assume is not None:
|
||||||
|
if not isinstance(assume, str) or not assume.strip():
|
||||||
|
errors.append(f"{where}: assume 必须是非空字符串")
|
||||||
|
else:
|
||||||
|
forbidden: set[str] = set()
|
||||||
|
risk_name = str(case.get("risk") or "")
|
||||||
|
if risk_name and risk_name != "readonly":
|
||||||
|
env = authorize.get(risk_name)
|
||||||
|
if env:
|
||||||
|
forbidden.add(str(env))
|
||||||
|
if gate and gate in gates:
|
||||||
|
forbidden.add(str(gates[gate]))
|
||||||
|
for var in sorted(forbidden):
|
||||||
|
if re.search(rf"\b{re.escape(var)}\b", assume):
|
||||||
|
errors.append(f"{where}: assume 重复了 {var}(已由 risk/gate 授权)")
|
||||||
expected = case.get("expected")
|
expected = case.get("expected")
|
||||||
if not isinstance(expected, list) or not expected:
|
if not isinstance(expected, list) or not expected:
|
||||||
errors.append(f"{where}: expected 必须是非空列表")
|
errors.append(f"{where}: expected 必须是非空列表")
|
||||||
@@ -597,8 +732,8 @@ def cmd_validate(root: Path) -> int:
|
|||||||
print(f"validate: {len(errors)} 个问题", file=sys.stderr)
|
print(f"validate: {len(errors)} 个问题", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
cases = load_all_cases(root)
|
cases = load_all_cases(root)
|
||||||
active = sum(1 for c in cases if c.get("status", "active") == "active")
|
active = sum(1 for c in cases if c.get("lifecycle", "active") == "active")
|
||||||
pending = sum(1 for c in cases if c.get("status") == "pending")
|
pending = sum(1 for c in cases if c.get("lifecycle") == "pending")
|
||||||
print(f"✓ validate 通过 active={active} pending={pending} total={len(cases)}")
|
print(f"✓ validate 通过 active={active} pending={pending} total={len(cases)}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -627,11 +762,11 @@ def cmd_next_id(root: Path, category: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def authorized(vars_map: dict[str, str], meta: dict[str, Any], case: dict[str, Any]) -> bool:
|
def authorized(vars_map: dict[str, str], meta: dict[str, Any], case: dict[str, Any]) -> bool:
|
||||||
risk = str(case.get("risk") or "safe")
|
risk = str(case.get("risk") or "readonly")
|
||||||
risks = list(meta.get("risks") or ["safe"])
|
risks = list(meta.get("risks") or ["readonly"])
|
||||||
lowest = risks[0] if risks else "safe"
|
lowest = risks[0] if risks else "readonly"
|
||||||
if risk != lowest:
|
if risk != lowest:
|
||||||
env_name = (meta.get("auth_env") or {}).get(risk)
|
env_name = (meta.get("authorize") or {}).get(risk)
|
||||||
if env_name and not truthy(vars_map.get(str(env_name), "")):
|
if env_name and not truthy(vars_map.get(str(env_name), "")):
|
||||||
return False
|
return False
|
||||||
if not env_name and not case.get("gate"):
|
if not env_name and not case.get("gate"):
|
||||||
@@ -652,15 +787,24 @@ def cmd_run_script(root: Path, case_id: str) -> int:
|
|||||||
print(json.dumps({"id": case_id, "result": "environment-fail", "evidence": "找不到用例"}, ensure_ascii=False))
|
print(json.dumps({"id": case_id, "result": "environment-fail", "evidence": "找不到用例"}, ensure_ascii=False))
|
||||||
return 2
|
return 2
|
||||||
public_id = case.get("id")
|
public_id = case.get("id")
|
||||||
if case.get("status") != "active":
|
if case.get("lifecycle", "active") != "active":
|
||||||
status = str(case.get("status"))
|
lifecycle = str(case.get("lifecycle"))
|
||||||
reason = STATUS_SKIP_REASON.get(status, status)
|
reason = LIFECYCLE_SKIP_REASON.get(lifecycle, lifecycle)
|
||||||
print(json.dumps({"id": public_id, "result": "skipped", "evidence": reason}, ensure_ascii=False))
|
print(json.dumps({"id": public_id, "result": "skipped", "evidence": reason}, ensure_ascii=False))
|
||||||
return 0
|
return 0
|
||||||
if case.get("surface") != "script":
|
automation = str(case.get("automation") or "automated")
|
||||||
|
if automation != "automated":
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{"id": public_id, "result": "environment-fail", "evidence": "run-script 只能跑 surface=script"},
|
{"id": public_id, "result": "not-run", "evidence": f"automation={automation},需人工,run-script 不执行"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
if case.get("driver") != "script":
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"id": public_id, "result": "environment-fail", "evidence": "run-script 只能跑 driver=script"},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -673,7 +817,7 @@ def cmd_run_script(root: Path, case_id: str) -> int:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
risk = str(case.get("risk") or "safe")
|
risk = str(case.get("risk") or "readonly")
|
||||||
if not authorized(vars_map, meta, case):
|
if not authorized(vars_map, meta, case):
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@@ -745,9 +889,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
p_sel = sub.add_parser("select", help="筛选用例 JSON", parents=[parent])
|
p_sel = sub.add_parser("select", help="筛选用例 JSON", parents=[parent])
|
||||||
p_sel.add_argument("--category")
|
p_sel.add_argument("--category")
|
||||||
p_sel.add_argument("--id", action="append", dest="ids", default=[])
|
p_sel.add_argument("--id", action="append", dest="ids", default=[])
|
||||||
p_sel.add_argument("--suite", choices=SUITES)
|
p_sel.add_argument("--tag", choices=TAGS)
|
||||||
p_sel.add_argument("--max-risk")
|
p_sel.add_argument("--max-risk")
|
||||||
p_sel.add_argument("--surface", choices=SURFACES)
|
p_sel.add_argument("--driver", choices=DRIVERS)
|
||||||
|
p_sel.add_argument(
|
||||||
|
"--automation",
|
||||||
|
choices=[*AUTOMATIONS, "any"],
|
||||||
|
default=None,
|
||||||
|
help="默认 automated;any 表示不按人工参与度筛选",
|
||||||
|
)
|
||||||
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])
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ FIELDS = (
|
|||||||
"maps_to",
|
"maps_to",
|
||||||
"title",
|
"title",
|
||||||
"what",
|
"what",
|
||||||
"suite",
|
"tag",
|
||||||
"risk",
|
"risk",
|
||||||
"gate",
|
"gate",
|
||||||
"surface",
|
"driver",
|
||||||
|
"automation",
|
||||||
"session",
|
"session",
|
||||||
"status",
|
"lifecycle",
|
||||||
"requires",
|
|
||||||
"setup",
|
"setup",
|
||||||
"how",
|
"how",
|
||||||
"expected",
|
"expected",
|
||||||
@@ -186,13 +186,13 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path:
|
|||||||
"maps_to": case.get("maps_to") or "",
|
"maps_to": case.get("maps_to") or "",
|
||||||
"title": case.get("title") or "",
|
"title": case.get("title") or "",
|
||||||
"what": one_line(case.get("what")),
|
"what": one_line(case.get("what")),
|
||||||
"suite": case.get("suite") or "",
|
"tag": case.get("tag") or "",
|
||||||
"risk": case.get("risk") or "",
|
"risk": case.get("risk") or "",
|
||||||
"gate": case.get("gate") or "",
|
"gate": case.get("gate") or "",
|
||||||
"surface": case.get("surface") or "",
|
"driver": case.get("driver") or "",
|
||||||
|
"automation": case.get("automation") or "automated",
|
||||||
"session": case.get("session") or "",
|
"session": case.get("session") or "",
|
||||||
"status": case.get("status") or "active",
|
"lifecycle": case.get("lifecycle") or "active",
|
||||||
"requires": one_line(case.get("requires")),
|
|
||||||
"setup": one_line(case.get("setup")),
|
"setup": one_line(case.get("setup")),
|
||||||
"how": how_brief(case.get("how")),
|
"how": how_brief(case.get("how")),
|
||||||
"expected": one_line(case.get("expected")),
|
"expected": one_line(case.get("expected")),
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ except ImportError: # pragma: no cover
|
|||||||
|
|
||||||
from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir
|
from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir
|
||||||
|
|
||||||
RESULTS = ("pass", "fail", "environment-fail", "skipped")
|
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
|
||||||
EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"})
|
EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"})
|
||||||
|
EXECUTED_NO = frozenset({"skipped", "not-run"})
|
||||||
FIELDS = (
|
FIELDS = (
|
||||||
"id",
|
"id",
|
||||||
"result",
|
"result",
|
||||||
@@ -28,9 +29,9 @@ FIELDS = (
|
|||||||
"notes",
|
"notes",
|
||||||
"category",
|
"category",
|
||||||
"title",
|
"title",
|
||||||
"suite",
|
"tag",
|
||||||
"risk",
|
"risk",
|
||||||
"surface",
|
"driver",
|
||||||
"maps_to",
|
"maps_to",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ FIELDS = (
|
|||||||
def executed_cell(result: str) -> str:
|
def executed_cell(result: str) -> str:
|
||||||
if result in EXECUTED_YES:
|
if result in EXECUTED_YES:
|
||||||
return "yes"
|
return "yes"
|
||||||
if result == "skipped":
|
if result in EXECUTED_NO:
|
||||||
return "no"
|
return "no"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -115,9 +116,9 @@ def export_run_csv(
|
|||||||
"notes": one_line(item.get("notes")),
|
"notes": one_line(item.get("notes")),
|
||||||
"category": case.get("category") or "",
|
"category": case.get("category") or "",
|
||||||
"title": case.get("title") or "",
|
"title": case.get("title") or "",
|
||||||
"suite": case.get("suite") or "",
|
"tag": case.get("tag") or "",
|
||||||
"risk": case.get("risk") or "",
|
"risk": case.get("risk") or "",
|
||||||
"surface": case.get("surface") or "",
|
"driver": case.get("driver") or "",
|
||||||
"maps_to": case.get("maps_to") or "",
|
"maps_to": case.get("maps_to") or "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
version: 1
|
version: 2
|
||||||
updatedAt: "1970-01-01T00:00:00+00:00"
|
updatedAt: "1970-01-01T00:00:00+00:00"
|
||||||
project:
|
project:
|
||||||
name: ""
|
name: ""
|
||||||
id_pattern: "^[A-Z]+-[A-Za-z0-9-]+$"
|
id_pattern: "^[A-Z]+-[A-Za-z0-9-]+$"
|
||||||
categories: []
|
categories: []
|
||||||
risks:
|
risks:
|
||||||
- safe
|
- readonly
|
||||||
- act
|
- mutating
|
||||||
- harm
|
- destructive
|
||||||
default_max_risk: safe
|
default_max_risk: readonly
|
||||||
vars: {}
|
vars: {}
|
||||||
auth_env:
|
authorize:
|
||||||
act: AUTHORIZE_ACT
|
mutating: AUTHORIZE_MUTATING
|
||||||
harm: AUTHORIZE_HARM
|
destructive: AUTHORIZE_DESTRUCTIVE
|
||||||
gates: {}
|
gates: {}
|
||||||
sessions:
|
sessions:
|
||||||
browser:
|
browser:
|
||||||
@@ -24,3 +24,9 @@ sessions:
|
|||||||
reuse: persist
|
reuse: persist
|
||||||
probe: ""
|
probe: ""
|
||||||
init: ""
|
init: ""
|
||||||
|
# 分类级前置条件(可选)。顶层为默认值,categories[].status_probe 可覆盖:
|
||||||
|
# 缺省继承 / false 关闭 / mapping 整体替换。
|
||||||
|
# status_probe:
|
||||||
|
# url: "${STATUS_BASE}/api/v1/status"
|
||||||
|
# stop_if:
|
||||||
|
# robot_fsm_state: [1, 2]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 复制为 .pouch/tester/local.yaml 后填写。不要提交 local.yaml。
|
# 复制为 .pouch/tester/local.yaml 后填写。不要提交 local.yaml。
|
||||||
HOST: ""
|
HOST: ""
|
||||||
AUTHORIZE_ACT: false
|
AUTHORIZE_MUTATING: false
|
||||||
AUTHORIZE_HARM: false
|
AUTHORIZE_DESTRUCTIVE: false
|
||||||
|
|||||||
Reference in New Issue
Block a user