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:
2026-10-15 01:05:09 +08:00
parent 2fcf16627d
commit 5fef8247fc
9 changed files with 223 additions and 259 deletions
+8 -5
View File
@@ -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
列出测试用例 列出测试用例
+1 -1
View File
@@ -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。
+4 -3
View File
@@ -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 项目登记。
## 配置更新结果 ## 配置更新结果
+10 -10
View File
@@ -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`。
+1 -1
View File
@@ -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"}}
}
}
}
}
}
+15 -25
View File
@@ -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`
与产品测试失败分开记录。
+129 -142
View File
@@ -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
+55 -46
View File
@@ -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__":