Folo 移动端自动化自测方案:mobile-self-test Skill 的隔离模拟器、双模式构建与截图取证实战
本文以 Folo(AI RSS Reader)仓库中 mobile-self-test Skill 为核心,完整拆解这套“实现完成后如何验证一次移动端改动”的工程化流程:从平台与 API 模式(prod/local)决策、临时模拟器隔离、Release 构建,到 Maestro 注册引导与截图驱动的视觉验收。读完本文,你可以直接复用该 Skill 的决策树与命令序列,对任意 apps/mobile 的功能改动执行一次带截图证据的端到端自测,并理解每条规则背后对应的仓库实现(Maestro 运行脚本、构建 profiles、注册流程)。
Skill 的定位:它是 mobile-e2e 的“验收升级版”
mobile-self-test 明确声明自己是 mobile-e2e Skill 的扩展:
- 继承基线:环境 doctor 检查、iOS 模拟器启动规则、Java/Android SDK 配置、Maestro 产物约定全部沿用 mobile-e2e;
- 覆盖设备策略:self-test 运行时,同机上可能有其他 Agent 正在占用模拟器/模拟器,因此它禁止复用
booted状态的设备或通用序列号(如emulator-5554),必须为当前运行创建专属临时设备; - 核心差异(与 mobile-e2e 最大的不同):Maestro 只用于非认证类改动的“注册引导”(bootstrap 一个干净的登录态账号),真正的功能验证切换为截图驱动的视觉验证,并以截图作为验收的唯一事实来源。
这一分层设计解决了一个实际问题:自动化 UI 流程(Maestro 的 id 断言)适合“把账号准备好”,但不适合“证明我改的 UI 是对的”——后者必须看屏幕。
相关文件清单
| 文件 | 作用 |
|---|---|
| .agents/skills/mobile-e2e/SKILL.md | 基线 Skill(doctor 检查、模拟器启动、SDK 配置) |
| apps/mobile/e2e/run-maestro.sh | Maestro 运行脚本,负责设备解析、应用包解析、模拟器准备与流程调度 |
| apps/mobile/e2e/flows/ios/register.yaml | iOS 注册流程(认证引导) |
| apps/mobile/e2e/flows/android/register.yaml | Android 注册流程(认证引导) |
| apps/mobile/e2e/flows/shared/ | 双平台共享的 auth/onboarding/timeline 流程片段 |
| apps/mobile/eas.json | 构建 profiles(e2e-ios-simulator、e2e-android 等) |
| apps/mobile/e2e/README.md | E2E 环境要求、命令与可选环境变量说明 |
| apps/mobile/e2e/artifacts/ | Maestro 调试产物与截图证据目录 |
说明:原文档中还引用了作者本机路径
/Users/diygod/Code/Projects/follow-server(本地 follow-server 仓库)与/Users/diygod/.agents/skills/axe/SKILL.md(iOS 模拟器交互的外部 skill)。这两处是执行环境的本地资源,不属于本仓库内容,实际使用时需按各自机器替换。
默认假设:三个“未指定时的选择”
Skill 首先固定了三组默认值,避免每次验证都要来回确认:
- 平台:默认 iOS 模拟器,除非用户明确要求 Android 或改动本身是 Android 特有的;
- API 模式:用户未指定时默认 prod API 模式;但如果任务涉及本地 server 改动、后端调试或修改了 follow-server 仓库内的文件,则默认 local 模式;
- 语言:保持
EXPO_PUBLIC_E2E_LANGUAGE=en。现有 Maestro 流程(如 android/register.yaml 中依赖register-email-input、onboarding-next等 id 与英文界面状态)都假设英文 UI,换语言会导致断言失效。
模拟器/模拟器隔离规则(覆盖基线 Skill 的设备选择)
这是 self-test 相对 mobile-e2e 的第一处硬性覆盖。多 Agent 共用一台开发机时,“抢别人已启动的模拟器”会导致构建目标漂移和截图串设备。规则是:
- 每次运行创建一个专用临时模拟器/AVD,创建后立即记录名称与标识(iOS 的 UDID / Android 的 serial),后续 build、install、launch、截图、Maestro 只允许使用这个已存储的标识;
- 在启动设备之前就注册清理(
trap ... EXIT),保证即使测试中途失败设备也会被删除; - 若最终清理失败,必须在收尾回复中报告残留设备的名称与标识。
与 run-maestro.sh 的默认行为对比可以印证这一点:脚本里的 resolve_ios_device 在未设置 MAESTRO_IOS_DEVICE_ID 时会从 xcrun simctl list devices booted 里任选一个已启动设备(脚本第 17-24 行)——这正是不适用于 self-test 的“共享设备”路径,因此 Skill 要求先通过 MAESTRO_IOS_DEVICE_ID(iOS)/ MAESTRO_ANDROID_DEVICE_ID(Android)显式锁定自己创建的设备。
先决策 API 模式:prod vs local
决策顺序是严格的:
- 用户明确说了
prod或local→ 照做; - 否则,任务依赖本地后端改动/本地 server 行为 →
local; - 否则 →
prod。
选定的模式要编译进 Release 包,通过 Expo 的公开环境变量注入:
prod模式:EXPO_PUBLIC_E2E_ENV_PROFILE=prodlocal模式:EXPO_PUBLIC_E2E_ENV_PROFILE=local
关键纪律:在 prod 与 local 之间切换时必须重新构建 Release 应用,不得静默复用另一个模式的构建产物。从 eas.json 的 e2e-ios-simulator / e2e-android profiles 可以看到,这两个 profile 默认注入 EXPO_PUBLIC_E2E_ENV_PROFILE=prod 与 EXPO_PUBLIC_E2E_LANGUAGE=en,也就是说 profile 本身带的是 prod 默认值,切 local 时必须用环境变量显式覆盖。
必做前置检查:doctor + typecheck
从仓库根目录执行:
cd apps/mobile
pnpm run e2e:doctor
pnpm run typecheck
对应 apps/mobile/package.json 中的两个 script:
e2e:doctor:对e2e/flows/shared/*.yaml、e2e/flows/android/*.yaml、e2e/flows/ios/*.yaml逐一执行maestro check-syntax,即只做流程语法体检,不跑流程;typecheck:tsc --noEmit。
这两条任何一条失败都要停下来报告阻塞点,而不是带病进入模拟器阶段——后面每一分钟模拟器/构建时间都比这一步贵得多。
Local 模式:启动并复用本地 follow-server
local 模式要求本地 server 可用在 http://localhost:3000。启动前必须先探测是否已在运行,绝不启动第二个实例:
FOLLOW_SERVER_LOG=/tmp/follow-server-dev-core.log
if pgrep -af "pnpm dev:core" >/dev/null 2>&1 || lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then
echo "follow-server already running"
else
(
cd /Users/diygod/Code/Projects/follow-server # 作者本机的 follow-server 仓库路径,按各自环境替换
nohup pnpm dev:core >"$FOLLOW_SERVER_LOG" 2>&1 &
)
fi
for _ in $(seq 1 60); do
nc -z 127.0.0.1 3000 >/dev/null 2>&1 && break
sleep 2
done
nc -z 127.0.0.1 3000 >/dev/null 2>&1
逻辑是双通道探测(pgrep 查 pnpm dev:core 进程 + lsof 查 3000 端口 LISTEN),任一命中即复用;否则 nohup 后台启动,随后最多 60 次、每次 2 秒的 nc -z 端口轮询(合计约 120 秒)等待就绪。
另外两点边界说明:
- 如果任务还依赖其他本地服务(例如
http://localhost:2233这类其他前端表面),必须明确指出来,而不能假装移动端测试已经覆盖了它们; - 失败处理中有一条硬规则:local 模式连不上本地 server 时,不许静默回退到 prod——回退会让“后端改动的验证”变成假通过。
Release 构建 profiles:测试必须贴近用户拿到的包
自测要用 release 形态的构建(而不是 Expo development client),因为要验证的是用户可感知的行为:
- iOS 模拟器构建:
PROFILE=e2e-ios-simulator - Android 模拟器构建:
PROFILE=e2e-android
并始终与选定的 API 模式、语言配套:
export EXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local>
export EXPO_PUBLIC_E2E_LANGUAGE=en
对照 eas.json 中这两个 profile 的定义,可以看到它们都 extends 了 preview 分布形态(internal 分发),iOS 侧额外开启 ios.simulator: true,并在 env 中固化 PROFILE、EXPO_PUBLIC_E2E_ENV_PROFILE(默认 prod)、EXPO_PUBLIC_E2E_LANGUAGE(默认 en)——这解释了为什么 Skill 强调“切换 API 模式要重建”:profile 的默认 env 是 prod,local 只能靠外部变量覆盖后重新打包。
iOS 工作流:临时模拟器全生命周期
创建专用临时模拟器
选择最新可用 iOS runtime 和较新一代的 iPhone 设备类型(从 xcrun simctl list runtimes / xcrun simctl list devicetypes 中选取,与 mobile-e2e “偏向最新机型 + 最新 runtime”的偏好一致):
IOS_SIM_NAME="CodexSelfTest-$(date +%Y%m%d-%H%M%S)"
IOS_RUNTIME_ID="<latest available iOS runtime identifier from `xcrun simctl list runtimes`>"
IOS_DEVICE_TYPE_ID="<recent iPhone device type identifier from `xcrun simctl list devicetypes`>"
IOS_UDID="$(xcrun simctl create "$IOS_SIM_NAME" "$IOS_DEVICE_TYPE_ID" "$IOS_RUNTIME_ID")"
cleanup_ios_simulator() {
xcrun simctl shutdown "$IOS_UDID" >/dev/null 2>&1 || true
xcrun simctl delete "$IOS_UDID" >/dev/null 2>&1 || true
}
trap cleanup_ios_simulator EXIT
IOS_UDID 一经创建,后续不得再切换到任何其他模拟器。
启动专用模拟器
xcrun simctl boot "$IOS_UDID"
xcrun simctl bootstatus "$IOS_UDID" -b
open -a Simulator --args -CurrentDeviceUDID "$IOS_UDID"
若其他模拟器已经处于启动状态,原地不动,只操作自己的 IOS_UDID。bootstatus -b 是阻塞式等待启动完成的官方方式,避免“刚 boot 就 install 导致安装失败”的竞态。
构建 Release 模拟器应用
cd apps/mobile/ios
pod install
PROFILE=e2e-ios-simulator \
EXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local> \
EXPO_PUBLIC_E2E_LANGUAGE=en \
xcodebuild -workspace Folo.xcworkspace \
-scheme Folo \
-configuration Release \
-sdk iphonesimulator \
-destination "id=$IOS_UDID" \
clean build
Apple Silicon Mac 上,当构建产物只服务于本次自测创建的专用模拟器时,追加只编活跃 arm64 架构的参数以显著提速:
ONLY_ACTIVE_ARCH=YES \
ARCHS=arm64
这条优化在 run-maestro.sh 的 append_ios_arch_args 中有对应实现:脚本检测 uname -m 为 arm64 时自动向 xcodebuild 追加这两个参数(脚本第 117-122 行)。约束是:需要分发给其他机器的通用模拟器包、或宿主是 Intel Mac 时不能使用该优化。
预期产物路径模式:
~/Library/Developer/Xcode/DerivedData/.../Build/Products/Release-iphonesimulator/Folo.app
安装并启动
xcrun simctl install "$IOS_UDID" <PATH_TO_Folo.app>
xcrun simctl launch "$IOS_UDID" is.follow
Bundle id is.follow 与 flows/ios/register.yaml 头部的 appId: is.follow 一致。
启动后的交互层
应用在专用模拟器上跑起来后,iOS 侧的视觉验证交互默认使用外部 $axe skill(模拟器级点按/滚动工具)作为截图驱动检查的交互层。若该工具不可用,按失败处理章节的要求如实报告局限,但仍要返回已截到的图。
Android 工作流:临时 AVD 与 Release APK
创建专用临时 AVD
不复用共享模拟器,新建一个由已安装 phone 系统镜像支撑的 AVD,并扫描空闲端口对(emulator 的 console 端口与 adb 端口相邻,各占用一个):
ANDROID_AVD_NAME="codex-self-test-$(date +%Y%m%d-%H%M%S)"
ANDROID_AVD_PACKAGE="<installed Android system image package>"
ANDROID_AVD_DEVICE="<phone hardware profile>"
avdmanager create avd -n "$ANDROID_AVD_NAME" -k "$ANDROID_AVD_PACKAGE" -d "$ANDROID_AVD_DEVICE" --force
ANDROID_EMULATOR_PORT=""
for port in 5554 5556 5558 5560 5562 5564; do
if ! lsof -nP -iTCP:$port >/dev/null 2>&1 && ! lsof -nP -iTCP:$((port + 1)) >/dev/null 2>&1; then
ANDROID_EMULATOR_PORT="$port"
break
fi
done
[ -n "$ANDROID_EMULATOR_PORT" ] || {
echo "No free Android emulator port found"
exit 1
}
ANDROID_DEVICE_ID="emulator-$ANDROID_EMULATOR_PORT"
cleanup_android_emulator() {
adb -s "$ANDROID_DEVICE_ID" emu kill >/dev/null 2>&1 || true
avdmanager delete avd -n "$ANDROID_AVD_NAME" >/dev/null 2>&1 || true
}
trap cleanup_android_emulator EXIT
启动专用模拟器
emulator @"$ANDROID_AVD_NAME" -port "$ANDROID_EMULATOR_PORT" -no-snapshot -wipe-data &
adb -s "$ANDROID_DEVICE_ID" wait-for-device
-no-snapshot -wipe-data 保证冷启动、无残留数据,与“干净验收”的目标一致。其他已启动的模拟器直接无视。Java/SDK 环境(Android Studio 自带 JBR、ANDROID_HOME、缺失时的 local.properties)沿用 mobile-e2e 的设置。
如果本地还没有 apps/mobile/android 目录,先生成原生工程:
cd apps/mobile
pnpm expo prebuild android
构建 Release APK
cd apps/mobile/android
PROFILE=e2e-android \
EXPO_PUBLIC_E2E_ENV_PROFILE=<prod-or-local> \
EXPO_PUBLIC_E2E_LANGUAGE=en \
./gradlew clean app:assembleRelease --console=plain
预期 APK 路径:
apps/mobile/android/app/build/outputs/apk/release/app-release.apk
安装并启动
adb -s "$ANDROID_DEVICE_ID" install -r apps/mobile/android/app/build/outputs/apk/release/app-release.apk
adb -s "$ANDROID_DEVICE_ID" shell monkey -p is.follow -c android.intent.category.LAUNCHER 1
monkey ... LAUNCHER 1 是标准的“拉起应用主 Activity”技巧。
清理是强制项
返回控制权给用户之前,必须删除本次运行创建的设备,不给其他 Agent 留垃圾:
iOS:
xcrun simctl shutdown "$IOS_UDID" >/dev/null 2>&1 || true
xcrun simctl delete "$IOS_UDID" >/dev/null 2>&1 || true
Android:
adb -s "$ANDROID_DEVICE_ID" emu kill >/dev/null 2>&1 || true
avdmanager delete avd -n "$ANDROID_AVD_NAME" >/dev/null 2>&1 || true
由于创建阶段已经用 trap ... EXIT 注册了清理,正常与异常退出路径都会触发;收尾报告中要写明“清理结果”,若失败则附残留设备的名称与标识。
认证策略:Maestro 只做引导,视觉验证做结论
这是 self-test 与 mobile-e2e 的核心分界。按改动是否触碰认证链路二选一:
A. 改动与登录/注册无关
先用现成的自动化注册流程引导出一个干净的登录态账号,之后再做真正的视觉验证。典型场景:时间线行为、订阅管理、认证后的 onboarding 内容、与登录态无关的设置页、播放器/阅读器/分享/发现/资料编辑等。
先生成唯一测试账号:
export E2E_PASSWORD='Password123!'
export E2E_EMAIL="folo-self-test-$(date +%Y%m%d%H%M%S)@example.com"
非认证类 iOS 自测的默认路径是走标准 runner 的 bootstrap 模式(应用需已安装并至少启动过一次):
cd apps/mobile
pnpm run e2e:ios:bootstrap
对应 package.json 中 e2e:ios:bootstrap: sh ./e2e/run-maestro.sh ios bootstrap-auth。从 run-maestro.sh 的 run_ios_bootstrap_auth(第 145-157 行)可以看到其分派逻辑:当 EXPO_PUBLIC_E2E_ENV_PROFILE 为 prod 或 local 时,调用 e2e:bootstrap:ios:prod-auth(即 scripts/e2e-prod-ios-auth-bootstrap.ts 辅助脚本);其他环境值则回退为直接跑 Maestro 注册流程。e2e/README.md 进一步说明:该 bootstrap 脚本通过移动端 fallback token 头对 prod 登录,把 auth cookie 写入模拟器里的 ExpoSQLiteStorage fallback store,然后重启应用——因此比完整走 UI 注册快得多,且同样适用于 local 模式。
只有当被测功能本身就是登录、注册、登出、会话恢复或别的认证专属流程(必须端到端视觉验证)时,才跳过 bootstrap。
也可以直接调用 Maestro 执行注册流程(iOS 版本):
cd apps/mobile
maestro test --format junit --platform ios --device "$IOS_UDID" \
--debug-output e2e/artifacts/ios/register-bootstrap \
-e E2E_EMAIL="$E2E_EMAIL" \
-e E2E_PASSWORD="$E2E_PASSWORD" \
e2e/flows/ios/register.yaml
Android 版本:
cd apps/mobile
maestro test --format junit --platform android --device "$ANDROID_DEVICE_ID" \
--debug-output e2e/artifacts/android/register-bootstrap \
-e E2E_EMAIL="$E2E_EMAIL" \
-e E2E_PASSWORD="$E2E_PASSWORD" \
e2e/flows/android/register.yaml
两个注册流程的实现值得一看:ios/register.yaml 很短——等待并断言 login-screen 出现后 runFlow: ../shared/register.yaml,把具体表单操作委托给 shared/register.yaml;而 android/register.yaml 则是完整表单流:先跑 dismiss-system-dialogs.yaml 清系统弹窗,再 open-auth.yaml 打开认证页,点击 login-provider-credential,等待 register-email-input 出现,用 setClipboard + pasteText 依次填入 ${E2E_EMAIL} / ${E2E_PASSWORD}(剪贴板粘贴而非逐字输入,避开输入法差异),回车提交,条件性地连点 4 次 onboarding-next 跳过引导页,最后等待 timeline-entry-first 出现(30s 超时)——到达时间线首条 entry 即证明登录态已建立。注册成功后,切换到截图驱动的视觉测试。
B. 改动就是登录/注册/登出/会话本身
此时不得依赖现成的 Maestro 认证流程做最终验证——否则测的是“旧流程还能过”,而不是“被改的 UX 本身”。必须全视觉/手工执行:
- 需要测试账号时通过 UI 手动创建;
- 每个关键步骤后截图;
- 视觉上确认成功态与错误态;
- 完整记录展示给用户的屏幕顺序。
典型场景:注册屏/登录屏改动、凭证校验改动、认证开关、登出行为、auth/session 恢复、依赖 auth 状态显示/隐藏的 onboarding 门槛。
截图驱动的视觉测试:截图是唯一验收事实
应用进入目标状态后,剩余验证全部由视觉驱动:iOS 用 $axe 作为默认交互工具,Android 用当前环境可用的交互工具。先建带时间戳的产物目录:
REPO_ROOT="$(git rev-parse --show-toplevel)"
ARTIFACT_DIR="$REPO_ROOT/apps/mobile/e2e/artifacts/manual/$(date +%Y%m%d-%H%M%S)-<platform>-<prod-or-local>"
mkdir -p "$ARTIFACT_DIR"
每个有意义的检查点之后截图。平台命令:
iOS:
xcrun simctl io "$IOS_UDID" screenshot "$ARTIFACT_DIR/<name>.png"
Android:
adb -s "$ANDROID_DEVICE_ID" exec-out screencap -p > "$ARTIFACT_DIR/<name>.png"
一次完整自测的最少截图集是 3 张:
- 变更流程之前的入口屏;
- 正在变化中的屏幕/交互;
- 最终成功态,或复现出的 bug 态。
流程有多个重要状态时加拍。没有截图证据不许报告成功。
视觉验证清单、收尾输出与失败处理
截图上要确认的点(如相关)
- 到达了正确的屏幕;
- 变更的控件、文案或布局可见;
- loading / empty / error / success 各状态看起来正确;
- 操作完整结束,无明显回归或阻塞弹窗;
- 应用连接的是预期环境(
prod或local)。
UI 或行为有歧义时,再截一张图,而不是靠猜。
最终回复必须包含
- 使用了哪个 API 模式及选择原因;
- 平台、专用模拟器/AVD 的名称与标识;
- 临时设备的清理结果;
- 本地 server 是复用还是新启动,若启动则给出日志路径(如
/tmp/follow-server-dev-core.log); - 使用的构建命令;
- 认证引导是自动化的还是全视觉的;
- 逐步结果摘要与 pass/fail 结论;
- 带绝对文件路径的截图证据(客户端支持时直接把关键截图作为图片附上,否则列出绝对路径)。
失败处理边界
- doctor、typecheck、build、install、server 启动任一失败 → 停下,报告确切失败的命令;
local模式连不上本地 server → 不得静默回退prod;- iOS 视觉流程因
$axe不可用无法完成 → 明确报告该局限,并仍返回已捕获的截图; - Android 视觉流程因环境缺少合适的交互工具无法完成 → 同样明确报告并返回已有截图。
小结:这套自测流程的可迁移要点
把 mobile-self-test Skill 抽象出来,是四条可复用的工程纪律:设备隔离(每次运行专用临时设备 + trap 清理,杜绝多 Agent 互相污染);构建即环境(API 模式与语言编译进 Release 包,模式切换必须重建);自动化只做引导,结论交给证据(Maestro 负责登录态 bootstrap,验收以“入口—过程—终态”三类截图为准);失败不静默(doctor 阻塞即停、local 连不上不回退 prod、工具缺失要申报并保留已有截图)。配合 run-maestro.sh 的设备解析与 app bundle 解析、eas.json 的 e2e-* profiles、flows 目录 的共享注册流程,这套流程可以直接作为 apps/mobile 任何功能改动落地前的标准验收动作。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00