| name | QA iOS Simulator (vellum-client-qa) |
| description | Verify native iOS behavior of the vellum-assistant Capacitor app on a real iOS simulator: build from a worktree, boot against the qa-mock-backend server, drive with XCUITest, and record video proof. Use for anything WKWebView/Swift-side — edit menus, native plugins, permissions, keyboard behavior, safe-area — that web emulation can't reach. |
| metadata | {"vellum":{"emoji":"🍎","activation-hints":["user asks to test or verify a native iOS feature or fix","user asks whether an iOS-only behavior works (edit menu, keyboard, safe area, permissions)","user asks for simulator proof of an iOS PR"],"avoid-when":["web-rendered behavior only (CSS, layout, gestures, React) — use qa-web-ui, it's much faster","no macOS + Xcode available"],"category":"development"}} |
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-backend standalone 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-backend bun 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 a VELLUM_ENVIRONMENT export — a production value silently overrides the config; force VELLUM_ENVIRONMENT=dev for the sync. Info.plist needs NSAllowsArbitraryLoads for local http.
- XCUITest target: add to
project.yml (including scheme.testTargets), test file under UITests/. Worktree-only — don't commit the target unless asked.
- Run:
xcodebuild test -destination 'id=<sim-udid>' -derivedDataPath /tmp/ios-dd. Get the udid from xcrun simctl list devices available. When you need the app's bundle id (install/uninstall/launch/simctl calls), read it from the built .app's Info.plist (CFBundleIdentifier) rather than assuming it matches capacitor.config.ts — xcodegen can emit a different id than the Capacitor config declares.
- Video proof:
xcrun simctl io <udid> recordVideo <file>.mov during 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 -fl then kill — not pkill), delete the worktree and /tmp/ios-dd, xcrun simctl shutdown <udid>, and delete the run's video/artifacts once delivered. Confirm nothing is left with pgrep -fl 'mock-server|simctl|ffmpeg'. A failed xcodebuild test must not leave the mock server, simulator, or multi-GB worktree/node_modules behind — that's how disk/fd pressure builds. Check df -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
onClick on 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 as staticTexts. For anything web-rendered, don't drive it through XCUITest at all: trigger the interaction from inside the web layer by injecting document.querySelector('<selector>').click() into the served index.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-notifications requestPermissions()), 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
simctl screenshots without a test target (the common case for one-off visual checks): use the computer-use skill. Recipe: (a) confirm a host_cu desktop 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's / now appear as AX elements with IDs; (d) by (reliable — CU operates in desktop screen space, so raw coordinates from a screenshot won't map). Then gives a scrim-free capture. Pitfalls: the first may 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 from ); can't find the alert ( AX path fails with -1728) — use + instead.
SKILL COMPLETE WHEN