Files
Michał Pierzchała 1fb276448f fix(wait): poll through a retriable runner refusal instead of surrendering the budget (#2493)
* fix(wait): poll through a retriable runner refusal instead of surrendering the budget

The iOS Smoke lane started failing on main at the merge of #2486: the new
smoke:webview-remote-content scenario ended with `wait text "Jump to form" 20000`
failing after 288 ms with RUNNER_BUSY. Three defects stacked up.

The scenario reused acceptDeepLinkConfirmationIfPresent, whose readiness landmark
was hard-coded to the Automation lab's text. Off that route it can never match, so
the helper always fell through to its `alert get` probe — and an XCTest alert query
against a live WKWebView screen exceeds the runner's 30 s main-thread execution
watchdog (measured 10.1 s to fail locally, 10.6 s in CI), abandoning main-thread
work and leaving the runner refusing every following command as RUNNER_BUSY. The
landmark is now a parameter and each caller passes its own route's, so the probe
runs only when the destination genuinely did not arrive. The depth-frontier
scenario carried the same mismatch and is fixed with it.

A `wait` is a budgeted retry loop, but it abandoned its whole budget on the first
retriable refusal. A poll whose failure the producer itself marked retriable is now
ridden out like an unreadable capture: the wait keeps polling to its deadline,
records the poll as `retriable` in its timeout evidence, and surfaces the refusal
only when no readable capture ever completed. RUNNER_WEDGED is not retriable and
still ends the wait at once.

That classification was also missing on the path the failure actually took. A
runner error recovered from the lifecycle journal after a lost transport response
was built with a bare toAppErrorCode, so RUNNER_BUSY reached callers as a
RUNNER_BUSY wire code with no `retriable` flag, while the live-response path
published it as COMMAND_FAILED plus details.runnerErrorCode and retriable: true.
Both paths now read the runner's code through one classifier in runner-contract.

Live-validated on a booted iPhone 17 Pro simulator against the fixture app: the
destination landmark resolves in 389 ms with no alert probe, the page wait
succeeds in 81 ms, and the snapshot still carries Link "Jump to form", the
"Email address" field label, and the remote-content-boundary XCTest fallback
warning. Driving the old sequence first reproduces the wedge, after which the
fixed wait polls its full 20 s in `retriable` polls instead of failing instantly.

* test(apple-runner): move journaled runner-code classification to the recovery test

The two new cases landed in runner-command-retry.test.ts, which was already over
the 1,000-line test-file tripwire, so the size ratchet refused its growth. They
assert runnerStatusFailureError's reading of the lifecycle journal, so their home
is runner-command-recovery.test.ts, which mirrors that module and drives recovery
through the real stack against a scripted fake runner.

* test(e2e): let a deep-link route mount before probing for its confirmation alert

The 2500 ms destination budget was tuned to the Automation lab on a warm
simulator. On CI the WebView lab rendered correctly but was not in the bridge
tree that fast, so the helper fell through to its `alert get` probe — and that
XCTest query against a live WKWebView exceeds the runner's execution watchdog,
leaving every later command refused as RUNNER_BUSY.

Measured on a freshly created simulator: with the confirmation alert up the probe
is correct and costs 1.6 s, because the alert blocks the route and there is no web
view to query; with no alert the landmark resolves in 0.1-1.7 s. The budget only
has to outlast an honest mount, and overshooting it costs nothing when a
confirmation really is up, since that route never renders until it is accepted.

Cold-simulator run of the whole scenario: landmark 400 ms, page wait 613 ms,
snapshot keeps the page link, the field label, and the XCTest fallback warning.

* fix(wait): keep the poll timeline on a wait exhausted by retriable refusals

Review finding on #2493: a wait that spent its whole budget being refused threw
the last refusal raw, so the common all-RUNNER_BUSY case carried no captures,
waitedMs or polls and could not show where its budget went — contradicting the
evidence this PR documents. The mirror gap existed on the other exhaustion shape:
when the deadline cancelled the final poll, the wait reported a generic stall and
dropped the runner code and retry details instead.

Both shapes now raise one error that keeps the producer's code, message, hint and
retry details and carries the wait's own evidence, with reason wait_capture_stalled
and the original as its cause. A content verdict is still preserved untouched,
since it already describes the capture it came from, and whether it outranks the
stall verdict stays the caller's policy (wait absent).

Live-verified against a genuinely wedged simulator runner: COMMAND_FAILED,
retriable true, runnerErrorCode RUNNER_BUSY, reason wait_capture_stalled,
captures 6, readableCaptures 0, waitedMs 8041, polls
retriable,retriable,retriable,retriable,retriable,deadline.
2026-09-11 17:37:11 +02:00
..