Giggable

Tech blog

Running React Native E2E Tests on my Kids' Mac Minis

September 16, 2026 · Andy from Giggable

A Mac Mini with GitHub Actions, Maestro, and React Native logos

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

  1. When a path-filtered PR or a merge to main touches mobile, one of the minis first builds a Release Simulator .app
  2. The full maestro suite is then split in half, each assigned to one mini.
  3. The minis then download the .app artifact from step 1, then they set up the docker environment required to run the e2e tests.
  4. After docker setup, the maestro tests run. Each mini:
    1. Launches a simulator
    2. Installs the .app
    3. 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 pipes xcodebuild through 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:

  1. Downloads and unzips the same .app
  2. Copies host secrets from ~/.config/giggable/api.env.e2e into the fresh checkout (the work tree is ephemeral and gitignored .env files are never in git)
  3. Brings up local Postgres/Redis, migrates giggable_e2e, seeds, starts api:dev:e2e and Vite on loopback
  4. Shuts the catalog simulator down, writes LaunchServices schemeapproval.plist so giggable:// does not pop “Open in Giggable?”, then cold-boots
  5. Installs the app, runs that shard’s Maestro flows against the UDID
  6. Reseeds after mutating flows (pnpm api:e2e:reset)
  7. 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”:

  • docker lives in ~/.docker/bin, not /usr/local/bin
  • maestro lives in ~/.maestro/bin; the installer only edited ~/.zshrc
  • java at /usr/bin/java is Apple’s stub. Maestro is a JVM. emini needs JAVA_HOME pointed at sdkman; otherwise set it from /usr/libexec/java_home
  • node / pnpm come from nvm. Job scripts source ~/.nvm/nvm.sh and nvm 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:ci DerivedData tree is many gigabytes. If you leave it across checkouts and the next lipo dies with No space left on device. Fail early under 15GB, delete .ci-derived-data around 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…