feat(tester): require caller artifact locators instead of --release
Mobile runs now take --artifact android|ios=<path-or-url> from the caller and create a fresh isolated worktree each time. Drop CI release/manifest lookup.
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
- 「加一条相机开盖的测试」「完善 TC-CAM-003 的验证」
|
- 「加一条相机开盖的测试」「完善 TC-CAM-003 的验证」
|
||||||
- 「跑 smoke」「测 CAM」「跑 TC-INF-001 和 TC-CAM-001」
|
- 「跑 smoke」「测 CAM」「跑 TC-INF-001 和 TC-CAM-001」
|
||||||
- 「现场复测」
|
- 「现场复测」
|
||||||
- 「按 CI release 跑 Android+iOS」
|
- 「用这个 APK/IPA 地址跑 Android+iOS」
|
||||||
|
|
||||||
不管 ACK。这是项目自己的测试目录。
|
不管 ACK。这是项目自己的测试目录。
|
||||||
|
|
||||||
@@ -32,12 +32,15 @@
|
|||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
<skill-dir>/tester run --release ci-07aa8f3 --platform android,ios
|
<skill-dir>/tester run \
|
||||||
|
--artifact android=<path-or-url> \
|
||||||
|
--artifact ios=<path-or-url> \
|
||||||
|
--platform android,ios
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令自动从现有 CI Release 取得 manifest、下载并校验 APK/IPA,按 commit 准备隔离 worktree,
|
调用方必须给出每个平台的产物位置,可以是本地文件或下载地址。tester 按这个输入取用或下载
|
||||||
在 Android 和 iOS tester 节点安装并验证身份,然后交给真实移动 session 执行 catalog。默认全量闸门,
|
APK/IPA,为本轮创建隔离 worktree,在 Android 和 iOS tester 节点安装并验证身份,然后交给
|
||||||
只有显式 `--partial` 才允许单平台继续;不要求手工 scp、复制或安装。
|
真实移动 session 执行 catalog。默认全量闸门,只有显式 `--partial` 才允许单平台继续。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
列出测试用例
|
列出测试用例
|
||||||
|
|||||||
@@ -144,4 +144,4 @@ python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
|
|||||||
3. 有任一根或任一行不可用时,先完成其它根的 preflight,再一次性输出完整不可用清单、观测结果、修复动作和可复制的验证命令,然后停止测试执行;不能先执行已通过 preflight 的根,也不能把缺失平台记成测试通过。
|
3. 有任一根或任一行不可用时,先完成其它根的 preflight,再一次性输出完整不可用清单、观测结果、修复动作和可复制的验证命令,然后停止测试执行;不能先执行已通过 preflight 的根,也不能把缺失平台记成测试通过。
|
||||||
4. 只有矩阵所有行都可用,才创建本轮测试 terminal/session,按 `select` 结果执行并记录成绩。
|
4. 只有矩阵所有行都可用,才创建本轮测试 terminal/session,按 `select` 结果执行并记录成绩。
|
||||||
|
|
||||||
测试环境由本机 deployer 触发,正式环境由 app00 Agent terminal 委派。随后按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表。移动端如果给出 release,矩阵行必须记录 release、manifest、精确 commit、产物身份和对应的 `tester.<platform>` binding,并使用 `<skill-dir>/tester run --release <release> --platform android,ios` 获取和验证 CI bundle;Android 在 `tester.android`、iOS 在 `tester.ios` 节点执行。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。无论部署或测试成功、失败、超时还是中断,都要清理各自本轮创建的 terminal。
|
测试环境由本机 deployer 触发,正式环境由 app00 Agent terminal 委派。随后按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表。移动端每个平台的产物位置必须由调用方给出,值为本地文件或 URL;缺了就停,不要猜 builder、CI release 或本机旧包。矩阵行记产物位置、身份和 `tester.<platform>` binding,并用 `<skill-dir>/tester run --artifact android=<path-or-url> --artifact ios=<path-or-url> --platform android,ios` 下载或取用、安装并准备本轮隔离 worktree。Android 在 `tester.android`、iOS 在 `tester.ios` 节点执行。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。无论部署或测试成功、失败、超时还是中断,都要清理各自本轮创建的 terminal。
|
||||||
|
|||||||
@@ -111,9 +111,10 @@ python3 <skill-dir>/scripts/resolve_agent.py \
|
|||||||
之后重新查询并取得 `worktree` selector。项目准备失败要记为
|
之后重新查询并取得 `worktree` selector。项目准备失败要记为
|
||||||
`environment-fail`,不要继续创建 terminal。
|
`environment-fail`,不要继续创建 terminal。
|
||||||
|
|
||||||
这里的“没有项目就创建”指创建/登记 Orca 的项目记录,不是删除或重建用户目录,
|
这里的“没有项目就创建”指创建/登记 Orca 的项目记录,不是删除或重建用户目录。
|
||||||
也不是创建新的 Git worktree。目标目录必须已经存在且包含待测项目;如果目录本身
|
目标目录必须已经存在且包含待测项目;如果目录本身不存在,停止并说明需要先在
|
||||||
不存在,停止并说明需要先在目标机器准备项目。
|
目标机器准备项目。移动端准备阶段会另外为本轮创建隔离 Git worktree,那一步
|
||||||
|
不替代这里的 Orca 项目登记。
|
||||||
|
|
||||||
## 配置更新结果
|
## 配置更新结果
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ Android | … | … | page.writes | tester.android | local-app
|
|||||||
iOS | … | … | page.writes | tester.ios | remote-ios
|
iOS | … | … | page.writes | tester.ios | remote-ios
|
||||||
```
|
```
|
||||||
|
|
||||||
矩阵来源按优先级使用:用户明确指定的产物、builder/CI 已成功并已 obtain 的产物、
|
矩阵里的产物位置必须由调用方给出,值为本地文件或 URL。不能根据“曾经构建成功过”、
|
||||||
项目本地明确标记的候选产物。不能根据“曾经构建成功过”或 `builder.<platform>`
|
`builder.<platform>` 绑定、CI release 名或本机旧文件猜测本轮产物。缺少路径或下载
|
||||||
绑定猜测本轮产物。缺少路径、下载地址、commit 或平台身份时,先报告“待确认”并停止。
|
地址时,先报告“待确认”并停止。
|
||||||
|
|
||||||
平台绑定是测试执行目标,不是构建目标:`builder.ios` 只能证明构建配置,不能替代
|
平台绑定是测试执行目标,不是构建目标:`builder.ios` 只能证明构建配置,不能替代
|
||||||
`tester.ios`;`builder.android` 同理。多平台运行不得使用无法表达平台的通用
|
`tester.ios`;`builder.android` 同理。多平台运行不得使用无法表达平台的通用
|
||||||
@@ -28,8 +28,8 @@ iOS | … | … | page.writes | tester.ios | remote-ios
|
|||||||
|
|
||||||
完成矩阵后,按以下顺序检查**所有行**,但不执行测试用例:
|
完成矩阵后,按以下顺序检查**所有行**,但不执行测试用例:
|
||||||
|
|
||||||
1. 产物:文件或下载地址存在,SHA/commit(若有)与本轮目标一致;Android APK 的
|
1. 产物:调用方给出的文件或下载地址存在;Android APK 的 package、iOS IPA 的
|
||||||
package、iOS IPA 的 Bundle ID 与矩阵一致。
|
Bundle ID 与 catalog 契约(若有)一致。
|
||||||
2. 执行目标:按 [execution.md](execution.md) 精确解析每个平台的 `tester` 绑定;
|
2. 执行目标:按 [execution.md](execution.md) 精确解析每个平台的 `tester` 绑定;
|
||||||
不允许把本机 worktree 当成远程 Mac 的 worktree,也不允许用旧 commit 的远程目录
|
不允许把本机 worktree 当成远程 Mac 的 worktree,也不允许用旧 commit 的远程目录
|
||||||
代替本轮产物对应的项目目录。
|
代替本轮产物对应的项目目录。
|
||||||
@@ -118,22 +118,22 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
|
|||||||
|
|
||||||
`driver=mobile` 不是一个可以互换的平台。运行前必须根据 catalog session、用例变量或用户指定,明确本轮是 Android 还是 iOS;无法判断时先停下询问平台,不得猜测。两类平台分别执行以下闸门:
|
`driver=mobile` 不是一个可以互换的平台。运行前必须根据 catalog session、用例变量或用户指定,明确本轮是 Android 还是 iOS;无法判断时先停下询问平台,不得猜测。两类平台分别执行以下闸门:
|
||||||
|
|
||||||
### Android(CI bundle 模式)
|
### Android
|
||||||
|
|
||||||
按顺序确认:
|
按顺序确认:
|
||||||
|
|
||||||
1. `adb` 可执行,且版本与当前 SDK 配置一致。
|
1. `adb` 可执行,且版本与当前 SDK 配置一致。
|
||||||
2. `adb devices -l` 至少有一个状态为 `device` 的目标;`unauthorized`、`offline` 或空列表都不能继续。多个目标时必须显式设置 `ANDROID_SERIAL`。
|
2. `adb devices -l` 至少有一个状态为 `device` 的目标;`unauthorized`、`offline` 或空列表都不能继续。多个目标时必须显式设置 `ANDROID_SERIAL`。
|
||||||
3. 如果命令带 `--release`,由 tester skill 下载 CI manifest 指定的 APK,校验 sha256、读取 APK 身份,自动执行 `adb install -r`,再用 `adb -s "$ANDROID_SERIAL" shell pm path <package>` 验证。没有 `--release` 时才沿用已有 APK 前置条件。
|
3. 调用方已给出本轮 APK 的本地路径或 URL。由 tester skill 取用或下载该文件,读取 APK 身份,自动执行 `adb install -r`,再用 `adb -s "$ANDROID_SERIAL" shell pm path <package>` 验证。
|
||||||
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从设备访问。
|
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从设备访问。
|
||||||
|
|
||||||
### iOS(CI bundle 模式)
|
### iOS
|
||||||
|
|
||||||
按顺序确认:
|
按顺序确认:
|
||||||
|
|
||||||
1. 运行节点是 macOS,并且 `xcrun`、Xcode Command Line Tools 和 `simctl` 可用。Linux/Windows 节点不能伪装成 iOS 环境;需要 macOS 或可访问的远程 Mac。
|
1. 运行节点是 macOS,并且 `xcrun`、Xcode Command Line Tools 和 `simctl` 可用。Linux/Windows 节点不能伪装成 iOS 环境;需要 macOS 或可访问的远程 Mac。
|
||||||
2. 模拟器用 `xcrun simctl list devices available` 确认目标存在且已启动,再用 `xcrun simctl bootstatus <UDID> -b` 确认完成启动;真机用 `xcrun xctrace list devices` 确认已连接、已信任且允许开发。
|
2. 模拟器用 `xcrun simctl list devices available` 确认目标存在且已启动,再用 `xcrun simctl bootstatus <UDID> -b` 确认完成启动;真机用 `xcrun xctrace list devices` 确认已连接、已信任且允许开发。
|
||||||
3. 如果命令带 `--release`,由 tester skill 在 tester.ios 的 Mac 下载 CI manifest 指定的 IPA,解包读取 `Payload/*.app/Info.plist`,校验 sha256 和 Bundle ID,自动执行 `xcrun simctl install`,再用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。没有 `--release` 时才沿用已有 App 前置条件。
|
3. 调用方已给出本轮 IPA 的本地路径或 URL。由 tester skill 在 tester.ios 的 Mac 取用或下载该文件,解包读取 `Payload/*.app/Info.plist`,校验 Bundle ID,自动执行 `xcrun simctl install`,再用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。
|
||||||
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。
|
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。
|
||||||
|
|
||||||
Android+iOS 默认是全量 preflight 闸门:任一项不满足,另一平台也不得安装或执行,并在记录中标为 `not-run`。只有用户显式加 `--partial` 才允许已通过的平台继续。不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化被闸门挡住的平台 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。
|
Android+iOS 默认是全量 preflight 闸门:任一项不满足,另一平台也不得安装或执行,并在记录中标为 `not-run`。只有用户显式加 `--partial` 才允许已通过的平台继续。不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化被闸门挡住的平台 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。
|
||||||
@@ -163,7 +163,7 @@ Android+iOS 默认是全量 preflight 闸门:任一项不满足,另一平台
|
|||||||
可按实际缺失替换为以下平台化提示:
|
可按实际缺失替换为以下平台化提示:
|
||||||
|
|
||||||
- Android 无设备:提示用户连接并解锁设备、开启 USB 调试、接受 RSA 授权;多个设备时设置 `ANDROID_SERIAL`;再运行 `adb devices -l`,直到目标状态为 `device`。
|
- Android 无设备:提示用户连接并解锁设备、开启 USB 调试、接受 RSA 授权;多个设备时设置 `ANDROID_SERIAL`;再运行 `adb devices -l`,直到目标状态为 `device`。
|
||||||
- Android 未安装 APK:提示用户构建/安装与当前测试环境匹配的 APK,并用 `adb -s "$ANDROID_SERIAL" shell pm path "$PACKAGE"` 验证包名。
|
- Android 缺少产物位置:停下向调用方要本轮 APK 的本地路径或 URL,不要手工 scp 或沿用旧包。
|
||||||
- iOS 非 macOS 或无 Xcode:明确说明 iOS 测试需要 macOS + Xcode/Command Line Tools,提示用户切换到本机 Mac 或配置可访问的远程 Mac;不要建议在 Linux 上安装一个同名命令来绕过检查。
|
- iOS 非 macOS 或无 Xcode:明确说明 iOS 测试需要 macOS + Xcode/Command Line Tools,提示用户切换到本机 Mac 或配置可访问的远程 Mac;不要建议在 Linux 上安装一个同名命令来绕过检查。
|
||||||
- iOS 模拟器未启动:提示用户在 Xcode 或 `simctl` 中创建/启动可用设备,使用 `xcrun simctl bootstatus <UDID> -b` 验证。
|
- iOS 模拟器未启动:提示用户在 Xcode 或 `simctl` 中创建/启动可用设备,使用 `xcrun simctl bootstatus <UDID> -b` 验证。
|
||||||
- iOS 真机未就绪:提示用户连接数据线、解锁并信任此电脑、开启开发者模式并确认设备出现在 `xcrun xctrace list devices`。
|
- iOS 真机未就绪:提示用户连接数据线、解锁并信任此电脑、开启开发者模式并确认设备出现在 `xcrun xctrace list devices`。
|
||||||
|
|||||||
@@ -22,7 +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 由每轮测试矩阵提供,不提交临时产物路径。 |
|
| `artifacts` | 可选的移动产物身份契约。按 `android` / `ios` 声明 `required` 和 `package` / `bundle_id`;每轮文件路径或下载地址由调用方提供,不提交临时产物路径。 |
|
||||||
| `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 活着就附着) |
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"required": ["schema_version", "release", "commit", "artifacts"],
|
|
||||||
"properties": {
|
|
||||||
"schema_version": {"const": 1},
|
|
||||||
"release": {"type": "string", "minLength": 1},
|
|
||||||
"commit": {"type": "string", "pattern": "^[0-9a-fA-F]{40}$"},
|
|
||||||
"artifacts": {
|
|
||||||
"type": "object",
|
|
||||||
"required": ["android", "ios"],
|
|
||||||
"properties": {
|
|
||||||
"android": {
|
|
||||||
"type": "object",
|
|
||||||
"required": ["url", "sha256", "package"],
|
|
||||||
"properties": {"url": {"type": "string"}, "sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"}, "package": {"const": "page.writes"}}
|
|
||||||
},
|
|
||||||
"ios": {
|
|
||||||
"type": "object",
|
|
||||||
"required": ["url", "sha256", "bundle_id"],
|
|
||||||
"properties": {"url": {"type": "string"}, "sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"}, "bundle_id": {"const": "page.writes"}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,21 @@
|
|||||||
# CI mobile test bundle
|
# 移动产物准备
|
||||||
|
|
||||||
项目 CI 负责把 APK、IPA 和 `test-bundle.manifest.json` 发布到已有的 Release;tester skill 负责消费它。
|
调用方必须给出每个目标平台的产物位置:本地文件或可下载 URL。tester 不从
|
||||||
不要手工复制或安装产物。
|
CI release、builder 绑定或本机旧文件猜测产物。
|
||||||
|
|
||||||
manifest 至少包含:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"release": "ci-07aa8f3",
|
|
||||||
"commit": "<40-character-commit>",
|
|
||||||
"artifacts": {
|
|
||||||
"android": {"url": "<download-url>", "sha256": "<64-hex>", "package": "page.writes"},
|
|
||||||
"ios": {"url": "<download-url>", "sha256": "<64-hex>", "bundle_id": "page.writes"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
运行入口:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
<skill-dir>/tester run --release ci-07aa8f3 --platform android,ios
|
<skill-dir>/tester run \
|
||||||
|
--artifact android=<path-or-url> \
|
||||||
|
--artifact ios=<path-or-url> \
|
||||||
|
--platform android,ios
|
||||||
```
|
```
|
||||||
|
|
||||||
默认先完成 Android+iOS 双平台 preflight;任一平台无法自动准备时整轮停止。只有显式加
|
URL 就下载,本地文件就直接用。每个平台都在自己的 `tester.<platform>` 节点校验身份并安装,
|
||||||
`--partial` 才允许运行可用平台。每个平台都必须在自己的 `tester.<platform>` 节点下载、校验、
|
随后由现有 tester catalog 驱动真实移动 session。默认先完成 Android+iOS 双平台
|
||||||
解包、验证身份并安装,随后由现有 tester catalog 驱动真实移动 session。
|
preflight;任一平台无法准备时整轮停止。只有显式加 `--partial` 才允许运行可用平台。
|
||||||
|
|
||||||
运行记录写入目标项目 `.pouch/tester/runs/`,包含 manifest、sha256、身份、实际 worktree commit、
|
每轮另外创建隔离 Git worktree,不切用户当前分支。`--commit` 缺省为当前 HEAD。
|
||||||
节点、安装证据和 terminal 生命周期;`automatic-repair-failed` 与产品测试失败分开记录。
|
|
||||||
|
运行记录写入目标项目 `.pouch/tester/runs/`,包含产物位置、sha256、身份、实际
|
||||||
|
worktree commit、节点、安装证据和 terminal 生命周期;`automatic-repair-failed`
|
||||||
|
与产品测试失败分开记录。
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Acquire and provision a CI mobile test bundle.
|
"""Provision caller-provided mobile artifacts for tester.
|
||||||
|
|
||||||
This is shared tester infrastructure. It never replaces real Android/iOS
|
This is shared tester infrastructure. It never replaces real Android/iOS
|
||||||
execution with HTTP calls: it only downloads, verifies and installs the
|
execution with HTTP calls: it only obtains, verifies and installs the
|
||||||
release artifact before the catalog runner drives the mobile session.
|
artifact the caller supplied, then the catalog runner drives the mobile
|
||||||
|
session.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -30,7 +31,6 @@ from platform import system as platform_system
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
||||||
COMMIT = re.compile(r"^[0-9a-fA-F]{40}$")
|
COMMIT = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||||
PLATFORMS = {"android", "ios"}
|
PLATFORMS = {"android", "ios"}
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ class EnvironmentFailure(RuntimeError):
|
|||||||
"""An external environment or recoverable provisioning failure."""
|
"""An external environment or recoverable provisioning failure."""
|
||||||
|
|
||||||
|
|
||||||
class ManifestFailure(RuntimeError):
|
class ArtifactFailure(RuntimeError):
|
||||||
"""A malformed or mismatched CI bundle contract."""
|
"""A missing or unusable caller-provided artifact locator."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -102,78 +102,6 @@ def fetch_bytes(url: str, *, attempts: int = 3, token: str = "") -> bytes:
|
|||||||
raise EnvironmentFailure(f"下载失败({attempts} 次): {redact_url(url)}: {last}")
|
raise EnvironmentFailure(f"下载失败({attempts} 次): {redact_url(url)}: {last}")
|
||||||
|
|
||||||
|
|
||||||
def fetch_json(url: str, *, attempts: int = 3, token: str = "") -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
value = json.loads(fetch_bytes(url, attempts=attempts, token=token).decode("utf-8"))
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ManifestFailure(f"manifest 不是有效 JSON: {redact_url(url)}: {exc}") from exc
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ManifestFailure("manifest 顶层必须是 mapping")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_manifest(data: dict[str, Any], requested_release: str | None = None) -> None:
|
|
||||||
if not isinstance(data, dict) or data.get("schema_version") != 1:
|
|
||||||
raise ManifestFailure("manifest.schema_version 必须为 1")
|
|
||||||
release = data.get("release")
|
|
||||||
commit = data.get("commit")
|
|
||||||
if not isinstance(release, str) or not release:
|
|
||||||
raise ManifestFailure("manifest.release 不能为空")
|
|
||||||
if requested_release and release != requested_release:
|
|
||||||
raise ManifestFailure(f"manifest.release={release} 与请求的 release={requested_release} 不匹配")
|
|
||||||
if not isinstance(commit, str) or not COMMIT.fullmatch(commit):
|
|
||||||
raise ManifestFailure("manifest.commit 必须是 40 位 commit SHA")
|
|
||||||
artifacts = data.get("artifacts")
|
|
||||||
if not isinstance(artifacts, dict):
|
|
||||||
raise ManifestFailure("manifest.artifacts 必须是 mapping")
|
|
||||||
identities = {"android": ("package", "page.writes"), "ios": ("bundle_id", "page.writes")}
|
|
||||||
for platform, (key, expected) in identities.items():
|
|
||||||
item = artifacts.get(platform)
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
raise ManifestFailure(f"缺少 artifacts.{platform}")
|
|
||||||
url = item.get("url")
|
|
||||||
if not isinstance(url, str) or not url:
|
|
||||||
raise ManifestFailure(f"artifacts.{platform}.url 不能为空")
|
|
||||||
if not urllib.parse.urlsplit(url).scheme and not Path(url).is_file():
|
|
||||||
raise ManifestFailure(f"artifacts.{platform}.url 不是 URL 或现有文件: {url}")
|
|
||||||
digest = item.get("sha256")
|
|
||||||
if not isinstance(digest, str) or not SHA256.fullmatch(digest):
|
|
||||||
raise ManifestFailure(f"artifacts.{platform}.sha256 必须是 64 位十六进制")
|
|
||||||
if item.get(key) != expected:
|
|
||||||
raise ManifestFailure(f"artifacts.{platform}.{key} 必须为 {expected}")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_manifest(release: str, *, attempts: int = 3) -> tuple[dict[str, Any], str]:
|
|
||||||
token = os.environ.get("GITEA_TOKEN", "")
|
|
||||||
source = os.environ.get("TEST_BUNDLE_MANIFEST_URL", "").strip()
|
|
||||||
if not source:
|
|
||||||
candidate = Path(release)
|
|
||||||
if candidate.is_file() or release.startswith(("http://", "https://", "file://")):
|
|
||||||
source = release
|
|
||||||
else:
|
|
||||||
api = os.environ.get("GITEA_API", "").rstrip("/")
|
|
||||||
if not api:
|
|
||||||
raise EnvironmentFailure("缺少 TEST_BUNDLE_MANIFEST_URL 或 GITEA_API;不要手工复制/安装产物")
|
|
||||||
release_data = fetch_json(f"{api}/releases/tags/{urllib.parse.quote(release, safe='')}", attempts=attempts, token=token)
|
|
||||||
assets = [item for item in release_data.get("assets", []) if item.get("name") == "test-bundle.manifest.json"]
|
|
||||||
if len(assets) != 1:
|
|
||||||
raise EnvironmentFailure(f"release {release} 没有唯一 test-bundle.manifest.json;请等待 CI 双平台发布完成")
|
|
||||||
asset = assets[0]
|
|
||||||
source = asset.get("browser_download_url") or asset.get("download_url") or ""
|
|
||||||
if not source:
|
|
||||||
source = f"{api}/releases/{release_data['id']}/assets/{asset['id']}"
|
|
||||||
if urllib.parse.urlsplit(source).scheme:
|
|
||||||
data = fetch_json(source, attempts=attempts, token=token)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
data = json.loads(Path(source).read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
raise ManifestFailure(f"读取 manifest 失败: {source}: {exc}") from exc
|
|
||||||
requested = None if Path(release).is_file() or urllib.parse.urlsplit(release).scheme else release
|
|
||||||
validate_manifest(data, requested_release=requested)
|
|
||||||
return data, redact_url(source)
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_file(path: Path) -> str:
|
def sha256_file(path: Path) -> str:
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
with path.open("rb") as handle:
|
with path.open("rb") as handle:
|
||||||
@@ -182,21 +110,89 @@ def sha256_file(path: Path) -> str:
|
|||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def download_artifact(platform: str, item: dict[str, Any], destination: Path, context: RuntimeContext) -> dict[str, Any]:
|
def parse_artifacts(values: list[str]) -> dict[str, str]:
|
||||||
|
pairs: dict[str, str] = {}
|
||||||
|
for raw in values:
|
||||||
|
platform, sep, value = raw.partition("=")
|
||||||
|
platform = platform.strip().lower()
|
||||||
|
value = value.strip()
|
||||||
|
if not sep or platform not in PLATFORMS or not value:
|
||||||
|
raise ArtifactFailure(f"--artifact 格式必须是 android=<路径或 URL> 或 ios=<路径或 URL>: {raw}")
|
||||||
|
if platform in pairs:
|
||||||
|
raise ArtifactFailure(f"--artifact 重复平台: {platform}")
|
||||||
|
pairs[platform] = value
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def local_path(locator: str) -> Path | None:
|
||||||
|
parsed = urllib.parse.urlsplit(locator)
|
||||||
|
if parsed.scheme in ("http", "https"):
|
||||||
|
return None
|
||||||
|
if parsed.scheme == "file":
|
||||||
|
return Path(parsed.path)
|
||||||
|
if parsed.scheme:
|
||||||
|
raise ArtifactFailure(f"不支持的产物地址 scheme: {parsed.scheme}")
|
||||||
|
return Path(locator).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def obtain_artifact(platform: str, locator: str, destination: Path, context: RuntimeContext) -> dict[str, Any]:
|
||||||
|
path = local_path(locator)
|
||||||
|
if path is not None:
|
||||||
|
if not path.is_file():
|
||||||
|
raise ArtifactFailure(f"{platform} 产物不是现有文件: {path}")
|
||||||
|
resolved = path.resolve()
|
||||||
|
return {
|
||||||
|
"path": str(resolved),
|
||||||
|
"locator": str(resolved),
|
||||||
|
"sha256": sha256_file(resolved),
|
||||||
|
"attempts": 1,
|
||||||
|
"source": "file",
|
||||||
|
"verified": True,
|
||||||
|
}
|
||||||
|
|
||||||
last: Exception | None = None
|
last: Exception | None = None
|
||||||
for attempt in range(1, context.retries["download"] + 1):
|
for attempt in range(1, context.retries["download"] + 1):
|
||||||
try:
|
try:
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
destination.write_bytes(fetch_bytes(item["url"], attempts=1, token=os.environ.get("GITEA_TOKEN", "")))
|
destination.write_bytes(fetch_bytes(locator, attempts=1, token=os.environ.get("GITEA_TOKEN", "")))
|
||||||
actual = sha256_file(destination)
|
return {
|
||||||
if actual.lower() != item["sha256"].lower():
|
"path": str(destination),
|
||||||
raise EnvironmentFailure(f"{platform} sha256 不匹配: expected={item['sha256']} actual={actual}")
|
"locator": redact_url(locator),
|
||||||
return {"path": str(destination), "url": redact_url(item["url"]), "sha256": actual, "attempts": attempt, "verified": True}
|
"sha256": sha256_file(destination),
|
||||||
|
"attempts": attempt,
|
||||||
|
"source": "url",
|
||||||
|
"verified": True,
|
||||||
|
}
|
||||||
except (OSError, EnvironmentFailure) as exc:
|
except (OSError, EnvironmentFailure) as exc:
|
||||||
last = exc
|
last = exc
|
||||||
if attempt < context.retries["download"]:
|
if attempt < context.retries["download"]:
|
||||||
time.sleep(min(attempt, 2))
|
time.sleep(min(attempt, 2))
|
||||||
raise EnvironmentFailure(f"{platform} 下载/校验失败({context.retries['download']} 次): {last}")
|
raise EnvironmentFailure(f"{platform} 下载失败({context.retries['download']} 次): {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_identities(root: Path) -> dict[str, str]:
|
||||||
|
path = root / ".pouch" / "tester" / "catalog.yaml"
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
except (OSError, yaml.YAMLError):
|
||||||
|
return {}
|
||||||
|
artifacts = data.get("artifacts")
|
||||||
|
if not isinstance(artifacts, dict):
|
||||||
|
return {}
|
||||||
|
identities: dict[str, str] = {}
|
||||||
|
android = artifacts.get("android")
|
||||||
|
ios = artifacts.get("ios")
|
||||||
|
if isinstance(android, dict) and isinstance(android.get("package"), str) and android["package"]:
|
||||||
|
identities["android"] = android["package"]
|
||||||
|
if isinstance(ios, dict) and isinstance(ios.get("bundle_id"), str) and ios["bundle_id"]:
|
||||||
|
identities["ios"] = ios["bundle_id"]
|
||||||
|
return identities
|
||||||
|
|
||||||
|
|
||||||
def android_identity(path: Path, context: RuntimeContext) -> str:
|
def android_identity(path: Path, context: RuntimeContext) -> str:
|
||||||
@@ -336,6 +332,18 @@ def sanitize(value: str) -> str:
|
|||||||
return re.sub(r"[^A-Za-z0-9._-]+", "-", value)
|
return re.sub(r"[^A-Za-z0-9._-]+", "-", value)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_commit(explicit: str, context: RuntimeContext) -> str:
|
||||||
|
if explicit:
|
||||||
|
if not COMMIT.fullmatch(explicit):
|
||||||
|
raise ArtifactFailure("--commit 必须是 40 位 commit SHA")
|
||||||
|
return explicit
|
||||||
|
git = shutil.which("git") or "git"
|
||||||
|
result = run_command([git, "rev-parse", "HEAD"], context=context, cwd=context.root)
|
||||||
|
if result.code != 0 or not COMMIT.fullmatch(result.output):
|
||||||
|
raise EnvironmentFailure(f"无法读取当前 commit: {context.root}")
|
||||||
|
return result.output
|
||||||
|
|
||||||
|
|
||||||
def ensure_worktree(commit: str, context: RuntimeContext, parent: Path | None = None) -> tuple[Path, str]:
|
def ensure_worktree(commit: str, context: RuntimeContext, parent: Path | None = None) -> tuple[Path, str]:
|
||||||
git = shutil.which("git") or "git"
|
git = shutil.which("git") or "git"
|
||||||
check = run_command([git, "rev-parse", "--show-toplevel"], context=context, cwd=context.root)
|
check = run_command([git, "rev-parse", "--show-toplevel"], context=context, cwd=context.root)
|
||||||
@@ -349,22 +357,13 @@ def ensure_worktree(commit: str, context: RuntimeContext, parent: Path | None =
|
|||||||
raise EnvironmentFailure(f"目标 commit 不在本地且无法从 origin 获取: {commit}")
|
raise EnvironmentFailure(f"目标 commit 不在本地且无法从 origin 获取: {commit}")
|
||||||
base = parent or Path(os.environ.get("TESTER_WORKTREE_ROOT", Path(tempfile.gettempdir()) / "pouch-tester" / "worktrees"))
|
base = parent or Path(os.environ.get("TESTER_WORKTREE_ROOT", Path(tempfile.gettempdir()) / "pouch-tester" / "worktrees"))
|
||||||
base.mkdir(parents=True, exist_ok=True)
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
path = (base / sanitize(commit[:12])).resolve()
|
token = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + os.urandom(3).hex()
|
||||||
listing = run_command([git, "worktree", "list", "--porcelain"], context=context, cwd=repo)
|
path = (base / f"{sanitize(commit[:12])}-{token}").resolve()
|
||||||
managed = any(re.search(rf"^worktree {re.escape(str(path))}$", block, re.M) for block in listing.output.split("\n\n"))
|
if path.exists():
|
||||||
if path.exists() and not managed:
|
raise EnvironmentFailure(f"隔离 worktree 路径已存在,拒绝覆盖: {path}")
|
||||||
raise EnvironmentFailure(f"隔离 worktree 路径已存在但不是 Git worktree,拒绝覆盖: {path}")
|
added = run_command([git, "worktree", "add", "--detach", str(path), commit], context=context, cwd=repo)
|
||||||
if managed:
|
if added.code != 0:
|
||||||
current = run_command([git, "-C", str(path), "rev-parse", "HEAD"], context=context)
|
raise EnvironmentFailure(f"创建精确 commit worktree 失败: {path}\n{added.output[-500:]}")
|
||||||
if current.code != 0 or current.output.lower() != commit.lower():
|
|
||||||
removed = run_command([git, "worktree", "remove", "--force", str(path)], context=context, cwd=repo)
|
|
||||||
if removed.code != 0:
|
|
||||||
raise EnvironmentFailure(f"worktree commit 不匹配且自动修复失败: {path}")
|
|
||||||
managed = False
|
|
||||||
if not managed:
|
|
||||||
added = run_command([git, "worktree", "add", "--detach", str(path), commit], context=context, cwd=repo)
|
|
||||||
if added.code != 0:
|
|
||||||
raise EnvironmentFailure(f"创建精确 commit worktree 失败: {path}\n{added.output[-500:]}")
|
|
||||||
actual = run_command([git, "-C", str(path), "rev-parse", "HEAD"], context=context)
|
actual = run_command([git, "-C", str(path), "rev-parse", "HEAD"], context=context)
|
||||||
if actual.code != 0 or actual.output.lower() != commit.lower():
|
if actual.code != 0 or actual.output.lower() != commit.lower():
|
||||||
raise EnvironmentFailure(f"worktree commit 不匹配: expected={commit} actual={actual.output}")
|
raise EnvironmentFailure(f"worktree commit 不匹配: expected={commit} actual={actual.output}")
|
||||||
@@ -376,7 +375,7 @@ def platforms(raw: str) -> list[str]:
|
|||||||
if not values:
|
if not values:
|
||||||
return ["android", "ios"]
|
return ["android", "ios"]
|
||||||
if any(item not in PLATFORMS for item in values) or len(values) != len(set(values)):
|
if any(item not in PLATFORMS for item in values) or len(values) != len(set(values)):
|
||||||
raise ManifestFailure("--platform 只能是 android,ios 且不能重复")
|
raise ArtifactFailure("--platform 只能是 android,ios 且不能重复")
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@@ -395,7 +394,6 @@ def execute(args: argparse.Namespace) -> int:
|
|||||||
record: dict[str, Any] = {
|
record: dict[str, Any] = {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"release_request": args.release,
|
|
||||||
"platforms_requested": requested,
|
"platforms_requested": requested,
|
||||||
"partial": bool(args.partial),
|
"partial": bool(args.partial),
|
||||||
"binding": args.binding,
|
"binding": args.binding,
|
||||||
@@ -405,31 +403,37 @@ def execute(args: argparse.Namespace) -> int:
|
|||||||
"platforms": {},
|
"platforms": {},
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
manifest, source = resolve_manifest(args.release, attempts=context.retries["download"])
|
locators = parse_artifacts(args.artifacts)
|
||||||
record["artifact_manifest"] = manifest
|
missing = [platform for platform in requested if platform not in locators]
|
||||||
record["manifest_source"] = source
|
extra = [platform for platform in locators if platform not in requested]
|
||||||
worktree, actual_commit = ensure_worktree(manifest["commit"], context, Path(args.worktree_root).resolve() if args.worktree_root else None)
|
if missing:
|
||||||
record["worktree"] = {"path": str(worktree), "expected_commit": manifest["commit"], "actual_commit": actual_commit, "isolated": True}
|
raise ArtifactFailure("缺少调用方产物位置: " + ", ".join(f"--artifact {item}=<路径或 URL>" for item in missing))
|
||||||
artifact_dir = Path(tempfile.mkdtemp(prefix=f"pouch-tester-{manifest['release']}-"))
|
if extra:
|
||||||
|
raise ArtifactFailure("产物平台超出 --platform: " + ", ".join(extra))
|
||||||
|
record["artifacts_requested"] = {platform: redact_url(locator) if local_path(locator) is None else locator for platform, locator in locators.items()}
|
||||||
|
commit = resolve_commit(args.commit, context)
|
||||||
|
worktree, actual_commit = ensure_worktree(commit, context, Path(args.worktree_root).resolve() if args.worktree_root else None)
|
||||||
|
record["worktree"] = {"path": str(worktree), "expected_commit": commit, "actual_commit": actual_commit, "isolated": True}
|
||||||
|
artifact_dir = Path(tempfile.mkdtemp(prefix="pouch-tester-artifacts-"))
|
||||||
record["artifact_dir"] = str(artifact_dir)
|
record["artifact_dir"] = str(artifact_dir)
|
||||||
prepared: dict[str, tuple[dict[str, Any], dict[str, str]]] = {}
|
expected = catalog_identities(context.root)
|
||||||
|
prepared: dict[str, tuple[dict[str, Any], dict[str, str], str]] = {}
|
||||||
failures: dict[str, str] = {}
|
failures: dict[str, str] = {}
|
||||||
for platform in requested:
|
for platform in requested:
|
||||||
item = manifest["artifacts"][platform]
|
|
||||||
result: dict[str, Any] = {"node": "tester.android" if platform == "android" else "tester.ios", "status": "preparing"}
|
result: dict[str, Any] = {"node": "tester.android" if platform == "android" else "tester.ios", "status": "preparing"}
|
||||||
record["platforms"][platform] = result
|
record["platforms"][platform] = result
|
||||||
try:
|
try:
|
||||||
path = artifact_dir / ("app.apk" if platform == "android" else "app.ipa")
|
path = artifact_dir / ("app.apk" if platform == "android" else "app.ipa")
|
||||||
result["artifact"] = download_artifact(platform, item, path, context)
|
result["artifact"] = obtain_artifact(platform, locators[platform], path, context)
|
||||||
identity = android_identity(path, context) if platform == "android" else ios_identity(path)
|
identity = android_identity(Path(result["artifact"]["path"]), context) if platform == "android" else ios_identity(Path(result["artifact"]["path"]))
|
||||||
expected = item["package"] if platform == "android" else item["bundle_id"]
|
wanted = expected.get(platform)
|
||||||
if identity != expected:
|
if wanted and identity != wanted:
|
||||||
raise EnvironmentFailure(f"{platform} 身份不匹配: expected={expected} actual={identity}")
|
raise EnvironmentFailure(f"{platform} 身份不匹配: expected={wanted} actual={identity}")
|
||||||
node = android_preflight(context) if platform == "android" else ios_preflight(context)
|
node = android_preflight(context) if platform == "android" else ios_preflight(context)
|
||||||
check_network(context, node)
|
check_network(context, node)
|
||||||
result.update({"identity": identity, "node_preflight": node, "status": "preflight-passed"})
|
result.update({"identity": identity, "node_preflight": node, "status": "preflight-passed"})
|
||||||
prepared[platform] = (result, node)
|
prepared[platform] = (result, node, identity)
|
||||||
except EnvironmentFailure as exc:
|
except (EnvironmentFailure, ArtifactFailure) as exc:
|
||||||
failures[platform] = str(exc)
|
failures[platform] = str(exc)
|
||||||
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
|
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
|
||||||
if failures and not args.partial:
|
if failures and not args.partial:
|
||||||
@@ -438,10 +442,9 @@ def execute(args: argparse.Namespace) -> int:
|
|||||||
record["platforms"][platform].update({"status": "not-run", "reason": "全量 preflight 闸门被另一平台阻断"})
|
record["platforms"][platform].update({"status": "not-run", "reason": "全量 preflight 闸门被另一平台阻断"})
|
||||||
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "next_action": "修复列出的平台环境后重跑;默认不会静默降级"})
|
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "next_action": "修复列出的平台环境后重跑;默认不会静默降级"})
|
||||||
else:
|
else:
|
||||||
for platform, (result, node) in prepared.items():
|
for platform, (result, node, identity) in prepared.items():
|
||||||
try:
|
try:
|
||||||
item = manifest["artifacts"][platform]
|
installed = install_android(node, Path(result["artifact"]["path"]), identity, context) if platform == "android" else install_ios(node, Path(result["artifact"]["path"]), identity, context)
|
||||||
installed = install_android(node, Path(result["artifact"]["path"]), item["package"], context) if platform == "android" else install_ios(node, Path(result["artifact"]["path"]), item["bundle_id"], context)
|
|
||||||
result.update({"status": "ready", "install": installed, "failure_class": None})
|
result.update({"status": "ready", "install": installed, "failure_class": None})
|
||||||
except EnvironmentFailure as exc:
|
except EnvironmentFailure as exc:
|
||||||
failures[platform] = str(exc)
|
failures[platform] = str(exc)
|
||||||
@@ -450,7 +453,7 @@ def execute(args: argparse.Namespace) -> int:
|
|||||||
if not failures:
|
if not failures:
|
||||||
record["product_test"].update({"status": "pending", "failure_class": None})
|
record["product_test"].update({"status": "pending", "failure_class": None})
|
||||||
record["evidence"] = context.evidence
|
record["evidence"] = context.evidence
|
||||||
except (EnvironmentFailure, ManifestFailure) as exc:
|
except (EnvironmentFailure, ArtifactFailure) as exc:
|
||||||
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc), "evidence": context.evidence})
|
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc), "evidence": context.evidence})
|
||||||
path = write_run(context.root, record)
|
path = write_run(context.root, record)
|
||||||
output = {"status": record["status"], "run": str(path), "platforms": record["platforms"], "worktree": record.get("worktree")}
|
output = {"status": record["status"], "run": str(path), "platforms": record["platforms"], "worktree": record.get("worktree")}
|
||||||
@@ -462,34 +465,18 @@ def execute(args: argparse.Namespace) -> int:
|
|||||||
return 0 if record["status"] == "ready" else 1
|
return 0 if record["status"] == "ready" else 1
|
||||||
|
|
||||||
|
|
||||||
def manifest_validate(args: argparse.Namespace) -> int:
|
|
||||||
try:
|
|
||||||
data = json.loads(Path(args.path).read_text(encoding="utf-8"))
|
|
||||||
validate_manifest(data, requested_release=args.release)
|
|
||||||
except (OSError, json.JSONDecodeError, ManifestFailure) as exc:
|
|
||||||
print(f"manifest invalid: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def parser() -> argparse.ArgumentParser:
|
def parser() -> argparse.ArgumentParser:
|
||||||
root = argparse.ArgumentParser(prog="tester", description="Acquire and provision a CI mobile test bundle")
|
root = argparse.ArgumentParser(prog="tester", description="Obtain and provision caller-provided mobile artifacts")
|
||||||
sub = root.add_subparsers(dest="command", required=True)
|
sub = root.add_subparsers(dest="command", required=True)
|
||||||
run = sub.add_parser("run")
|
run = sub.add_parser("run")
|
||||||
run.add_argument("--release", required=True)
|
run.add_argument("--artifact", action="append", dest="artifacts", default=[], metavar="PLATFORM=PATH_OR_URL", help="调用方产物,可重复,例如 android=dist/app.apk 或 ios=https://...")
|
||||||
run.add_argument("--platform", default="android,ios")
|
run.add_argument("--platform", default="android,ios")
|
||||||
|
run.add_argument("--commit", default="", help="本轮隔离 worktree 的 40 位 commit;缺省使用当前 HEAD")
|
||||||
run.add_argument("--partial", action="store_true")
|
run.add_argument("--partial", action="store_true")
|
||||||
run.add_argument("--root", default=".")
|
run.add_argument("--root", default=".")
|
||||||
run.add_argument("--worktree-root", default="")
|
run.add_argument("--worktree-root", default="")
|
||||||
run.add_argument("--binding", default="")
|
run.add_argument("--binding", default="")
|
||||||
run.set_defaults(func=execute)
|
run.set_defaults(func=execute)
|
||||||
manifest = sub.add_parser("manifest")
|
|
||||||
manifest_sub = manifest.add_subparsers(dest="manifest_command", required=True)
|
|
||||||
validate = manifest_sub.add_parser("validate")
|
|
||||||
validate.add_argument("path")
|
|
||||||
validate.add_argument("--release", default=None)
|
|
||||||
validate.set_defaults(func=manifest_validate)
|
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import plistlib
|
import plistlib
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -15,49 +14,54 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|||||||
import mobile_bundle as bundle
|
import mobile_bundle as bundle
|
||||||
|
|
||||||
|
|
||||||
def manifest(commit: str, apk: Path, ipa: Path) -> dict:
|
def init_repo(root: Path) -> str:
|
||||||
return {
|
run = lambda args: subprocess.run(args, check=True, stdout=subprocess.DEVNULL)
|
||||||
"schema_version": 1,
|
run(["git", "init", "-q", str(root)])
|
||||||
"release": "ci-test",
|
run(["git", "-C", str(root), "config", "user.email", "tester@example.invalid"])
|
||||||
"commit": commit,
|
run(["git", "-C", str(root), "config", "user.name", "Tester"])
|
||||||
"artifacts": {
|
(root / "marker").write_text("x")
|
||||||
"android": {"url": str(apk), "sha256": hashlib.sha256(apk.read_bytes()).hexdigest(), "package": "page.writes"},
|
run(["git", "-C", str(root), "add", "marker"])
|
||||||
"ios": {"url": str(ipa), "sha256": hashlib.sha256(ipa.read_bytes()).hexdigest(), "bundle_id": "page.writes"},
|
run(["git", "-C", str(root), "commit", "-qm", "bundle"])
|
||||||
},
|
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MobileBundleTests(unittest.TestCase):
|
class MobileBundleTests(unittest.TestCase):
|
||||||
def test_manifest_contract(self):
|
def test_artifact_locator_is_required(self):
|
||||||
with tempfile.TemporaryDirectory() as temp:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
root = Path(temp)
|
root = Path(temp) / "repo"
|
||||||
apk, ipa = root / "a.apk", root / "a.ipa"
|
root.mkdir()
|
||||||
apk.write_bytes(b"apk")
|
init_repo(root)
|
||||||
ipa.write_bytes(b"ipa")
|
args = bundle.parser().parse_args(["run", "--platform", "android,ios", "--root", str(root)])
|
||||||
bundle.validate_manifest(manifest("a" * 40, apk, ipa), "ci-test")
|
self.assertEqual(bundle.execute(args), 1)
|
||||||
with self.assertRaises(bundle.ManifestFailure):
|
runs = list((root / ".pouch" / "tester" / "runs").glob("*.yaml"))
|
||||||
bundle.validate_manifest(manifest("a" * 40, apk, ipa), "wrong")
|
self.assertEqual(len(runs), 1)
|
||||||
|
record = json.loads(runs[0].read_text(encoding="utf-8"))
|
||||||
|
self.assertIn("缺少调用方产物位置", record["error"])
|
||||||
|
|
||||||
def test_exact_worktree_repairs_stale_commit(self):
|
def test_parse_artifacts_accepts_file_and_url(self):
|
||||||
|
parsed = bundle.parse_artifacts([
|
||||||
|
"android=/tmp/app.apk",
|
||||||
|
"ios=https://ci.example/app.ipa",
|
||||||
|
])
|
||||||
|
self.assertEqual(parsed["android"], "/tmp/app.apk")
|
||||||
|
self.assertEqual(parsed["ios"], "https://ci.example/app.ipa")
|
||||||
|
with self.assertRaises(bundle.ArtifactFailure):
|
||||||
|
bundle.parse_artifacts(["android"])
|
||||||
|
with self.assertRaises(bundle.ArtifactFailure):
|
||||||
|
bundle.parse_artifacts(["android=/tmp/a.apk", "android=/tmp/b.apk"])
|
||||||
|
|
||||||
|
def test_new_worktree_each_run(self):
|
||||||
with tempfile.TemporaryDirectory() as temp:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
repo, worktrees = Path(temp) / "repo", Path(temp) / "worktrees"
|
repo, worktrees = Path(temp) / "repo", Path(temp) / "worktrees"
|
||||||
repo.mkdir()
|
repo.mkdir()
|
||||||
run = lambda args: subprocess.run(args, check=True, stdout=subprocess.DEVNULL)
|
commit = init_repo(repo)
|
||||||
run(["git", "init", "-q", str(repo)])
|
first, first_commit = bundle.ensure_worktree(commit, bundle.RuntimeContext(repo), worktrees)
|
||||||
run(["git", "-C", str(repo), "config", "user.email", "tester@example.invalid"])
|
second, second_commit = bundle.ensure_worktree(commit, bundle.RuntimeContext(repo), worktrees)
|
||||||
run(["git", "-C", str(repo), "config", "user.name", "Tester"])
|
self.assertEqual(first_commit, commit)
|
||||||
(repo / "file").write_text("one")
|
self.assertEqual(second_commit, commit)
|
||||||
run(["git", "-C", str(repo), "add", "file"])
|
self.assertNotEqual(first, second)
|
||||||
run(["git", "-C", str(repo), "commit", "-qm", "one"])
|
self.assertTrue(first.is_dir())
|
||||||
first = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
|
self.assertTrue(second.is_dir())
|
||||||
(repo / "file").write_text("two")
|
|
||||||
run(["git", "-C", str(repo), "commit", "-qam", "two"])
|
|
||||||
second = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
|
|
||||||
path = worktrees / second[:12]
|
|
||||||
run(["git", "-C", str(repo), "worktree", "add", "--detach", str(path), first])
|
|
||||||
actual, commit = bundle.ensure_worktree(second, bundle.RuntimeContext(repo), worktrees)
|
|
||||||
self.assertEqual(actual, path.resolve())
|
|
||||||
self.assertEqual(commit, second)
|
|
||||||
|
|
||||||
def test_ipa_identity_and_install(self):
|
def test_ipa_identity_and_install(self):
|
||||||
with tempfile.TemporaryDirectory() as temp:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
@@ -101,22 +105,27 @@ class MobileBundleTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as temp:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
root = Path(temp) / "repo"
|
root = Path(temp) / "repo"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
commit = init_repo(root)
|
||||||
subprocess.run(["git", "-C", str(root), "config", "user.email", "tester@example.invalid"], check=True)
|
|
||||||
subprocess.run(["git", "-C", str(root), "config", "user.name", "Tester"], check=True)
|
|
||||||
(root / "marker").write_text("x")
|
|
||||||
subprocess.run(["git", "-C", str(root), "add", "marker"], check=True)
|
|
||||||
subprocess.run(["git", "-C", str(root), "commit", "-qm", "bundle"], check=True)
|
|
||||||
commit = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
|
|
||||||
apk, ipa = Path(temp) / "a.apk", Path(temp) / "a.ipa"
|
apk, ipa = Path(temp) / "a.apk", Path(temp) / "a.ipa"
|
||||||
apk.write_bytes(b"apk")
|
apk.write_bytes(b"apk")
|
||||||
ipa.write_bytes(b"ipa")
|
ipa.write_bytes(b"ipa")
|
||||||
manifest_path = Path(temp) / "manifest.json"
|
args = bundle.parser().parse_args([
|
||||||
manifest_path.write_text(json.dumps(manifest(commit, apk, ipa)))
|
"run",
|
||||||
args = bundle.parser().parse_args(["run", "--release", str(manifest_path), "--root", str(root), "--worktree-root", str(Path(temp) / "wt")])
|
"--artifact", f"android={apk}",
|
||||||
|
"--artifact", f"ios={ipa}",
|
||||||
|
"--root", str(root),
|
||||||
|
"--worktree-root", str(Path(temp) / "wt"),
|
||||||
|
])
|
||||||
with mock.patch.object(bundle, "android_identity", return_value="page.writes"), mock.patch.object(bundle, "ios_identity", return_value="page.writes"), mock.patch.object(bundle, "android_preflight", return_value={"adb": "adb", "serial": "device"}), mock.patch.object(bundle, "ios_preflight", side_effect=bundle.EnvironmentFailure("Mac unavailable")), mock.patch.object(bundle, "check_network"), mock.patch.object(bundle, "install_android") as install:
|
with mock.patch.object(bundle, "android_identity", return_value="page.writes"), mock.patch.object(bundle, "ios_identity", return_value="page.writes"), mock.patch.object(bundle, "android_preflight", return_value={"adb": "adb", "serial": "device"}), mock.patch.object(bundle, "ios_preflight", side_effect=bundle.EnvironmentFailure("Mac unavailable")), mock.patch.object(bundle, "check_network"), mock.patch.object(bundle, "install_android") as install:
|
||||||
self.assertEqual(bundle.execute(args), 1)
|
self.assertEqual(bundle.execute(args), 1)
|
||||||
install.assert_not_called()
|
install.assert_not_called()
|
||||||
|
self.assertEqual(args.commit, "")
|
||||||
|
self.assertTrue(COMMIT_HEAD(root, commit))
|
||||||
|
|
||||||
|
|
||||||
|
def COMMIT_HEAD(root: Path, commit: str) -> bool:
|
||||||
|
actual = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
return actual == commit
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user