From 706c5537786e3f892c33282da1e5e1d7c8948bd8 Mon Sep 17 00:00:00 2001 From: laily Date: Wed, 14 Oct 2026 17:29:12 +0800 Subject: [PATCH] feat(tester): provision CI mobile bundles in shared skill --- skills/tester/README.md | 9 + skills/tester/SKILL.md | 4 +- skills/tester/references/run.md | 13 +- .../test-bundle.manifest.schema.json | 26 + skills/tester/references/test-bundle.md | 31 ++ skills/tester/scripts/mobile_bundle.py | 494 ++++++++++++++++++ skills/tester/tester | 2 + skills/tester/tests/test_mobile_bundle.py | 123 +++++ 8 files changed, 693 insertions(+), 9 deletions(-) create mode 100644 skills/tester/references/test-bundle.manifest.schema.json create mode 100644 skills/tester/references/test-bundle.md create mode 100755 skills/tester/scripts/mobile_bundle.py create mode 100755 skills/tester/tester create mode 100755 skills/tester/tests/test_mobile_bundle.py diff --git a/skills/tester/README.md b/skills/tester/README.md index 7151796..3d2d61f 100644 --- a/skills/tester/README.md +++ b/skills/tester/README.md @@ -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 +/tester run --release ci-07aa8f3 --platform android,ios +``` + +该命令自动从现有 CI Release 取得 manifest、下载并校验 APK/IPA,按 commit 准备隔离 worktree, +在 Android 和 iOS tester 节点安装并验证身份,然后交给真实移动 session 执行 catalog。默认全量闸门, +只有显式 `--partial` 才允许单平台继续;不要求手工 scp、复制或安装。 + ```text 列出测试用例 ``` diff --git a/skills/tester/SKILL.md b/skills/tester/SKILL.md index 0984cd8..749e56e 100644 --- a/skills/tester/SKILL.md +++ b/skills/tester/SKILL.md @@ -137,11 +137,11 @@ python3 /scripts/catalog.py show --root --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.` 执行绑定。用户要求 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.` binding,并使用 `/tester run --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。 diff --git a/skills/tester/references/run.md b/skills/tester/references/run.md index 379ad45..adcdfda 100644 --- a/skills/tester/references/run.md +++ b/skills/tester/references/run.md @@ -118,27 +118,26 @@ python3 /scripts/catalog.py select --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 ` 验证。没有 `--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 -b` 确认完成启动;真机用 `xcrun xctrace list devices` 确认已连接、已信任且允许开发。 -3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container ` 验证。 +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 ` 验证。没有 `--release` 时才沿用已有 App 前置条件。 4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。 -任一项不满足,都要在 preflight 汇总中列出平台、具体检查项、实际探测命令和简短输出, -然后停止整轮;不能先执行其它平台,不能改成 API 测试,也不能把它们标成产品 `fail`。 -只有 preflight 全部通过后,才把对应移动用例的结果写入运行快照。 +Android+iOS 默认是全量 preflight 闸门:任一项不满足,另一平台也不得安装或执行,并在记录中标为 `not-run`。只有用户显式加 `--partial` 才允许已通过的平台继续。不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化被闸门挡住的平台 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。 +任一项不满足,都要在 preflight 汇总中列出平台、具体检查项、实际探测命令和简短输出,然后停止整轮;只有 preflight 全部通过后,才把对应移动用例的结果写入运行快照。 ### 环境缺失时的用户引导 diff --git a/skills/tester/references/test-bundle.manifest.schema.json b/skills/tester/references/test-bundle.manifest.schema.json new file mode 100644 index 0000000..b4d79d2 --- /dev/null +++ b/skills/tester/references/test-bundle.manifest.schema.json @@ -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"}} + } + } + } + } +} diff --git a/skills/tester/references/test-bundle.md b/skills/tester/references/test-bundle.md new file mode 100644 index 0000000..63e5027 --- /dev/null +++ b/skills/tester/references/test-bundle.md @@ -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": "", "sha256": "<64-hex>", "package": "page.writes"}, + "ios": {"url": "", "sha256": "<64-hex>", "bundle_id": "page.writes"} + } +} +``` + +运行入口: + +```bash +/tester run --release ci-07aa8f3 --platform android,ios +``` + +默认先完成 Android+iOS 双平台 preflight;任一平台无法自动准备时整轮停止。只有显式加 +`--partial` 才允许运行可用平台。每个平台都必须在自己的 `tester.` 节点下载、校验、 +解包、验证身份并安装,随后由现有 tester catalog 驱动真实移动 session。 + +运行记录写入目标项目 `.pouch/tester/runs/`,包含 manifest、sha256、身份、实际 worktree commit、 +节点、安装证据和 terminal 生命周期;`automatic-repair-failed` 与产品测试失败分开记录。 diff --git a/skills/tester/scripts/mobile_bundle.py b/skills/tester/scripts/mobile_bundle.py new file mode 100755 index 0000000..5e09fab --- /dev/null +++ b/skills/tester/scripts/mobile_bundle.py @@ -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, "", "")) + + +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)) diff --git a/skills/tester/tester b/skills/tester/tester new file mode 100755 index 0000000..2e96f44 --- /dev/null +++ b/skills/tester/tester @@ -0,0 +1,2 @@ +#!/bin/sh +exec python3 "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/scripts/mobile_bundle.py" "$@" diff --git a/skills/tester/tests/test_mobile_bundle.py b/skills/tester/tests/test_mobile_bundle.py new file mode 100755 index 0000000..15aba72 --- /dev/null +++ b/skills/tester/tests/test_mobile_bundle.py @@ -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()