QA iOS Simulator — vellum-assistant iOS (Capacitor)
Real native verification: actual WKWebView, actual Swift, actual Capacitor plugins, on an iOS simulator. Slower than qa-web-ui — reach for it only when the behavior under test is native.
Requirements
- macOS with Xcode (full, not just CLT), an iOS simulator runtime,
bun,xcodegen QA_REPO_DIR= a git worktree of vellum-assistant on the branch under test (never test in your main checkout)- The
qa-mock-backendstandalone server (this skill's sibling)
One-time setup
iOS runtime: xcodebuild -downloadPlatform iOS (~8GB, once per Xcode major; persists across reboots). Long download — run backgrounded (nohup ... &) and poll rather than blocking a shell timeout.
End-to-end procedure
- Worktree build: in
$QA_REPO_DIR/clients/web:bun install→VITE_PLATFORM_MODE=true bun run build→bun run ios:setup(cap sync + xcodegen). - Mock backend: run the
qa-mock-backendbun server (DIST=$QA_REPO_DIR/clients/web/dist PORT=<free port> bun mock-server.ts). Script the exact conversation state the test needs. - Point the app at it:
capacitor.config.ts→server: { url: "http://localhost:<port>/assistant", cleartext: true }, then re-run cap sync. ⚠️ Check your shell for aVELLUM_ENVIRONMENTexport — a production value silently overrides the config; forceVELLUM_ENVIRONMENT=devfor the sync.Info.plistneedsNSAllowsArbitraryLoadsfor local http. - XCUITest target: add to
project.yml(includingscheme.testTargets), test file underUITests/. Worktree-only — don't commit the target unless asked. - Run:
xcodebuild test -destination 'id=<sim-udid>' -derivedDataPath /tmp/ios-dd. Get the udid fromxcrun simctl list devices available. When you need the app's bundle id (install/uninstall/launch/simctlcalls), read it from the built.app'sInfo.plist(CFBundleIdentifier) rather than assuming it matchescapacitor.config.ts—xcodegencan emit a different id than the Capacitor config declares. - Video proof:
xcrun simctl io <udid> recordVideo <file>.movduring the run, convert via ffmpeg (libx264,-profile:v main -level 4.0 -pix_fmt yuv420p -r 30), deliver. - Teardown when done (do it even on failure): kill the mock server by PID (
pgrep -flthenkill— notpkill), delete the worktree and/tmp/ios-dd,xcrun simctl shutdown <udid>, and delete the run's video/artifacts once delivered. Confirm nothing is left withpgrep -fl 'mock-server|simctl|ffmpeg'. A failedxcodebuild testmust not leave the mock server, simulator, or multi-GB worktree/node_modulesbehind — that's how disk/fd pressure builds. Checkdf -h <workspace>before the build; keep the worktree only while its PR is open.
Gotchas (each one cost real debugging time)
- WKWebView caches index.html hard — uninstall/reinstall the app in the simulator after changing the served bundle.
- Synthetic XCUITest taps do NOT reliably fire React
onClickon WKWebView content. Both coordinate.tap()/.press()and a11y-element taps can resolve onto the button's exact frame and still not trigger the React handler — the web layer never sees a real pointer event. Web text is also not reliably exposed asstaticTexts. For anything web-rendered, don't drive it through XCUITest at all: trigger the interaction from inside the web layer by injectingdocument.querySelector('<selector>').click()into the servedindex.html(via the mock, see gotcha #4), and time your capture off a beacon log. Reserve XCUITest taps for genuinely native controls (menus, alerts, keyboard) and assert on native artifacts (app.menuItems,springboard.alerts). - The notification permission alert appears late and blocks/obscures the screen. It fires after the push-token POST, so it can eat your first tap and sit on top of any screenshot — it dims the whole view with a scrim, ruining color/pixel captures. Do not rely on
xcrun simctl privacy grant notifications <bundle-id>(returns "Operation not permitted", re-fires every launch), and do not try to suppress it from the web bundle — the prompt is native (@capacitor/push-notificationsrequestPermissions()), so gating JS never reaches it. Two ways to clear it, by context:- Inside an XCUITest run:
addUIInterruptionMonitor+ tap Allow on the springboard alert (a direct springboard button tap is most reliable). Accept it, don't just dismiss, so later captures are clean. - Driving manually / scripting
simctlscreenshots without a test target (the common case for one-off visual checks): use the computer-use skill. Recipe: (a) confirm ahost_cudesktop client is connected (assistant clients list --capability host_cu— availability can flip between runs, re-check); (b)computer_use_open_app "Simulator"to bring the sim window frontmost — critical: if another window (e.g. the Electron shell) overlaps the sim, the alert's buttons are NOT in the queried accessibility tree; (c)computer_use_observe— the alert'sAllow/Don't Allownow appear as AX elements with IDs; (d)computer_use_clickbyelement_id(reliable — CU operates in desktop screen space, so raw coordinates from asimctlscreenshot won't map). Thenxcrun simctl io <udid> screenshotgives a scrim-free capture. Pitfalls: the firstobservemay return a stale "Accessibility permission not granted" error — retry, a real click/observe returns the tree; a click can trigger a macOS screen-recording nag that drops and restarts the CU client (its client ID changes — re-fetch fromclients list);computer_use_run_applescriptcan't find the alert (window 1AX path fails with -1728) — useopen_app+element_idinstead.
- Inside an XCUITest run:
- Debug visibility inside WKWebView: no devtools attached — inject beacons by rewriting the served
index.html(see qa-mock-backend "Debugging the frontend through the mock"). Beware quote-escaping through generator layers. cap sync/xcodegen generateregenerate the project and wipe your rig edits. Every re-sync rewritesInfo.plist(droppingNSAllowsArbitraryLoads) and regenerates fromproject.yml(dropping any UITests target you added by hand). Re-apply the ATS entry and the test target after each sync, or bake them intoproject.ymlsoxcodegenreproduces them. Never edit the generated.xcodeprojand then re-sync.- All the qa-mock-backend contract rules apply (numeric contentOrder ids, SSE
/events/, 404-not-wrong-200, consent field names). - Always include a negative control in the test (e.g. menu must NOT appear on the other role's message).
SKILL COMPLETE WHEN
- XCUITest ran green on the simulator with positive + negative assertions
- Video proof recorded, converted, delivered
- Teardown done (mock server killed, simulator shut down, worktree +
/tmp/ios-ddremoved, artifacts cleaned) — or explicitly deferred with the rig's state noted -
pgrep -fl 'mock-server|simctl|ffmpeg'confirmed empty; no tokens left in scratch