fix(mobile): wait through an iOS app not running yet after a launch

Merged
KR
7d ago
+51-13

On a physical iPhone, an assertion right after app.restart() failed at once with ENGINE_FAILURE: snapshot failed: app '…' is not running, while the splash was still up. Reported through e2e feedback (ENG-939); the workaround was expect.poll swallowing the error.

  • agent-device relaunches on a physical device with devicectl device process launch, which returns before XCTest reports the app as running. The iOS runner refuses every read in that window with APP_NOT_RUNNING, and our error translation made it a non-retryable ENGINE_FAILURE.
  • A locate now reads that refusal as a screen still arriving, the same way #1166 treats an unreadable Android capture: zero matches, and the poll keeps waiting until the deadline.
  • The tolerance only holds between a launch (open, restart, openLink) and the first successful snapshot. The runner also raises APP_NOT_RUNNING for an app that crashed or was closed, and that must not read as an empty screen, or not.toBeVisible() would pass on a dead app (packages/mobile/src/surface.ts, launching).
  • A wait that runs out while the app is still down fails with agent-device's message and hint, not with LOCATOR_NOT_FOUND.
  • An app that crashes before its first readable screen gives the same refusal as one still starting, so an immediate negative read (not.toBeVisible(), count()) sees no matches there, as on Android since #1166. Positive waits and actions still fail with the reason. docs/mobile.mdx says so.

Verified

Ran it locally: yes, on an iOS 26.5 simulator on the Mac, using the SwiftUI example app (examples/with-swiftui) and the built packages. The simulator's simctl launch doesn't leave the physical-device gap, so a plain restart loop passed on main too (10/10). The repro recreates the gap: it stops the app right after app.restart() and relaunches it 2 s later in the background, then asserts.

await app.restart();
execFileSync('xcrun', ['simctl', 'terminate', UDID, BUNDLE]);
spawn('sh', ['-c', `sleep 2 && xcrun simctl launch ${UDID} ${BUNDLE}`], { stdio: 'ignore', detached: true }).unref();
await expect(screen.getByText('Say hello')).toBeVisible();
Scenariomainthis branch
App comes up about 2 s after the restartfails at once: ENGINE_FAILURE: snapshot failed: app 'dev.e2e.examples.swiftui' is not runningpasses 3/3, visible after 3.5 to 4.4 s
App never comes back after the restartfails at once with that messagewaits the expect timeout, then fails with the same message and hint
App terminated mid-test, then not.toBeVisible()fails at once with that messagefails at once with that message

Also run on a physical iPhone 16 Pro (iOS 27.0.1) with three app.restart()s each followed at once by an assertion. The phone's free developer profile leaves room only for agent-device's runner, so the app under test was the built-in Settings app. It did not reproduce the bug on main: agent-device's relaunch took about 60 s on the phone and Settings was up by the time it returned. Main and this branch both passed with the element visible about 6 s after each restart, so the branch does not regress on a device. The user's case, an installed app still on its splash when the assertion runs, is covered by the simulator repro above, not observed on a device.

Not in this PR

  • An observation in the same window (the agent's next turn right after app.restart()) still fails at once, as the docs already say for Android. The fix there would be a readiness wait in open(), which costs an extra read on every launch; left for a follow-up if anyone hits it.

Opus 5.5 in Claude Code (T3 Code), reviewed in a fresh context by Fable 5.