Table of Contents
The Setup
PR description box says "screenshots required for any customer-facing UI change." The PR is a mobile RBAC parity ticket: gate write affordances by role on the React Native app to match what the web already does. Three roles to capture: viewer, driver, org admin. The whole point of the change is that viewer should NOT see the pen icon, the "Add document" button, or the "Scan permit" affordance. Org admin should see all three.
Web is a one-liner with the brand-screenshot skill: log in, inject a session cookie, drive Playwright. Mobile is its own world. No browser automation, no cookie injection, no DOM. React Native runs on a real (or simulated) device, talks to Auth0 over OAuth, and accepts touch input. The closest primitive is Maestro.
This is the story of getting Claude Code to drive a booted iPhone simulator end to end: launch the app, log in as a provisioned test identity, navigate to four surfaces, capture screenshots, and report back. With every blocker I hit and every wrong turn I took, in case you ever have to do it.
Why Mobile Is Harder Than Web
Three things make mobile UI automation strictly harder than web from an agent's point of view.
- No DOM, no cookies, no devtools protocol. On web, you can paste a session token and skip login entirely. On mobile you can't. The OAuth flow has to actually run, and the app stores the token in the keychain.
- State leaks across runs. A web browser can be torn down per test. A simulator persists app state, keychain entries, cached responses, and iOS system dialogs ("Save Password?", notification permissions, location prompts). Every flow has to be defensive.
- The driver app wedges. Maestro's XCTest runner sometimes returns
kAXErrorInvalidUIElementafter a successful launch transition and locks up. You won't see this on web because the agent talks to Chrome over CDP, not through a third-party harness running INSIDE the browser.
None of this is unsolvable. It's just more moving parts to coordinate, and the agent has to know about every one of them to keep going when something breaks.
The Stack: Maestro + Auth0 + SWEny
The toolchain breaks into layers, each owning one concern.
- Maestro drives the simulator. YAML flows that say "tap this id, type this text, wait until that text appears, take a screenshot." Selectors are id (testID props), visible text, or coordinates. Open source and free for local runs.
- Auth0 machine-to-machine provisioning owns the test identity. A
client_credentialsgrant mints a Management API token, and the runner uses it to create or reset each persona with a known password and a verified email. The first cut polled Apple Mail's SQLite envelope index for a login OTP. Owning the identity outright is simpler and has no mailbox to babysit. - SWEny orchestrates the run as a workflow DAG: provision personas, then one capture case per role, then aggregate. The cases depend only on the setup node, never on each other, and every node runs in its own clean, isolated context. That isolation is the reliability property. A wedged step, a renamed button, or a failed role is contained to its own node and can't poison the rest of the run.
- A Claude agent is the executor inside each node. It drives the Maestro flow, adapts to the live UI (a renamed control becomes a finding, not a false-fail), and makes the pass/fail call. Claude executes within a node. It does not orchestrate the DAG.
- Deterministic bash owns the exact bits inside each node: XCTest driver hygiene, Auth0 M2M provisioning, fixture staging, writing screenshots into the repo. The pattern got picked up from a previous rbac-verification pack and adapted.
None of these is novel. The combination is what makes it work. Each layer is dumb on its own and only does one thing.
The Pipeline
End to end, here is what SWEny runs every time, as a DAG of isolated nodes rather than one long loop:
It isn't one monolithic script. SWEny runs each box as a discrete node with its own clean context, and a capture case depends only on the setup node, never on a sibling. No persistent daemons, no state shared in memory across steps, no sockets. Every node can be re-run in isolation, which is exactly why one wedged step can't rot the rest of the run.
Owning the Test Identity
The first thing I tried was password login through the hosted web form. It seemed obvious: the seed creates Auth0 users with a known password, the login screen has a "Use password instead" link, type and submit. Done. Except iOS pops a system "Save Password?" dialog the first time you sign in through a web form. That dialog covers the screen, blocks every Maestro selector, and the defensive tapOn: "Not Now" step races against the dialog actually appearing. The pipeline became a coin flip.
So I detoured to email-OTP. No password to save means no dialog, which fixed the coin flip. But it traded one problem for a worse one: the agent now had to read a verification code out of a real inbox before it expired, which meant polling Apple Mail's SQLite envelope index on every login. It worked, and it tied the whole pipeline to a mailbox.
The real answer was to stop reading credentials and start owning them. The test tenant has an Auth0 machine-to-machine app. A client_credentials grant mints a Management API token, and with that token the runner idempotently provisions every persona: a known password, email_verified: true, on the Username-Password-Authentication connection. Re-running just resets the password, so the credential is always known. No human, no email, no mailbox.
# 1. Mint a Management API token with the M2M client credentials.
token=$(curl -sS -X POST "https://$AUTH0_DOMAIN/oauth/token" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "'"$AUTH0_M2M_CLIENT_ID"'",
"client_secret": "'"$AUTH0_M2M_CLIENT_SECRET"'",
"audience": "https://'"$AUTH0_DOMAIN"'/api/v2/"
}' | jq -r .access_token)
# 2. Idempotently provision the persona with a KNOWN password and a
# verified email. If the user already exists, PATCH
# /api/v2/users/{id} to reset the password instead of POSTing.
curl -sS -X POST "https://$AUTH0_DOMAIN/api/v2/users" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d '{
"connection": "Username-Password-Authentication",
"email": "'"$email"'",
"password": "'"$KNOWN_PASSWORD"'",
"email_verified": true
}'Login then standardizes on password everywhere, with one detail that closes the loop. On native iOS the app signs in through the Auth0 SDK's password-realm grant ( loginWithPasswordRealm), a direct API call, not a Safari web form. No web form means iOS never offers to save the password. The dialog that sent me to OTP in the first place simply never appears, and there is no mailbox anywhere in the path. The email_verified: true at provision time also kills the OnboardingScreen bounce that seeded users hit before.
Test preconditions (loads, shares, routes) get built the same way: by calling the real product services in-process ( LoadsService.create, SharedAccessService.createShare) against a booted app context, not by driving the UI or fabricating raw SQL. A Maestro UI session hands you no extractable bearer token, and the dev SPA client has no resource-owner-password grant, so there is no user JWT to curl from bash anyway.
Patterns Worth Stealing
Five patterns generalize beyond this PR.
testIDs at write time
The login flow is rock-solid because someone added testID="login-email", testID="login-code", and friends when the screen was authored. Anywhere else in the app, you fall back to text matching that breaks on copy changes. If you KNOW a screen is going to be a test target, put the testIDs in at write time. Retrofitting later is another PR plus a build.
Selector priority
Reach for id: first. Visible text second. Coordinate taps last. Coordinates work for one device size and break the moment iOS bumps to a new simulator default. Tab labels like "Scan Permits", "Docs", "All Projects" are stable text that the user actually sees, and they survive layout changes.
One concern per tool layer
Maestro drives the UI. Bash mints tokens and provisions identities. SWEny orchestrates the DAG and keeps each node on its own clean context. Don't merge them. Don't try to do an Auth0 token grant inside a Maestro JS runScript (the sandbox has no shell exec and no secrets). Don't try to do UI assertions in bash. Each layer stays small and testable on its own.
Defensive launchApp
Every flow that isn't the very first launch should open with launchApp: stopApp: false. It costs one line. It saves the entire pipeline from iOS's background-on-detach behavior. There is no downside.
Lift, don't rewrite
Before re-deriving the simulator runner from scratch I almost wrote it from a spec. The team already had a battle-hardened version at projects/sweny-e2e-rbac/.../mobile-cases/lib/case-runner.sh with retry logic, stale-process killing, and timeout wrapping. Lifting it cost ten minutes. Rewriting from spec would have cost an hour and a half and produced something worse. Past attempts are an asset.
What I Got Wrong
An honest section because the agent took several wrong turns and the lessons are more valuable than the recipe.
- I started with password login through the web form because it looked simpler. Two hours later I had the iOS save-password dialog blocking selectors and was writing fragile workaround logic. OTP dodged the dialog but chained me to a mailbox. Provisioning known-password personas and signing in through the native password-realm grant was the move I should have reached for first.
- I dismissed the past pack as "elaborate friction." The user called me on it. The XCTest reset, the retry-on-kAXError logic, the timeout wrapping, all of that was not friction. It was the working pattern. I just hadn't read it carefully enough.
- I tried to commit before I had screenshots. The pipeline ran end-to-end through OTP entry, and I declared victory and started writing the PR description. The user pointed out, accurately, that there were zero actual screenshots in the artifact.
- I assumed environment was the agent's problem. Seeded test users bounced to OnboardingScreen on every login because their
email_verifiedwas never set, and I treated it as a blocker on this PR when it wasn't. Provisioning each persona withemail_verified: truenow handles it at the source, so the bounce is gone.
Try It
If you want to wire this up for your own React Native app:
curl -Ls "https://get.maestro.mobile.dev" | bash
brew install openjdk@17 # required by Maestro
brew install coreutils # for timeout(1) on macOSAdd testIDs to your login screen for the email input, password input, and sign-in button. Boot a simulator and install your app via npx expo run:ios (or your platform equivalent). Stand up an Auth0 machine-to-machine app with users:create and users:update on the Management API, and provision your test personas with known passwords and email_verified: true before the run.
Lift the four flow YAMLs and the bash runner from the rbac-verification pack (linked below), swap the bundle id, point them at your provisioned personas, and run. The first round will fail in some interesting way. Read the LEARNINGS doc before assuming it's your code.
Resources
Official documentation for workflows, skills, the CLI, and provider configuration.
The mobile UI automation tool used throughout this post.
The agent that executes each node of this pipeline.
Companion post on autonomous SRE workflows with SWEny.
The web counterpart to this post: agents driving end-to-end browser flows.
The full pattern, including the LEARNINGS and PATTERN reference docs lifted from this work, lives at projects/sweny-e2e-rbac/e2e/rbac-verification/mobile-cases/ in the Offload monorepo. If you're building something similar, start there.
See also: The Anatomy of a Claude Skill for how to wrap the bash runner into a reusable skill, and From Cron to Real-Time for the agent reliability patterns that translate from SRE to mobile QA.
