Running React Native E2E Tests on my Kids' Mac Minis
September 16, 2026 · Andy from Giggable
Motivation
GitHub-hosted macOS minutes are a tax that I don’t want to pay. I’ll probably use Expo’s EAS Workflows for a short Android smoke on a canary rollout (that’ll be fun).
For the stage I’m at, neither is a good place to constantly run a full iOS Maestro suite against a local API, local web server, and a mutating test matrix that has to reseed the db between flows. In both cases, this gets pricey, complex, and hard to debug (and good god do e2e tests need to be debugged).
More Toil for AI
As much as e2e tests are a pain, the confidence of mobile e2e tests is crucial in this age where AI is making most of the code changes.
But the good news is, AI can help us both write AND debug them!
My Journey
Here was my timeline last week:
- Monday/Tuesday: I wrote a bunch of maestro tests and successfully ran them on my machine ☑️
- Wednesday: I realized i could use my kids’ minis to run maestro tests for my CI 💡
- Thursday: i set up all the prerequisite tools on their macs 🖥️
- Friday: I set up each as a self-hosted GHA runner, and worked late into the evening trying to get a green check mark on my build. 🤖
- Today: Just got my first green check marks from my self-hosted runners! ✅
Finished Setup
- When a path-filtered PR or a merge to
maintouches mobile, one of the minis first builds a Release Simulator.app - The full maestro suite is then split in half, each assigned to one mini.
- The minis then download the
.appartifact from step 1, then they set up the docker environment required to run the e2e tests. - After docker setup, the maestro tests run. Each mini:
- Launches a simulator
- Installs the
.app - Runs their assigned test suites.
You could probably pop that into your favorite LLM interface and it’ll guide you through the steps, but if you want to read and do it yourself old school (or if you are a robot, beep boop), read on.
YAML Config
My real github action file is longer. This is the shape that matters:
name: Mobile Maestro
on:
push:
branches: [main]
paths: ["apps/mobile/**", "packages/**"]
pull_request:
paths: ["apps/mobile/**", "packages/**"]
concurrency:
group: maestro-${{ github.ref }}
cancel-in-progress: true
# Self-hosted runners execute the checkout. Skip fork PRs.
jobs:
matrix:
if: github.event.pull_request.head.repo.full_name == github.repository ||
github.event_name != 'pull_request'
runs-on: ubuntu-latest
outputs:
devices: ${{ steps.resolve.outputs.devices }}
steps:
- uses: actions/checkout@v4
- id: resolve
run: ./scripts/resolve-devices.sh # JSON list of simulator catalog ids
build:
needs: matrix
if: github.event.pull_request.head.repo.full_name == github.repository ||
github.event_name != 'pull_request'
runs-on: [self-hosted, macOS, maestro]
steps:
- uses: actions/checkout@v4
- run: ./scripts/build-simulator-app.sh
# Zip the .app — artifact upload flattens raw directories.
- uses: actions/upload-artifact@v4
with:
name: app
path: dist/App.app.zip
test:
needs: [matrix, build]
if: github.event.pull_request.head.repo.full_name == github.repository ||
github.event_name != 'pull_request'
runs-on: [self-hosted, macOS, maestro]
strategy:
fail-fast: false
matrix:
shard: [0, 1]
device: ${{ fromJson(needs.matrix.outputs.devices) }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: app
path: dist
- run: ditto -x -k dist/App.app.zip dist
- env:
SHARD: ${{ matrix.shard }}
DEVICE_ID: ${{ matrix.device }}
run: ./scripts/run-maestro-shard.sh # local API + sim + Maestro
- if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-debug-${{ matrix.shard }}-${{ matrix.device }}
path: maestro-debug/
Setting Up the Minis
If I was going to pull this off, I needed to make sure that my kids’ sessions on their computers were not interrupted by the runner executions. Here are the setup details:
User/Account
I opted to create an admin user on each mini as my own name. I signed that account into my iCloud so App Store / Xcode licensing worked, but turned off Photos, Messages, and the rest of the iCloud sync that would quietly fill the disk. Some other things I had to do:
- Enable Fast User Switching, so someone can jump back to the family profile without logging the CI user out. Maestro needs an Aqua GUI session. If the CI user is not logged in at the console, Simulator and Docker Desktop are gone, and SSH-only “headless” is a trap.
- Remote Login (SSH) so I can debug a stuck runner from my computer.
- Screen Sharing is nice-to-have. When a Simulator is wedged, being
able to look at the desktop is faster than guessing from
simctl.
Tools
Tooling on both machines, kept in sync as much as possible (Maestro XCUITest flakes change personality across iOS runtimes):
- git
- Xcode + an iOS Simulator runtime, license accepted
- Node 22 via nvm, pnpm via corepack
- Docker Desktop (same GUI user as the runner)
- Maestro CLI 2.10.0
- CocoaPods and
xcbeautify(the build pipesxcodebuildthrough it) - Homebrew for brewing refreshing adult beverages
Then the named simulators. The workflow never stores UDIDs. It stores
catalog ids (iphone-17, iphone-se) and looks up a name on the
machine (Giggable CI iPhone 17). Create those names on both Minis
with xcrun simctl create. UDIDs will differ. That is fine. Maestro
gets the UDID at job time — passing the display name to
maestro --device fails with “not connected” even while the sim is
booted.
Manual Run
A manual run from the minis was the last step to verify setup was good to go!
Registering the runners
With the boxes all set, I moved on to registering the runners on Github, which was way easier than expected.
In the GitHub repo: Settings → Actions → Runners → New self-hosted
runner, macOS, ARM64. Follow Apple’s installer into
~/actions-runner.
Labels on both Minis: self-hosted, macOS, maestro.
The workflow is explicit:
runs-on: [self-hosted, macOS, maestro]
That is how they do not steal Linux jobs, and how Linux jobs do not
land on a Mini that is busy compiling an .app.
Install the runner as a LaunchAgent for the logged-in CI user
(./svc.sh install from the runner directory), not a LaunchDaemon.
The Simulator needs the GUI session. Auto-login that user, disable
sleep / screen lock that tears the session down, and set Docker Desktop
to start at login.
Fork PRs never run here. The workflow skips any pull request whose head repo is not this one. Self-hosted runners execute whatever is in the checkout. That is not a place for untrusted code.
What a run actually does
One GitHub-hosted ubuntu-latest job resolves the device matrix. That
is the only hosted job. Everything else is emini / rmini.
Build, once. One Mini compiles a Release Simulator .app, zips it
with ditto (GitHub’s artifact upload flattens raw directories and
destroys the bundle), and uploads Giggable.app.zip.
Test, as a matrix. shard ∈ {0, 1} × each selected device. PRs
use the default device only (iphone-17) so a typical change is one
build and two test jobs. main uses the full catalog. Manual
workflow_dispatch can pick default, all, or a comma list.
Each test job:
- Downloads and unzips the same
.app - Copies host secrets from
~/.config/giggable/api.env.e2einto the fresh checkout (the work tree is ephemeral and gitignored.envfiles are never in git) - Brings up local Postgres/Redis, migrates
giggable_e2e, seeds, startsapi:dev:e2eand Vite on loopback - Shuts the catalog simulator down, writes LaunchServices
schemeapproval.plistsogiggable://does not pop “Open in Giggable?”, then cold-boots - Installs the app, runs that shard’s Maestro flows against the UDID
- Reseeds after mutating flows (
pnpm api:e2e:reset) - On XCUITest “failed to connect” flakes, retries the flow once
Failed shards upload Maestro debug output. Green shards do not.
Concurrency is mobile-maestro-${{ github.ref }} with
cancel-in-progress: true. Pushing another commit to the same PR
cancels the previous run instead of queuing a second Simulator war.
The LaunchAgent PATH tax
Interactive Terminal on these Minis has nvm, ~/.maestro/bin, and
Docker Desktop’s CLI. A LaunchAgent runner has whatever you put in
~/actions-runner/.env. $HOME is not expanded there. Use absolute
paths.
The failures all look like “works over SSH, exit 127 in Actions”:
dockerlives in~/.docker/bin, not/usr/local/binmaestrolives in~/.maestro/bin; the installer only edited~/.zshrcjavaat/usr/bin/javais Apple’s stub. Maestro is a JVM. emini needsJAVA_HOMEpointed at sdkman; otherwise set it from/usr/libexec/java_homenode/pnpmcome from nvm. Job scriptssource ~/.nvm/nvm.shandnvm use. Do not assume a login PATH
The shard script also opens Docker.app and polls if the engine is
asleep. A stale ~/.docker/run/docker.sock is common after the Mini
naps.
Final Thoughts
Some final take-aways to keep in mind:
- Watch the disk. A single
ios:e2e:ciDerivedData tree is many gigabytes. If you leave it across checkouts and the nextlipodies withNo space left on device. Fail early under 15GB, delete.ci-derived-dataaround the compile, and keep iCloud Photos off the CI user. - Dedicated macOS users on hardware that was already on. Fast User Switching instead of stealing the family login.
- Labels specific enough that these runners cannot pick up a random Linux job.
- One build artifact, many Maestro shards.
- A local database per machine, not a shared one. Secrets on disk outside the Actions work tree.
- Simulator CI is a logged-in desktop that happens to also be a runner. Treat it like a computer someone sits at, because that is what Xcode still is.
Pre-AI, this would have been quite a bear to set up, but we’re in a world where we can delegate much of the nitty gritty parts to an agent.
Now if I could only get my kids to charge me less on my compute…