首页
/ Folo 移动端自动化自测方案:mobile-self-test Skill 的隔离模拟器、双模式构建与截图取证实战

Folo 移动端自动化自测方案:mobile-self-test Skill 的隔离模拟器、双模式构建与截图取证实战

2026-09-05 22:45:59作者:霍妲思

本文以 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-simulatore2e-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 首先固定了三组默认值,避免每次验证都要来回确认:

  1. 平台:默认 iOS 模拟器,除非用户明确要求 Android 或改动本身是 Android 特有的;
  2. API 模式:用户未指定时默认 prod API 模式;但如果任务涉及本地 server 改动、后端调试或修改了 follow-server 仓库内的文件,则默认 local 模式
  3. 语言:保持 EXPO_PUBLIC_E2E_LANGUAGE=en。现有 Maestro 流程(如 android/register.yaml 中依赖 register-email-inputonboarding-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

决策顺序是严格的:

  1. 用户明确说了 prodlocal → 照做;
  2. 否则,任务依赖本地后端改动/本地 server 行为 → local
  3. 否则 → prod

选定的模式要编译进 Release 包,通过 Expo 的公开环境变量注入:

  • prod 模式:EXPO_PUBLIC_E2E_ENV_PROFILE=prod
  • local 模式:EXPO_PUBLIC_E2E_ENV_PROFILE=local

关键纪律:在 prod 与 local 之间切换时必须重新构建 Release 应用,不得静默复用另一个模式的构建产物。从 eas.jsone2e-ios-simulator / e2e-android profiles 可以看到,这两个 profile 默认注入 EXPO_PUBLIC_E2E_ENV_PROFILE=prodEXPO_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/*.yamle2e/flows/android/*.yamle2e/flows/ios/*.yaml 逐一执行 maestro check-syntax,即只做流程语法体检,不跑流程;
  • typechecktsc --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

逻辑是双通道探测(pgreppnpm 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 的定义,可以看到它们都 extendspreview 分布形态(internal 分发),iOS 侧额外开启 ios.simulator: true,并在 env 中固化 PROFILEEXPO_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_UDIDbootstatus -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.shappend_ios_arch_args 中有对应实现:脚本检测 uname -marm64 时自动向 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.followflows/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.jsone2e:ios:bootstrap: sh ./e2e/run-maestro.sh ios bootstrap-auth。从 run-maestro.shrun_ios_bootstrap_auth(第 145-157 行)可以看到其分派逻辑:当 EXPO_PUBLIC_E2E_ENV_PROFILEprodlocal 时,调用 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 张:

  1. 变更流程之前的入口屏;
  2. 正在变化中的屏幕/交互;
  3. 最终成功态,或复现出的 bug 态。

流程有多个重要状态时加拍。没有截图证据不许报告成功。

视觉验证清单、收尾输出与失败处理

截图上要确认的点(如相关)

  • 到达了正确的屏幕;
  • 变更的控件、文案或布局可见;
  • loading / empty / error / success 各状态看起来正确;
  • 操作完整结束,无明显回归或阻塞弹窗;
  • 应用连接的是预期环境(prodlocal)。

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.jsone2e-* profiles、flows 目录 的共享注册流程,这套流程可以直接作为 apps/mobile 任何功能改动落地前的标准验收动作。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395