mobile-dev-inc

Stars and mentions

Last 30 days, Oct 4 – Nov 2

GitHub stars258
mentions9
05101520Oct 4Oct 18Nov 2
UTC days · repositories mobile-dev-inc owns3 repositories

Package downloads

No package download history available yet.

Top repositories

mobile-dev-inc’s repositories ranked by pull request authors, as on trending.

Loading

Contributes to

Repositories outside mobile-dev-inc’s account where mobile-dev-inc opened or reviewed pull requests in the past 365 days.

Who noticed mobile-dev-inc

Everyone who starred or forked mobile-dev-inc’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

9posts
Nov 30 posts
  1. Maestro Deep Dive: Testing a SwiftUI App End to End In [Part 1](#) we covered what Maestro is, how it works and how it compares to Appium, XCUITest, Detox and the paid device clouds. Now it is time to write real tests. In this article we will: 1. Build a small SwiftUI app with a login screen and a to-do list. 2. Make it testable with accessibility identifiers. 3. Write a Maestro suite with reusable sub-flows, environment variables, conditional logic, JavaScript and tags. 4. Run it locally, generate reports and debug failures. 5. Look at how the same test would look in XCUITest, and where AI agents fit in. Everything here uses the free, open-source Maestro CLI. The full app is about 120 lines of Swift, so you can copy it into a new Xcode project and follow along. --- Prerequisites - macOS with Xcode 16 or newer and an iOS 17+ Simulator runtime - Java 17 or newer (java -version) - Maestro CLI: curl -fsSL "https://get.maestro.mobile.dev" | bash maestro --version This article was written against Maestro CLI 2.10.0. --- Part A: The sample app Create a new Xcode project: - Template: iOS > App - Product name: TodoDemo - Organization identifier: com.example - Interface: SwiftUI - Minimum deployment: iOS 17 That gives the bundle ID com.example.TodoDemo, which Maestro uses as the appId. The app has two screens: - Login: email, password, a "Log In" button and an error message for wrong credentials. The valid credentials are demo@example.com / password123. - My Tasks: add tasks, tap to complete them, swipe to delete, see a progress label, and log out. After login it asks for notification permission, which gives us a system dialog to deal with. TodoDemoApp.swift import SwiftUI @main struct TodoDemoApp: App { @AppStorage("isLoggedIn") private var isLoggedIn = false var body: some Scene { WindowGroup { if isLoggedIn { TodoListView() } else { LoginView() } } } } The login state is stored in UserDefaults through @AppStorage. That matters for testing: it survives an app restart, so we will need to clear app state between tests. LoginView.swift import SwiftUI struct LoginView: View { @AppStorage("isLoggedIn") private var isLoggedIn = false @State private var email = "" @State private var password = "" @State private var errorMessage: String? var body: some View { NavigationStack { Form { Section { TextField("Email", text: $email) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() .accessibilityIdentifier("emailField") SecureField("Password", text: $password) .accessibilityIdentifier("passwordField") } if let errorMessage { Text(errorMessage) .foregroundStyle(.red) .accessibilityIdentifier("loginError") } Button("Log In", action: logIn) .accessibilityIdentifier("loginButton") .disabled(email.isEmpty || password.isEmpty) } .navigationTitle("Welcome") } } private func logIn() { // Demo only. A real app would call an auth service here. if email.lowercased() == "demo@example.com" && password == "password123" { errorMessage = nil isLoggedIn = true } else { errorMessage = "Invalid email or password" } } } TodoListView.swift import SwiftUI import UserNotifications struct TodoItem: Identifiable { let id = UUID() var title: String var isDone = false } struct TodoListView: View { @AppStorage("isLoggedIn") private var isLoggedIn = false @State private var items: [TodoItem] = [] @State private var newTitle = "" private var doneCount: Int { items.filter(\.isDone).count } var body: some View { NavigationStack { VStack(spacing: 12) { HStack { TextField("New task", text: $newTitle) .textFieldStyle(.roundedBorder) .onSubmit(addItem) .accessibilityIdentifier("newTaskField") Button("Add", action: addItem) .accessibilityIdentifier("addTaskButton") .disabled(newTitle.trimmingCharacters(in: .whitespaces).isEmpty) } .padding(.horizontal) if items.isEmpty { ContentUnavailableView( "No tasks yet", systemImage: "checklist", description: Text("Add your first task above.") ) } else { List { ForEach($items) { $item in Button { item.isDone.toggle() } label: { HStack { Image(systemName: item.isDone ? "checkmark.circle.fill" : "circle") Text(item.title) .strikethrough(item.isDone) } } } .onDelete { items.remove(atOffsets: $0) } } Text("\(doneCount) of \(items.count) done") .font(.footnote) .foregroundStyle(.secondary) .accessibilityIdentifier("progressLabel") } } .navigationTitle("My Tasks") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Log Out") { isLoggedIn = false } .accessibilityIdentifier("logoutButton") } } .task { _ = try? await UNUserNotificationCenter.current() .requestAuthorization(options: [.alert, .sound]) } } } private func addItem() { let title = newTitle.trimmingCharacters(in: .whitespaces) guard !title.isEmpty else { return } items.append(TodoItem(title: title)) newTitle = "" } } Run it once in the Simulator from Xcode to make sure it works. Why the accessibility identifiers? Maestro can find elements by visible text (tapOn: "Log In"), and that is often fine. But text changes: a designer renames "Log In" to "Sign In", or you test in German. Identifiers do not change with copy or locale. A practical rule: - Static controls (buttons, fields, labels you assert on): give them an accessibilityIdentifier and target them with id. - Dynamic content (task titles, user names, server data): target by text, because the text is the thing you are verifying. You can see exactly what Maestro sees with: maestro hierarchy or by opening the Maestro Studio desktop app and clicking on elements in the live device view. --- Part B: Build and install for the Simulator Maestro tests the Simulator build of your app (.app). You can install it by running from Xcode, or from the command line, which is what CI will do: # Boot a simulator (the device name depends on your installed runtimes) xcrun simctl boot "iPhone 16" || true # Build for the simulator xcodebuild \ -project TodoDemo.xcodeproj \ -scheme TodoDemo \ -configuration Debug \ -sdk iphonesimulator \ -destination 'platform=iOS Simulator,name=iPhone 16' \ -derivedDataPath build \ build # Install the .app on the booted simulator xcrun simctl install booted build/Build/Products/Debug-iphonesimulator/TodoDemo.app --- Part C: The test suite Here is the structure we are going to build: .maestro/ ├── config.yaml ├── subflows/ │ ├── login.yaml │ └── dismiss_permission_dialog.yaml └── flows/ ├── 01_login_invalid.yaml ├── 02_login_success.yaml ├── 03_add_and_complete_tasks.yaml ├── 04_delete_task.yaml └── 05_session_and_logout.yaml flows/ contains the tests. subflows/ contains reusable building blocks that are never run on their own. config.yaml: the workspace configuration # Only files matching these patterns are treated as tests. # Sub-flows live outside flows/, so they are not run directly. flows: - "flows/*" When you run maestro test .maestro/, Maestro reads this file to decide which flows belong to the suite. Sub-flow 1: login.yaml appId: com.example.TodoDemo --- - tapOn: id: "emailField" - inputText: ${EMAIL || 'demo@example.com'} - tapOn: id: "passwordField" - inputText: ${PASSWORD || 'password123'} - tapOn: id: "loginButton" Two things to notice: - Everything inside ${...} is JavaScript. EMAIL || 'demo@example.com' means "use the EMAIL variable if someone passed one, otherwise fall back to the default". That lets the same sub-flow handle valid and invalid logins. - There are no waits. After tapOn: { id: "emailField" }, Maestro waits for the screen to settle before typing. Sub-flow 2: dismiss_permission_dialog.yaml appId: com.example.TodoDemo --- - runFlow: when: visible: "Allow" commands: - tapOn: "Allow" This is a conditional flow. If the system notification dialog is on screen, tap "Allow". If it is not, do nothing and continue. Because Maestro drives the whole device rather than just your app process, it can tap system alerts that in-process frameworks cannot reach directly. We will mostly avoid the dialog entirely by pre-granting permissions in launchApp, but this sub-flow is a safety net for cases where you cannot. Test 1: 01_login_invalid.yaml appId: com.example.TodoDemo name: Login fails with wrong credentials tags: - auth - regression --- - launchApp: clearState: true - assertVisible: "Welcome" - runFlow: file: ../subflows/login.yaml env: EMAIL: "wrong@example.com" PASSWORD: "not-the-password" - assertVisible: id: "loginError" text: "Invalid email or password" - assertNotVisible: "My Tasks" - clearState: true wipes the app's data before launching, so every test starts logged out. This is the single most important habit for a reliable suite: never let one test depend on what a previous test left behind. - runFlow with env overrides the variables for that sub-flow only. - The selector { id: "loginError", text: "..." } matches an element that has that identifier and that text. Test 2: 02_login_success.yaml appId: com.example.TodoDemo name: Login succeeds with valid credentials tags: - smoke - auth --- - launchApp: clearState: true permissions: notifications: allow - runFlow: ../subflows/login.yaml - runFlow: ../subflows/dismiss_permission_dialog.yaml - assertVisible: "My Tasks" - assertVisible: "No tasks yet" - takeScreenshot: after_login permissions: { notifications: allow } grants the permission before the app starts, so the dialog should never appear. takeScreenshot saves a PNG, which is useful for reviewing UI in CI artifacts. Test 3: 03_add_and_complete_tasks.yaml appId: com.example.TodoDemo name: Add tasks and mark one as done tags: - smoke - tasks --- - launchApp: clearState: true permissions: notifications: allow - runFlow: ../subflows/login.yaml - runFlow: ../subflows/dismiss_permission_dialog.yaml - tapOn: id: "newTaskField" - inputText: "Buy milk" - tapOn: id: "addTaskButton" - tapOn: id: "newTaskField" - inputText: "Write Maestro article" - tapOn: id: "addTaskButton" - assertVisible: "Buy milk" - assertVisible: "Write Maestro article" - assertVisible: id: "progressLabel" text: "0 of 2 done" - tapOn: "Buy milk" - assertVisible: id: "progressLabel" text: "1 of 2 done" Notice how the test reads almost exactly like a manual QA script. That readability is the main reason teams adopt Maestro. Test 4: 04_delete_task.yaml (loops, JavaScript and swipes) appId: com.example.TodoDemo name: Delete a task with swipe tags: - tasks - regression --- - launchApp: clearState: true permissions: notifications: allow - runFlow: ../subflows/login.yaml - runFlow: ../subflows/dismiss_permission_dialog.yaml # Create "Task 1", "Task 2" and "Task 3" using a loop and a JS counter - evalScript: ${output.counter = 0} - repeat: times: 3 commands: - evalScript: ${output.counter = output.counter + 1} - tapOn: id: "newTaskField" - inputText: "Task ${output.counter}" - tapOn: id: "addTaskButton" - assertVisible: "Task 3" # Swipe left on the row to reveal the system Delete action - swipe: from: text: "Task 2" direction: LEFT - tapOn: "Delete" - assertNotVisible: "Task 2" - assertVisible: "Task 1" - assertVisible: "Task 3" - assertVisible: id: "progressLabel" text: "0 of 2 done" - output is a JavaScript object Maestro provides for storing values across steps. - repeat runs a block a fixed number of times (it also supports a while condition). - swipe with from starts the gesture on a specific element, which is how you trigger SwiftUI's .onDelete. Use loops and scripts sparingly. If a flow starts to look like a program, consider seeding data through a test backend or launch arguments instead. Test 5: 05_session_and_logout.yaml appId: com.example.TodoDemo name: Session persists across restarts until logout tags: - auth - regression --- - launchApp: clearState: true permissions: notifications: allow - runFlow: ../subflows/login.yaml - runFlow: ../subflows/dismiss_permission_dialog.yaml - assertVisible: "My Tasks" # Restart without clearing state: the user should still be logged in - stopApp - launchApp - assertVisible: "My Tasks" # Log out and confirm we are back on the login screen - tapOn: id: "logoutButton" - assertVisible: "Welcome" - assertVisible: id: "loginButton" This test checks behavior that crosses an app restart, something that is awkward to do in many in-process frameworks. --- Part D: Running the suite Make sure the Simulator is booted and the app is installed, then from the project root: # Run the whole suite maestro test .maestro/ # Run a single flow maestro test .maestro/flows/02_login_success.yaml # Run only smoke tests maestro test --include-tags smoke .maestro/ # Exclude slow tests maestro test --exclude-tags regression .maestro/ # Pass variables from the command line maestro test -e EMAIL=qa@example.com -e PASSWORD=secret .maestro/flows/02_login_success.yaml Faster authoring with continuous mode While writing a flow, use continuous mode. Maestro reruns the flow every time you save the file: maestro test -c .maestro/flows/03_add_and_complete_tasks.yaml Combined with Maestro Studio for finding selectors, this gives you a feedback loop of a few seconds. Validate syntax without a device maestro check-syntax .maestro/flows/04_delete_task.yaml Useful as a pre-commit hook or a quick CI step. Reports and artifacts # JUnit XML, understood by most CI systems maestro test .maestro/ --format junit --output build/maestro-report.xml # HTML report for humans maestro test .maestro/ --format html --output build/maestro-report.html # Put screenshots, logs and per-flow artifacts in a known folder maestro test .maestro/ --test-output-dir build/maestro-artifacts When a flow fails, the artifacts include a screenshot before each step, the view hierarchy at the moment of failure, device logs and crash reports. Start debugging there, not in the terminal output. Running on several simulators in parallel If you have more than one simulator booted, you can split the suite across them: maestro test --shard-split 2 .maestro/ --shard-split divides the flows between devices. --shard-all runs every flow on every device, which is useful for testing several iOS versions or screen sizes at once. --- Part E: The same test in XCUITest To make the comparison from Part 1 concrete, here is roughly what Test 2 (successful login) looks like in XCUITest: import XCTest final class LoginUITests: XCTestCase { override func setUp() { continueAfterFailure = false } func testLoginSucceeds() { let app = XCUIApplication() app.launchArguments = ["-isLoggedIn", "NO"] // reset @AppStorage app.launch() let email = app.textFields["emailField"] XCTAssertTrue(email.waitForExistence(timeout: 5)) email.tap() email.typeText("demo@example.com") let password = app.secureTextFields["passwordField"] password.tap() password.typeText("password123") app.buttons["loginButton"].tap() // System alerts live in SpringBoard, outside the app under test let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") let allow = springboard.buttons["Allow"] if allow.waitForExistence(timeout: 3) { allow.tap() } XCTAssertTrue(app.navigationBars["My Tasks"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["No tasks yet"].exists) } } XCUITest is not bad. It is fast, it is Apple's own tooling and it runs on physical devices. But compare the two: | | Maestro | XCUITest | |---|---|---| | Lines for this test | About 10 (plus a reusable sub-flow) | About 30 | | Explicit waits | None | waitForExistence on most steps | | System dialogs | tapOn: "Allow" | Separate SpringBoard app handle | | Resetting state | clearState: true | Launch arguments or custom reset code | | Rebuild after change | No | Yes, test target recompiles | | Reusable on Android | Often, with the same flow | No, rewrite in Espresso | | Physical iPhones | Not officially, see Part 1 | Yes | Many teams use both: XCUITest or unit tests for detailed component behavior, Maestro for end-to-end user journeys. --- Part F: Writing flows with an AI coding agent Maestro ships an MCP server inside the CLI. It lets an AI coding agent see the simulator, inspect the view hierarchy, run flows and read the results. To connect it, add this to your agent's MCP configuration: { "mcpServers": { "maestro": { "command": "maestro", "args": ["mcp"] } } } Most agents also have a one-line command. For example, with the Codex CLI: codex mcp add maestro -- maestro mcp and with the Gemini CLI: gemini mcp add maestro maestro mcp Then you can ask something like: "Write a Maestro flow that logs in to TodoDemo, adds three tasks, completes the second one and checks the progress label. Run it and fix it until it passes." --- Troubleshooting checklist | Symptom | What to check | |---|---| | Element not found | Run maestro hierarchy or open Studio. Is the identifier set on the element you think it is? Is the text an exact match? | | Tap lands on the keyboard | The keyboard may cover the target. Add - hideKeyboard or scroll first with scrollUntilVisible. | | Test passes locally, fails in CI | CI machines are slower. Use extendedWaitUntil with a longer timeout for slow screens, and increase MAESTRO_DRIVER_STARTUP_TIMEOUT. | | Tests affect each other | Use launchApp: { clearState: true } at the start of every flow. | | Long animations break assertions | Add - waitForAnimationToEnd before the assertion. | | .ipa will not install | Maestro needs a Simulator build (.app) built with -sdk iphonesimulator. | | Flags stopped working after an upgrade | Check the changelog. --ios-version is being replaced by --device-os. | Example of a longer explicit wait for a slow screen: - extendedWaitUntil: visible: id: "progressLabel" timeout: 15000 # milliseconds --- Best practices summary 1. One user journey per flow. Short flows fail for one clear reason. 2. Reset state at the start of every flow with clearState: true. 3. Use identifiers for controls, text for content. 4. Extract repeated steps into sub-flows (login, onboarding, navigation). 5. Keep logic out of YAML where possible. Seed data through APIs or launch arguments. 6. Tag your flows (smoke, regression, feature names) and run smoke tests on every PR, the full suite nightly. 7. Pin the Maestro version in CI and upgrade on purpose. 8. Always keep artifacts from failed runs. --- Ending With about 120 lines of Swift and five short YAML files, we have a test suite that covers login, error handling, adding, completing and deleting tasks, session persistence, logout and system permission dialogs. It runs locally in under a couple of minutes, runs in GitHub Actions without changes, and can move to a device cloud with one command. That is Maestro's real value: the distance between "we should test this" and "it is tested in CI" is very short. Resources - GitHub: mobile-dev-inc/maestro

    5 reactions
  2. Maestro: Mobile UI Testing Without the Pain - Introduction If you have ever written mobile end-to-end tests, you probably know the routine. You set up Appium, fight with drivers and capabilities, write a test that passes locally, and then watch it fail in CI because an animation took 200ms longer than expected. You add a sleep(2). Then another. Six months later nobody trusts the suite. Maestro is an open-source framework built to make that experience less painful. This article covers what it is, how it works, what you should know before adopting it, and how it stacks up against the other free and paid options. This is Part 1 of a two-part series. Part 2 is a hands-on deep dive with a small SwiftUI app and a complete test suite. What is Maestro? Maestro is a UI and end-to-end testing framework for Android, iOS and web apps. Instead of writing tests in Java, Kotlin, Swift or JavaScript, you describe user journeys (called Flows) in YAML: appId: com.example.myapp --- - launchApp - tapOn: "Sign in" - tapOn: "Email" - inputText: "jane@example.com" - tapOn: "Password" - inputText: "hunter2" - tapOn: "Continue" - assertVisible: "Welcome, Jane" That is a complete, runnable test. No page objects, no driver setup code, no explicit waits. How Maestro works (and why it matters) Maestro is a black-box tool. It does not link into your app, does not need an SDK, and does not need your source code. It drives the device the way a user would, and it reads the screen through the platform's accessibility layer and view hierarchy. On iOS, Maestro installs a small XCTest-based driver on the simulator. On Android, it installs a driver app that talks to UIAutomator. The CLI sends commands to the driver, reads back the UI hierarchy, and decides what to do next. Three design choices make it feel different from older tools: 1. Automatic waiting. After each command, Maestro waits for the screen to settle before moving on. If you tapOn: "Continue" and the button is not visible yet, it keeps looking for a while before failing. In practice this removes most of the sleep() calls you would write elsewhere. 2. Flakiness tolerance. Mobile UIs are noisy: taps sometimes do not register, elements shift during animations, keyboards appear and disappear. Maestro's commands are designed to retry and re-check rather than fail on the first miss. 3. Interpreted, not compiled. Flows are read at runtime. Change a YAML file, rerun, and see the result in seconds. There is no test target to rebuild. Because it operates "at arm's length", Maestro can also do things in-process tools cannot: tap system permission dialogs, open Safari or Settings, and come back to your app in the same flow. --- The ecosystem: CLI, Studio, MCP and Cloud Maestro is more than one binary. Here is what each piece does and what it costs. | Component | What it does | Cost | Open source? | |---|---|---|---| | Maestro CLI | Runs flows locally and in CI. The core of everything. | Free | Yes (Apache 2.0) | | Maestro Studio (desktop) | Visual IDE: inspect elements, record interactions, build flows without the terminal. Available for macOS, Windows and Linux. | Free | No | | Maestro MCP | Built into the CLI (maestro mcp). Lets AI coding agents such as Claude Code, Cursor, Copilot, Codex and Gemini run flows, inspect the screen and write tests. | Free | Yes | | Maestro Cloud | Hosted parallel execution on managed simulators, emulators and browsers. | Paid | No | A note on Studio: older versions of the CLI shipped a browser-based Studio (maestro studio). That was removed in CLI 2.6.0 in favor of the desktop app. If you find older tutorials using maestro studio, that is why they no longer match. A note on AI: the old maestro chat command was discontinued in 2.7.0. The supported path for AI-assisted test authoring is now Maestro MCP. --- Getting started in five minutes 1. Install You need Java 17 or newer. java -version Then install the CLI on macOS, Linux or Windows (WSL): curl -fsSL "https://get.maestro.mobile.dev" | bash Or, on macOS with Homebrew: brew tap mobile-dev-inc/tap brew install maestro Verify: maestro --version 2. Start a device For iOS you need Xcode with at least one Simulator runtime installed. For Android, an emulator from Android Studio. Maestro can also start one for you: maestro list-devices maestro start-device --platform ios 3. Write a flow Save this as contacts.yaml. It uses the built-in iOS Contacts app, so you do not need your own app yet: appId: com.apple.MobileAddressBook --- - launchApp - tapOn: "Add" - tapOn: "First name" - inputText: "Ada" - tapOn: "Last name" - inputText: "Lovelace" - tapOn: "Done" - assertVisible: "Ada Lovelace" Button and field labels in Apple's built-in apps can change between iOS versions. If a step fails, open Maestro Studio or run maestro hierarchy to see the exact labels on your simulator. 4. Run it maestro test contacts.yaml You will see each step printed as it runs, with a pass or fail marker. On failure, Maestro saves a screenshot, the view hierarchy and logs so you can see what was on screen at the moment it broke. --- What is important for developers to know These are the things that are easy to miss in the marketing material but matter once you adopt Maestro on a real project. 1. iOS testing targets Simulators Maestro's officially supported local iOS target is the Simulator. You need a Simulator build (.app), not an App Store or Ad Hoc .ipa. Support for physical iPhones has been one of the most requested features in the repository for a long time. Today, if you need real iOS hardware, your options are third-party projects or device clouds that have added Maestro support on physical devices (TestingBot is one example). Check the current docs before you plan around real devices. Android has no such limitation: emulators and USB-connected physical devices both work. 2. Accessibility identifiers make tests stable Maestro can find elements by visible text, which is great for getting started. For a test suite you plan to keep, add stable identifiers: - SwiftUI / UIKit: accessibilityIdentifier - Android: resource-id / testTag - React Native: testID - Flutter: Semantics(identifier: ...) or keys exposed through semantics Then target them with id: - tapOn: id: "loginButton" Text selectors break when copy changes or when you test in another language. IDs do not. 3. YAML is simple until it isn't For linear user journeys, YAML is a joy. For complex logic (loops with conditions, computed data, calling a backend to seed state), you will reach for Maestro's JavaScript support (runScript, evalScript, ${...} expressions and an http client). It works, but it is not a full programming language with an IDE, types and a debugger. If your test strategy depends on heavy logic, factor that into your choice. 4. Black-box means no app internals You cannot call into your app's code, stub a network layer from inside the process, or read internal state. If you need that, it has to happen outside Maestro: a test backend, launch arguments, or a debug-only screen. Maestro's own built-in network mocking was deprecated years ago. 5. It is not a replacement for unit or in-process UI tests Maestro is excellent for high-level user journeys: sign-up, checkout, onboarding, critical paths. It is not the right tool for testing every state of a single component. Keep XCTest/XCUITest, Espresso or your framework's widget tests for that. 6. CI is straightforward, scaling is the hard part Running Maestro in CI is just maestro test plus JUnit or HTML reports. Scaling is where costs appear. iOS Simulators need macOS machines. Locally you can shard across several simulators (parallel iOS simulator execution became reliable in CLI 2.6.0), but at some point teams either buy more Mac runners or move to a cloud. 7. The project moves fast The CLI ships frequently, and some releases deprecate flags. For example, --ios-version and --android-api-level are being replaced by --device-os and --device-model. Pin your Maestro version in CI and read the changelog when upgrading. --- How Maestro compares to other tools Free and open-source frameworks | Tool | Platforms | Test language | Approach | Setup effort | Best for | |---|---|---|---|---|---| | Maestro | iOS, Android, Web | YAML (+ JS) | Black-box | Minutes | Cross-platform user journeys, mixed teams, fast feedback | | Appium | iOS, Android, Web, Windows, more | Java, JS/TS, Python, C#, Ruby | Black-box (WebDriver) | Hours to days | Large cross-platform suites, teams with existing Selenium skills | | XCUITest | iOS only | Swift / Obj-C | In Xcode, separate test process | Low (built into Xcode) | iOS-only teams who want native tooling and real-device support | | Espresso | Android only | Kotlin / Java | Gray-box, in-process | Low | Android-only teams, fast and stable component-level UI tests | | Detox | iOS, Android (React Native) | JS / TS | Gray-box, syncs with RN internals | Moderate to high | React Native apps that need tight synchronization | | Flutter integration_test / Patrol | iOS, Android (Flutter) | Dart | In-process, Patrol adds native interaction | Moderate | Flutter teams who want tests in Dart | Maestro vs Appium. Appium is the most flexible and the most mature: any language, any platform, huge ecosystem, real iOS devices. It is also the most work to set up and maintain, and tests tend to need explicit waits. Maestro trades flexibility for speed of authoring and lower flakiness. Many teams that move from Appium to Maestro do so because test maintenance, not test execution, was their bottleneck. Maestro vs XCUITest. XCUITest is Apple's own framework. It is fast, deeply integrated with Xcode and supports real devices. But tests are Swift code that needs a test target, compilation and more boilerplate, and it only covers iOS. If you ship on both platforms, you would also need Espresso and two separate suites. Maestro gives you one suite, often with shared flows across platforms. Maestro vs Detox. Detox is gray-box: it knows when React Native is idle, which makes it very stable for RN apps. That same coupling makes setup harder and limits it to React Native. Maestro works with any framework but synchronizes based on what is on screen rather than JS thread state. Paid platforms and device clouds Most paid offerings are infrastructure: they run tests you have already written on hosted devices. Prices below are list prices published at the time of writing (October 2026). Always confirm on the vendor's pricing page. | Service | What you get | Pricing model | Maestro support | |---|---|---|---| | Maestro Cloud | Official hosted runner for Maestro flows, parallel execution, artifacts, notifications | $250 per device per month for iOS or Android, $125 per browser per month for web; enterprise plans available; 7-day free trial | Native | | BrowserStack App Automate | Large real-device cloud for Appium, Espresso, XCUITest and more | Per parallel session; App Automate starts around $199 per parallel per month billed annually | Yes, via integration | | Sauce Labs | Real devices and emulators, enterprise features | Per parallel test with unlimited users and minutes | Check current docs | | TestingBot | Real devices and simulators, Maestro CLI integration | Subscription | Yes, including physical iOS devices | | DeviceCloud (devicecloud.dev) | Maestro-focused cloud runner | Pro plan from $25 per month including test credits, usage on top | Yes | | AWS Device Farm / Firebase Test Lab | Pay-as-you-go device access from the major clouds | Metered usage with free tiers or trials | Possible with custom setup, not first-class | | AI-native testing tools (for example Drizz, Autosana) | Natural-language or vision-based test authoring and self-healing | Subscription, usually quote-based | Not applicable; they are alternatives | A simple way to think about it: - Authoring cost (how long tests take to write and maintain): Maestro is among the lowest. - Execution cost (where tests run): free on your own machines, then you pay either for Mac CI runners or for a device cloud. The cloud choice is mostly about price per parallel, device coverage (real vs virtual) and where your data is allowed to go. --- When to choose Maestro Maestro is a strong fit if: - You ship on both iOS and Android, or plan to. - You want end-to-end coverage of critical user journeys without building a test framework. - QA engineers, product people or designers should be able to read and contribute to tests. - Flaky tests are a bigger problem for you than raw test speed. Look elsewhere, or combine tools, if: - You must run iOS tests on physical devices in your own lab with official support. - Your tests need deep access to app internals or in-process mocking. - You need thousands of fine-grained UI assertions per component (use XCUITest, Espresso or framework tests for those). - Your organization already has a large, healthy Appium suite. --- Wrapping up Maestro's pitch is simple: describe what a user does, in plain YAML, and let the framework handle the waiting and retrying. For most teams the biggest win is not execution speed but the fact that tests are quick to write, easy to read and less likely to become the flaky suite nobody trusts. In Part 2, we will build a small SwiftUI app with a login screen and a to-do list, then write a full Maestro suite for it: stable selectors, sub-flows, environment variables, conditional logic for permission dialogs, tags, reports and a GitHub Actions workflow. Resources - GitHub: mobile-dev-inc/maestro

    5 reactions
  3. Twelve Cores, Seven Devices, One Disk This is a republish from my site; the original is at codebyramiz.com. A release test run on one of my Android apps sat hung for 7 hours 22 minutes and used 258 ms of CPU, before it ran a single test step. Most advice on running several Claude Code agents says give each its own git worktree. I think that is the wrong first answer. A worktree keeps the files apart. It does nothing for the CPU, the one test phone or the disk, and my own run proves it. The run wasn't slow, it was stuck, inside the test tool's own driver install. Nobody checked on it. Several Claude Code sessions were working on the same machine, and each had its own work to watch. That is what running multiple Claude Code agents at once actually looks like after a few months. The problems are not in the code. If you only want the checklist, it's the table at the end; the sections in between are how each row was earned. Git worktrees solve the files, not the machine If you search for how to run several Claude Code agents in parallel, the answer is consistent: give each session its own git worktree and branch, so two agents never edit the same file. That's right, and I do it. It's also where the guides ranking for that search stop, from Towards Data Science to kanbots. Here's what changed for me. One agent at a time is a tool. Four agents at a time, each with its own worker processes, builds and device runs, is a small team sharing one desk. A worktree gives each of them a separate folder. They still share: - The CPU. A Gradle build, an emulator and a test run each want most of the machine. - The test devices. There's one Pixel. Two agents can't both install onto it at once. - The disk. Every build adds cache, and nothing removes it. - Time. A hung run looks exactly like a slow one unless something checks. The hard part is that every session is behaving reasonably on its own. No single agent does anything wrong. The failure only exists when you add them up, and no session can see the others. So the guards have to live outside the sessions, at the machine level. My setup, for scale: an Apple M3 Pro MacBook Pro (12 cores, 36 GB), a Windows PC with an Intel i9-11900H (8 cores, 32 GB) that takes Android builds, a 2014 MacBook Pro kept as a spare, seven registered test devices, and a real-device cloud for what I don't own. The agents are Claude Code sessions that plan and review, plus cheaper coding agents that do the bulk of the work. That split is covered in how I review AI-generated mobile code. Claude Code high CPU usage: when several sessions fan out The first thing that broke was speed, and it took a while to see why. A session would launch background shell commands to generate files: about twelve of them, each burning 8 to 10 percent CPU. On its own, fine. But three sessions doing something similar at the same time oversubscribed the M3 Pro, and every build, test and device run on the box slowed to a crawl. Each session saw only its own twelve workers. None of them knew the machine was full. The fix is a Claude Code PreToolUse hook. It's registered once in ~/.claude/settings.json, so it runs before every Bash call in every session: "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$HOME/.claude-shared/scripts/resource-budget-guard.sh" } ] } ] } The hook reads the tool call as JSON on stdin. If it isn't a run_in_background call, it exits at once. Otherwise it takes one ps snapshot and counts busy processes across all Claude sessions on the machine, not just its own: # condensed from resource-budget-guard.sh MAX=${CLAUDE_MAX_BG_WORKERS:-$(( NCPU / 2 ))} # 6 on a 12-core Mac FLOOR=${CLAUDE_BG_CPU_FLOOR:-3} # %CPU that counts as "working" # for each process: walk up its parents until one is a `claude` process. # skip the hook itself, and skip MCP servers (long-lived, not fan-out) # if its %CPU >= FLOOR, count it against that session [ "$COUNT" -lt "$MAX" ] && exit 0 printf '{"decision":"block","reason":%s}\n' "$(jq -Rs . <<<"$REASON")" The block reason names which session owns the busy workers and tells the agent what to do instead: run the command in the foreground, batch the work into one loop, or wait. Foreground calls aren't checked at all, so a blocked agent can still do the work, one step at a time. For a run that genuinely needs more, a leading CLAUDE_MAX_BG_WORKERS=8 on that one command raises the limit for that call only. The hook reads the assignment from the command text itself, because a variable set in the command never reaches the hook's own environment. The second half is cleanup. A Stop hook warns when a session still owns more than four busy processes, and a SessionEnd hook kills the busy ones it can trace back to that session (MCP servers excluded; on the Windows box it only logs them unless reaping is switched on). That matters more than it sounds. Claude Code's own issue tracker has reports like this one, of processes that pile up and outlive the work they were started for. A worker that never gets reaped costs the machine for hours. Sharing test phones between agents: lease, don't grab Two agents on one phone is worse than two agents on one file, because a phone has no merge step. One installs a build while the other is mid-test, and the second agent reports a failure the app doesn't have. The worst version of this took a long misdiagnosis to find. Two Maestro runs, on two different Android phones connected to the same machine, kept dying with: Connection refused: localhost:7001 That error looks exactly like an app hang. I blamed the number of MCP server processes first, then a concurrent iOS session. Neither was it. The real cause was an upstream Maestro bug: before version 2.6.0 it tracked sessions per platform, not per device, so the second run's driver install was silently skipped (Maestro issue #3065). The rule now is simple: Maestro 2.6.1 or later, everywhere. The general fix is a small device farm. Every command that touches a phone goes through a wrapper that takes a lease first: farm-with-device --alias plannedping-pixel --holder me:gate --ttl 7800 -- \ run-with-watchdog.sh --log gate.log -- ./scripts/run-local-release-gate.sh The phones hang off the Windows PC, and the farm there is the only lease authority. Single commands (farm-adb, farm-maestro, screenshots) forward from the Mac over SSH; a whole gate script like the one above runs on the PC itself. If the PC can't be reached, device commands fail rather than guess. The lease is an atomic mkdir lock with a time limit, set longer than the watchdog's own cap so it can't expire mid-run. A hook blocks raw adb and maestro calls, so an agent can't skip the queue by accident. Devices carry a policy, and the policy decides who can pick them: | Device | Availability | How an agent reaches it | |---|---|---| | Pixel 4a 5G | always | by role (default pick) | | Sony | always | by role (default pick) | | TCL tablet, Android 16 | always | by name only | | POCO (MIUI) | always | by name only, for OEM-specific bugs | | Samsung S26 Ultra | on-demand | by name plus an extra flag | | iPhone 15, iOS 26 | always | by name only, via Appium | | iPhone 11, iOS 18 | on-demand | by name only | Picking by role fails closed when more than one device matches, so an agent never lands on a phone by luck. For iPhones the agent drives the device without touching the Mac's mouse at all, which is its own story: an agent driving a real iPhone. There was a second device problem I didn't see coming. To keep a long run from being interrupted, agents set the screen to stay on while charging, then finished and walked away. Phones sat fully awake around the clock, which drains and heats a battery even on a charging cable. Now releasing a lease arms a marker. The next Stop hook that finds a marker older than five minutes turns stay-awake off on that Android device, sets the screen timeout back to 60 seconds and switches the display off. A device that's leased again in the meantime is skipped. When a session ends, it does the same for its devices straight away. Claude Code disk space: build caches only grow Disk is the slowest problem and the easiest to ignore. Here's one night from the cleanup log: 00:45 disk-hygiene summary: free 56G -> 56G 03:52 disk-hygiene summary: free 24G -> 24G 05:52 disk-hygiene summary: free 12G -> 12G 07:54 disk-hygiene summary: free 10G -> 7G 08:55 disk-hygiene summary: free 3G -> 3G 10:52 disk-hygiene start: free 1G Below 100 GB free the cleaner starts on its own, so that night it ran almost every hour, and free space kept falling anyway. A cleanup schedule doesn't stop a disk filling: the cleaner may only delete what a rebuild restores, and the warning threshold is what tells a human to look. That restriction is deliberate, and it's what makes the job safe to run unattended. It deletes merged worktrees, merged branches and old build/, .gradle/ and DerivedData/ folders. It never touches source, .git, .env files or keystores. A worktree with commits that no remote has is never deleted; it's logged for a human to decide. The tricky case was dirty worktrees. A coding agent that dies mid-task leaves one behind, and I'd found six of them holding 3 to 8 GB of build output each. Keeping them forever fills the disk. Deleting them loses work. So a dirty worktree idle for 72 hours is first archived, as a binary patch plus a tarball of every untracked and ignored file except regenerable build folders, both size-checked, and only then removed. The ignored files matter: a .env or a keystore is untracked by design, and a cleaner that ignores them deletes the only copy. Normally it runs every six hours at low priority. On a good pass it frees a lot: the largest single sweep cleared 44.5 GB across 92 paths. The honest part: for a while it freed nothing at all, while looking like it worked. The cleanup script loaded a helper that had its own variable named APPLY, set to off. So --apply silently became a dry run. The log said "DRY-RUN", deleted nothing, and the job looked healthy. The fix was renaming the variables. The lesson was to check what a cleanup job freed, not whether it ran. Long runs: is it hung or just slow? Back to the opening. Nothing polled that run, and when a session finally did report on it, it read the log and said "no Maestro process alive". The process was still resident. An earlier stop signal had killed the wrapper shell and left the Java process underneath untouched. And it wasn't the port 7001 bug from the last section: that machine was already on Maestro 2.6.1. Every run expected to take more than five minutes now goes through a watchdog: | Exit code | Meaning | |---|---| | 0 | finished | | 125 | stalled: the log stopped growing for 600 seconds | | 124 | hit the hard cap (7,200 seconds) | | 143 | killed by a signal | 124 and 125 mean no result was produced. They are never reported as a failing test. A ten-minute stall check plus one thread dump named that cause immediately. Before it kills a stalled run, the watchdog takes a thread dump of the run's Java processes (jstack) and a sample of the rest of its process tree, then kills the whole tree, not just the wrapper. The dump settles the argument you'd otherwise have: if the blocking frame is inside the test tool, it's the tool; if it's inside the app's code, it's a possible product bug worth a closer look. No dump, no verdict either way. Two rules came straight out of that run. Answer "is it still running?" from the process table, never from a log file. And kill the tree, not the wrapper. Moving Android builds to a second machine Once the guards were in, the Mac was still the bottleneck, because it does everything. So Android builds moved to the Windows PC. A win-build <project> <tasks> command streams the tracked files over SSH, runs Gradle on a warm cache there, and pulls the APKs back. All 13 Android repos build there. iOS stays on the Mac, since it has to. The 2014 MacBook Pro is deliberately not a build worker. It's several times slower than the M3, and putting anything on it that someone waits for just moves the bottleneck somewhere worse. Two machines bring a new problem: the hooks and scripts above have to be identical on both. A sync job runs every five minutes on each host through a private git repo. Before pushing, it scans local edits for secret patterns. Incoming changes are checked for shell syntax, JSON and file size before they go live, then swapped in one file at a time with an atomic rename. If an incoming check fails, the live files stay as they were and every new session on that host sees a drift warning until it's fixed. The same thinking applies to the code itself, in keeping agent-written code clean: a rule only holds if something fails when it breaks. What to set up first If you're running more than two Claude Code sessions on one machine, this is the order I'd do it in, by how much each one cost me before it existed: | Problem | What breaks | Guard | |---|---|---| | Runs that hang | hours lost, false "still running" | watchdog: stall at 600 s log silence, thread dump, kill the tree | | CPU fan-out | every build slows, nobody knows why | block new background launches once half the cores' worth of workers are busy | | Shared phones | false failures, a phone taken mid-test | leases with a time limit; raw adb blocked | | Leaked processes | slow machine hours later | reap busy processes at session end | | Disk | builds fail at 100% | scheduled cleanup of regenerable files only; archive before delete | | Two machines | a fix on one host, not the other | validated 5-minute sync with a drift warning | None of this is about the model. It's the same work a team does when several people share one build server, done for agents that can't see each other. If you've inherited an app whose build and test setup already looks like this, that is the kind of thing I sort out in an app rescue. --- Originally published at codebyramiz.com.

  4. Retesting the same app flow after every UI change? 📱 Maestro uses readable YAML flows to automate taps, typing and assertions on mobile apps. 🧪 mobile-dev-inc/Maestro #MobileTesting #浇朋友 #蓝朋友 #互关 #fo

    34 views
  5. App 每改一次界面,登录流程都要手动重测?📱 Maestro 用 YAML 描述点击、输入和断言,自动测试手机 App。适合回归测试。🧪 mobile-dev-inc/Maestro #MobileTesting #浇朋友 #蓝朋友 #互关 #fo

    1 like · 44 views
  6. 6/ Gotchas from the docs: - Physical iPhones aren't supported yet. Simulators and Android phones work. - Claude Desktop needs the full path to maestro and JAVA_HOME set in its config. - After a CLI update, reconnect the MCP (/mcp in Claude Code). Repo: mobile-dev-inc/maestro

    21 views
  7. Reddit · · r/BestGitHubRepos · u/company_url_finder · mobile-dev-inc/Maestro

    Maestro: Painless E2E Automation for Mobile and Web (16.1k stars) What it does All text below is quoted directly from the project README. >Maestro is an open-source framework that makes UI and end-to-end testing for Android, iOS, and web apps simple and fast. >Hand-write your first YAML flow with the CLI in under 5 minutes, build flows visually in Maestro Studio, or add Maestro MCP to your coding agent for agentic UI testing. >Flows run on any emulator, simulator, browser, or physical Android device. Physical iOS devices are not yet supported. Why Maestro? >Maestro is built on learnings from its predecessors (Appium, Espresso, UIAutomator, XCTest, Selenium, Playwright), plus 3 decisions made early: drive the app through the platform's accessibility layer, install as a single binary with no drivers or SDK, and keep every test a flat list of YAML commands. - Human-readable YAML flows: express interactions as commands like launchApp, tapOn, and assertVisible. - Built for coding agents: the Maestro MCP server ships inside the Maestro CLI (maestro mcp, nothing else to install). Claude Code, Cursor, Codex, Grok Build, or any MCP client can control your app on a live device: inspect the screen, tap, scroll, assert, and check its own work while it builds. Ask for a flow when you want a repeatable test, that's agentic UI testing. - Framework agnostic: React Native, Flutter, native iOS and Android, Ionic, and hybrid apps all use the same commands. Meta uses Maestro to test React Native itself, and Expo supports Maestro as its preferred E2E testing platform. - Cross-platform coverage: one flow syntax for Android, iOS, and web, on emulators, simulators, browsers, and physical Android devices. - Resilience & smart waiting: built-in flakiness tolerance and automatic waiting handle dynamic UIs without manual sleep() calls. - Fast iteration & simple install: one install line, no drivers, no SDK, no dependencies; flows are interpreted, so there's no compilation step. First test in 5 minutes. Maestro MCP: Agentic UI Testing >Maestro MCP ships inside the Maestro CLI and works with Claude Code, Cursor, Codex, Gemini CLI, Copilot, Grok Build, or any MCP client. It gives your agent a live device (an emulator, simulator, or physical Android device), and Maestro Viewer embeds that device inside your coding agent, showing every MCP command as it runs. >Your agent can also write tests. Ask for an end-to-end flow and it writes plain YAML, runs it until it passes, and the flow stays in your repo as a deterministic test for CI. >Maestro requires Java 17 or higher to be installed on your system. >Maestro is open-source under the Apache 2.0 license, contributions are welcome! Repository mobile-dev-inc/Maestro Repository stats |Metric|Value| |:-|:-| |Stars|16,068| |Forks|989| |Last release|cli-2.11.0 (2026-09-29)| |Languages|Kotlin 85.5%, Objective-C 4.6%, Swift 4%| |Contributors|127| |AI contributor accounts (Claude / Codex / Cursor / Copilot)|claude (22 commits), cursoragent (2)| |AI co-authored commits|Claude: 22, Cursor: 2|

    5 points
  8. Repo: mobile-dev-inc/Maestro More of what I'm building: chuongle.dev

    4 views
  9. Library pick for solo iOS + Android devs: Maestro. UI tests as short YAML flows (launch, tap, check text) that drive a real simulator or emulator. It waits for the UI itself, so no sleep() hacks. Nice as a quick smoke run before each store build. mobile-dev-inc/Maestro

Trending awards, every completed day and week
Gold
01st place finishes
Silver
02nd place finishes
Bronze
03rd place finishes
Awarded repositories
0Owned by mobile-dev-inc now

No trending awards yet

None of the repositories mobile-dev-inc owns has finished in the top three of a completed trending day or week, by stars, forks or PR authors. See who is trending now.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.