The local stack that found a sign-out bug as old as the app
Homies, the bilingual React Native app I build privately on the side, has a Supabase backend. supabase start brings that whole backend up on your own machine in Docker containers: Postgres, the auth server, the API gateway, a mailbox that catches outgoing email. On this Mac my Docker runtime was Colima, and under Colima supabase start did not start.
This post is about what I did instead, what that could and could not test, and what the first day the stack did run found: a sign-out bug as old as the app's git history, sitting for eleven weeks beside a flow that could have caught it. The lesson is about test state more than tools. A flow that depends on the state a previous run left behind only finds its bugs when reruns are cheap.
Three sessions lost to a stack that would not start
Agent sessions working on database tasks lost three runs to the missing local stack: two in two days, then a third. The issue filed that day lists them:
- One could not generate types, because the CLI runs that tool inside Docker and the container could not reach a database on the host's loopback address. It worked around it by running the tool on the host.
- One reached for
supabase start, which is the natural reading of "a disposable Supabase environment", and died on this:
docker.sock: operation not supported- One replayed the whole migration chain by hand on a Homebrew Postgres, got 96 of 96 migrations applied, and then needed the API gateway, which exists only in Docker.
Two of the three ended with no code written. None of the three was a code defect. Each session had rebuilt the same recipe from scratch, and the third had to correct the recipe recorded in the issue before it could use it.
I do not remember spending time on the mount error itself. The decision the issue records is the other one: stop needing Docker for database work.
Replacing Colima
The harness was written that day and reached main six weeks later. It is one script with three verbs: start <name> creates a throwaway PostgreSQL 17 cluster from the Homebrew binaries on a loopback port between 55440 and 55449, applies a vendored baseline of the schemas the platform provides, replays every committed migration, and prints a URL the repo's SQL contract runners accept; teardown is safe to repeat, and a failed start tears itself down.
The recipe it froze is the part the sessions kept losing: pg_cron and pg_net both preloaded before the first start, and LC_ALL=C, because PostgreSQL 17 on macOS refuses to start its postmaster under a UTF-8 locale. But the harness is only a database, with no auth server, no gateway and no mailbox. The app could not sign in to it, so the app's Maestro flows kept running where they always had, against the production backend, signed in as a seeded reviewer household.
Four days after the harness reached main, I replaced Colima with OrbStack, and supabase start worked with main as it is. Its first run failed with 42P07, relation "users" already exists, for a reason never established. Several sessions share this machine, so the stack is started only when a task needs it, treated as shared, and stopped by the session that started it.
What the local stack still could not do
It could not run the sign-in flow. The CLI's built-in sign-in email carries a link and no code, the app signs in with a six-digit code, and there was no local account to sign in as.
An earlier attempt at this was thrown away, because it got the smoke flow to pass by opening a sign-in link and patching the database in the middle of the test, which tests a path the app does not use.
What replaced it leaves the app and the flows unchanged:
- a local-only email template that includes the code;
- a provisioner that builds the fixture household through the app's own API paths, and refuses any URL whose host is not loopback;
- a watcher that polls the local mailbox and puts the newest code on the simulator's clipboard, which is where the login subflow's paste button already looks for it.
What a fresh simulator exposed
Running the tiers on a new simulator against a new household showed what the long-lived reviewer setup had been hiding. On the current iOS runtime, Maestro's openLink raises the system "Open in Homies?" sheet on every open. No flow answered it, so it failed that flow and every flow after it. Every openLink step now goes through one subflow that answers the sheet if it appears:
# .maestro/subflows/open-link.yaml (the steps)
- openLink: ${LINK}
- extendedWaitUntil:
visible:
text: "^(Open|פתיחה)$"
timeout: 4000
optional: true
- runFlow:
when:
visible:
text: "^(Open|פתיחה)$"
commands:
- tapOn:
text: "^(Open|פתיחה)$"A household that has not finished setup also pushes some rows below the fold, and those taps now scroll first. No assertion was loosened. On a Release build the smoke tier passed 5 of 5 and the read-only tier 22 of 24; both failures are flows that cannot pass on that setup by construction.
The bug the first day found
Before it passed, the auth smoke flow failed four runs in a row, every time it had to sign out first.
Signing out happens in a sheet that sits above the tab layout. useSignOut ended like this:
router.replace("/(onboarding)/onboarding");replace swaps the top screen. The top screen was the sheet, so the tab layout stayed mounted underneath onboarding. That layout has an auth-loss guard, added in the first week of the app's git history for sessions revoked by the server: when there is no session and auth is not loading, replace with onboarding. It was still mounted, so it was still listening. The moment the signed-out user typed an email and tapped Continue, the auth loading state toggled, the guard fired, and the login screen was replaced with a fresh welcome screen. The code had been emailed and there was no screen left to type it into. Only a force-quit recovered.
Each half is correct alone. useSignOut has not changed since the app's git history begins, and the guard does what its commit says.
The fix is the same line in both places:
if (router.canGoBack()) {
router.dismissAll();
}
router.replace("/(onboarding)/onboarding");Pop everything above the tabs, then replace. It is in useSignOut, and in the guard itself, which had the same fault whenever a session was lost under a modal.
Why no earlier run hit it
I do not know, and the repo cannot say.
The bug needs no backend. The production backend would have shown it just as well. And the flow that walks into it was not new: auth-smoke.yaml has signed out through the sheet and back in by email for more than two months. It takes that branch only when it starts signed in, which happens because clearState wipes the app's data and leaves the keychain, where the session lives. So the bug and a flow that could catch it sat side by side for eleven weeks.
What I can say is what changed. Against production, the flow's own header makes putting the code on the clipboard a manual step before each run. On the local stack the watcher does it, so the flow could be run again and again from whatever state the last run left, and a run that starts signed in is the state the bug needs. That is my reading of the setup; no record of earlier runs exists to check it.
What guards it, and what does not
- The jest test pins a call order.
useSignOut.test.tsxmocks the router and asserts thatdismissAllis called beforereplace, that it is skipped when there is nothing to pop, and that a failed sign-out navigates nowhere. It cannot see a mounted layout, so it would pass against any router in whichdismissAlldid nothing. - The guard's half has no test. Nothing renders the tab layout and takes the session away.
- What
dismissAlldoes is a source reading. In the installed expo-router, 56.2.9, it queues aPOP_TO_TOP. I read that; I did not instrument it. - The flow that found the bug is the real guard, and nothing runs it on a schedule. The repo's Maestro workflow is manual dispatch, and when I checked, GitHub listed no run of it, ever. The release script runs the smoke tier before a build and accepts
--skip-e2e. And the flow reaches the bug's path only when it starts signed in. - The local-stack run cannot become a CI job on my runner as it stands. The Docker socket is a link into OrbStack's directory under my home, which only my user can enter, and CI on this machine runs as a different user.
- A phone has seen it. After the fix I signed out and back in by email on a real device, and it works. No test stands behind that, only my having done it.