diff --git a/skills/tester/README.md b/skills/tester/README.md index f284694..ce132e7 100644 --- a/skills/tester/README.md +++ b/skills/tester/README.md @@ -1,6 +1,6 @@ # tester -维护并执行项目里的黑盒测试目录。用例按分类放在 `.pouch/tester/`,可以列出、增改,也可以按分类或编号去跑。 +维护并执行项目里的黑盒测试目录。用例按分类放在 `.pouch/tester/`,可以列出、增改,也可以按分类或编号去跑。术语、命名与设计理由见 `references/concepts.md`。 ## 什么时候使用 @@ -15,7 +15,7 @@ - 目录在项目根的 `.pouch/tester/`。没有就说「初始化 tester」。 - 跑真实环境前,复制 `.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 -加一条测试:分类 CAM,风险 hardware,开盖再关盖,用脚本打 /api/camera_3d +加一条测试:分类 CAM,风险 mutating,开盖再关盖,用脚本打 /api/camera_3d ``` ```text @@ -41,7 +41,7 @@ 2. 改用例前先给你看将要写入的内容,你确认再保存。 3. 执行时:脚本类直接跑命令;浏览器和移动模拟器先初始化一次,同一轮里复用。 4. 超出你允许的风险级别会跳过,不会偷偷执行。 -5. 跑完给出按分类计数的结果,以及 `pass / fail / environment-fail / skipped`。 +5. 跑完给出按分类计数的结果,以及 `pass / fail / environment-fail / skipped / not-run`。 ## 如何判断完成 diff --git a/skills/tester/SKILL.md b/skills/tester/SKILL.md index 4882a52..4215d28 100644 --- a/skills/tester/SKILL.md +++ b/skills/tester/SKILL.md @@ -11,7 +11,7 @@ description: >- 开始时解析本 `SKILL.md` 所在目录为 ``。项目根用 `git rev-parse --show-toplevel`。目录固定为 `/.pouch/tester/`。 -用脚本读写目录,不要把 `cases/` 整库灌进上下文: +用脚本读写目录,不要把 `cases/` 整库灌进上下文。术语与命名理由见 [references/concepts.md](references/concepts.md): ```bash python3 /scripts/catalog.py \ @@ -31,8 +31,9 @@ python3 /scripts/catalog.py /scripts/catalog.py list --root python3 /scripts/generate_cases_csv.py --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 /scripts/generate_cases_csv.py --root python3 /scripts/catalog.py show --root --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 /scripts/catalog.py show --root --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) 整组执行。 diff --git a/skills/tester/references/concepts.md b/skills/tester/references/concepts.md new file mode 100644 index 0000000..9336a2d --- /dev/null +++ b/skills/tester/references/concepts.md @@ -0,0 +1,111 @@ +# tester 概念、命名与设计理由 + +这是 tester 的**唯一术语真相**。写用例、改脚本、看文档遇到词义冲突时,以本文件为准。 + +## 0. 三层结构 + +``` +catalog(整个测试集) +└── category(= 一个 suite,一个 cases/.yaml) + └── case(一条用例) +``` + +| 层 | 是什么 | 业界对应 | +|---|---|---| +| catalog | 全部用例 + 全局配置(`catalog.yaml`) | pytest 的一次 collection;整个 test suite | +| category | 一组用例 + 自己的前置条件(`status_probe`) | Robot **suite**;pytest 一个目录 + conftest.py | +| case | 一条用例 | test case | + +**「suite」只作总称**(泛指整个测试集),不作 YAML 键。集合级的角色由 **category** 承担:`cases/.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/.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` 一种前置,出现第二种再抽象 | diff --git a/skills/tester/references/levels.md b/skills/tester/references/levels.md index b2521b8..6f744de 100644 --- a/skills/tester/references/levels.md +++ b/skills/tester/references/levels.md @@ -1,8 +1,8 @@ -# suite 与 risk +# tag 与 risk -维护或用户问套件/安全级别时读本文件。两套词对所有项目相同,不要自造名字。 +维护或用户问套件/安全级别时读本文件。两套词对所有项目相同,不要自造名字。命名理由见 [concepts.md](concepts.md)。 -## suite:跑哪一批 +## tag:跑哪一批 只表示耗时和依赖,不表示危不危险。危不危险看 `risk`。 @@ -12,7 +12,7 @@ | 值 | 何时用 | |----|--------| -| `smoke` | 最短路径证明系统还活着。必须快、少依赖、**`risk` 只能是 `safe`**。 | +| `smoke` | 最短路径证明系统还活着。必须快、少依赖、**`risk` 只能是 `readonly`**。 | | `full` | 日常回归里其余自动化用例。说「跑 full」仍受风险上限约束。 | | `slow` | 太慢或环境太重,不进日常 full:真实焊接、付费 API、要工件/账号、跑很久。 | @@ -23,18 +23,20 @@ 3. 其余自动化 → `full`。 4. 拿不准 → `full`。不要把会动真机或会删数据的放进 `smoke`。 -未指定套件时只跑 `smoke`。`跑 full` 不含 `slow`。 +未指定 tag 时只跑 `smoke`。`跑 full` 不含 `slow`。 ## risk:副作用有多大 -三个短词,网站 / App / 机器人共用。`catalog.yaml` 的 `risks` 必须按此顺序,且以 `safe` 开头: +三个词,网站 / App / 机器人共用,单调升级。`catalog.yaml` 的 `risks` 必须按此顺序,且以 `readonly` 开头: | 值 | 含义 | 网站 | App | 机器人 | |----|------|------|-----|--------| -| `safe` | 只看,不留下变化 | 打开页、只读 API | 打开界面 | 读位姿、查状态 | -| `act` | 会动手,一般可补救 | 提交表单、改配置 | 发消息、保存 | 点动、使能、开盖扫描 | -| `harm` | 难恢复或伤人/伤钱/伤环境 | 删库、真实扣费 | 清数据、内购 | 急停、断电、真焊 | +| `readonly` | 只看,不留下变化 | 打开页、只读 API | 打开界面 | 读位姿、查状态 | +| `mutating` | 会改状态,一般可补救 | 提交表单、改配置 | 发消息、保存 | 点动、使能、开盖扫描 | +| `destructive` | 难恢复或伤人/伤钱/伤环境 | 删库、真实扣费 | 清数据、内购 | 急停、断电、真焊 | -默认只跑 `safe`。项目细分闸(点动 vs 焊接)用可选 `gate`,不要再发明 risk 名。 +默认只跑 `readonly`。项目细分闸(点动 vs 焊接)用可选 `gate`,不要再发明 risk 名。 -`suite=smoke` 且 `risk!=safe` 为非法。 +`tag=smoke` 且 `risk!=readonly` 为非法。 + +不想跑某一档时,`authorize.` 指向的环境变量保持 false 即可——该档用例记 `skipped`,不是 `not-run`。 diff --git a/skills/tester/references/maintain.md b/skills/tester/references/maintain.md index df21743..1eb13b4 100644 --- a/skills/tester/references/maintain.md +++ b/skills/tester/references/maintain.md @@ -11,8 +11,8 @@ python3 /scripts/catalog.py next-id --root --category ``` -3. 拟一条符合 [schema.md](schema.md) 的用例。`suite`/`risk` 按 [levels.md](levels.md):`smoke` 只能 `safe`;会动手用 `act`,难恢复用 `harm`。细分授权用 `gate`,不要发明新的 risk 名。 -4. `surface=browser|mobile` 必须填 `session`,且 catalog 里已声明。 +3. 拟一条符合 [schema.md](schema.md) 的用例。`tag`/`risk` 按 [levels.md](levels.md):`smoke` 只能 `readonly`;会改状态用 `mutating`,难恢复用 `destructive`。细分授权用 `gate`,不要发明新的 risk 名。需人工参与的填 `automation`(见 [concepts.md](concepts.md))。 +4. `driver=browser|mobile` 必须填 `session`,且 catalog 里已声明。 5. `what` 和 `expected` 写成可观察信号,禁止「应该正常」。 6. 给用户看拟写内容。确认后写入 `cases/.yaml` 的 `cases` 列表末尾。 7. `validate`。失败则修好再结束。通过后跑 `generate_cases_csv.py --root ` 刷新 `.pouch/tester/cases.csv`。不要手改这份 CSV。 @@ -20,13 +20,13 @@ python3 /scripts/catalog.py next-id --root --category ## 完善 / 更新 1. `show --id `。 -2. 只改用户点名的字段(常见:`how`、`expected`、`risk`、`notes`)。 +2. 只改用户点名的字段(常见:`how`、`expected`、`risk`、`automation`、`notes`)。 3. 展示 diff 或更新后的整条。确认后写回。 4. `validate`。通过后跑 `generate_cases_csv.py` 刷新 `cases.csv`。 ## 退役 -把 `status` 改为 `retired`,不要删文件、不要复用 id。`notes` 写原因。确认后写回,再跑 `generate_cases_csv.py`。 +把 `lifecycle` 改为 `retired`,不要删文件、不要复用 id。`notes` 写原因。确认后写回,再跑 `generate_cases_csv.py`。 ## 禁止 diff --git a/skills/tester/references/run.md b/skills/tester/references/run.md index 4f9ad5a..b5076fa 100644 --- a/skills/tester/references/run.md +++ b/skills/tester/references/run.md @@ -4,16 +4,21 @@ ## 1. 选范围 -未指定范围 → `--suite smoke`,风险上限 = `default_max_risk`。 +未指定范围 → `--tag smoke`,风险上限 = `default_max_risk`,`automation` 默认只跑 `automated`。 -用户说分类、id、suite、风险上限时,原样传给 select: +用户说分类、id、tag、风险上限时,原样传给 select: ```bash python3 /scripts/catalog.py select --root \ - [--category CAT] [--id ID]... [--suite smoke|full|slow] \ - [--max-risk ] [--surface script|browser|mobile] + [--category CAT] [--id ID]... [--tag smoke|full|slow] \ + [--max-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` 里说明,不执行)。 若 catalog 有 `run_together`,命中组内任一条就把整组按 `ids` 顺序扩进列表(JSON 带 `run_together`)。这不是擅自扩大范围。 @@ -23,8 +28,19 @@ python3 /scripts/catalog.py select --root \ ## 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。 +- 命中条的 `risk` 在 catalog `authorize` 里有对应变量:变量不是 `true` 则该条 `skipped`。有 `gate` 时还要 `catalog.gates` 对应变量为 `true`;`assume` 不成立同样 `skipped`。即使用户点了 id 也不执行。 +- 授权/假设不成立是**主动不跑**(`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 @@ -33,7 +49,7 @@ python3 /scripts/catalog.py select --root \ - 尚未初始化:按 catalog `sessions..init` 做。`reuse: persist` 的先 `probe`,活着就附着。 - 本轮已初始化:直接用,不要重开浏览器或模拟器。 - `reuse: run`:整轮结束再关。`persist`:不关。 -- 中途 session 挂了:后续同 surface 标 environment-fail,不要标产品失败。 +- 中途 session 挂了:后续同 driver 标 environment-fail,不要标产品失败。 ## 4. 执行 @@ -41,6 +57,12 @@ python3 /scripts/catalog.py select --root \ 例外:当前条和下一条同属 `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** 有 `how.run`: @@ -59,9 +81,9 @@ python3 /scripts/catalog.py run-script --root --id ## 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/-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、逐条结果。立刻用同一快照生成 CSV,不要手改: +写入 `.pouch/tester/runs/-<范围>.yaml`,包含筛选条件、HOST 是否已设(可以写 HOST 值)、session 是否复用、每个分类的 `status_probe` 生效值与观测结果(`source` / `disabled` 原因 / `hit` / `observed`)、逐条结果。立刻用同一快照生成 CSV,不要手改: ```bash python3 /scripts/generate_run_csv.py --root --run @@ -77,7 +99,7 @@ python3 /scripts/generate_run_csv.py --root --run 范围: … 风险上限: … 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 + 原因 快照: .pouch/tester/runs/.yaml diff --git a/skills/tester/references/schema.md b/skills/tester/references/schema.md index ea090de..7107a33 100644 --- a/skills/tester/references/schema.md +++ b/skills/tester/references/schema.md @@ -1,5 +1,7 @@ # tester 目录字段 +术语与命名理由见 [concepts.md](concepts.md);本文件只列字段与约束。 + 项目根 `.pouch/tester/`: ``` @@ -16,15 +18,16 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对 | 字段 | 含义 | |------|------| -| `categories` | `{id, title}` 列表,id 与 `cases/.yaml` 对应;列表顺序即 `select` / 执行顺序 | -| `risks` | 从低到高,只能是 `safe` → `act` → `harm` 的一段,且以 `safe` 开头。含义见 [levels.md](levels.md) | +| `categories` | `{id, title, status_probe?}` 列表,id 与 `cases/.yaml` 对应;列表顺序即 `select` / 执行顺序。`status_probe` 见下 | +| `risks` | 从低到高,只能是 `readonly` → `mutating` → `destructive` 的一段,且以 `readonly` 开头。含义见 [levels.md](levels.md) | | `default_max_risk` | 未指定时的运行上限,必须是 `risks` 里的值 | | `vars` | 可替换变量,如 `API_BASE: http://${HOST}:8095` | -| `auth_env` | 风险名 → 环境变量名。该档执行前变量必须为 `true` | +| `authorize` | 风险名 → 环境变量名。该档执行前变量必须为 `true`(`readonly` 不需要) | | `gates` | 可选。项目细分闸名 → 环境变量。用在用例的 `gate` 上,不要拿它代替 `risk` | | `sessions` | 可复用会话。`kind`: `browser` / `mobile`;`reuse`: `run`(本轮结束关掉)或 `persist`(probe 活着就附着) | | `id_pattern` | 用例 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`。 @@ -38,33 +41,37 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对 | `category` | 是 | 必须等于文件声明的 category | | `title` | 是 | 短名 | | `what` | 是 | 一句可观察的「测什么」 | -| `status` | 是 | `active` / `pending` / `retired`。`pending` = 暂缓,仍保留在目录里但不执行,原因写在 `notes` | -| `suite` | 是 | `smoke` / `full` / `slow`,判定见 [levels.md](levels.md) | -| `risk` | 是 | `safe` / `act` / `harm` | -| `gate` | 否 | 项目细分闸,必须在 catalog `gates` 里 | -| `surface` | 是 | `script` / `browser` / `mobile` | +| `lifecycle` | 是 | `active` / `pending` / `retired`。`pending` = 暂缓,仍保留在目录里但不执行,原因写在 `notes` | +| `tag` | 是 | `smoke` / `full` / `slow`,判定见 [levels.md](levels.md)。`tag=smoke` 只能配 `risk=readonly` | +| `risk` | 是 | `readonly` / `mutating` / `destructive` | +| `gate` | 否 | 项目细分闸,必须在 catalog `gates` 里;`risk=readonly` 不要填 | +| `driver` | 否 | `script` / `browser` / `mobile`;`automation=manual` 时禁止填 | +| `automation` | 否 | `automated`(默认)/ `assisted` / `manual`,见 [concepts.md](concepts.md) | | `session` | browser/mobile 必填 | 指向 catalog `sessions` 的键 | | `how` | 是 | 怎么测,见下 | | `expected` | 是 | 怎么过,至少一条 `{kind, value}` | | `maps_to` | 否 | 对人文档的功能项 id | -| `requires` | 否 | 前置标签 | -| `skip_unless` | 否 | 额外跳过条件(环境变量表达式) | +| `assume` | 否 | 额外跳过条件(环境变量表达式)。不要重复 `risk` / `gate` 已覆盖的授权变量,否则 `validate` 报错 | | `setup` | 否 | 人话前置 | -| `cleanup` | 否 | 跑完回退 | +| `teardown` | 否 | 跑完回退 | | `notes` | 否 | 备注 | ## how +按 `driver` 分派;`automation=manual` 时没有 `driver`,只有 `how.steps`(人工步骤)。 + - `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。 +`automation=assisted` 时,至少一个 `how.steps` 条目要以 `[人工]` 开头(写在 YAML 里要加引号,`[` 开头会被当成流式序列)。 + ## expected.kind `api-status` / `api-field` / `script-exit` / `visible-text` / `url` / `interaction` ## 判定 -`pass` / `fail` / `environment-fail` / `skipped` +`pass` / `fail` / `environment-fail` / `skipped` / `not-run` diff --git a/skills/tester/scripts/catalog.py b/skills/tester/scripts/catalog.py index b58f1d0..21863ef 100755 --- a/skills/tester/scripts/catalog.py +++ b/skills/tester/scripts/catalog.py @@ -19,15 +19,16 @@ 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", "pending") -STATUS_SKIP_REASON = { +DRIVERS = ("script", "browser", "mobile") +LIFECYCLES = ("active", "retired", "pending") +LIFECYCLE_SKIP_REASON = { "retired": "retired", "pending": "pending(暂缓,见 notes)", } -SUITES = ("smoke", "full", "slow") -CANONICAL_RISKS = ("safe", "act", "harm") -RESULTS = ("pass", "fail", "environment-fail", "skipped") +TAGS = ("smoke", "full", "slow") +CANONICAL_RISKS = ("readonly", "mutating", "destructive") +AUTOMATIONS = ("automated", "assisted", "manual") +RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run") EXPECTED_KINDS = ( "api-status", "api-field", @@ -146,7 +147,7 @@ def resolve_vars(root: Path, meta: dict[str, Any]) -> dict[str, str]: 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 {}): + for mapping in (meta.get("authorize") 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(): @@ -212,12 +213,12 @@ def cmd_list(root: Path) -> int: 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"] - pending = [c for c in cases if c.get("status") == "pending"] + active = [c for c in cases if c.get("lifecycle", "active") == "active"] + retired = [c for c in cases if c.get("lifecycle") == "retired"] + pending = [c for c in cases if c.get("lifecycle") == "pending"] print(f"## 测试目录 active {len(active)} / pending {len(pending)} / retired {len(retired)}") print() - headers = ["分类", "标题", "条数", "smoke"] + risks + list(SURFACES) + headers = ["分类", "标题", "条数", "smoke"] + risks + list(DRIVERS) print("| " + " | ".join(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 ( 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: 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))) + for driver in DRIVERS: + row.append(str(sum(1 for c in group if c.get("driver") == driver))) print("| " + " | ".join(row) + " |") print() print("跑法: 跑 <分类> | 跑 | 跑 smoke | 跑 <分类> 风险到 ") @@ -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("_")} +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]]: raw = meta.get("run_together") or [] if not isinstance(raw, list): @@ -294,6 +364,7 @@ def expand_run_together( selected: list[dict[str, Any]], skipped: list[dict[str, str]], max_i: int, + automation_filter: str = "automated", ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: groups = run_together_groups(meta) if not groups: @@ -318,10 +389,14 @@ def expand_run_together( members.append(selected_map[cid]) continue 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"}) 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: r_i = risk_index(meta, risk) except SystemExit: @@ -354,8 +429,9 @@ def expand_run_together( 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_risk = args.max_risk or meta.get("default_max_risk") or "readonly" max_i = risk_index(meta, max_risk) + automation_filter = args.automation or "automated" wanted = set(parse_ids(args.ids or [])) selected: list[dict[str, Any]] = [] skipped: list[dict[str, str]] = [] @@ -363,12 +439,12 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int: for case in cases: cid = str(case.get("id") or "") - if case.get("status", "active") != "active": + if case.get("lifecycle", "active") != "active": if cid in wanted: - status = str(case.get("status")) + lifecycle = str(case.get("lifecycle")) skipped.append({ "id": cid, - "reason": STATUS_SKIP_REASON.get(status, status), + "reason": LIFECYCLE_SKIP_REASON.get(lifecycle, lifecycle), }) missing.discard(cid) continue @@ -379,11 +455,18 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int: else: if args.category and case.get("category") != args.category: continue - if args.suite and case.get("suite") != args.suite: + if args.tag and case.get("tag") != args.tag: continue - if args.surface and case.get("surface") != args.surface: + if args.driver and case.get("driver") != args.driver: 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: r_i = risk_index(meta, risk) except SystemExit: @@ -401,15 +484,27 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int: ) 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( json.dumps( { "max_risk": max_risk, + "automation": automation_filter, "count": len(selected), + "vars": vars_map, "skipped": skipped, "run_together": together, + "status_probe": status_probes, "cases": selected, }, ensure_ascii=False, @@ -449,22 +544,28 @@ def validate(root: Path) -> list[str]: errors.append(f"catalog.categories[{i}] 需要 id") else: 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") if not isinstance(risks, list) or not risks: errors.append("catalog.risks 必须是非空列表") risks = [] - if risks and risks[0] != "safe": - errors.append("catalog.risks 必须以 safe 开头") + if risks and risks[0] != "readonly": + errors.append("catalog.risks 必须以 readonly 开头") last_pos = -1 for name in risks: if name not in CANONICAL_RISKS: errors.append( - f"非法 risk 名 {name!r},只能是 safe/act/harm" + f"非法 risk 名 {name!r},只能是 readonly/mutating/destructive" ) continue pos = CANONICAL_RISKS.index(name) if pos <= last_pos: - errors.append("catalog.risks 必须按 safe < act < harm 升序且不重复") + errors.append("catalog.risks 必须按 readonly < mutating < destructive 升序且不重复") last_pos = pos default_max = meta.get("default_max_risk") if default_max not in risks: @@ -473,10 +574,10 @@ def validate(root: Path) -> list[str]: 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 = {} + authorize = meta.get("authorize") or {} + if authorize and not isinstance(authorize, dict): + errors.append("catalog.authorize 必须是 mapping") + authorize = {} sessions = meta.get("sessions") or {} if not isinstance(sessions, dict): errors.append("catalog.sessions 必须是 mapping") @@ -535,26 +636,27 @@ def validate(root: Path) -> list[str]: 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("lifecycle", "active") not in LIFECYCLES: + errors.append(f"{where}: 非法 lifecycle") + if case.get("tag") not in TAGS: + errors.append(f"{where}: 非法 tag") 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") + if case.get("tag") == "smoke" and case.get("risk") != "readonly": + errors.append(f"{where}: tag=smoke 只能 risk=readonly") 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") + if case.get("risk") in (None, "readonly"): + errors.append(f"{where}: risk=readonly 不要填 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 risk and risk != "readonly" and risk not in authorize and not gate: + errors.append(f"{where}: {risk} 需要 authorize.{risk} 或 gate") + automation = str(case.get("automation") or "automated") + if automation not in AUTOMATIONS: + errors.append(f"{where}: 非法 automation={case.get('automation')}") + automation = "automated" if not str(case.get("title") or "").strip(): errors.append(f"{where}: 缺少 title") if not str(case.get("what") or "").strip(): @@ -563,18 +665,51 @@ def validate(root: Path) -> list[str]: 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") + 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") + if driver in ("browser", "mobile"): + session = case.get("session") + if not session: + errors.append(f"{where}: {driver} 需要 session") + elif session not in sessions: + errors.append(f"{where}: session={session} 未在 catalog.sessions 声明") + if driver == "browser" and not how.get("url") and not steps: + errors.append(f"{where}: browser 需要 how.url 或 how.steps") + if driver == "mobile" and not 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") if not isinstance(expected, list) or not expected: errors.append(f"{where}: expected 必须是非空列表") @@ -597,8 +732,8 @@ def cmd_validate(root: Path) -> int: 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") - pending = sum(1 for c in cases if c.get("status") == "pending") + active = sum(1 for c in cases if c.get("lifecycle", "active") == "active") + pending = sum(1 for c in cases if c.get("lifecycle") == "pending") print(f"✓ validate 通过 active={active} pending={pending} total={len(cases)}") 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: - risk = str(case.get("risk") or "safe") - risks = list(meta.get("risks") or ["safe"]) - lowest = risks[0] if risks else "safe" + risk = str(case.get("risk") or "readonly") + risks = list(meta.get("risks") or ["readonly"]) + lowest = risks[0] if risks else "readonly" 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), "")): return False 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)) return 2 public_id = case.get("id") - if case.get("status") != "active": - status = str(case.get("status")) - reason = STATUS_SKIP_REASON.get(status, status) + if case.get("lifecycle", "active") != "active": + lifecycle = str(case.get("lifecycle")) + reason = LIFECYCLE_SKIP_REASON.get(lifecycle, lifecycle) print(json.dumps({"id": public_id, "result": "skipped", "evidence": reason}, ensure_ascii=False)) return 0 - if case.get("surface") != "script": + automation = str(case.get("automation") or "automated") + if automation != "automated": print( 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, ) ) @@ -673,7 +817,7 @@ def cmd_run_script(root: Path, case_id: str) -> int: ) ) return 2 - risk = str(case.get("risk") or "safe") + risk = str(case.get("risk") or "readonly") if not authorized(vars_map, meta, case): print( json.dumps( @@ -745,9 +889,15 @@ def build_parser() -> argparse.ArgumentParser: 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("--tag", choices=TAGS) 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.add_argument("--id", required=True, dest="case_id") sub.add_parser("validate", help="校验目录", parents=[parent]) diff --git a/skills/tester/scripts/generate_cases_csv.py b/skills/tester/scripts/generate_cases_csv.py index 735e995..7004d47 100755 --- a/skills/tester/scripts/generate_cases_csv.py +++ b/skills/tester/scripts/generate_cases_csv.py @@ -21,13 +21,13 @@ FIELDS = ( "maps_to", "title", "what", - "suite", + "tag", "risk", "gate", - "surface", + "driver", + "automation", "session", - "status", - "requires", + "lifecycle", "setup", "how", "expected", @@ -186,13 +186,13 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path: "maps_to": case.get("maps_to") or "", "title": case.get("title") or "", "what": one_line(case.get("what")), - "suite": case.get("suite") or "", + "tag": case.get("tag") or "", "risk": case.get("risk") 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 "", - "status": case.get("status") or "active", - "requires": one_line(case.get("requires")), + "lifecycle": case.get("lifecycle") or "active", "setup": one_line(case.get("setup")), "how": how_brief(case.get("how")), "expected": one_line(case.get("expected")), diff --git a/skills/tester/scripts/generate_run_csv.py b/skills/tester/scripts/generate_run_csv.py index e494ac3..80bdc63 100644 --- a/skills/tester/scripts/generate_run_csv.py +++ b/skills/tester/scripts/generate_run_csv.py @@ -18,8 +18,9 @@ except ImportError: # pragma: no cover 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_NO = frozenset({"skipped", "not-run"}) FIELDS = ( "id", "result", @@ -28,9 +29,9 @@ FIELDS = ( "notes", "category", "title", - "suite", + "tag", "risk", - "surface", + "driver", "maps_to", ) @@ -38,7 +39,7 @@ FIELDS = ( def executed_cell(result: str) -> str: if result in EXECUTED_YES: return "yes" - if result == "skipped": + if result in EXECUTED_NO: return "no" return "" @@ -115,9 +116,9 @@ def export_run_csv( "notes": one_line(item.get("notes")), "category": case.get("category") or "", "title": case.get("title") or "", - "suite": case.get("suite") or "", + "tag": case.get("tag") or "", "risk": case.get("risk") or "", - "surface": case.get("surface") or "", + "driver": case.get("driver") or "", "maps_to": case.get("maps_to") or "", } ) diff --git a/skills/tester/templates/catalog.yaml b/skills/tester/templates/catalog.yaml index 633c6df..06e1bf9 100644 --- a/skills/tester/templates/catalog.yaml +++ b/skills/tester/templates/catalog.yaml @@ -1,18 +1,18 @@ -version: 1 +version: 2 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 + - readonly + - mutating + - destructive +default_max_risk: readonly vars: {} -auth_env: - act: AUTHORIZE_ACT - harm: AUTHORIZE_HARM +authorize: + mutating: AUTHORIZE_MUTATING + destructive: AUTHORIZE_DESTRUCTIVE gates: {} sessions: browser: @@ -24,3 +24,9 @@ sessions: reuse: persist probe: "" init: "" +# 分类级前置条件(可选)。顶层为默认值,categories[].status_probe 可覆盖: +# 缺省继承 / false 关闭 / mapping 整体替换。 +# status_probe: +# url: "${STATUS_BASE}/api/v1/status" +# stop_if: +# robot_fsm_state: [1, 2] diff --git a/skills/tester/templates/local.yaml.example b/skills/tester/templates/local.yaml.example index 440f6ff..d689299 100644 --- a/skills/tester/templates/local.yaml.example +++ b/skills/tester/templates/local.yaml.example @@ -1,4 +1,4 @@ # 复制为 .pouch/tester/local.yaml 后填写。不要提交 local.yaml。 HOST: "" -AUTHORIZE_ACT: false -AUTHORIZE_HARM: false +AUTHORIZE_MUTATING: false +AUTHORIZE_DESTRUCTIVE: false