Debugging iOS Safari From the Command Line: Scripting the Simulator With `xcrun simctl`
A while back I wrote about three iOS Safari gotchas that don’t show up on desktop or in Playwright — audio, autoplay, scroll-snap — and how I finally cracked them by putting a query-param debug overlay on the page and screenshotting it on a real iPhone. That trick is perfect for behavioral bugs you can print to a <div>.
It’s useless for layout bugs. And this time the problem was layout.
Daily Bedtime Story ends each story with a small drawer: rate tonight’s story, then either share it or tell us how to make it better. On desktop Chrome it was pixel-perfect. On a real iPhone it was subtly, stubbornly wrong. An overlay can’t tell you a button is three pixels past the edge, and passing a physical phone back and forth is a slow way to iterate on CSS. So I reached for something I’d been underusing: the iOS Simulator, driven entirely from the command line.
The three bugs I found were boring — one line each. Getting to a place where I could see them was the whole story.
The screenshots were lying to me
Early iterations were pure whack-a-mole: I’d get a screenshot, spot something off, ship a fix, and get back another screenshot that was also off — differently.
The screenshots were phone-frame mockups: a glossy 3D iPhone, tilted, screen recessed into the bezel. Beautiful for a landing page, useless for debugging. The perspective foreshortened the right edge, so when an element ran off the side I genuinely couldn’t tell whether it was clipped, padded, or just receding into the tilt. I “fixed” the wrong side more than once.
If you can’t trust that a straight line is straight, you can’t trust anything you infer from the image. I needed pixels, not a render.
The Simulator is fully scriptable — and its screenshots are flat
The unlock: xcrun simctl turns the running Simulator into a device you can script, and its screenshots come out flat — no bezel, no tilt, true geometry.
# find the running simulator
xcrun simctl list devices booted
# capture exactly what's on screen — real WebKit, no perspective
xcrun simctl io booted screenshot shot.png
# point its Safari at a local dev URL (the sim shares your Mac's network)
xcrun simctl openurl booted "http://localhost:4321/preview/some-story"
That’s the entire core loop. screenshot gives you what the browser actually rendered; openurl puts you where you want to be. The “which side is clipping?” question that had eaten three rounds answered itself in a single flat capture.
The keyboard that refused to appear
Next wall: I needed the drawer with the keyboard open, but tapping the text field only produced a thin ⌃ ⌄ ✓ accessory bar — never the full keyboard, and the viewport never resized.
The cause is a Simulator default: it connects your Mac’s hardware keyboard, so iOS assumes you’re typing on that and never raises the on-screen one. Every screenshot I’d been reasoning about wasn’t even the real keyboard state. Toggle it under I/O → Keyboard → Connect Hardware Keyboard, or with ⇧⌘K.
You can automate the toggle, but it’s the one step that needs a permission grant:
osascript -e 'tell application "Simulator" to activate' \
-e 'tell application "System Events" to keystroke "k" using {shift down, command down}'
System Events keystrokes require your terminal to have Accessibility permission (System Settings → Privacy & Security → Accessibility). Without it you get a flat not allowed to send keystrokes — a confusing error until you know exactly what it means.
Reaching UI that’s locked behind a scroll gate
One more obstacle, common enough to be worth its own trick. The drawer only appears at the very end of a story, and the reader deliberately locks scrolling until the story’s been read. So openurl dropped me at the top of the page, and every programmatic scrollIntoView I fired got snapped back by the app’s own scroll logic. I could reach the page but not the component.
The move is to stop fighting the app and render the component alone. I stood up a throwaway harness page with just the drawer in a viewport-sized stage — no reader around it — and a query param that forced the panel into the exact state I wanted and focused the input:
<section class="stage"> <!-- viewport-sized, nothing else -->
<EndFlowDrawer slug="test" title="Test Story" coverUrl={placeholder} />
</section>
<script>
// force the panel + raise the keyboard, no story required
document.querySelector('.note-field')?.focus();
</script>
Now openurl to /kbtest?state=improve dropped me straight onto the component, keyboard up, ready to screenshot. Keep it untracked so it never ships — it’s scaffolding, not a feature. The pattern generalizes: whenever a component lives behind multi-step state or a gesture gate, a one-file harness that renders it in isolation beats automating your way through the whole flow.
The three bugs (one line each)
With a flat view of the real component and a real keyboard, the actual defects fell out in minutes:
-
box-sizing. The drawer’s elements werecontent-box, sowidth: 100%plus horizontal padding summed to wider than the viewport — pushing the text field and the “Send” button off the right edge. Abox-sizing: border-boxreset scoped to the drawer, and everything sat inside its padding again. (This is why the tilted screenshots looked like a right-side clip: it was one.) -
iOS auto-zoom on focus. The note field was
13px. iOS Safari zooms the page in whenever you focus an input under16px, and that zoom shifted the whole panel sideways — which had looked like a positioning bug for three rounds. Bumping the field to16px(the documented no-zoom threshold) killed it instantly. -
The keyboard resizing the world. When the soft keyboard opened, it shrank the visual viewport out from under the drawer. The robust fix is the
visualViewportAPI — measureinnerHeight - visualViewport.heightand lift the drawer above the keyboard — plusinteractive-widget=resizes-contentin the viewport meta. The subtle part: reset those adjustments when the keyboard closes or the panel changes, or the styles get stuck and leak on the next scroll.
Three trivial fixes. Hours of thrash before them — nearly all of it spent unable to see the truth.
Two modes for the same problem
There’s a nice symmetry with the earlier iOS post. Both start from “works on desktop, broken on iPhone,” and both are really about the same discipline: stop reasoning from emulators and pretty pictures, and go look at what the device actually does. They just pull different levers:
- Behavioral bugs (audio, autoplay, timing) → a query-param debug overlay, screenshotted on a real device. You print internal state to the screen.
- Layout and input bugs (clipping, zoom, keyboard) → the Simulator scripted with
xcrun simctl. You capture true geometry and drive the browser from your shell.
simctl collapses the loop from “guess, ship, wait for a distorted photo” down to “look at the actual pixels.” When the real UI is buried behind app state, a disposable one-file harness gets you straight to it. Five minutes of tooling beats an hour of squinting at a render of a phone.