Mobile end-to-end testing — which servers, in which order, and why¶
Running the native suites means standing up the right backends before building the app. Almost every failure in this tier is a precondition that was not met, and none of them say so: they surface as a missing control, a blank screen, or an assertion about the wrong thing entirely.
This guide is the map. Read the failure table at the bottom first if something is already broken.
The one thing that causes the most lost time¶
The app bakes its API base at BUILD time. VITE_API_BASE_URL is read by npm run build and
compiled into the bundle; nothing at runtime can repoint it (the dev/prod switch only chooses
between the baked value and the live site — see src/services/tier.ts).
So a build and a backend are a matched pair. Rebuilding against the wrong port, or forgetting to rebuild at all, produces an app that looks completely normal and silently talks to the wrong place.
Two consequences worth internalising:
- Running the suites does not rebuild the app.
make ios-contact-sheetandtest-app-ios-journey-uirun tests against whatever is installed on the simulator. A whole contact sheet was once reviewed showing sheet geometry from a build two fixes old. npm run buildwith noVITE_API_BASE_URLfalls back to production. The bundle then points athttps://closelistening.app/api/appand every fixture assertion fails against the live site.
make ios-app-install exists so you never do this by hand. It builds against the single origin,
installs, and then verifies the installed bundle and that the origin serves audio, failing loudly
if either is wrong.
The servers¶
| Port | What | Started by | Serves |
|---|---|---|---|
8011 |
Fixture API container (lp-e2e-api) |
make app-e2e-api-up |
/api/** only |
18765 |
Fixture media host | make ios-origin-up |
the episode .mp3 files |
4174 |
Single origin (vite preview) | make ios-origin-up |
/api → 8011, /audio → 18765 |
Always build against :4174, not :8011¶
The corpus stores content.media_url as a relative /audio/<id>.mp3, and resolveMediaUrl()
absolutises it against the API base. Point the app at :8011 and every audio URL becomes
http://127.0.0.1:8011/audio/<id>.mp3, which does not exist.
The episode page then renders "Couldn't load the audio from the source. Try again later." with no transport at all — so a test looking for a Play control reports "no Play control", which reads like a UI regression and is really a missing media host.
:4174 fronts both, which is also the shape production has (one origin for API and media).
Order of operations¶
make ios-origin-up # 8011 + 18765 + 4174 (app-e2e-api-up is implied)
make ios-app-install # build against :4174, install, VERIFY the bundle
make ios-journey-signin # mint a native session — AFTER the install, see below
make test-app-ios-journey-ui # the assertion suites
make ios-contact-sheet # install + signin + seed + tour + grid (does all of this itself)
The order of those middle two matters. ios-app-install pulls in app-e2e-api-up, which
recreates the fixture api container — so an account minted BEFORE the install is destroyed moments
later, and the app is left holding a token for a user that no longer exists. Every auth-gated
surface then returns empty and the suites fail as "element not found", which reads like a UI
regression rather than a wiped backend. ios-contact-sheet now sequences this for you.
Tear down with make ios-origin-down && make app-e2e-api-down.
Installing the app WIPES the backend — always re-seed after it¶
ios-app-install depends on ios-origin-up → app-e2e-api-up, and that target tears the fixture
api container down and re-seeds it from the corpus. Every board, favourite, colour, chosen
interest and minute of listening history created by a previous run is gone.
So "the data is already seeded, I'll just re-run the tour to save ten minutes" is wrong, and it
fails in a way that looks like a UI regression: the tour shoots Home and Discover fine, then misses
the Library sub-tabs, the profile tabs and every entity surface, because there is nothing in them
to tap. Re-running ios-contact-sheet end to end is the cheap path; the seeding suites regenerate
everything as a side effect of asserting on it.
The session token survives (the session secret is a fixed env var); only the DATA does not.
Sign-in is a precondition, not part of the suites¶
Every consumer surface is auth-gated — the server 401s anonymous reads — and the journey suites do
not sign in. make ios-journey-signin mints a session through the mock provider's native flow and
writes it into the app's durable store. Skip it and the suites fail on empty surfaces.
Write through the preferences daemon, never the container plist directly; the plist lags and the two views converge lazily. The target already does this.
Suites and their preconditions¶
These differ per suite, and the bundle-wide -only-testing:OfflineSpikeUITests runs them all —
including ones whose harness you have not started. Use the make targets.
| Suite | Needs | Target |
|---|---|---|
AppJourneyTests, PersonalisationTests, OfflineCacheTests |
signed in, network up | test-app-ios-journey-ui |
NativeCapabilityTests |
signed in + host permissions (mic, photo) | (journey tier) |
DownloadThroughUITests |
single origin, network up | test-app-ios-sim-download |
OfflinePlaybackTests, OfflineAutoAdvanceTests |
episodes ALREADY downloaded, then everything down | test-app-ios-journey |
ScreenshotTourTests |
a POPULATED account — runs after the journey suites | ios-contact-sheet |
Suites also inherit each other's leftovers today; see issue #2091 for the per-suite setup/teardown
plan. Until that lands, prefer the make targets over ad-hoc -only-testing selections.
Failure → cause¶
| Symptom | Actual cause |
|---|---|
| "no Play control" / "Couldn't load the audio from the source" | App built against :8011; no /audio. Rebuild via ios-app-install. |
| Every assertion fails at once, app looks fine | Bundle points at prod — VITE_API_BASE_URL was missing at build time. |
| Empty accessibility inventory; app appears blank | iOS's "Open in 'Close Listening'?" SpringBoard prompt is up (it re-appears after a reinstall). While it is, the app is not frontmost and every query returns empty. AppSession.openEpisode taps it. |
| "neither Sign in nor Sign out present" | The profile entry point is labelled with the display name, not "Your profile". Use Journey.openProfile. |
| Frames missing from the middle of the contact sheet | A sheet was left open and swallowed every later tap. Look for =====SHEETS_STUCK=====. A card's own ✕ can scroll out of the viewport (seen at y = −619). |
| Screenshots don't show a fix you just made | The app was not rebuilt. Suites do not rebuild; ios-app-install does. |
| Tour shoots Home/Discover then misses Library sub-tabs, profile and entity surfaces | The backend was re-seeded (any ios-app-install) and the seeding suites were not re-run. The app is empty, so there is nothing to tap. |
A card cannot be dismissed; SHEETS_STUCK |
An entity card labels its dismiss control Back, not Close, when dismissAtRoot is false. A Close-only search finds nothing. |
MODULE_NOT_FOUND from a node step you ran BY HAND |
The cmux NODE_OPTIONS shim: it injects --require=$TMPDIR/cmux-claude-node-options/restore-node-options.cjs, and macOS purges $TMPDIR, so the path stops existing while the exported value lives on. Make targets are already immune (unexport NODE_OPTIONS, Makefile line 17). Only hand-run npm/npx/node needs env -u NODE_OPTIONS. |
A make ios-* target never returns, though its work plainly finished |
It was piped (\| grep, \| tail). ios-origin-up leaves a backgrounded vite preview holding the pipe open, so the reader never sees EOF. Redirect to a file (> /tmp/x.log 2>&1) and read that; never pipe these targets. |
| Suite passes but shot fewer screens than expected | Best-effort frames. The tour now asserts its full list; older runs did not. |
Reading results¶
xcodebuild output is filtered by the make targets. Diagnostics are printed as =====TAG=====
lines (INVENTORY, TAP_MISS, SHEETS_STUCK, SHOT), so any filter must keep ===== or you lose
the only explanation of a failure.
For a failed run, the assertion text lives in the result bundle:
RES=$(ls -td /tmp/lp-ios-dd-uitests/Logs/Test/*.xcresult | head -1)
xcrun xcresulttool get test-results tests --path "$RES"
A bundle with no Info.plist is still being written — take the next one down.
Contact sheet¶
make ios-contact-sheet seeds by running the journey + personalisation suites (they create boards,
favourites, colours, interests and listening history as a side effect of asserting on them), tours
every surface, and stitches the frames into one labelled grid.
At native resolution the grid lands around 82 MP, which macOS Preview will not open, so
contact_sheet.py also emits row-aligned -partN.png files. Cuts fall between rows — splitting
on a fixed pixel height slices screenshots in half.
Screenshots for UI work: use the WEB app, not the simulator¶
Default to the browser. A web screenshot is seconds; the iOS path is minutes — rebuild the
bundle, cap sync, xcodebuild, install, re-seed the session, launch, navigate, export attachments,
resize. Whole review cycles have gone into producing one picture of a CSS change (operator
2026-09-17).
make ios-origin-up # once: api + audio on :4174
# then, in a browser at 127.0.0.1:4174 — sign in through the fixture provider:
# fetch('/api/app/auth/login?as=simtest&platform=web', { credentials: 'include' })
The player renders the same components either way, so a layout change looks the same in both. Reach for the simulator only when the thing under test is genuinely native:
- Capacitor plugins — dictation, push, the photo picker,
Preferences - The
capacitor://localhostorigin — anything where a relative URL or a cookie behaves differently from web (the avatar and collection-cover bugs were both of this kind) - Offline / downloaded playback
- The operator explicitly asks for an iOS screenshot
Everything else — spacing, clamping, ordering, tab state, icon rendering — verify on web.
A contact sheet is TWO sheets: mobile and desktop¶
Standing instruction (operator 2026-09-17). Every contact sheet request means one sweep at phone width and one at desktop width, delivered together.
Days of layout work were reviewed at phone width alone, and the desktop rendering of the same components was never looked at — so a change that read correctly on a phone left a 144px column of wrapped text beside 900px of empty space on a 1440px screen, and nobody saw it. The app has four surfaces (iOS app, Android app, mobile browser, desktop browser) sharing ONE web build, so a single viewport can never be evidence that a layout is right.
Fixes are universal too: no mobile-only patch that leaves desktop broken, and no sm:-gated
half-fix unless the two viewports genuinely need different compositions — and when they do, both are
driven from one source value rather than two copies that can drift.