Skip to main content
Back to blogCopy Markdown
·9 min read
·iosiphone duoswiftuixcuitestagents

Testing a SwiftUI app on the iPhone Duo simulator, headless

Rydger UI tests on the iPhone Duo simulator: Cancel and Save vanished, SpringBoard crash-looped, and the inner screen needed a workaround. Here are the fixes →

Óscar Gallego

Software engineer · Málaga

Rydger's garage on an iPhone Duo, open and closed, next to the title UI tests on the iPhone Duo simulator
On this page

Short answer: on the iPhone Duo simulator, Cancel and Save vanished from my app’s sheets as soon as the form scrolled. The agent on the Duo story tried eight fixes and none worked. One line did: toolbarVerticalBehavior(.disabled). Getting there also cost me a SpringBoard crash loop, a simulator my own cleanup script deleted, and a test that couldn’t tap a field. Each one is below, with the fix.

The app is Rydger, a SwiftUI app with an iOS 18 deployment target. The setup: Xcode 27.2, the iOS 27.1 simulator runtime, headless, the way my agents run every test.

Cancel and Save vanished as soon as the form scrolled

Rydger’s add and edit vehicle forms live in a sheet: a NavigationStack, a Form, and a toolbar with .cancellationAction and .confirmationAction items. On an iPhone 17 Pro simulator, both add-vehicle UI tests pass. On the Duo, they failed. The test that edits the plate failed with “Failed to tap Save”, and the accessibility tree from the failed run showed an empty navigation bar.

The agent working on the Duo story reproduced it with a minimal Form and, by its own account, tried eight variants: removing the top fade, the drag indicator and the keyboard Done button, changing contentMargins, the principal toolbar item, safeAreaInset, presentationSizing(.page) and forcing toolbarVisibility. None brought the buttons back. It logged the bug as unresolved and moved on.

One modifier brings them back

Grok’s search pointed me to Sarun’s post on sheets on the Duo: on the closed device in portrait, iOS moves a sheet’s close and confirm buttons into a vertical bar, and you can turn that behavior off. He suggests turning it off for one-button sheets. Mine have two. I never saw a vertical bar in the simulator, only buttons that disappeared on scroll, but disabling the behavior brought them back. The modifier ships in the iOS 27.2 SDK and works back to iOS 27.1:

private extension View {
    /// iPhone Duo (iOS 27.1+) moves a sheet's Cancel and Save into a vertical toolbar,
    /// and on the 27.1 simulator both vanish once the form scrolls: keep the usual bar.
    @ViewBuilder func horizontalToolbar() -> some View {
        if #available(iOS 27.1, *) { toolbarVerticalBehavior(.disabled) } else { self }
    }
}

Applied to the shared sheet, the next run showed Cancel and Save still in the bar after scrolling (y=112 in the accessibility tree). The plate test still failed, for a different reason further down. I asked Grok to search X and the web for this bug between September 10 and November 5. It found no reports, so I can’t say whether Apple considers it a bug or intended behavior.

Faking a fold with simctl crash-looped SpringBoard

To capture the inner screen, the agent tried to “open” the device by turning its screens on and off with xcrun simctl io <udid> screenConfig … power. The simulator’s SpringBoard then crashed about every five seconds: 14 crash reports between 18:18:50 and 18:20:20, all but the first an assertion in -[SBMainDisplayConfigurationTransformer transformDisplayConfiguration:], and each one popped up a macOS crash dialog on my screen. An hour later, another agent ran the same screenConfig call on a second Duo simulator, and within a second it was crash-looping the same way.

A fresh Duo simulator that never saw a screenConfig call booted headless with simctl boot and logged no SpringBoard crashes. Blake Crosley hit something similar in September: powering off the outer display “crashed the simulator’s system shell”. Don’t fake a fold with screenConfig.

My own cleanup script deleted the simulator

The fresh simulator then shut down on me twice and finally vanished from simctl list. My repo has a cleanup script for finished worktrees, and its --orphans mode shuts down and deletes every simulator named “Rydger Agent ” whose worktree no longer exists. I had named the test device “Rydger Agent duo-check”, and other agent sessions were cleaning up their own work on the same Mac. At 20:35 another agent session, cleaning up its own worktrees, ran it, and its output listed “Simulator duo-check”: the name matched the rule and no worktree with that name existed. The two shutdowns before that I can’t explain. A device named “Duo Capture (Rydger)” has survived since.

There is no supported way to fold the simulator

simctl has no posture command. An Apple Developer Forums thread asks for one, and Apple hasn’t answered. In Xcode 27.2 the simulator window is Device Hub, which has the pose buttons, but my automation tool couldn’t click inside its window because it couldn’t tell which window it was looking at.

