ns-ios-verification — Drive the iOS Simulator to prove a mobile change works
SkillDev toolsVerify NowStack Mobile iOS/Expo changes in the Apple Simulator with `xcrun simctl`. Covers single or parallel Metro/device flows, deep-link navigation, preview routes, and OTP sign-in. Use for `ns ios verification`, "test on simulator", or changed `mobile-app/**`; never use computer-use.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the ns-ios-verification — Drive the iOS Simulator to prove a mobile change works skill
What this skill tells your AI
The instructions your AI receives, as published by melvynx/saveit.now in .agents/skills/ns-ios-verification/SKILL.md and read by ahel’s review.
- Single-agent — one Simulator, one Metro on
8081. The default. - Multi-agent / parallel — each agent gets its OWN Simulator device AND its OWN Metro port, so navigation, reloads, and screenshots never cross-talk. This is what makes it safe to verify several screens/flows at the same time.
NEVER drive the Simulator (or a browser) with the computer-use / control-the-computer tools. simctl for mobile, dev-browser for web. Computer-use is a last resort only when explicitly requested.
For a live browser-visible mirror, pair this workflow with .agents/skills/ns-preview/SKILL.md. The preview is a transport layer; this skill still owns navigation, app-state proof, and the verification verdict.
<platform_guard>
iOS verification requires macOS (Xcode + Simulator). uname -s must be Darwin. A dev build must already exist (/ns ios local-setup → npm run ios once). Native modules (Stripe, IAP, Apple Sign In) only exist in the dev build, never Expo Go.
</platform_guard>
Toolchain (every command)
Run Metro/builds with Node 22 (eas.json pins 22.20.0) and a UTF-8 locale:
export PATH="$HOME/.nvm/versions/node/v22.20.0/bin:$PATH"
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
Node 18 fails reading app.config.ts (it requires the ESM site-config.ts); a non-UTF-8 locale fails CocoaPods pod install.
Resolve the slug, bundle id, and built .app once:
cd mobile-app
APP=$(find ~/Library/Developer/Xcode/DerivedData -path '*/Build/Products/Debug-iphonesimulator/*.app' -maxdepth 6 -type d 2>/dev/null | head -1)
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP/Info.plist")
SLUG=$(/usr/libexec/PlistBuddy -c 'Print CFBundleURLTypes:0:CFBundleURLSchemes:0' "$APP/Info.plist" 2>/dev/null)
echo "slug=$SLUG bundle=$BUNDLE_ID app=$APP"
SLUG is the deep-link scheme (the default product is nowstack; it's the scheme in site-config.ts/app.config.ts). Reading it from the built .app Info.plist is the reliable source — app.config.ts is TS and can't be required by plain Node.
Mode A — Single-agent (default)
Use this exact flow to render one mobile change — no gestures, no computer-use.
- One Metro on 8081. Both dev builds default to Metro port
8081, so a sibling app's Metro on8081makes this app load the WRONG bundle. Free8081, then start this app's Metro there (tell the user if you stopped another app's Metro):cd mobile-app && npx expo start --port 8081 --dev-client - Clean launch the dev build so it connects to
8081and is foreground (same-app deep links then skip the cross-app "Open in …?" prompt):xcrun simctl terminate booted "$BUNDLE_ID"; xcrun simctl launch booted "$BUNDLE_ID" - Navigate by deep link to the slug scheme (include the route-group segment, e.g.
(app)/(tabs)/...):xcrun simctl openurl booted "$SLUG://(app)/(tabs)/settings" - Capture and Read it:
For a scrolled state without gestures, temporarily addxcrun simctl io booted screenshot /tmp/shot.pngcontentOffset={{ x: 0, y: N }}to theScrollView(Fast Refresh applies it), screenshot, then revert. Crop withsips/PIL to inspect header/detail.
Mode B — Multi-agent isolation (parallel agents)
When several agents (or parallel tasks) drive the app at once, never share one Simulator on 8081 — every navigate/reload/screenshot from one agent disturbs the others. Give each agent its own autonomous pair: a dedicated Simulator device + a dedicated Metro port. The dev build (.app) is identical everywhere — install it on each device, then tell each one which Metro to load.
For each agent (example: agent A → port 8083):
- Dedicated Simulator device — create + boot a throwaway device, and install the same dev build on it (a fresh device has no app):
UDID_A=$(xcrun simctl create "agent-a" "iPhone 16 Pro") xcrun simctl boot "$UDID_A" xcrun simctl install "$UDID_A" "$APP" - Dedicated Metro port — one per agent (
8083,8084, …), in the background:cd mobile-app && EXPO_NO_INTERACTIVE=1 npx expo start --port 8083 --dev-client & - Point this dev build at its Metro via the
expo-development-clientdeep link. Theurlmust be URL-encoded (http://localhost:8083→http%3A%2F%2Flocalhost%3A8083):xcrun simctl launch "$UDID_A" "$BUNDLE_ID" xcrun simctl openurl "$UDID_A" "$SLUG://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8083" - Navigate + capture on THAT device — always the explicit
<udid>, neverbooted(ambiguous once several devices are booted):xcrun simctl openurl "$UDID_A" "$SLUG://(app)/(tabs)/settings" xcrun simctl io "$UDID_A" screenshot /tmp/agent-a.png
Agent B repeats with $UDID_B, --port 8084, and ?url=...8084. Each agent's navigation/reloads/screenshots stay independent.
Teardown throwaway devices when done:
xcrun simctl shutdown "$UDID_A"; xcrun simctl delete "$UDID_A"
Pitfalls. Always address the explicit
<udid>(neverbooted). The?url=value must be URL-encoded. Multipleexpo startfrom the same project dir share.expo/and the file-watcher — it works on different ports, but if you see cache races add--clearto the first or give each a distinctTMPDIR. The Simulator shares the host network, solocalhost:<port>reaches Metro directly (no Android10.0.2.2).
Auth-gated screens — two ways to reach signed-in UI
simctl cannot type into a TextInput, so you do NOT walk the sign-in form by hand. Pick one:
1. Temp no-auth preview route (no session — fastest for pure UI)
Render the real screen OUTSIDE the (app) auth gate but INSIDE the root providers. Create mobile-app/app/<name>-preview.tsx, deep-link to it, screenshot, then trash the file when done. Force a theme with useTheme().setPreference('dark') if the simulator appearance doesn't reach a system preference.
2. Programmatic OTP sign-in (real session — for signed-in data/flows)
Auth is Better Auth email OTP (convex/auth.ts). The OTP is NOT magic:
- Built-in test account
appstoretest@email.com→ code is always123456.generateOTPhardcodes it andsendVerificationOTPreturns early — no Resend / no email needed. This is THE test login. - For any OTHER email with no
RESEND_API_KEYon the deployment, the OTP is logged in the Convex dashboard logs ([AUTH] … OTP for <email>: <otp>) but the send call also throws — so prefer the test account. WithRESEND_API_KEYset, a real email is sent and the code is NOT in the logs.
To sign in WITHOUT typing, create a TEMP preview route that hits the same two endpoints the sign-in form uses (mobile-app/app/onboarding/sign-in-form.tsx), then trash it after:
// mobile-app/app/login-test-preview.tsx — TEMP, trash after verifying
import { useEffect, useState } from 'react';
import { View } from 'react-native';
import { authClient } from '@/lib/auth-client';
import { Text } from '@/components/ui/text';
const EMAIL = 'appstoretest@email.com';
const OTP = '123456';
export default function LoginTestPreview() {
const [status, setStatus] = useState('signing in…');
useEffect(() => {
(async () => {
await authClient.$fetch('/email-otp/send-verification-otp', {
method: 'POST',
body: { email: EMAIL, type: 'sign-in' },
});
const res = await authClient.$fetch('/sign-in/email-otp', {
method: 'POST',
body: { email: EMAIL, otp: OTP },
});
setStatus(res.error ? `error: ${res.error.message}` : 'signed in ✓ — deep-link to (app) now');
})();
}, []);
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text>{status}</Text>
</View>
);
}
Deep-link to it ($SLUG://login-test-preview), wait for "signed in ✓", then navigate to the real (app) screens — the session token now lives in SecureStore and auth-store.tsx picks it up. To read codes for an arbitrary email when Resend is unset: npx convex logs (root) and grep for [AUTH].
Static checks (run alongside the Simulator)
cd mobile-app && npx tsc --noEmitfor TypeScript-sensitive changes.cd mobile-app && npm run lintwhen touching shared UI or many files.npx convex dev --once(root) for Convex changes.
Finish
trashevery temp*-preview.tsxroute you created.- Shut down + delete any throwaway multi-agent Simulators.
- Report what you verified, on which screen(s), and any verification you skipped + the concrete reason.
Signals
- GitHub stars
- 31
- Forks
- 5
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
ns-ios-verification- Source
- github.com/melvynx/saveit.now