maestro-mobile-testing by tovimx/maestro-mobile-testing-skill
npx skills add https://github.com/tovimx/maestro-mobile-testing-skill --skill maestro-mobile-testingMaestro 是一个基于声明式 YAML 的移动端端到端测试框架。它提供自动等待、内置重试逻辑和快速执行,无需样板代码。对于 React Native 应用,它比 Detox 或 Appium 更稳定。
sleep() 或不稳定的等待maestro studio)curl -Ls "https://get.maestro.mobile.dev" | bash
brew install openjdk@17
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
appId: com.myapp
---
- launchApp
- tapOn:
id: "my-button"
- assertVisible: "Expected Text"
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
maestro test .maestro/smoke-test.yaml
maestro test --debug .maestro/smoke-test.yaml # 逐步执行
maestro studio # 交互式构建器
根据项目上下文选择你的选择器方法。两者都有效——正确的选择取决于你的应用是否支持多语言以及团队的测试理念。
| 上下文 | 推荐选择器 | 理由 |
|---|---|---|
| 多语言 / i18n | id: (testID) | 在不同翻译版本间保持稳定 |
| 单一语言 | 文本标签 | 人类可读,测试自文档化 |
| 由代理维护的测试 | 两者皆可 — 询问开发者 | 对于 AI 维护的流程,可读性不那么重要 |
| 系统对话框 | 文本 (总是) | 原生警告框上不可能有 testID |
# testID 选择器 — 在不同翻译版本间保持稳定
- tapOn:
id: "submit-button"
# 文本选择器 — 人类可读,自文档化
- tapOn: "Submit"
何时优先使用 testID:
何时优先使用文本选择器:
在 React Native 中,使用基于 ID 的选择器时添加 testID 属性:
<TouchableOpacity testID="submit-button" onPress={handleSubmit}>
<Text>{t('submit')}</Text>
</TouchableOpacity>
使用基于 ID 的选择器时:
{组件}-{动作/类型}[-{变体}]
示例:
- auth-prompt-login-button
- product-card-{id}
- otp-input-0
- tab-home
- dashboard-loading
防止 Maestro 在认证状态解析之前与 UI 交互导致的竞态条件。添加一个零尺寸的 auth-loaded 标记,仅在认证加载完成后渲染:
// 在你的标签栏或根布局中
{!isLoading && <View testID="auth-loaded" style={{ width: 0, height: 0 }} />}
然后在每个测试中:
- launchApp
# 防止冷启动时 XCTest 崩溃 (iOS)
- swipe:
direction: DOWN
duration: 100
# 等待认证状态解析
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
# 现在可以安全交互了
- tapOn:
id: "tab-home"
无论用户是否已认证,测试都应正常工作:
# 认证流程 — 仅在登录提示可见时运行
- runFlow:
when:
visible: "Sign In"
file: flows/auth-flow.yaml
# 已认证 — 直接继续
- runFlow:
when:
visible:
id: "tab-home"
file: flows/authenticated-action.yaml
使用短超时来验证 UI 更改发生在服务器响应之前:
# 触发变更
- tapOn:
id: "action-button"
# 乐观更新:UI 必须在 3 秒内更改 (不等待服务器)
- extendedWaitUntil:
visible:
id: "undo-button"
timeout: 3000
# 验证派生的 UI 状态
- extendedWaitUntil:
visible:
id: "user-indicator"
timeout: 5000
| 动作 | 预期更改 | 超时 |
|---|---|---|
| 触发变更 | 按钮状态翻转 | < 3s |
| 列表更新 | 项目出现/消失 | < 5s |
| 重新执行动作 | 证明持久性 | < 3s |
React Native 的 Alert.alert() 会创建阻塞 UI 的原生对话框:
- tapOn:
id: "action-button"
# 首先等待预期的状态更改
- extendedWaitUntil:
visible:
id: "new-state-element"
timeout: 5000
# 关闭警告框 (如果已经关闭,此步骤可选)
- tapOn:
text: "OK"
optional: true
# 为警告框动画添加短暂延迟
- swipe:
direction: DOWN
duration: 300
将重复的序列拆分为子流程文件:
.maestro/
├── flows/
│ ├── auth-and-return.yaml
│ ├── complete-purchase.yaml
│ └── verify-result.yaml
├── smoke-test.yaml
└── feature-test.yaml
# 在主测试中
- runFlow:
file: flows/auth-and-return.yaml
使用 app.json 中的 Expo 方案,而不是 bundle ID:
# 错误
- openLink: "com.myapp://profile/settings"
# 正确
- openLink: "myapp://profile/settings"
深度链接必须在应用的深度链接处理器中注册。未注册的路由会静默失败。
- runFlow:
when:
platform: ios
file: flows/ios-specific.yaml
- runFlow:
when:
platform: android
file: flows/android-specific.yaml
appId: com.myapp
env:
TEST_EMAIL: maestro-test@example.com
API_BASE_URL: http://localhost:3000
---
- inputText: ${TEST_EMAIL}
使用 enabled、selected、checked 和 focused 来根据元素的当前状态定位元素。这对于在动作前后验证交互元素的状态非常有用。
# 仅当提交按钮启用时才点击它
- tapOn:
id: "submit-button"
enabled: true
# 断言复选框被选中
- assertVisible:
id: "terms-checkbox"
checked: true
# 等待输入框获得焦点
- extendedWaitUntil:
visible:
id: "email-input"
focused: true
timeout: 3000
| 属性 | 值 | 使用场景 |
|---|---|---|
enabled | true / false | 在提交期间或表单无效时禁用的按钮 |
checked | true / false | 复选框,切换开关 |
selected | true / false | 标签项,分段控件 |
focused | true / false | 自动获得焦点的输入字段 |
通过元素与其他元素的空间关系来区分相似元素。这比基于索引的选择更符合习惯且更具弹性。
# 不好 — 脆弱,顺序改变会失效
- tapOn:
text: "Add to Basket"
index: 1
# 好 — 上下文相关,自文档化
- tapOn:
text: "Add to Basket"
below:
text: "Awesome Shoes"
可用的相对选择器:
# 目标元素在另一个元素下方
- tapOn:
text: "Buy Now"
below: "Product Title"
# 目标元素是某个父元素的子元素
- tapOn:
text: "Delete"
childOf:
id: "item-card-42"
# 目标父元素包含特定的子元素
- tapOn:
containsChild: "Urgent"
# 通过多个后代元素定位
- tapOn:
containsDescendants:
- id: title_id
text: "Specific Title"
- "Another descendant text"
# 水平定位
- tapOn:
text: "Edit"
rightOf: "Username"
| 选择器 | 含义 |
|---|---|
below: | 元素位于参考元素下方 |
above: | 元素位于参考元素上方 |
leftOf: | 元素位于参考元素左侧 |
rightOf: | 元素位于参考元素右侧 |
childOf: | 元素是参考父元素的直接子元素 |
containsChild: | 元素包含与参考匹配的直接子元素 |
containsDescendants: | 元素包含所有指定的后代元素 |
在端到端测试中测试 OTP 或魔法链接认证需要以编程方式捕获电子邮件。通用模式:
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Maestro │────▶│ 认证 │────▶│ 邮件捕获 │
│ 测试 │ │ 提供商 │ │ 服务 │
└─────────────┘ └──────────────┘ └─────────────────┘
│ │
│ ┌──────────────────────────────┘
│ ▼
│ ┌─────────────┐
└──▶│ REST API │ ─── GET /api/v1/messages
│ (邮件) │ ─── 提取 OTP 代码
└─────────────┘
使用 Maestro 的 GraalJS 运行时从你的邮件捕获服务获取 OTP 代码:
// 关键:Maestro 使用 GraalJS — 不支持 async/await,不支持 fetch()
var email = typeof EMAIL !== "undefined" ? EMAIL : "test@example.com";
var emailServiceUrl = typeof EMAIL_SERVICE_URL !== "undefined"
? EMAIL_SERVICE_URL : "http://localhost:8025";
var response = http.get(emailServiceUrl + "/api/v1/messages");
if (!response.ok) {
throw new Error("Failed to fetch emails: " + response.status);
}
var data = json(response.body);
// 查找最新的邮件并提取 OTP 代码
var body = data.messages[0].Content.Body;
var match = body.match(/(\d{6})/);
output.OTP_CODE = match[1];
具有自动对焦功能的 OTP 组件需要逐个数字输入。在输入前点击每个输入框:
# 通过辅助脚本将 OTP 拆分为数字
- runScript:
file: scripts/split-otp.js
env:
OTP_CODE: ${output.OTP_CODE}
# 通过点击其输入框来输入每个数字
- tapOn:
id: "otp-input-0"
- inputText: ${output.OTP_0}
- tapOn:
id: "otp-input-1"
- inputText: ${output.OTP_1}
# ... 对所有数字重复
对于特定提供商的实现 (Supabase + Mailpit、Firebase Auth、Auth0),创建一个项目级别的技能来扩展此技能。
Maestro 使用 GraalJS 运行时。这些约束是不可协商的:
| 特性 | 状态 |
|---|---|
async/await | 不支持 |
fetch() | 不支持 |
http.get(), http.post() | 使用这些替代 |
json() | 用于解析响应体 |
output.VAR | 设置变量以供 YAML 流程中使用 |
var 声明 | 必需 (为安全起见使用 var,而不是 const/let) |
// 脚本模板
var response = http.get("http://localhost:8025/api/endpoint");
if (!response.ok) {
throw new Error("Request failed: " + response.status);
}
var data = json(response.body);
output.RESULT = data.value;
clearState: true 会清除应用沙盒 (UserDefaults、文件、缓存),但不会清除 iOS 钥匙串。通过 expo-secure-store (或任何基于钥匙串的存储) 存储的认证令牌会在 clearState 重置甚至应用重新安装后持续存在。
# 错误 — 用户可能仍然处于认证状态
- launchApp:
clearState: true
- assertVisible: "Welcome" # 如果钥匙串中有令牌,此断言会失败
# 正确 — 等待认证解析,然后自适应处理
- launchApp
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
规则:
clearState 在 iOS 上产生访客状态clearState,使用 auth-loaded 预检clearState 之后永远不要断言仅限访客的 UI注意: 在 Android 上,clearState: true 会完全重置应用数据,包括凭据。这是仅限 iOS 的陷阱。
如果 Maestro 在冷启动后的第一个渲染周期完成之前与无障碍树交互,XCTest 驱动可能会崩溃。
修复: 在 launchApp 后立即添加一个无操作滑动:
- launchApp
- swipe:
direction: DOWN
duration: 100
调用本地主机后端 API 的移动应用需要运行完整的服务器或模拟服务器。如果没有,所有依赖 API 的屏幕都会显示加载指示器或空状态 (查询会静默失败)。
修复: 在运行 Maestro 测试之前启动一个模拟 API 服务器:
# 启动模拟服务器 (在你的 API 端口上提供预设响应)
npx tsx scripts/mock-api-server.ts &
# 然后运行测试
maestro test .maestro/my-test.yaml
创建一个轻量级的模拟服务器,为你的应用调用的每个端点返回预设的 JSON。这比运行完整的后端更快、更确定。
为访客和认证用户显示不同标签的标签栏将导致选择器失败:
| 状态 | 典型标签 |
|---|---|
| 访客 | 首页、搜索、购物车、个人资料 |
| 已认证 | 首页、动态、创建、消息、个人资料 |
仅断言两种状态下都存在的标签,或使用自适应的 when: 条件。
# {功能} {动作} 测试
#
# 测试:{此测试验证的内容}
# 先决条件:
# - 模拟器/仿真器正在运行且已安装应用
# - 后端或模拟服务器正在运行 (如果依赖 API)
appId: com.myapp
env:
TEST_EMAIL: maestro-{feature}@example.com
EMAIL_SERVICE_URL: http://localhost:8025
---
# ==========================================
# 步骤 1: 启动 + 认证预检
# ==========================================
- launchApp
- swipe:
direction: DOWN
duration: 100
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
- takeScreenshot: 01-初始状态
# ==========================================
# 步骤 2: {动作}
# ==========================================
- tapOn:
id: "target-element"
# ==========================================
# 步骤 3: 验证
# ==========================================
- extendedWaitUntil:
visible:
id: "expected-result"
timeout: 5000
- takeScreenshot: 02-最终状态
.maestro/
├── README.md # 快速参考 + testID 清单
├── config.yaml # 共享配置
├── flows/ # 可重用的子流程
│ ├── auth-and-return.yaml
│ ├── complete-action.yaml
│ └── verify-result.yaml
├── scripts/ # GraalJS 辅助脚本
│ ├── fetch-otp.js
│ └── split-otp.js
├── smoke-test.yaml # 访客导航
├── auth-signin.yaml # OTP 登录流程
├── feature-screenshots.yaml # 截图捕获流程
└── feature-action.yaml # 功能特定测试
scripts/
├── mock-api-server.ts # 用于端到端测试的轻量级模拟服务器
└── run-e2e.sh # 编排脚本
| 类型 | 模式 | 示例 |
|---|---|---|
| 主测试 | {功能}-{动作}.yaml | checkout-purchase.yaml |
| 子流程 | {动作}-{上下文}.yaml | auth-and-return-to-dashboard.yaml |
| 脚本 | {动词}-{名词}.js | fetch-otp.js |
创建一个轻量级的模拟服务器,为你的 API 层提供预设响应。这比在端到端测试期间运行完整的后端更快、更确定。
# 在运行 Maestro 测试之前启动模拟服务器
npx tsx scripts/mock-api-server.ts &
使用一个 shell 脚本自动化完整的端到端设置,该脚本:
bash scripts/run-e2e.sh
依赖于特定数据的测试需要已植入数据的数据库。将种子脚本与你的测试基础设施放在一起,并在每个测试套件运行前执行它们。
Android 测试需要仿真器或通过 USB 连接的物理设备。Maestro 会自动检测连接的设备。
# 列出可用的系统镜像
sdkmanager --list | grep system-images
# 创建仿真器
avdmanager create avd -n maestro_test \
-k "system-images;android-34;google_apis;arm64-v8a"
# 启动仿真器
emulator -avd maestro_test
| 方面 | iOS | Android |
|---|---|---|
| 设备类型 | 仅模拟器 (无物理设备) | 仿真器 + 通过 ADB 连接的物理设备 |
clearState | 不会清除钥匙串 | 完全重置应用数据 |
| 冷启动崩溃 | XCTest kAXError (添加滑动延迟) | 无等效问题 |
| 性能 | 原生运行 (快) | ARM 仿真 (在 x86 上较慢) |
| 权限对话框 | 系统警告框 | 具有不同文本的系统对话框 |
adb devices # 列出连接的设备
adb shell am start -n com.myapp/.MainActivity # 启动应用
adb logcat | grep Maestro # 过滤 Maestro 日志
adb shell input keyevent 82 # 解锁屏幕
Android 权限以系统对话框形式出现。使用可选点击来关闭:
- tapOn:
text: "Allow"
optional: true
- tapOn:
text: "While using the app"
optional: true
Maestro Cloud 在 CI 中提供真机测试,无需本地模拟器。使用官方 action:
name: Mobile E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
maestro-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# 构建 Android APK
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build Android APK
run: |
cd apps/mobile
npx expo prebuild --platform android --no-install
cd android && ./gradlew assembleRelease
- name: Run Maestro Cloud Tests
id: maestro
uses: mobile-dev-inc/action-maestro-cloud@v2
with:
api-key: ${{ secrets.MAESTRO_API_KEY }}
app-file: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
workspace: .maestro
include-tags: ci
# 访问结果
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_CONSOLE_URL }}
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_UPLOAD_STATUS }}
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_FLOW_RESULTS }}
使用标签来控制哪些测试在 CI 中运行,哪些在本地运行:
# 在你的流程文件头部
appId: com.myapp
tags:
- ci
- smoke
---
- launchApp
# ... 测试步骤
# 仅在本地运行带有 CI 标签的流程
maestro test --include-tags ci .maestro/
# 排除进行中的流程
maestro test --exclude-tags wip .maestro/
FROM openjdk:17-slim
RUN curl -Ls "https://get.maestro.mobile.dev" | bash
ENV PATH="/root/.maestro/bin:${PATH}"
COPY .maestro/ /app/.maestro/
WORKDIR /app
CMD ["maestro", "test", ".maestro/"]
注意: iOS 测试无法在 Docker 中运行 (需要 macOS)。在 CI 中使用 Maestro Cloud 进行 iOS 测试。
Maestro Cloud 在真机上运行测试,无需本地模拟器设置。
MAESTRO_API_KEY 密钥# 上传并在 Maestro Cloud 上运行
maestro cloud --api-key $MAESTRO_API_KEY \
--app-file ./app-release.apk \
.maestro/
# 使用标签过滤
maestro cloud --api-key $MAESTRO_API_KEY \
--app-file ./app-release.apk \
--include-tags smoke \
.maestro/
MAESTRO_CLOUD_CONSOLE_URL、MAESTRO_CLOUD_FLOW_RESULTSMaestro MCP 服务器 将 Maestro 的完整命令集作为模型上下文协议工具公开,让 AI 代理能够直接执行测试并与设备交互——而不仅仅是编写 YAML。
| 此技能 | Maestro MCP
---|---|---
角色 | 教授正确的模式 | 提供运行时执行
层级 | 创作 (编写好的 YAML) | 执行 (运行、点击、断言、截图)
输出 | 更好的测试文件 | 实时设备交互
两者结合使用:此技能确保 AI 编写正确的测试;MCP 让它能够立即运行测试,查看失败并迭代。
MCP 随 Maestro CLI 一起提供 — 无需额外安装:
# 验证其可用性
maestro mcp
Claude Code — 添加到项目 .mcp.json 或全局设置:
{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}
Claude Desktop — 添加到 ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}
在 Cursor、Windsurf、VS Code 和 JetBrains IDE 中也受支持。有关 IDE 特定设置,请参阅 Maestro MCP 文档。
MCP 服务器按类别公开了 47 个工具:
| 类别 | 工具 | 示例 |
|---|---|---|
| UI 交互 | 点击、滑动、滚动、长按 | tapOn、scrollUntilVisible |
| 文本输入 | 输入、擦除、粘贴、复制 | inputText、eraseText |
| 断言 | 可见性、AI 驱动 | assertVisible、assertWithAI |
| 应用生命周期 | 启动、停止、清除状态 | launchApp、clearState |
| 设备控制 | 位置、方向、飞行模式 | setLocation、hideKeyboard |
| 流程控制 | 运行流程、重复、评估脚本 | runFlow、evalScript |
| 媒体 | 截图、录制 | takeScreenshot、startRecording |
| AI 驱动 | 视觉断言、缺陷检测 | assertWithAI、assertNoDefectsWithAI |
当此技能和 MCP 都激活时,AI 可以:
runFlow / launchApp + 交互工具maestro test --debug .maestro/test.yaml # 交互式逐步执行
maestro record .maestro/test.yaml # 录制为视频
maestro studio # 交互式 UI 构建器
maestro hierarchy # 查看元素树
截图保存到 ~/.maestro/tests/{时间戳}/。
[ ] 唯一的测试邮箱 (maestro-{feature}@example.com)
[ ] 选择器策略已选定 (i18n 应用用 testID,单语言应用用文本 — 参见模式 1)
[ ] 选择器在相关处使用了状态属性 (enabled, checked — 参见模式 10)
[ ] 相似元素使用相对选择器区分,而非索引 (参见模式 11)
[ ] 使用了认证预检模式 (auth-loaded)
[ ] 添加了启动后滑动 (iOS 崩溃预防)
[ ] 处理了两种认证状态 (自适应流程)
[ ] 在变更后关闭了原生警告框
[ ] 为乐观更新设置了短超时 (3-5s)
[ ] 为可重用序列创建了子流程
[ ] 在关键点添加了描述性截图
[ ] 添加了带有先决条件的头部注释
[ ] 添加到 README.md 测试表中
[ ] 如果依赖后端,则启动了模拟 API 服务器
[ ] 为 CI 过滤添加了标签 (ci, smoke, wip)
| 错误 | 原因 | 修复 |
|---|---|---|
| "Unable to locate Java Runtime" | Java 不在 PATH 中 | export JAVA_HOME=/opt/homebrew/opt/openjdk@17/... |
| 点击后 "Element not found" | 原生警告框阻塞 | 添加 tapOn: text: "OK" optional: true |
| OTP 数字未输入 | 自动对焦干扰 | 使用单独的 otp-input-N testID |
| 测试通过但什么都没发生 | optional: true 误用 | 仅对真正可选的动作使用 optional |
| 可见性断言 "Assertion is false" | 元素尚未渲染 | 增加超时或验证 testID 存在 |
| 脚本输出为空 | 错误的 JS API | 使用 http.get() 而不是 fetch() |
| clearState 后认证状态不一致 | iOS 钥匙串未清除 | 不要使用 clearState,使用自适应流程 |
| kAXErrorInvalidUIElement 崩溃 | 冷启动竞态 (iOS) | 添加启动后滑动延迟 |
| 加载指示器 / 空屏幕 | 无 API 服务器运行 | 在测试前启动模拟 API 服务器 |
| 权限对话框阻塞 (Android) | 系统对话框未关闭 | 添加 tapOn: text: "Allow" optional: true |
每周安装数
95
仓库
GitHub 星标数
5
首次出现
2026年2月8日
安全审计
安装于
gemini-cli94
codex94
cursor94
opencode94
github-copilot92
amp90
Maestro is a declarative YAML-based mobile E2E testing framework. It provides automatic waiting, built-in retry logic, and fast execution without boilerplate. It's more stable than Detox or Appium for React Native apps.
sleep() or flaky waitsmaestro studio)curl -Ls "https://get.maestro.mobile.dev" | bash
brew install openjdk@17
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
appId: com.myapp
---
- launchApp
- tapOn:
id: "my-button"
- assertVisible: "Expected Text"
maestro test .maestro/smoke-test.yaml
maestro test --debug .maestro/smoke-test.yaml # step through
maestro studio # interactive builder
Choose your selector approach based on project context. Both are valid — the right choice depends on whether your app is localized and your team's testing philosophy.
| Context | Recommended Selector | Rationale |
|---|---|---|
| Multi-language / i18n | id: (testID) | Stable across translations |
| Single language | Text labels | Human-readable, self-documenting tests |
| Agent-maintained tests | Either — ask the developer | Readability matters less for AI-maintained flows |
| System dialogs | Text (always) | No testID possible on native alerts |
# testID selector — stable across translations
- tapOn:
id: "submit-button"
# Text selector — human-readable, self-documenting
- tapOn: "Submit"
When to prefer testIDs:
When to prefer text selectors:
In React Native, add testID props when using ID-based selectors:
<TouchableOpacity testID="submit-button" onPress={handleSubmit}>
<Text>{t('submit')}</Text>
</TouchableOpacity>
When using ID-based selectors:
{component}-{action/type}[-{variant}]
Examples:
- auth-prompt-login-button
- product-card-{id}
- otp-input-0
- tab-home
- dashboard-loading
Prevent race conditions where Maestro interacts with the UI before auth state resolves. Add a zero-size auth-loaded marker that only renders when auth loading completes:
// In your tab bar or root layout
{!isLoading && <View testID="auth-loaded" style={{ width: 0, height: 0 }} />}
Then in every test:
- launchApp
# Prevent XCTest crash on cold boot (iOS)
- swipe:
direction: DOWN
duration: 100
# Wait for auth state to resolve
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
# Now safe to interact
- tapOn:
id: "tab-home"
Tests should work regardless of whether the user is authenticated:
# Auth flow — only runs if login prompt is visible
- runFlow:
when:
visible: "Sign In"
file: flows/auth-flow.yaml
# Already authenticated — proceed directly
- runFlow:
when:
visible:
id: "tab-home"
file: flows/authenticated-action.yaml
Use short timeouts to verify UI changes happen before server response:
# Trigger mutation
- tapOn:
id: "action-button"
# OPTIMISTIC: UI must change within 3s (not waiting for server)
- extendedWaitUntil:
visible:
id: "undo-button"
timeout: 3000
# Verify derived UI state
- extendedWaitUntil:
visible:
id: "user-indicator"
timeout: 5000
| Action | Expected Change | Timeout |
|---|---|---|
| Mutation trigger | Button state flips | < 3s |
| List update | Item appears/disappears | < 5s |
| Re-do action | Proves persistence | < 3s |
React Native Alert.alert() creates native dialogs that block the UI:
- tapOn:
id: "action-button"
# Wait for expected state change first
- extendedWaitUntil:
visible:
id: "new-state-element"
timeout: 5000
# Dismiss alert (optional in case it already closed)
- tapOn:
text: "OK"
optional: true
# Brief delay for alert animation
- swipe:
direction: DOWN
duration: 300
Break repeated sequences into sub-flow files:
.maestro/
├── flows/
│ ├── auth-and-return.yaml
│ ├── complete-purchase.yaml
│ └── verify-result.yaml
├── smoke-test.yaml
└── feature-test.yaml
# In main test
- runFlow:
file: flows/auth-and-return.yaml
Use the Expo scheme from app.json, not the bundle ID:
# WRONG
- openLink: "com.myapp://profile/settings"
# CORRECT
- openLink: "myapp://profile/settings"
Deep links must be registered in your app's deep link handler. Unregistered routes silently fail.
- runFlow:
when:
platform: ios
file: flows/ios-specific.yaml
- runFlow:
when:
platform: android
file: flows/android-specific.yaml
appId: com.myapp
env:
TEST_EMAIL: maestro-test@example.com
API_BASE_URL: http://localhost:3000
---
- inputText: ${TEST_EMAIL}
Use enabled, selected, checked, and focused to target elements by their current state. This is useful for validating interactive element states before or after actions.
# Only tap the submit button if it's enabled
- tapOn:
id: "submit-button"
enabled: true
# Assert a checkbox is checked
- assertVisible:
id: "terms-checkbox"
checked: true
# Wait for an input to be focused
- extendedWaitUntil:
visible:
id: "email-input"
focused: true
timeout: 3000
| Property | Values | Use Case |
|---|---|---|
enabled | true / false | Buttons that disable during submission or until form is valid |
checked | true / false | Checkboxes, toggle switches |
selected | true / |
Distinguish between similar elements by their spatial relationship to other elements. This is more idiomatic and resilient than index-based selection.
# BAD — fragile, breaks if order changes
- tapOn:
text: "Add to Basket"
index: 1
# GOOD — contextual, self-documenting
- tapOn:
text: "Add to Basket"
below:
text: "Awesome Shoes"
Available relative selectors:
# Target element below another
- tapOn:
text: "Buy Now"
below: "Product Title"
# Target element that is a child of a parent
- tapOn:
text: "Delete"
childOf:
id: "item-card-42"
# Target a parent that contains a specific child
- tapOn:
containsChild: "Urgent"
# Target by multiple descendants
- tapOn:
containsDescendants:
- id: title_id
text: "Specific Title"
- "Another descendant text"
# Horizontal positioning
- tapOn:
text: "Edit"
rightOf: "Username"
| Selector | Meaning |
|---|---|
below: | Element is positioned below the referenced element |
above: | Element is positioned above the referenced element |
leftOf: | Element is to the left of the referenced element |
rightOf: | Element is to the right of the referenced element |
childOf: | Element is a direct child of the referenced parent |
containsChild: |
Testing OTP or magic-link authentication in E2E requires capturing emails programmatically. The general pattern:
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Maestro │────▶│ Auth │────▶│ Email Capture │
│ Test │ │ Provider │ │ Service │
└─────────────┘ └──────────────┘ └─────────────────┘
│ │
│ ┌──────────────────────────────┘
│ ▼
│ ┌─────────────┐
└──▶│ REST API │ ─── GET /api/v1/messages
│ (email) │ ─── Extract OTP code
└─────────────┘
Common email capture services: Mailpit, MailHog, Ethereal.
Fetch OTP codes from your email capture service using Maestro's GraalJS runtime:
// CRITICAL: Maestro uses GraalJS — NO async/await, NO fetch()
var email = typeof EMAIL !== "undefined" ? EMAIL : "test@example.com";
var emailServiceUrl = typeof EMAIL_SERVICE_URL !== "undefined"
? EMAIL_SERVICE_URL : "http://localhost:8025";
var response = http.get(emailServiceUrl + "/api/v1/messages");
if (!response.ok) {
throw new Error("Failed to fetch emails: " + response.status);
}
var data = json(response.body);
// Find the latest email and extract OTP code
var body = data.messages[0].Content.Body;
var match = body.match(/(\d{6})/);
output.OTP_CODE = match[1];
OTP components with auto-focus need individual digit entry. Tap each input before typing:
# Split OTP into digits via helper script
- runScript:
file: scripts/split-otp.js
env:
OTP_CODE: ${output.OTP_CODE}
# Enter each digit by tapping its input
- tapOn:
id: "otp-input-0"
- inputText: ${output.OTP_0}
- tapOn:
id: "otp-input-1"
- inputText: ${output.OTP_1}
# ... repeat for all digits
For provider-specific implementations (Supabase + Mailpit, Firebase Auth, Auth0), create a project-level skill that extends this one.
Maestro uses the GraalJS runtime. These constraints are non-negotiable:
| Feature | Status |
|---|---|
async/await | NOT supported |
fetch() | NOT supported |
http.get(), http.post() | Use these instead |
json() | Use to parse response bodies |
output.VAR | Set variables for use in YAML flow |
| declarations |
// Script template
var response = http.get("http://localhost:8025/api/endpoint");
if (!response.ok) {
throw new Error("Request failed: " + response.status);
}
var data = json(response.body);
output.RESULT = data.value;
clearState: true clears the app sandbox (UserDefaults, files, caches) but does NOT clear the iOS Keychain. Auth tokens stored via expo-secure-store (or any Keychain-based storage) persist across clearState resets and even app reinstalls.
# WRONG — user may still be authenticated
- launchApp:
clearState: true
- assertVisible: "Welcome" # Fails if Keychain has tokens
# CORRECT — wait for auth resolution, then adapt
- launchApp
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
Rules:
clearState to produce guest state on iOSclearState, use auth-loaded pre-flightclearStateNote: On Android, clearState: true fully resets app data including credentials. This is an iOS-only gotcha.
The XCTest driver may crash if Maestro interacts with the accessibility tree before the first render cycle completes on cold boot.
Fix: Add a no-op swipe immediately after launchApp:
- launchApp
- swipe:
direction: DOWN
duration: 100
Mobile apps calling backend APIs on localhost need either the full server or a mock server running. Without it, all API-dependent screens show loading spinners or empty states (queries fail silently).
Fix: Start a mock API server before running Maestro tests:
# Start mock server (serves canned responses on your API port)
npx tsx scripts/mock-api-server.ts &
# Then run tests
maestro test .maestro/my-test.yaml
Create a lightweight mock that returns canned JSON for each endpoint your app calls. This is faster and more deterministic than running your full backend.
Tab bars that show different tabs for guest vs authenticated users will cause selector failures:
| State | Typical Tabs |
|---|---|
| Guest | home, search, cart, profile |
| Auth | home, feed, create, messages, profile |
Only assert tabs that exist in both states, or use adaptive when: conditions.
# {Feature} {Action} Test
#
# Tests: {what this validates}
# Prerequisites:
# - Simulator/emulator running with app installed
# - Backend or mock server running (if API-dependent)
appId: com.myapp
env:
TEST_EMAIL: maestro-{feature}@example.com
EMAIL_SERVICE_URL: http://localhost:8025
---
# ==========================================
# STEP 1: LAUNCH + AUTH PRE-FLIGHT
# ==========================================
- launchApp
- swipe:
direction: DOWN
duration: 100
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
- takeScreenshot: 01-initial-state
# ==========================================
# STEP 2: {ACTION}
# ==========================================
- tapOn:
id: "target-element"
# ==========================================
# STEP 3: VERIFY
# ==========================================
- extendedWaitUntil:
visible:
id: "expected-result"
timeout: 5000
- takeScreenshot: 02-final-state
.maestro/
├── README.md # Quick reference + testID inventory
├── config.yaml # Shared configuration
├── flows/ # Reusable sub-flows
│ ├── auth-and-return.yaml
│ ├── complete-action.yaml
│ └── verify-result.yaml
├── scripts/ # GraalJS helpers
│ ├── fetch-otp.js
│ └── split-otp.js
├── smoke-test.yaml # Guest navigation
├── auth-signin.yaml # OTP sign-in flow
├── feature-screenshots.yaml # Screenshot capture flows
└── feature-action.yaml # Feature-specific tests
scripts/
├── mock-api-server.ts # Lightweight mock for E2E
└── run-e2e.sh # Orchestration script
| Type | Pattern | Example |
|---|---|---|
| Main test | {feature}-{action}.yaml | checkout-purchase.yaml |
| Sub-flow | {action}-{context}.yaml | auth-and-return-to-dashboard.yaml |
| Script | {verb}-{noun}.js | fetch-otp.js |
Create a lightweight mock server that serves canned responses for your API layer. This is faster and more deterministic than running your full backend during E2E tests.
# Start mock server before running Maestro tests
npx tsx scripts/mock-api-server.ts &
Automate the full E2E setup with a shell script that:
bash scripts/run-e2e.sh
Tests that depend on specific data require seeded databases. Keep seed scripts alongside your test infrastructure and run them before each test suite.
Android tests require an emulator or a USB-connected physical device. Maestro auto-detects connected devices.
# List available system images
sdkmanager --list | grep system-images
# Create emulator
avdmanager create avd -n maestro_test \
-k "system-images;android-34;google_apis;arm64-v8a"
# Start emulator
emulator -avd maestro_test
| Aspect | iOS | Android |
|---|---|---|
| Device type | Simulator only (no physical) | Emulator + physical via ADB |
clearState | Does NOT clear Keychain | Fully resets app data |
| Cold boot crash | XCTest kAXError (add swipe delay) | No equivalent issue |
| Performance | Runs natively (fast) | ARM emulation (slower on x86) |
| Permission dialogs | System alerts | System dialogs with different text |
adb devices # List connected devices
adb shell am start -n com.myapp/.MainActivity # Launch app
adb logcat | grep Maestro # Filter Maestro logs
adb shell input keyevent 82 # Unlock screen
Android permissions appear as system dialogs. Dismiss with optional taps:
- tapOn:
text: "Allow"
optional: true
- tapOn:
text: "While using the app"
optional: true
Maestro Cloud provides real devices in CI without local simulators. Use the official action:
name: Mobile E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
maestro-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Build Android APK
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build Android APK
run: |
cd apps/mobile
npx expo prebuild --platform android --no-install
cd android && ./gradlew assembleRelease
- name: Run Maestro Cloud Tests
id: maestro
uses: mobile-dev-inc/action-maestro-cloud@v2
with:
api-key: ${{ secrets.MAESTRO_API_KEY }}
app-file: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
workspace: .maestro
include-tags: ci
# Access results
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_CONSOLE_URL }}
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_UPLOAD_STATUS }}
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_FLOW_RESULTS }}
Use tags to control which tests run in CI vs locally:
# In your flow file header
appId: com.myapp
tags:
- ci
- smoke
---
- launchApp
# ... test steps
# Run only CI-tagged flows locally
maestro test --include-tags ci .maestro/
# Exclude work-in-progress flows
maestro test --exclude-tags wip .maestro/
FROM openjdk:17-slim
RUN curl -Ls "https://get.maestro.mobile.dev" | bash
ENV PATH="/root/.maestro/bin:${PATH}"
COPY .maestro/ /app/.maestro/
WORKDIR /app
CMD ["maestro", "test", ".maestro/"]
Note: iOS tests cannot run in Docker (requires macOS). Use Maestro Cloud for iOS in CI.
Maestro Cloud runs tests on real devices without local simulator setup.
MAESTRO_API_KEY secret in your CI provider# Upload and run on Maestro Cloud
maestro cloud --api-key $MAESTRO_API_KEY \
--app-file ./app-release.apk \
.maestro/
# With tag filtering
maestro cloud --api-key $MAESTRO_API_KEY \
--app-file ./app-release.apk \
--include-tags smoke \
.maestro/
MAESTRO_CLOUD_CONSOLE_URL, MAESTRO_CLOUD_FLOW_RESULTSThe Maestro MCP server exposes Maestro's full command set as Model Context Protocol tools, letting AI agents execute tests and interact with devices directly — not just write YAML.
| This Skill | Maestro MCP
---|---|---
Role | Teaches correct patterns | Provides runtime execution
Layer | Authoring (write good YAML) | Execution (run, tap, assert, screenshot)
Output | Better test files | Live device interaction
Use both together: this skill ensures the AI writes correct tests; the MCP lets it run them immediately, see failures, and iterate.
The MCP ships with the Maestro CLI — no extra install needed:
# Verify it's available
maestro mcp
Claude Code — add to project .mcp.json or global settings:
{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}
Claude Desktop — add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}
Also supported on Cursor, Windsurf, VS Code, and JetBrains IDEs. See the Maestro MCP docs for IDE-specific setup.
The MCP server exposes 47 tools organized by category:
| Category | Tools | Examples |
|---|---|---|
| UI Interaction | tap, swipe, scroll, long press | tapOn, scrollUntilVisible |
| Text Input | type, erase, paste, copy | inputText, eraseText |
| Assertions | visibility, AI-powered | assertVisible, assertWithAI |
| App Lifecycle | launch, stop, clear state |
With both this skill and the MCP active, the AI can:
runFlow / launchApp + interaction toolsmaestro test --debug .maestro/test.yaml # Step through interactively
maestro record .maestro/test.yaml # Record as video
maestro studio # Interactive UI builder
maestro hierarchy # View element tree
Screenshots saved to ~/.maestro/tests/{timestamp}/.
[ ] Unique test email (maestro-{feature}@example.com)
[ ] Selector strategy chosen (testID for i18n apps, text for single-language — see Pattern 1)
[ ] Selectors use state properties where relevant (enabled, checked — see Pattern 10)
[ ] Similar elements distinguished with relative selectors, not index (see Pattern 11)
[ ] Auth pre-flight pattern used (auth-loaded)
[ ] Post-launch swipe added (iOS crash prevention)
[ ] Both auth states handled (adaptive flows)
[ ] Native alerts dismissed after mutations
[ ] Short timeouts for optimistic updates (3-5s)
[ ] Sub-flows created for reusable sequences
[ ] Descriptive screenshots at key points
[ ] Header comment with prerequisites
[ ] Added to README.md test table
[ ] Mock API server started if backend-dependent
[ ] Tags added for CI filtering (ci, smoke, wip)
| Error | Cause | Fix |
|---|---|---|
| "Unable to locate Java Runtime" | Java not in PATH | export JAVA_HOME=/opt/homebrew/opt/openjdk@17/... |
| "Element not found" after tap | Native alert blocking | Add tapOn: text: "OK" optional: true |
| OTP digits not entering | Auto-focus interference | Use individual otp-input-N testIDs |
| Test passes but nothing happened | optional: true misused | Only use optional for truly optional actions |
| "Assertion is false" on visibility | Element not rendered yet |
Weekly Installs
95
Repository
GitHub Stars
5
First Seen
Feb 8, 2026
Security Audits
Gen Agent Trust HubFailSocketPassSnykWarn
Installed on
gemini-cli94
codex94
cursor94
opencode94
github-copilot92
amp90
Skills CLI 使用指南:AI Agent 技能包管理器安装与管理教程
44,900 周安装
false| Tab items, segmented controls |
focused | true / false | Input fields with auto-focus |
| Element contains a direct child matching the reference |
containsDescendants: | Element contains all specified descendant elements |
varRequired (use var, not const/let for safety) |
launchApp, clearState |
| Device Control | location, orientation, airplane | setLocation, hideKeyboard |
| Flow Control | run flows, repeat, eval scripts | runFlow, evalScript |
| Media | screenshots, recording | takeScreenshot, startRecording |
| AI-Powered | visual assertions, defect detection | assertWithAI, assertNoDefectsWithAI |
| Increase timeout or verify testID exists |
| Script output empty | Wrong JS API | Use http.get() not fetch() |
| Auth state inconsistent after clearState | iOS Keychain not cleared | Don't use clearState, use adaptive flows |
| kAXErrorInvalidUIElement crash | Cold boot race (iOS) | Add post-launch swipe delay |
| Loading spinners / empty screens | No API server running | Start mock API server before tests |
| Permission dialog blocking (Android) | System dialog not dismissed | Add tapOn: text: "Allow" optional: true |