fix(mobile): wait through an iOS app not running yet after a launch
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 withAPP_NOT_RUNNING, and our error translation made it a non-retryableENGINE_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 raisesAPP_NOT_RUNNINGfor an app that crashed or was closed, and that must not read as an empty screen, ornot.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.mdxsays 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();
| Scenario | main | this branch |
|---|---|---|
| App comes up about 2 s after the restart | fails at once: ENGINE_FAILURE: snapshot failed: app 'dev.e2e.examples.swiftui' is not running | passes 3/3, visible after 3.5 to 4.4 s |
| App never comes back after the restart | fails at once with that message | waits the expect timeout, then fails with the same message and hint |
App terminated mid-test, then not.toBeVisible() | fails at once with that message | fails 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 inopen(), 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.