feat(tester): provision CI mobile bundles in shared skill
This commit is contained in:
@@ -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
|
||||
列出测试用例
|
||||
```
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 与产品测试失败分开记录。
|
||||
Executable
+494
@@ -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))
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec python3 "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/scripts/mobile_bundle.py" "$@"
|
||||
Executable
+123
@@ -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()
|
||||
Reference in New Issue
Block a user