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:

  1. box-sizing. The drawer’s elements were content-box, so width: 100% plus horizontal padding summed to wider than the viewport — pushing the text field and the “Send” button off the right edge. A box-sizing: border-box reset 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.)

  2. iOS auto-zoom on focus. The note field was 13px. iOS Safari zooms the page in whenever you focus an input under 16px, and that zoom shifted the whole panel sideways — which had looked like a positioning bug for three rounds. Bumping the field to 16px (the documented no-zoom threshold) killed it instantly.

  3. 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 visualViewport API — measure innerHeight - visualViewport.height and lift the drawer above the keyboard — plus interactive-widget=resizes-content in 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.