feat(tester): provision CI mobile bundles in shared skill

This commit is contained in:
2026-10-14 17:29:12 +08:00
parent db92baf3a7
commit 706c553778
8 changed files with 693 additions and 9 deletions
+9
View File
@@ -8,6 +8,7 @@
- 「加一条相机开盖的测试」「完善 TC-CAM-003 的验证」
- 「跑 smoke」「测 CAM」「跑 TC-INF-001 和 TC-CAM-001」
- 「现场复测」
- 「按 CI release 跑 Android+iOS」
不管 ACK。这是项目自己的测试目录。
@@ -30,6 +31,14 @@
## 使用示例
```bash
<skill-dir>/tester run --release ci-07aa8f3 --platform android,ios
```
该命令自动从现有 CI Release 取得 manifest、下载并校验 APK/IPA,按 commit 准备隔离 worktree,
在 Android 和 iOS tester 节点安装并验证身份,然后交给真实移动 session 执行 catalog。默认全量闸门,
只有显式 `--partial` 才允许单平台继续;不要求手工 scp、复制或安装。
```text
列出测试用例
```
+2 -2
View File
@@ -137,11 +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)。运行必须按以下顺序执行:
读 [run.md](references/run.md)、[execution.md](references/execution.md)、[deployment.md](references/deployment.md) 和 [test-bundle.md](references/test-bundle.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。
测试环境由本机 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。
+6 -7
View File
@@ -118,27 +118,26 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
`driver=mobile` 不是一个可以互换的平台。运行前必须根据 catalog session、用例变量或用户指定,明确本轮是 Android 还是 iOS;无法判断时先停下询问平台,不得猜测。两类平台分别执行以下闸门:
### Android
### Android(CI bundle 模式)
按顺序确认:
1. `adb` 可执行,且版本与当前 SDK 配置一致。
2. `adb devices -l` 至少有一个状态为 `device` 的目标;`unauthorized`、`offline` 或空列表都不能继续。多个目标时必须显式设置 `ANDROID_SERIAL`。
3. 被测 APK 已安装,包名与 catalog 的变量一致;可用 `adb -s "$ANDROID_SERIAL" shell pm path "$PACKAGE"` 验证。
3. 如果命令带 `--release`,由 tester skill 下载 CI manifest 指定的 APK,校验 sha256、读取 APK 身份,自动执行 `adb install -r`,再用 `adb -s "$ANDROID_SERIAL" shell pm path <package>` 验证。没有 `--release` 时才沿用已有 APK 前置条件。
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从设备访问。
### iOS
### iOS(CI bundle 模式)
按顺序确认:
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` 确认已连接、已信任且允许开发。
3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。
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 前置条件。
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。
任一项不满足,都要在 preflight 汇总中列出平台、具体检查项、实际探测命令和简短输出,
然后停止整轮;不能先执行其它平台,不能改成 API 测试,也不能把它们标成产品 `fail`。
只有 preflight 全部通过后,才把对应移动用例的结果写入运行快照。
Android+iOS 默认是全量 preflight 闸门:任一项不满足,另一平台也不得安装或执行,并在记录中标为 `not-run`。只有用户显式加 `--partial` 才允许已通过的平台继续。不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化被闸门挡住的平台 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。
任一项不满足,都要在 preflight 汇总中列出平台、具体检查项、实际探测命令和简短输出,然后停止整轮;只有 preflight 全部通过后,才把对应移动用例的结果写入运行快照。
### 环境缺失时的用户引导
@@ -0,0 +1,26 @@
{
"$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"}}
}
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
# CI mobile test bundle
项目 CI 负责把 APK、IPA 和 `test-bundle.manifest.json` 发布到已有的 Release;tester skill 负责消费它。
不要手工复制或安装产物。
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
<skill-dir>/tester run --release ci-07aa8f3 --platform android,ios
```
默认先完成 Android+iOS 双平台 preflight;任一平台无法自动准备时整轮停止。只有显式加
`--partial` 才允许运行可用平台。每个平台都必须在自己的 `tester.<platform>` 节点下载、校验、
解包、验证身份并安装,随后由现有 tester catalog 驱动真实移动 session。
运行记录写入目标项目 `.pouch/tester/runs/`,包含 manifest、sha256、身份、实际 worktree commit、
节点、安装证据和 terminal 生命周期;`automatic-repair-failed` 与产品测试失败分开记录。
+494
View File
@@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""Acquire and provision a CI mobile test bundle.
This is shared tester infrastructure. It never replaces real Android/iOS
execution with HTTP calls: it only downloads, verifies and installs the
release artifact before the catalog runner drives the mobile session.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import plistlib
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from platform import system as platform_system
from typing import Any
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
COMMIT = re.compile(r"^[0-9a-fA-F]{40}$")
PLATFORMS = {"android", "ios"}
class EnvironmentFailure(RuntimeError):
"""An external environment or recoverable provisioning failure."""
class ManifestFailure(RuntimeError):
"""A malformed or mismatched CI bundle contract."""
@dataclass
class CommandResult:
code: int
output: str
@dataclass
class RuntimeContext:
root: Path
retries: dict[str, int] = field(default_factory=lambda: {"download": 3, "start": 3, "install": 3})
evidence: list[dict[str, Any]] = field(default_factory=list)
def record(self, command: str, result: CommandResult) -> None:
self.evidence.append({
"command": command,
"status": "ok" if result.code == 0 else "error",
"output": result.output[-800:],
})
def redact_url(value: str) -> str:
parsed = urllib.parse.urlsplit(value)
if not parsed.query:
return value
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "<redacted>", ""))
def run_command(args: list[str], *, context: RuntimeContext | None = None, cwd: Path | None = None) -> CommandResult:
try:
proc = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False)
result = CommandResult(proc.returncode, proc.stdout.strip())
except OSError as exc:
result = CommandResult(127, str(exc))
if context:
context.record(" ".join(args), result)
return result
def fetch_bytes(url: str, *, attempts: int = 3, token: str = "") -> bytes:
if url.startswith("file://"):
return Path(urllib.parse.urlsplit(url).path).read_bytes()
if not urllib.parse.urlsplit(url).scheme:
return Path(url).read_bytes()
last: Exception | None = None
for attempt in range(1, attempts + 1):
try:
headers = {"User-Agent": "pouch-tester/1"}
if token:
headers["Authorization"] = f"token {token}"
request = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(request, timeout=30) as response:
return response.read()
except (OSError, urllib.error.URLError) as exc:
last = exc
if attempt < attempts:
time.sleep(min(attempt, 2))
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:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def download_artifact(platform: str, item: dict[str, Any], destination: Path, context: RuntimeContext) -> dict[str, Any]:
last: Exception | None = None
for attempt in range(1, context.retries["download"] + 1):
try:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(fetch_bytes(item["url"], attempts=1, token=os.environ.get("GITEA_TOKEN", "")))
actual = sha256_file(destination)
if actual.lower() != item["sha256"].lower():
raise EnvironmentFailure(f"{platform} sha256 不匹配: expected={item['sha256']} actual={actual}")
return {"path": str(destination), "url": redact_url(item["url"]), "sha256": actual, "attempts": attempt, "verified": True}
except (OSError, EnvironmentFailure) as exc:
last = exc
if attempt < context.retries["download"]:
time.sleep(min(attempt, 2))
raise EnvironmentFailure(f"{platform} 下载/校验失败({context.retries['download']} 次): {last}")
def android_identity(path: Path, context: RuntimeContext) -> str:
commands: list[list[str]] = []
if shutil.which("apkanalyzer"):
commands.append(["apkanalyzer", "manifest", "application-id", str(path)])
if shutil.which("aapt2"):
commands.append(["aapt2", "dump", "badging", str(path)])
if shutil.which("aapt"):
commands.append(["aapt", "dump", "badging", str(path)])
if not commands:
raise EnvironmentFailure("缺少 apkanalyzer/aapt2/aapt;请在 Android tester 节点配置 Android SDK build-tools")
for command in commands:
result = run_command(command, context=context)
if result.code == 0:
match = re.search(r"(?:name|application-id)[=:]['\"]?([A-Za-z0-9_.]+)", result.output)
if match:
return match.group(1)
raise EnvironmentFailure("APK 身份读取失败;请检查 Android SDK build-tools")
def ios_identity(path: Path) -> str:
try:
with zipfile.ZipFile(path) as archive:
candidates = [name for name in archive.namelist() if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)]
if len(candidates) != 1:
raise EnvironmentFailure("IPA 中没有唯一 Payload/*.app/Info.plist")
plist = plistlib.loads(archive.read(candidates[0]))
except (OSError, zipfile.BadZipFile, plistlib.InvalidFileException) as exc:
raise EnvironmentFailure(f"IPA 解包/读取 Info.plist 失败: {exc}") from exc
bundle_id = plist.get("CFBundleIdentifier")
if not isinstance(bundle_id, str) or not bundle_id:
raise EnvironmentFailure("IPA Info.plist 缺少 CFBundleIdentifier")
return bundle_id
def retry_command(args: list[str], *, context: RuntimeContext, action: str) -> CommandResult:
last = CommandResult(1, "")
for attempt in range(1, context.retries[action] + 1):
last = run_command(args, context=context)
if last.code == 0:
return last
if attempt < context.retries[action]:
time.sleep(min(attempt, 2))
raise EnvironmentFailure(f"{action} 失败({context.retries[action]} 次): {' '.join(args)}\n{last.output[-500:]}")
def android_preflight(context: RuntimeContext) -> dict[str, str]:
adb = os.environ.get("ADB_BIN") or shutil.which("adb")
if not adb:
raise EnvironmentFailure("Android 节点缺少 adb;请安装/配置 Android SDK platform-tools")
retry_command([adb, "start-server"], context=context, action="start")
devices = run_command([adb, "devices", "-l"], context=context)
available = [line.split()[0] for line in devices.output.splitlines()[1:] if re.search(r"\sdevice\s", line)]
serial = os.environ.get("ANDROID_SERIAL", "").strip()
if serial and serial not in available:
raise EnvironmentFailure(f"ANDROID_SERIAL={serial} 不在 device 状态;请解锁/授权设备后检查 adb devices -l")
if not serial and len(available) != 1:
if not available:
raise EnvironmentFailure("没有 Android device;请连接、解锁、开启 USB 调试并接受 RSA 授权")
raise EnvironmentFailure(f"检测到多个 Android device ({', '.join(available)});请设置 ANDROID_SERIAL")
return {"adb": adb, "serial": serial or available[0], "session_lifecycle": "reused-or-started"}
def ios_preflight(context: RuntimeContext) -> dict[str, str]:
if platform_system() != "Darwin":
raise EnvironmentFailure("iOS 必须由 tester.ios 的 macOS 节点执行;Linux 不能伪装 Simulator")
xcrun = shutil.which("xcrun")
if not xcrun:
raise EnvironmentFailure("iOS 节点缺少 xcrun;请安装 Xcode Command Line Tools")
devices = run_command([xcrun, "simctl", "list", "devices", "available"], context=context)
if devices.code != 0:
raise EnvironmentFailure("simctl 不可用;请安装 Xcode 并确认已接受许可")
wanted = os.environ.get("IOS_SIMULATOR_UDID", "").strip()
matches: list[tuple[str, str, str]] = []
for line in devices.output.splitlines():
match = re.search(r"^\s*(.+?)\s+\(([0-9A-Fa-f-]{36})\)\s+\((Booted|Shutdown)\)", line)
if match:
matches.append((match.group(2), match.group(1), match.group(3)))
selected = next((item for item in matches if item[0] == wanted), None) if wanted else None
selected = selected or next((item for item in matches if item[2] == "Booted"), None) or (matches[0] if matches else None)
if not selected:
raise EnvironmentFailure("没有可用 iOS Simulator;请在 Mac 上安装一个可用 runtime/device")
udid, name, state = selected
if state != "Booted":
retry_command([xcrun, "simctl", "boot", udid], context=context, action="start")
retry_command([xcrun, "simctl", "bootstatus", udid, "-b"], context=context, action="start")
return {"xcrun": xcrun, "udid": udid, "name": name, "session_lifecycle": "reused" if state == "Booted" else "started"}
def check_network(context: RuntimeContext, node: dict[str, str] | None = None) -> None:
host = os.environ.get("HOST", "").strip()
api = os.environ.get("API_BASE", "").strip() or (f"http://{host}" if host else "")
if not api:
raise EnvironmentFailure("缺少 HOST/API_BASE;请在 tester.local.yaml 配置测试服务地址")
for attempt in range(1, context.retries["start"] + 1):
try:
with urllib.request.urlopen(urllib.request.Request(api.rstrip("/") + "/v1/me"), timeout=5) as response:
if response.status < 500:
break
except urllib.error.HTTPError as exc:
if exc.code < 500:
break
except (OSError, urllib.error.URLError):
pass
if attempt == context.retries["start"]:
raise EnvironmentFailure(f"测试服务不可达: {api};请检查 HOST、VPN、DNS 和局域网后重跑")
time.sleep(min(attempt, 2))
if node and "adb" in node:
if shutil.which("ping"):
result = run_command([node["adb"], "-s", node["serial"], "shell", "ping", "-c", "1", "-W", "3", host], context=context)
if result.code != 0:
raise EnvironmentFailure(f"Android device 无法访问 HOST={host};请检查设备 Wi-Fi/DNS/VPN")
elif node:
result = run_command([node["xcrun"], "simctl", "spawn", node["udid"], "/usr/bin/curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", api.rstrip("/") + "/v1/me"], context=context)
if result.code != 0 or result.output.strip() in {"", "000"} or result.output.strip().startswith("5"):
raise EnvironmentFailure(f"iOS Simulator 无法访问 {api};请检查 Mac 的 VPN/DNS/局域网权限")
def install_android(node: dict[str, str], path: Path, package: str, context: RuntimeContext) -> dict[str, Any]:
retry_command([node["adb"], "-s", node["serial"], "install", "-r", str(path)], context=context, action="install")
result = run_command([node["adb"], "-s", node["serial"], "shell", "pm", "path", package], context=context)
if result.code != 0 or "package:" not in result.output:
raise EnvironmentFailure(f"安装后未找到 Android package {package};请检查签名/设备空间")
return {"serial": node["serial"], "package": package, "installed": True, "verified": True}
def install_ios(node: dict[str, str], path: Path, bundle_id: str, context: RuntimeContext) -> dict[str, Any]:
retry_command([node["xcrun"], "simctl", "install", node["udid"], str(path)], context=context, action="install")
result = run_command([node["xcrun"], "simctl", "get_app_container", node["udid"], bundle_id], context=context)
if result.code != 0 or not result.output:
raise EnvironmentFailure(f"安装后未找到 iOS bundle {bundle_id};请检查签名/Simulator 架构")
return {"udid": node["udid"], "simulator": node["name"], "bundle_id": bundle_id, "installed": True, "verified": True}
def sanitize(value: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", value)
def ensure_worktree(commit: str, context: RuntimeContext, parent: Path | None = None) -> tuple[Path, str]:
git = shutil.which("git") or "git"
check = run_command([git, "rev-parse", "--show-toplevel"], context=context, cwd=context.root)
if check.code != 0:
raise EnvironmentFailure(f"目标目录不是 Git worktree: {context.root}")
repo = Path(check.output).resolve()
verify = run_command([git, "rev-parse", f"{commit}^{{commit}}"], context=context, cwd=repo)
if verify.code != 0:
fetched = run_command([git, "fetch", "--no-tags", "origin", commit], context=context, cwd=repo)
if fetched.code != 0:
raise EnvironmentFailure(f"目标 commit 不在本地且无法从 origin 获取: {commit}")
base = parent or Path(os.environ.get("TESTER_WORKTREE_ROOT", Path(tempfile.gettempdir()) / "pouch-tester" / "worktrees"))
base.mkdir(parents=True, exist_ok=True)
path = (base / sanitize(commit[:12])).resolve()
listing = run_command([git, "worktree", "list", "--porcelain"], context=context, cwd=repo)
managed = any(re.search(rf"^worktree {re.escape(str(path))}$", block, re.M) for block in listing.output.split("\n\n"))
if path.exists() and not managed:
raise EnvironmentFailure(f"隔离 worktree 路径已存在但不是 Git worktree,拒绝覆盖: {path}")
if managed:
current = run_command([git, "-C", str(path), "rev-parse", "HEAD"], context=context)
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)
if actual.code != 0 or actual.output.lower() != commit.lower():
raise EnvironmentFailure(f"worktree commit 不匹配: expected={commit} actual={actual.output}")
return path, actual.output
def platforms(raw: str) -> list[str]:
values = [item.strip().lower() for item in raw.split(",") if item.strip()]
if not values:
return ["android", "ios"]
if any(item not in PLATFORMS for item in values) or len(values) != len(set(values)):
raise ManifestFailure("--platform 只能是 android,ios 且不能重复")
return values
def write_run(root: Path, record: dict[str, Any]) -> Path:
runs = root / ".pouch" / "tester" / "runs"
runs.mkdir(parents=True, exist_ok=True)
run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-bundle-" + os.urandom(3).hex()
path = runs / f"{run_id}.yaml"
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def execute(args: argparse.Namespace) -> int:
requested = platforms(args.platform)
context = RuntimeContext(Path(args.root).resolve())
record: dict[str, Any] = {
"schema_version": 1,
"started_at": datetime.now(timezone.utc).isoformat(),
"release_request": args.release,
"platforms_requested": requested,
"partial": bool(args.partial),
"binding": args.binding,
"node": os.environ.get("TESTER_NODE", "local"),
"terminal": {"handle": os.environ.get("ORCA_TERMINAL_HANDLE"), "created": os.environ.get("TESTER_TERMINAL_CREATED", "false").lower() == "true", "closed": os.environ.get("TESTER_TERMINAL_CLOSED", "unknown")},
"product_test": {"status": "not-run", "failure_class": "automatic-repair-failed", "note": "准备完成后由 tester catalog 驱动真实移动 session;本工具不以 API 替代移动测试"},
"platforms": {},
}
try:
manifest, source = resolve_manifest(args.release, attempts=context.retries["download"])
record["artifact_manifest"] = manifest
record["manifest_source"] = source
worktree, actual_commit = ensure_worktree(manifest["commit"], context, Path(args.worktree_root).resolve() if args.worktree_root else None)
record["worktree"] = {"path": str(worktree), "expected_commit": manifest["commit"], "actual_commit": actual_commit, "isolated": True}
artifact_dir = Path(tempfile.mkdtemp(prefix=f"pouch-tester-{manifest['release']}-"))
record["artifact_dir"] = str(artifact_dir)
prepared: dict[str, tuple[dict[str, Any], dict[str, str]]] = {}
failures: dict[str, str] = {}
for platform in requested:
item = manifest["artifacts"][platform]
result: dict[str, Any] = {"node": "tester.android" if platform == "android" else "tester.ios", "status": "preparing"}
record["platforms"][platform] = result
try:
path = artifact_dir / ("app.apk" if platform == "android" else "app.ipa")
result["artifact"] = download_artifact(platform, item, path, context)
identity = android_identity(path, context) if platform == "android" else ios_identity(path)
expected = item["package"] if platform == "android" else item["bundle_id"]
if identity != expected:
raise EnvironmentFailure(f"{platform} 身份不匹配: expected={expected} actual={identity}")
node = android_preflight(context) if platform == "android" else ios_preflight(context)
check_network(context, node)
result.update({"identity": identity, "node_preflight": node, "status": "preflight-passed"})
prepared[platform] = (result, node)
except EnvironmentFailure as exc:
failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
if failures and not args.partial:
for platform in requested:
if platform not in failures:
record["platforms"][platform].update({"status": "not-run", "reason": "全量 preflight 闸门被另一平台阻断"})
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "next_action": "修复列出的平台环境后重跑;默认不会静默降级"})
else:
for platform, (result, node) in prepared.items():
try:
item = manifest["artifacts"][platform]
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})
except EnvironmentFailure as exc:
failures[platform] = str(exc)
result.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc)})
record.update({"status": "environment-fail" if failures else "ready", "failure_class": "automatic-repair-failed" if failures else None})
if not failures:
record["product_test"].update({"status": "pending", "failure_class": None})
record["evidence"] = context.evidence
except (EnvironmentFailure, ManifestFailure) as exc:
record.update({"status": "environment-fail", "failure_class": "automatic-repair-failed", "error": str(exc), "evidence": context.evidence})
path = write_run(context.root, record)
output = {"status": record["status"], "run": str(path), "platforms": record["platforms"], "worktree": record.get("worktree")}
print(json.dumps(output, ensure_ascii=False, indent=2), file=sys.stderr if record["status"] != "ready" else sys.stdout)
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:
root = argparse.ArgumentParser(prog="tester", description="Acquire and provision a CI mobile test bundle")
sub = root.add_subparsers(dest="command", required=True)
run = sub.add_parser("run")
run.add_argument("--release", required=True)
run.add_argument("--platform", default="android,ios")
run.add_argument("--partial", action="store_true")
run.add_argument("--root", default=".")
run.add_argument("--worktree-root", default="")
run.add_argument("--binding", default="")
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
if __name__ == "__main__":
parsed = parser().parse_args()
raise SystemExit(parsed.func(parsed))
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec python3 "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/scripts/mobile_bundle.py" "$@"
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
import hashlib
import json
import plistlib
import subprocess
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import mobile_bundle as bundle
def manifest(commit: str, apk: Path, ipa: Path) -> dict:
return {
"schema_version": 1,
"release": "ci-test",
"commit": commit,
"artifacts": {
"android": {"url": str(apk), "sha256": hashlib.sha256(apk.read_bytes()).hexdigest(), "package": "page.writes"},
"ios": {"url": str(ipa), "sha256": hashlib.sha256(ipa.read_bytes()).hexdigest(), "bundle_id": "page.writes"},
},
}
class MobileBundleTests(unittest.TestCase):
def test_manifest_contract(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
apk, ipa = root / "a.apk", root / "a.ipa"
apk.write_bytes(b"apk")
ipa.write_bytes(b"ipa")
bundle.validate_manifest(manifest("a" * 40, apk, ipa), "ci-test")
with self.assertRaises(bundle.ManifestFailure):
bundle.validate_manifest(manifest("a" * 40, apk, ipa), "wrong")
def test_exact_worktree_repairs_stale_commit(self):
with tempfile.TemporaryDirectory() as temp:
repo, worktrees = Path(temp) / "repo", Path(temp) / "worktrees"
repo.mkdir()
run = lambda args: subprocess.run(args, check=True, stdout=subprocess.DEVNULL)
run(["git", "init", "-q", str(repo)])
run(["git", "-C", str(repo), "config", "user.email", "tester@example.invalid"])
run(["git", "-C", str(repo), "config", "user.name", "Tester"])
(repo / "file").write_text("one")
run(["git", "-C", str(repo), "add", "file"])
run(["git", "-C", str(repo), "commit", "-qm", "one"])
first = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
(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):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
ipa = root / "app.ipa"
with zipfile.ZipFile(ipa, "w") as archive:
archive.writestr("Payload/Writes.app/Info.plist", plistlib.dumps({"CFBundleIdentifier": "page.writes"}))
self.assertEqual(bundle.ios_identity(ipa), "page.writes")
context = bundle.RuntimeContext(root)
calls = []
def fake(args, **kwargs):
calls.append(args)
return bundle.CommandResult(0, "/container" if "get_app_container" in args else "")
with mock.patch.object(bundle, "run_command", side_effect=fake):
result = bundle.install_ios({"xcrun": "xcrun", "udid": "UDID", "name": "Test"}, ipa, "page.writes", context)
self.assertTrue(result["verified"])
self.assertTrue(any("install" in call for call in calls))
def test_android_missing_device_is_environment_failure(self):
context = bundle.RuntimeContext(Path("."))
with mock.patch.object(bundle.shutil, "which", return_value="/sdk/adb"), mock.patch.object(
bundle, "run_command", side_effect=[bundle.CommandResult(0, ""), bundle.CommandResult(0, "List of devices attached")]
):
with self.assertRaises(bundle.EnvironmentFailure):
bundle.android_preflight(context)
def test_network_failure_is_actionable(self):
context = bundle.RuntimeContext(Path("."))
with mock.patch.object(bundle.urllib.request, "urlopen", side_effect=OSError("offline")), mock.patch.object(bundle.time, "sleep"):
with mock.patch.dict(bundle.os.environ, {"HOST": "test.invalid"}, clear=False):
with self.assertRaisesRegex(bundle.EnvironmentFailure, "VPN"):
bundle.check_network(context)
def test_retry_recovers(self):
context = bundle.RuntimeContext(Path("."))
responses = [bundle.CommandResult(1, "busy"), bundle.CommandResult(0, "ok")]
with mock.patch.object(bundle, "run_command", side_effect=responses), mock.patch.object(bundle.time, "sleep"):
self.assertEqual(bundle.retry_command(["fake"], context=context, action="install").code, 0)
def test_full_gate_blocks_install_when_ios_fails(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp) / "repo"
root.mkdir()
subprocess.run(["git", "init", "-q", str(root)], check=True)
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.write_bytes(b"apk")
ipa.write_bytes(b"ipa")
manifest_path = Path(temp) / "manifest.json"
manifest_path.write_text(json.dumps(manifest(commit, apk, ipa)))
args = bundle.parser().parse_args(["run", "--release", str(manifest_path), "--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:
self.assertEqual(bundle.execute(args), 1)
install.assert_not_called()
if __name__ == "__main__":
unittest.main()