fix tester multi-root fan-out execution
This commit is contained in:
@@ -16,6 +16,8 @@
|
|||||||
- 目录在目标测试项目根的 `.pouch/tester/`。父仓库和 `app` 等 Git submodule 可以各自有
|
- 目录在目标测试项目根的 `.pouch/tester/`。父仓库和 `app` 等 Git submodule 可以各自有
|
||||||
catalog;先运行 `catalog.py roots --root <repo-root>` 发现它们,不要把两个项目的 cases 合并。
|
catalog;先运行 `catalog.py roots --root <repo-root>` 发现它们,不要把两个项目的 cases 合并。
|
||||||
没有任何 catalog 才说「初始化 tester」。
|
没有任何 catalog 才说「初始化 tester」。
|
||||||
|
- 从父仓库直接执行 tester 命令时,只有一个子 catalog 会自动选中;多个子 catalog 会自动
|
||||||
|
fan-out,逐个执行且不合并 cases。无需在根 `.pouch` 里增加未定义的 `include`。
|
||||||
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认
|
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认
|
||||||
`HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量
|
`HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量
|
||||||
`HX_TEST_HOST` 覆盖。
|
`HX_TEST_HOST` 覆盖。
|
||||||
@@ -56,8 +58,12 @@
|
|||||||
|
|
||||||
1. 用脚本读 `.pouch/tester/`,按分类统计或筛出你点名的用例,不会把整库塞进对话。
|
1. 用脚本读 `.pouch/tester/`,按分类统计或筛出你点名的用例,不会把整库塞进对话。
|
||||||
2. 改用例前先给你看将要写入的内容,你确认再保存。
|
2. 改用例前先给你看将要写入的内容,你确认再保存。
|
||||||
3. 执行时:若用户要求准备环境,先按目标环境走 deployer 路由;再解析并更新 Agent 配置,输出生效列表,再准备 Orca 项目并创建临时 terminal;脚本类、
|
3. 执行时先建立“产物 × 平台 × tester 绑定 × 执行环境”矩阵,并把平台、产物和 binding
|
||||||
浏览器和移动模拟器都在该 Agent 环境中执行。
|
传给 `catalog.py select` 做硬性校验,再对矩阵全部行做
|
||||||
|
preflight。任一产物、平台或环境不可用,都先返回完整阻塞清单,不创建测试 session、
|
||||||
|
不执行可用平台;只有全部可用才按目标环境走 deployer 路由、解析 Agent、输出生效
|
||||||
|
列表、准备 Orca 项目并创建临时 terminal。脚本类、浏览器和移动模拟器都在对应
|
||||||
|
Agent 环境中执行。`builder.<platform>` 不能替代 `tester.<platform>`。
|
||||||
4. 超出你允许的风险级别会跳过,不会偷偷执行。
|
4. 超出你允许的风险级别会跳过,不会偷偷执行。
|
||||||
5. 无论测试成功、失败或异常,都会关闭本轮创建的 terminal;跑完给出按分类计数的
|
5. 无论测试成功、失败或异常,都会关闭本轮创建的 terminal;跑完给出按分类计数的
|
||||||
结果,以及 `pass / fail / environment-fail / skipped / not-run`。
|
结果,以及 `pass / fail / environment-fail / skipped / not-run`。
|
||||||
|
|||||||
+14
-2
@@ -30,12 +30,17 @@ python3 <skill-dir>/scripts/catalog.py roots --root <repo-root>
|
|||||||
- `<repo-root>/.pouch/tester/catalog.yaml` 存在时,它是一个独立测试根;
|
- `<repo-root>/.pouch/tester/catalog.yaml` 存在时,它是一个独立测试根;
|
||||||
- 同时检查 Git submodule 和一层普通子项目目录;
|
- 同时检查 Git submodule 和一层普通子项目目录;
|
||||||
- 只有一个嵌套 catalog 时,可直接用该路径继续 `list/select/run`;
|
- 只有一个嵌套 catalog 时,可直接用该路径继续 `list/select/run`;
|
||||||
- 有多个 catalog 时按用户指定的组件分别执行,例如 `--root app`,不要猜测或合并;
|
- 有多个 catalog 时由 tester 自动 fan-out,逐个项目分别执行,不等待用户选择,也不合并 cases;
|
||||||
- 每个测试根单独生成 `.pouch/tester/runs/*.yaml`/`.csv`,交付记录可分别引用这些快照。
|
- 每个测试根单独生成 `.pouch/tester/runs/*.yaml`/`.csv`,交付记录可分别引用这些快照。
|
||||||
|
|
||||||
如果父仓库没有 catalog 但子项目有 catalog,不能把“父目录无 catalog”报告为环境失败;先用
|
如果父仓库没有 catalog 但子项目有 catalog,不能把“父目录无 catalog”报告为环境失败;先用
|
||||||
`roots` 找到子项目并继续。只有发现不到任何 catalog,才按缺少 tester 目录处理。
|
`roots` 找到子项目并继续。只有发现不到任何 catalog,才按缺少 tester 目录处理。
|
||||||
|
|
||||||
|
CLI 也有同样的保护:从父仓库执行 `list/select/validate/run-script` 时,若只发现一个
|
||||||
|
子 tester 根会自动进入该根;发现多个候选会自动 fan-out,每个根独立输出、独立校验、
|
||||||
|
独立生成运行记录。不需要、也不能通过在根 `.pouch` 里写一个未定义的 `include` 来拼接
|
||||||
|
子项目配置。
|
||||||
|
|
||||||
Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>` binding,由共享
|
Agent 执行目标优先来自项目根 `.pouch/agent.yaml` 的 `tester.<name>` binding,由共享
|
||||||
`agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml`
|
`agent-runtime` 解析 environment、host、project、path 和 profile。`.pouch/tester/local.yaml`
|
||||||
仍是兼容覆盖层:解析 binding 后可 materialize 为旧 `agent` mapping,但不应再维护两套
|
仍是兼容覆盖层:解析 binding 后可 materialize 为旧 `agent` mapping,但不应再维护两套
|
||||||
@@ -132,4 +137,11 @@ python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
|
|||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
读 [run.md](references/run.md)、[execution.md](references/execution.md) 和 [deployment.md](references/deployment.md)。若本轮要求准备环境,先按目标环境完成部署,再 `select` 得到命中列表;测试环境由本机 deployer 触发,正式环境由 app00 Agent terminal 委派。随后按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表,创建测试用临时 terminal 并按 driver 分流。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。移动端还要先识别目标平台并完成 Android 或 iOS 环境闸门;闸门未通过时输出对应的用户引导,不继续猜测或代跑。无论部署或测试成功、失败、超时还是中断,都要清理各自本轮创建的 terminal。
|
读 [run.md](references/run.md)、[execution.md](references/execution.md) 和 [deployment.md](references/deployment.md)。运行必须按以下顺序执行:
|
||||||
|
|
||||||
|
1. 先建立本轮测试矩阵,列出实际要测的产物、平台、commit、包/Bundle 身份、产物路径或下载地址,以及对应的 `tester.<platform>` 执行绑定。用户要求 Android+iOS 时,两行都必须出现;不能因为只拿到 Android 产物就缩小范围。
|
||||||
|
2. 对所有 tester 根的矩阵行做完整 preflight:先用 `catalog.py select` 校验平台、产物定位和 tester binding,再检查目标 Agent/环境/项目路径、Android/iOS 设备闸门和 HOST 可达。所有根的检查完成前不得创建测试 session 或开始任何测试用例。
|
||||||
|
3. 有任一根或任一行不可用时,先完成其它根的 preflight,再一次性输出完整不可用清单、观测结果、修复动作和可复制的验证命令,然后停止测试执行;不能先执行已通过 preflight 的根,也不能把缺失平台记成测试通过。
|
||||||
|
4. 只有矩阵所有行都可用,才创建本轮测试 terminal/session,按 `select` 结果执行并记录成绩。
|
||||||
|
|
||||||
|
测试环境由本机 deployer 触发,正式环境由 app00 Agent terminal 委派。随后按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。无论部署或测试成功、失败、超时还是中断,都要清理各自本轮创建的 terminal。
|
||||||
|
|||||||
@@ -10,12 +10,21 @@
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
bindings:
|
bindings:
|
||||||
tester.test:
|
tester.android:
|
||||||
adapter: tester
|
adapter: tester
|
||||||
location: local-app
|
location: local-app
|
||||||
profile: test-agent
|
profile: test-agent
|
||||||
|
tester.ios:
|
||||||
|
adapter: tester
|
||||||
|
location: remote-ios
|
||||||
|
profile: test-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tester.android` / `tester.ios` 是推荐的平台绑定名。`builder.android`、`builder.ios`
|
||||||
|
只负责构建,不能被 tester 自动继承;这条边界用于防止 iOS 构建成功却仍在 Linux
|
||||||
|
上执行测试。`tester.test` 只能用于明确的单平台或非移动测试;同一轮包含 Android
|
||||||
|
和 iOS 时必须分别解析两个 tester 绑定。
|
||||||
|
|
||||||
`locations` 和 `profiles` 的定义见共享 `agent-runtime`。tester adapter 只把它 materialize
|
`locations` 和 `profiles` 的定义见共享 `agent-runtime`。tester adapter 只把它 materialize
|
||||||
到本轮兼容配置;每轮 terminal 仍然新建并在结束时关闭,不能因为 binding 是 persistent
|
到本轮兼容配置;每轮 terminal 仍然新建并在结束时关闭,不能因为 binding 是 persistent
|
||||||
就复用测试 terminal。
|
就复用测试 terminal。
|
||||||
@@ -54,7 +63,8 @@ terminal。
|
|||||||
- Orca terminal id,例如 `term_...`;
|
- Orca terminal id,例如 `term_...`;
|
||||||
- 不提供定位信息,使用当前项目。
|
- 不提供定位信息,使用当前项目。
|
||||||
|
|
||||||
有定位信息时优先使用解析脚本,避免手工拼接 Orca JSON:
|
有定位信息时优先使用解析脚本,避免手工拼接 Orca JSON。多平台运行要对每个
|
||||||
|
`tester.<platform>` 绑定分别解析,不能只解析一次 `tester.test` 后复用到所有平台:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 <skill-dir>/scripts/resolve_agent.py \
|
python3 <skill-dir>/scripts/resolve_agent.py \
|
||||||
|
|||||||
@@ -7,12 +7,60 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi
|
|||||||
已有 terminal。
|
已有 terminal。
|
||||||
测试结束、失败、超时或中断时,都要关闭本轮创建的 terminal。
|
测试结束、失败、超时或中断时,都要关闭本轮创建的 terminal。
|
||||||
|
|
||||||
## 0. 准备执行目标
|
## 0. 建立测试矩阵并完成全量 preflight
|
||||||
|
|
||||||
|
测试不是“找到一个能跑的设备就开始”。每轮先建立一份测试矩阵;移动端每个目标产物
|
||||||
|
必须单独占一行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
平台 | 产物路径/下载地址 | commit | 包名或 Bundle ID | tester 绑定 | 执行环境
|
||||||
|
Android | … | … | page.writes | tester.android | local-app
|
||||||
|
iOS | … | … | page.writes | tester.ios | remote-ios
|
||||||
|
```
|
||||||
|
|
||||||
|
矩阵来源按优先级使用:用户明确指定的产物、builder/CI 已成功并已 obtain 的产物、
|
||||||
|
项目本地明确标记的候选产物。不能根据“曾经构建成功过”或 `builder.<platform>`
|
||||||
|
绑定猜测本轮产物。缺少路径、下载地址、commit 或平台身份时,先报告“待确认”并停止。
|
||||||
|
|
||||||
|
平台绑定是测试执行目标,不是构建目标:`builder.ios` 只能证明构建配置,不能替代
|
||||||
|
`tester.ios`;`builder.android` 同理。多平台运行不得使用无法表达平台的通用
|
||||||
|
`tester.test` 绑定。每一行都必须解析出独立的 tester Agent、Orca 环境、项目和路径。
|
||||||
|
|
||||||
|
完成矩阵后,按以下顺序检查**所有行**,但不执行测试用例:
|
||||||
|
|
||||||
|
1. 产物:文件或下载地址存在,SHA/commit(若有)与本轮目标一致;Android APK 的
|
||||||
|
package、iOS IPA 的 Bundle ID 与矩阵一致。
|
||||||
|
2. 执行目标:按 [execution.md](execution.md) 精确解析每个平台的 `tester` 绑定;
|
||||||
|
不允许把本机 worktree 当成远程 Mac 的 worktree,也不允许用旧 commit 的远程目录
|
||||||
|
代替本轮产物对应的项目目录。
|
||||||
|
3. 平台环境:Android 执行 adb、设备、APK、HOST 检查;iOS 在 macOS 执行
|
||||||
|
`uname`、`xcrun`/`simctl`、模拟器或真机、App 安装、HOST 检查。
|
||||||
|
4. 依赖环境:浏览器、权限、网络、测试账号或 catalog 明确要求的其它 session 前置。
|
||||||
|
|
||||||
|
所有检查完成后才能进入下方的 `select` 和 session 初始化。任一行不可用时,必须返回
|
||||||
|
完整清单并停止:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## tester preflight:阻塞
|
||||||
|
|
||||||
|
目标产物: Android, iOS
|
||||||
|
可用: <平台 + 产物 + 环境>
|
||||||
|
不可用:
|
||||||
|
- <平台>: <具体检查项和观测结果>
|
||||||
|
修复: <用户可执行动作>
|
||||||
|
验证: <可复制命令>
|
||||||
|
|
||||||
|
本轮未创建测试 session,未执行测试用例。
|
||||||
|
完成修复后重跑原 tester 命令。
|
||||||
|
```
|
||||||
|
|
||||||
|
不能因为 Android 可用就先测 Android,也不能用 API、另一平台或旧产物替代不可用行。
|
||||||
|
|
||||||
先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标测试项目必须包含
|
先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标测试项目必须包含
|
||||||
可读取的 `.pouch/tester/catalog.yaml`。父仓库可能只是包含多个测试子项目;运行前用
|
可读取的 `.pouch/tester/catalog.yaml`。父仓库可能包含多个测试子项目;运行前用
|
||||||
`catalog.py roots --root <repo-root>` 发现并选择目标根,不能把父仓库无 catalog 当成失败,
|
`catalog.py roots --root <repo-root>` 发现全部目标根,不能把父仓库无 catalog 当成失败,
|
||||||
也不能偷偷回退到另一个子项目或复制测试文件。每个目标根单独生成运行快照。
|
也不能偷偷回退到某一个子项目或复制测试文件。所有目标根都要被 fan-out 调度,每个根独立
|
||||||
|
做产物/环境 preflight,并单独生成运行快照。
|
||||||
|
|
||||||
`catalog.py select` 输出的 `agent` 是本轮使用的配置快照。记录中要保留 Agent、目标
|
`catalog.py select` 输出的 `agent` 是本轮使用的配置快照。记录中要保留 Agent、目标
|
||||||
环境、主机、项目、路径、worktree id、terminal handle,以及 terminal 是否成功关闭。
|
环境、主机、项目、路径、worktree id、terminal handle,以及 terminal 是否成功关闭。
|
||||||
@@ -27,12 +75,21 @@ Agent、执行目标和临时 terminal 的完整协议见 [execution.md](executi
|
|||||||
python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
||||||
[--category CAT] [--id ID]... [--tag smoke|full|slow] \
|
[--category CAT] [--id ID]... [--tag smoke|full|slow] \
|
||||||
[--max-risk <risk>] [--driver script|browser|mobile] \
|
[--max-risk <risk>] [--driver script|browser|mobile] \
|
||||||
[--automation automated|assisted|manual|any]
|
[--automation automated|assisted|manual|any] \
|
||||||
|
[--platform android|ios]... \
|
||||||
|
[--artifact android=<path-or-url>] [--artifact ios=<path-or-url>] \
|
||||||
|
[--binding android=tester.android] [--binding ios=tester.ios]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
选中任一 `driver=mobile` 用例时,`--platform`、每个平台的 `--artifact` 和对应
|
||||||
|
`tester` binding 是硬性参数;`catalog.py select` 会检查产物文件/下载地址和
|
||||||
|
`.pouch/agent.yaml` 中的 binding。缺任何一项直接返回非零并打印 `preflight: blocked`,
|
||||||
|
不能拿到 cases 后绕过闸门开始测试。默认 binding 是 `tester.<platform>`,所以 iOS
|
||||||
|
会强制检查 `tester.ios`,不会回退到 `builder.ios` 或通用 `tester.test`。
|
||||||
|
|
||||||
`--automation` 默认 `automated`(需人工的条目会被跳过,并在 JSON `skipped` 里说明);用户明确要求现场配合时用 `assisted`,`any` 表示不按人工参与度筛。
|
`--automation` 默认 `automated`(需人工的条目会被跳过,并在 JSON `skipped` 里说明);用户明确要求现场配合时用 `assisted`,`any` 表示不按人工参与度筛。
|
||||||
|
|
||||||
`select` 的 JSON 字段:`max_risk` / `automation` / `count` / `vars`(已展开的变量,用来替换 `how` 里的 `${…}`)/ `skipped` / `run_together` / `status_probe`(按分类,`url` 已展开)/ `cases`。
|
单根 `select` 的 JSON 字段:`max_risk` / `automation` / `count` / `vars`(已展开的变量,用来替换 `how` 里的 `${…}`)/ `artifacts` / `preflight` / `skipped` / `run_together` / `status_probe`(按分类,`url` 已展开)/ `cases`。从父仓库 fan-out 时返回 `mode: fan-out` 和每个根的独立结果,不能把多个根的 cases 拼成一个列表。
|
||||||
|
|
||||||
`select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明,不执行)。
|
`select` 已排除 `retired` 和风险高于上限的条目(后者若用户点了具体 id,会在 JSON 的 `skipped` 里说明,不执行)。
|
||||||
|
|
||||||
@@ -54,8 +111,8 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
|||||||
| mapping | 整体覆盖顶层,不合并(`source: category`)|
|
| mapping | 整体覆盖顶层,不合并(`source: category`)|
|
||||||
|
|
||||||
- 同一生效探测一次运行只请求一次,结果复用。
|
- 同一生效探测一次运行只请求一次,结果复用。
|
||||||
- 探测不可达:用它的分类标 environment-fail,其余分类照跑。
|
- 探测不可达或命中 `stop_if`:在 preflight 阶段记录对应分类/平台的不可用项并停止整轮;
|
||||||
- 命中 `stop_if`:把用它的分类标 environment-fail 并记 `observed` 值,其余分类照跑;所有选中分类都被挡住时,整轮结论才是 environment-fail。
|
先返回用户处理,不创建后续测试 session。只有所有本轮目标环境通过,才进入用例执行。
|
||||||
|
|
||||||
## 2.1 移动端平台环境闸门与用户引导
|
## 2.1 移动端平台环境闸门与用户引导
|
||||||
|
|
||||||
@@ -79,7 +136,9 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
|||||||
3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。
|
3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。
|
||||||
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。
|
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。
|
||||||
|
|
||||||
任一项不满足,都要在执行记录中把受影响的移动用例标为 `environment-fail`,不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化后续移动 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。
|
任一项不满足,都要在 preflight 汇总中列出平台、具体检查项、实际探测命令和简短输出,
|
||||||
|
然后停止整轮;不能先执行其它平台,不能改成 API 测试,也不能把它们标成产品 `fail`。
|
||||||
|
只有 preflight 全部通过后,才把对应移动用例的结果写入运行快照。
|
||||||
|
|
||||||
### 环境缺失时的用户引导
|
### 环境缺失时的用户引导
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
|
|||||||
| `risks` | 从低到高,只能是 `readonly` → `mutating` → `destructive` 的一段,且以 `readonly` 开头。含义见 [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` |
|
||||||
|
| `artifacts` | 可选的本轮移动产物契约。按 `android` / `ios` 声明 `required` 和 `package` / `bundle_id`;具体文件路径、下载地址、commit 和 SHA 由每轮测试矩阵提供,不提交临时产物路径。 |
|
||||||
| `authorize` | 风险名 → 环境变量名。该档执行前变量必须为 `true`(`readonly` 不需要) |
|
| `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 活着就附着) |
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -59,6 +64,60 @@ def catalog_path(root: Path) -> Path:
|
|||||||
return catalog_dir(root) / "catalog.yaml"
|
return catalog_dir(root) / "catalog.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def discover_or_self(root: Path) -> list[Path]:
|
||||||
|
"""Return all independent tester projects below a parent repository."""
|
||||||
|
root = root.resolve()
|
||||||
|
return discover_catalog_roots(root)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_fanout(args: argparse.Namespace, roots: list[Path]) -> int:
|
||||||
|
"""Run a catalog command independently for every discovered tester root."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for child_root in roots:
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(output):
|
||||||
|
if args.cmd == "list":
|
||||||
|
exit_code = cmd_list(child_root)
|
||||||
|
elif args.cmd == "select":
|
||||||
|
exit_code = cmd_select(args, child_root)
|
||||||
|
elif args.cmd == "show":
|
||||||
|
exit_code = cmd_show(child_root, args.case_id)
|
||||||
|
elif args.cmd == "validate":
|
||||||
|
exit_code = cmd_validate(child_root)
|
||||||
|
elif args.cmd == "export-csv":
|
||||||
|
exit_code = cmd_export_csv(child_root)
|
||||||
|
elif args.cmd == "run-script":
|
||||||
|
exit_code = cmd_run_script(child_root, args.case_id)
|
||||||
|
else:
|
||||||
|
exit_code = 2
|
||||||
|
print(f"不支持从父仓库 fan-out 命令: {args.cmd}")
|
||||||
|
raw = output.getvalue().strip()
|
||||||
|
try:
|
||||||
|
result: Any = json.loads(raw) if raw else None
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
result = raw
|
||||||
|
results.append({
|
||||||
|
"root": str(child_root),
|
||||||
|
"exit": exit_code,
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
if args.cmd == "select":
|
||||||
|
print(json.dumps({"mode": "fan-out", "roots": results}, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
for item in results:
|
||||||
|
print(f"\n## tester root: {item['root']} (exit={item['exit']})")
|
||||||
|
result = item["result"]
|
||||||
|
if isinstance(result, (dict, list)):
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
elif result:
|
||||||
|
print(result)
|
||||||
|
return 0 if all(item["exit"] == 0 for item in results) else 2
|
||||||
|
|
||||||
|
|
||||||
|
FANOUT_COMMANDS = {"list", "select", "show", "validate", "export-csv", "run-script"}
|
||||||
|
|
||||||
|
|
||||||
def discover_catalog_roots(root: Path) -> list[Path]:
|
def discover_catalog_roots(root: Path) -> list[Path]:
|
||||||
"""Find tester projects owned by a repository and its Git submodules.
|
"""Find tester projects owned by a repository and its Git submodules.
|
||||||
|
|
||||||
@@ -560,6 +619,142 @@ def expand_run_together(
|
|||||||
return selected, hit_groups
|
return selected, hit_groups
|
||||||
|
|
||||||
|
|
||||||
|
def parse_platform_pairs(values: list[str], label: str) -> tuple[dict[str, str], list[str]]:
|
||||||
|
pairs: dict[str, str] = {}
|
||||||
|
errors: list[str] = []
|
||||||
|
for raw in values:
|
||||||
|
platform, sep, value = raw.partition("=")
|
||||||
|
platform = platform.strip().lower()
|
||||||
|
value = value.strip()
|
||||||
|
if not sep or platform not in ("android", "ios") or not value:
|
||||||
|
errors.append(f"{label} 格式必须是 android=<值> 或 ios=<值>: {raw}")
|
||||||
|
continue
|
||||||
|
if platform in pairs:
|
||||||
|
errors.append(f"{label} 重复平台: {platform}")
|
||||||
|
continue
|
||||||
|
pairs[platform] = value
|
||||||
|
return pairs, errors
|
||||||
|
|
||||||
|
|
||||||
|
def safe_locator(locator: str) -> str:
|
||||||
|
parsed = urllib.parse.urlsplit(locator)
|
||||||
|
if parsed.scheme in ("http", "https"):
|
||||||
|
path = parsed.path or "/"
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}{path}"
|
||||||
|
return locator
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_ready(locator: str) -> tuple[bool, str]:
|
||||||
|
parsed = urllib.parse.urlsplit(locator)
|
||||||
|
if parsed.scheme in ("http", "https"):
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(locator, method="HEAD")
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
if 200 <= response.status < 400:
|
||||||
|
return True, f"HTTP {response.status}"
|
||||||
|
return False, f"HTTP {response.status}"
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"下载地址不可达: {type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
path = Path(locator).expanduser()
|
||||||
|
if not path.is_file():
|
||||||
|
return False, "文件不存在"
|
||||||
|
if path.stat().st_size <= 0:
|
||||||
|
return False, "文件为空"
|
||||||
|
if path.suffix.lower() in (".apk", ".ipa") and not zipfile.is_zipfile(path):
|
||||||
|
return False, "不是有效的 APK/IPA zip 产物"
|
||||||
|
return True, f"文件 {path.stat().st_size} bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_binding(root: Path, binding_id: str) -> tuple[bool, str]:
|
||||||
|
config_path = root / ".pouch" / "agent.yaml"
|
||||||
|
if not config_path.is_file():
|
||||||
|
return False, f"缺少 {config_path}"
|
||||||
|
try:
|
||||||
|
config = load_yaml(config_path) or {}
|
||||||
|
except Exception as exc:
|
||||||
|
return False, f"读取 agent.yaml 失败: {exc}"
|
||||||
|
binding = (config.get("bindings") or {}).get(binding_id)
|
||||||
|
if not isinstance(binding, dict):
|
||||||
|
return False, "binding 不存在"
|
||||||
|
if binding.get("adapter") != "tester":
|
||||||
|
return False, f"adapter={binding.get('adapter')!r},不是 tester"
|
||||||
|
location = binding.get("location")
|
||||||
|
if not location:
|
||||||
|
return False, "未配置 location"
|
||||||
|
return True, f"location={location}"
|
||||||
|
|
||||||
|
|
||||||
|
def mobile_preflight(
|
||||||
|
root: Path,
|
||||||
|
meta: dict[str, Any],
|
||||||
|
selected: list[dict[str, Any]],
|
||||||
|
platforms_raw: list[str],
|
||||||
|
artifacts_raw: list[str],
|
||||||
|
bindings_raw: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
mobile_selected = [case for case in selected if case.get("driver") == "mobile"]
|
||||||
|
if not mobile_selected:
|
||||||
|
return {"required": False, "status": "not-needed", "checks": [], "errors": []}
|
||||||
|
|
||||||
|
platforms = [item.strip().lower() for item in platforms_raw if item.strip()]
|
||||||
|
errors: list[str] = []
|
||||||
|
if not platforms:
|
||||||
|
errors.append("选中了 mobile 用例,必须显式提供 --platform android/ios")
|
||||||
|
if len(platforms) != len(set(platforms)):
|
||||||
|
errors.append("--platform 不能重复")
|
||||||
|
invalid_platforms = [item for item in platforms if item not in ("android", "ios")]
|
||||||
|
if invalid_platforms:
|
||||||
|
errors.append("非法平台: " + ", ".join(invalid_platforms))
|
||||||
|
|
||||||
|
artifacts, pair_errors = parse_platform_pairs(artifacts_raw, "--artifact")
|
||||||
|
errors.extend(pair_errors)
|
||||||
|
bindings, pair_errors = parse_platform_pairs(bindings_raw, "--binding")
|
||||||
|
errors.extend(pair_errors)
|
||||||
|
contract = meta.get("artifacts")
|
||||||
|
if not isinstance(contract, dict) or not contract:
|
||||||
|
errors.append("catalog 缺少 artifacts 产物契约")
|
||||||
|
|
||||||
|
checks: list[dict[str, Any]] = []
|
||||||
|
for platform in platforms:
|
||||||
|
if platform not in ("android", "ios"):
|
||||||
|
continue
|
||||||
|
spec = (contract or {}).get(platform) if isinstance(contract, dict) else None
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
errors.append(f"{platform}: catalog.artifacts 未声明")
|
||||||
|
if platform not in artifacts:
|
||||||
|
errors.append(f"{platform}: 缺少 --artifact {platform}=<路径或下载地址>")
|
||||||
|
else:
|
||||||
|
ok, detail = artifact_ready(artifacts[platform])
|
||||||
|
checks.append({
|
||||||
|
"platform": platform,
|
||||||
|
"artifact": safe_locator(artifacts[platform]),
|
||||||
|
"artifact_ok": ok,
|
||||||
|
"artifact_detail": detail,
|
||||||
|
})
|
||||||
|
if not ok:
|
||||||
|
errors.append(f"{platform}: 产物不可用: {detail}")
|
||||||
|
|
||||||
|
binding_id = bindings.get(platform, f"tester.{platform}")
|
||||||
|
ok, detail = runtime_binding(root, binding_id)
|
||||||
|
checks.append({
|
||||||
|
"platform": platform,
|
||||||
|
"binding": binding_id,
|
||||||
|
"binding_ok": ok,
|
||||||
|
"binding_detail": detail,
|
||||||
|
})
|
||||||
|
if not ok:
|
||||||
|
errors.append(f"{platform}: binding {binding_id} 不可用: {detail}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"required": True,
|
||||||
|
"status": "blocked" if errors else "ready",
|
||||||
|
"platforms": platforms,
|
||||||
|
"checks": checks,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -620,6 +815,18 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
|||||||
|
|
||||||
selected, together = expand_run_together(meta, cases, selected, skipped, max_i, automation_filter)
|
selected, together = expand_run_together(meta, cases, selected, skipped, max_i, automation_filter)
|
||||||
|
|
||||||
|
preflight = mobile_preflight(
|
||||||
|
root,
|
||||||
|
meta,
|
||||||
|
selected,
|
||||||
|
args.platform or [],
|
||||||
|
args.artifacts or [],
|
||||||
|
args.bindings or [],
|
||||||
|
)
|
||||||
|
if preflight["status"] == "blocked":
|
||||||
|
print(json.dumps({"preflight": preflight}, ensure_ascii=False, indent=2))
|
||||||
|
return 2
|
||||||
|
|
||||||
# 按分类解析生效的 status_probe,让 Agent 不再自己按 catalog 猜作用范围。
|
# 按分类解析生效的 status_probe,让 Agent 不再自己按 catalog 猜作用范围。
|
||||||
vars_map = resolve_vars(root, meta)
|
vars_map = resolve_vars(root, meta)
|
||||||
status_probes: dict[str, dict[str, Any]] = {}
|
status_probes: dict[str, dict[str, Any]] = {}
|
||||||
@@ -636,6 +843,8 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
|||||||
"automation": automation_filter,
|
"automation": automation_filter,
|
||||||
"count": len(selected),
|
"count": len(selected),
|
||||||
"vars": vars_map,
|
"vars": vars_map,
|
||||||
|
"artifacts": meta.get("artifacts") or {},
|
||||||
|
"preflight": preflight,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"run_together": together,
|
"run_together": together,
|
||||||
"status_probe": status_probes,
|
"status_probe": status_probes,
|
||||||
@@ -718,6 +927,28 @@ def validate(root: Path) -> list[str]:
|
|||||||
if not isinstance(sessions, dict):
|
if not isinstance(sessions, dict):
|
||||||
errors.append("catalog.sessions 必须是 mapping")
|
errors.append("catalog.sessions 必须是 mapping")
|
||||||
sessions = {}
|
sessions = {}
|
||||||
|
artifacts = meta.get("artifacts")
|
||||||
|
if artifacts is not None:
|
||||||
|
if not isinstance(artifacts, dict):
|
||||||
|
errors.append("catalog.artifacts 必须是 mapping")
|
||||||
|
else:
|
||||||
|
for platform, spec in artifacts.items():
|
||||||
|
where = f"catalog.artifacts.{platform}"
|
||||||
|
if platform not in ("android", "ios"):
|
||||||
|
errors.append(f"{where}: 平台只能是 android/ios")
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
errors.append(f"{where}: 必须是 mapping")
|
||||||
|
continue
|
||||||
|
if "required" in spec and not isinstance(spec["required"], bool):
|
||||||
|
errors.append(f"{where}.required 必须是 boolean")
|
||||||
|
if platform == "android" and not spec.get("package"):
|
||||||
|
errors.append(f"{where}: Android 需要 package")
|
||||||
|
if platform == "ios" and not spec.get("bundle_id"):
|
||||||
|
errors.append(f"{where}: iOS 需要 bundle_id")
|
||||||
|
if platform == "android" and spec.get("bundle_id"):
|
||||||
|
errors.append(f"{where}: Android 不应填 bundle_id")
|
||||||
|
if platform == "ios" and spec.get("package"):
|
||||||
|
errors.append(f"{where}: iOS 不应填 package")
|
||||||
pattern = meta.get("id_pattern") or r"^.+$"
|
pattern = meta.get("id_pattern") or r"^.+$"
|
||||||
try:
|
try:
|
||||||
id_re = re.compile(pattern)
|
id_re = re.compile(pattern)
|
||||||
@@ -1057,6 +1288,29 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
default=None,
|
default=None,
|
||||||
help="默认 automated;any 表示不按人工参与度筛选",
|
help="默认 automated;any 表示不按人工参与度筛选",
|
||||||
)
|
)
|
||||||
|
p_sel.add_argument(
|
||||||
|
"--platform",
|
||||||
|
action="append",
|
||||||
|
choices=["android", "ios"],
|
||||||
|
default=[],
|
||||||
|
help="mobile 运行平台,可重复;选中 mobile 用例时必填",
|
||||||
|
)
|
||||||
|
p_sel.add_argument(
|
||||||
|
"--artifact",
|
||||||
|
action="append",
|
||||||
|
dest="artifacts",
|
||||||
|
default=[],
|
||||||
|
metavar="PLATFORM=PATH_OR_URL",
|
||||||
|
help="本轮移动产物,可重复,例如 android=dist/app.apk",
|
||||||
|
)
|
||||||
|
p_sel.add_argument(
|
||||||
|
"--binding",
|
||||||
|
action="append",
|
||||||
|
dest="bindings",
|
||||||
|
default=[],
|
||||||
|
metavar="PLATFORM=TESTER_BINDING",
|
||||||
|
help="平台 tester binding,可重复;默认 tester.<platform>",
|
||||||
|
)
|
||||||
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])
|
||||||
@@ -1073,6 +1327,19 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
root = Path(args.root).resolve()
|
root = Path(args.root).resolve()
|
||||||
|
if args.cmd != "roots":
|
||||||
|
roots = discover_or_self(root)
|
||||||
|
if len(roots) > 1:
|
||||||
|
if args.cmd in FANOUT_COMMANDS:
|
||||||
|
return cmd_fanout(args, roots)
|
||||||
|
print(
|
||||||
|
f"发现多个 tester 根目录,命令 {args.cmd} 不是可 fan-out 的执行命令;"
|
||||||
|
"请对具体项目根目录执行该维护命令。",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
if len(roots) == 1:
|
||||||
|
root = roots[0]
|
||||||
if args.cmd == "list":
|
if args.cmd == "list":
|
||||||
return cmd_list(root)
|
return cmd_list(root)
|
||||||
if args.cmd == "select":
|
if args.cmd == "select":
|
||||||
|
|||||||
Reference in New Issue
Block a user