What worked was duoctl, a small open-source tool that sends hinge events to the simulator through private HID calls. An agent read its code before running it: a Python wrapper that calls xcrun and codesign and compiles a helper that runs inside the simulator, with no network calls. It works headless with Xcode 27.2:

$ duoctl -d <udid> open && duoctl -d <udid> state
"hingeAngle": 180.0, "activeScreen": "inner", "orientation": "landscape", "sizePoints": [951.0, 669.0]

It’s private API, so the next Xcode can break it. For now it’s the only way I found to open the device from a script.

Capture the inner screen from the Mac, not from XCTest

Two more traps once the device is open:

  • Screenshots. Another developer reports that XCTest screenshots of the inner screen come out black, and only the active screen renders, so I never looked at them. simctl io <udid> screenshot --display=<awake screen> from the Mac works, and duoctl screenshot picks the screen for you.
  • Taps. On the inner screen, XCUITest never reported my elements as hittable, even after I rotated to portrait, but element.tap() still worked.

So the store-screenshot test pauses at each frame and the Mac takes the picture:

func snap(_ app: XCUIApplication, _ name: String) {
    if let pause = ProcessInfo.processInfo.environment["SNAP_HOST_PAUSE"].flatMap(Double.init) {
        print("SNAP-HOST-READY \(name)")
        Thread.sleep(forTimeInterval: pause)
    }
    // ... the usual XCTAttachment
}
TEST_RUNNER_SNAP_HOST_PAUSE=4 xcodebuild test-without-building … | while IFS= read -r line; do
  case "$line" in *SNAP-HOST-READY*)
    name=${line##*SNAP-HOST-READY }
    duoctl -d "$SIM" screenshot "out/$name.png";;
  esac
done

On those runs the agent skipped the hittable check and rotated the interface to portrait, and the test then stepped through all six store frames on the open device, in English and Spanish.

The UI test scrolled the field above the sheet’s bar

Back to the plate test. With Save fixed, it failed with “Neither element nor any descendant has keyboard focus”. The scroll loop had pushed the plate field up to y=75, and the sheet’s toolbar starts at y=106. The swipes carried the field past the bar and the tap missed it; I didn’t measure why it happens on the Duo and not on the iPhone. An agent changed the loop to swipe slowly, up or down, until the field sits below the bar, and reran it: it passes on the Duo and on an iPhone 17 Pro.

App Store Connect has its own iPhone Duo slot

Apple will require these screenshots starting in April 2027 for any app using the iOS 27.1 SDK or later. The slot takes the outer screen at 1398 × 2034 and the inner one at 2007 × 2853 in portrait, plus landscape versions. Product pages that include them show a badge saying the app is made for iPhone Duo. The simulator’s screens are exactly those sizes, so the raw frames fit. One catch: Apple doesn’t accept screenshots with an alpha channel, and my inner-screen PNGs from the simulator had one. My upload tool refused them before sending them, and converting to JPEG fixed it.

What still doesn’t work

  • The first tap. With the number pad showing, the first tap on “More details” in the add-vehicle form doesn’t open it on the Duo. On the iPhone 17 Pro it does. I haven’t figured out why.
  • Tent posture. App Store Connect only asks for the outer and inner screens, so I didn’t check tent mode at all.
  • The trade-off of the fix. toolbarVerticalBehavior(.disabled) opts out of the vertical bar Apple designed for the closed device. My buttons are back, but my sheets don’t behave the way Apple intended on the Duo.
  • The simulator isn’t the device. On X, iOS developer Ehsan Azish wrote that a Duo layout that looked right with the Xcode release candidate was wrong on the physical device: the tab bar fell back to the regular iPhone layout. I asked whether the simulator showed it too. “Only on device.” Until I get my hands on one, everything above is “works in the simulator”.

First move

If you build your app with the iOS 27.1 SDK:

  • Run your sheet UI tests on a fresh Duo simulator, headless. The vanishing-toolbar bug never appears on a regular iPhone.
  • If buttons vanish, try .toolbarVerticalBehavior(.disabled) on the content inside the sheet’s NavigationStack. It keeps the buttons and gives up Apple’s closed-device bar.
  • Leave screenConfig out of your scripts. It crash-loops SpringBoard.
  • Check every simulator’s name against your cleanup rules. Mine matched one, and another agent’s cleanup deleted it.
  • Capture the inner screen from the Mac, not from XCTest, and export JPEG for App Store Connect.

Sources

Related reading: Claude Code skills vs subagents and the shared simulator and the overloaded Mac and the agent’s botched report.


P.S. Have you tested on a real iPhone Duo yet? Tell me on LinkedIn what the simulator got wrong.

Share this article