Skip to content

fix(runner): bound XCTest's idle wait around alert activation - #2610

Merged
thymikee merged 3 commits into
mainfrom
fix/ios-alert-idle-timeout-2546
Sep 15, 2026
Merged

thymikee merged 3 commits into
mainfrom
fix/ios-alert-idle-timeout-2546

Conversation

@thymikee

@thymikee thymikee commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

The runner bounded XCTest's idle wait around alert activation so a bounded command cannot be answered as a failure for a tap the device performed afterwards (#2546). The hittable read that gates alert activation is that activation's readiness check, so XCTest's pre-synthesis wait adds nothing to it and can cost more than the command has.

Each caller now names which of XCTest's two waits it gives up, through RunnerInteractionIdleWaits: the gesture, type and swipe paths skip both, because their next step is a poll of their own, while alert activation drops only the pre-event wait and keeps the post-event settle. Its verification reads the alert this tap replaced, and an alert that dismisses in order to present an identical replacement passes through a moment with no alert at all — a first read landing in that moment reported a dismissal nothing had proved. The helper is also renamed from the scroll-only name that gesture, type and swipe were already using.## Validation

Tested at 6a9e275e19 (rebased on origin/main).

  • pnpm check:affected --run green at this head; it selects xctest-selection and packaged-runner-swift.
  • Live, dedicated Apex CI Smoke simulator, runner built with unit tests included: the nine alert-observation XCTests passed 9/9 in two consecutive runs, and testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt — the one this PR's Smoke run failed — passed in two more single-test runs. A third run reported eight passed with zero failed and failed the session aggregate, the same post-test xcodebuild anomaly as on the previous head.
  • testAlertActivationIgnoresAnAppThatNeverSettlesBeforeTheDeadline is the guard for iOS runner smoke: alert-observation XCTests are red or flaky on main #2546: the fixture animates until an alert button is answered, and with handleAlert reverted to the plain activateElement call that run waits 18.1 s, answers ALERT_DEADLINE_EXCEEDED with the button activated behind it, and trips the assertion that forbids exactly that. With this change it answers within a second of activation.
  • The failure at a41899ba59 is this change's: skipping the post-event wait let the first observation land in the dismiss-then-represent gap and answer ok: true. Restoring the post-tap settle is what closes it, and the alert tests that cover shared-title and identical replacement cases run in the same loop.
  • Unresolved: the ~13 s pre-synthesis stall of iOS runner smoke: alert-observation XCTests are red or flaky on main #2546 still cannot be provoked on demand locally, so the CI Smoke lane stays authoritative for whether the flake itself is gone.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.57 MB 4.57 MB +2.7 kB
Package (unpacked) 4.57 MB 4.57 MB +2.7 kB
Package (download) 1.36 MB 1.36 MB +682 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.6 ms 27.6 ms +1.0 ms
CLI --help 79.0 ms 76.5 ms -2.6 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at a41899b. The change can report an alert as activated when it should refuse, and this PR's own CI run shows that symptom.

withBoundedInteractionIdleTimeoutIfSupported now wraps the alert-button tap in performWithQuiescenceSkippedIfSupported (RunnerTests+Alert.swift:76), so XCTest hands control back to the handleAlert poll loop right after the tap, without waiting for the app to settle. The fixture dismisses the alert and presents an identical replacement inside the tap's completion handler. If the first observeAlert lands in that gap, it sees no alert, RunnerAlertVerification.verify maps that to .disappeared, and the response is ok: true with no error. That is the failure in this PR's Smoke Tests run: testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt fails at RunnerTests+AlertObservationTests.swift:72-73 with true where false is expected. Could the alert tap keep XCTest's post-tap quiescence wait (or read a settled state before the first poll), and could that test run in a loop on the final head to show the race is closed?

The same test also fails on #2603, which does not touch the runner, but at a different place (lines 75 and 80, an action-count mismatch). So the #2546 flake does not explain this run, and it looks related to this change.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at a41899b. The Smoke failure looks caused by this change.

Wrapping activateElement in withBoundedInteractionIdleTimeoutIfSupported on the alert path (RunnerTests+Alert.swift#L74) sends the tap through performWithQuiescenceSkippedIfSupported, which skips both the pre-event and the post-event wait. Before, the default wait after the tap let a dismiss-then-represent settle before activateElement returned. Now the first observeAlert can see a short "no alert" state and return .disappeared with ok: true, instead of detecting an identical replacement and refusing with ALERT_DEADLINE_EXCEEDED. That reports success for an action the runner did not verify. Can the alert path bound only the pre-event wait, which is what #2546 is about, or wait for the alert state to settle before the first observeAlert?

The new comment at RunnerTests+Lifecycle.swift#L216 covers only the pre-tap readiness gate. It does not say the helper also drops the post-event wait, which the alert verification depends on. Once the path is fixed, please say which wait each caller needs.

Smoke Tests fails in testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt with ok: true and no error. That test goes through handleAlert -> activateElement, the call this diff wraps, and the symptom differs from the #2546 flake on main (tap not registered, no actions), so it is likely related. Your local loop was also green in 5 of 6 runs. There are no conflicts.

Next: restore the post-tap margin on the alert path and get that test green repeatedly.

@thymikee
thymikee force-pushed the fix/ios-alert-idle-timeout-2546 branch from a41899b to afafe1a Compare September 15, 2026 08:23
@thymikee

Copy link
Copy Markdown
Member Author

Both findings are right and the Smoke failure at a41899ba59 is this change's. Fixed at afafe1a4c7.

The alert path now bounds only the pre-event wait. withBoundedInteractionIdleTimeoutIfSupported takes a RunnerInteractionIdleWaits, and the two cases say what each caller is giving up: .bothSkipped for the gesture, type and swipe paths, whose next step is a poll of their own, and .preEventSkipped for alert activation, which keeps XCTest's post-event quiescence. That settle is what the verification below reads: an alert that dismisses to present an identical replacement passes through a window with no alert, and the first observeAlert could land there and report .disappeared with ok: true. The option mask is built from the case rather than hard-coded, so the pre-event skip cannot take the post-event one with it.

The comment names both waits and which caller needs which. It previously described only the pre-synthesis gate, which is exactly how the dropped post-event wait went unnoticed. The enum carries the reasoning per case, and the alert call site says why the settle stays.

Loop evidence on the final head, dedicated Apex CI Smoke simulator, runner built with unit tests included: the eight alert-observation XCTests passed 8/8 in six consecutive runs (xcodebuild test-without-building, alert suite only), and testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt passed eight further single-test runs — 14 green runs at the pushed tree. pnpm check:affected --run is green at this head.

The mutation that makes the pinned behaviour fail is the one this review found: passing .bothSkipped at the alert call site returns the runner to skipping the post-event wait, which is what produced ok: true in the Smoke run at a41899ba59.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at afafe1a. The alert path now bounds only the pre-event idle wait, the gesture, type and swipe callers keep the old skip-both behaviour, and both earlier findings are addressed.

One gap is left. No test reaches activateElement while the app is busy and the deadline is close. The existing deadline tests return before activation, and the new tests use a 10000 ms timeout on an idle fixture, so reverting Alert.swift to the plain activateElement call keeps every test green. The #2546 failure (the tap lands after the caller was already told ALERT_DEADLINE_EXCEEDED) therefore has no regression guard. Can you add a test on the alert-replacement fixture that keeps the app busy, calls handleAlert with a short deadline such as 1.5 s, and asserts that the response returns within the deadline plus a small margin and never reports a landed tap after a deadline failure?

The live runs in the PR are author-reported. The original ~13 s pre-synthesis stall still cannot be provoked on demand, so it stays open whether that stall is gone.

Typecheck & Package fails with TS2353 on installArgs in packages/platform-android/src/__tests__/snapshot-helper-install.test.ts. This PR does not touch that file, and main fails the same way at 2cafab3 because #2618 removed the field while that test still sets it, so the failure is unrelated to this PR. A rebase will not clear it until main is fixed.

Next: the busy-app deadline test.

@thymikee
thymikee force-pushed the fix/ios-alert-idle-timeout-2546 branch from afafe1a to f94c74f Compare September 15, 2026 10:17
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto aba54b4cc6 (#2600 and #2625 landed), head f94c74fe7d; no conflicts, nothing here interacts with the runner transport or the settings vocabulary.

  • pnpm check:affected --run green at this head: xctest-selection, packaged-runner-swift, plus format:check, lint, typecheck, check:layering, check:fallow --base origin/main.
  • Runner rebuilt from this head with unit tests included, then the eight alert-observation XCTests on the dedicated Apex CI Smoke simulator: 8/8 passed in three clean runs, and testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt passed two further single-test runs.
  • One additional run exited 65 with seven cases passed and zero failed cases: IDETestOperationsObserverDebug: Failure collecting diagnostics from simulator: Timed out after 600.0 seconds. That is xcodebuild's post-test diagnostics collection, host-side, on a machine running several other simulators at the same time; the test session itself reported Executed 5 tests, with 0 failures for the group it finished.

Also unblocked: the red Typecheck & Package job was main's own snapshot-helper-install.test.ts fixture, fixed by #2625.

@thymikee
thymikee force-pushed the fix/ios-alert-idle-timeout-2546 branch from f94c74f to 6a9e275 Compare September 15, 2026 13:09
@thymikee

Copy link
Copy Markdown
Member Author

Added at 6a9e275e19 (rebased onto e3cbc91a1a, which carries #2625's fix for the installArgs fixture — Typecheck & Package is green here now).

The fixture can now hold an alert screen open in the state that cost the command its deadline: --agent-device-alert-activation-busy runs a UIView animation that never ends and stops it the moment an alert button is answered. testAlertActivationIgnoresAnAppThatNeverSettlesBeforeTheDeadline resolves the alert from that fixture, calls handleAlert with a 6 s deadline, and asserts the response comes back inside deadline plus margin, and that a ALERT_DEADLINE_EXCEEDED answer never arrives alongside an activated button (agent-device-alert-actions must still read First actions: 0).

Reverting handleAlert to the plain activateElement call fails it, and fails it as #2546 looked from the inside:

t =  5.23s  Wait for com.callstack.agentdevice.runner to idle
t = 21.60s  Synthesize event
error: XCTAssertLessThan failed: ("18.140172958374023") is not less than ("9.0") - activation waited 18.14s for a busy app to idle
error: XCTAssertTrue failed - ALERT_DEADLINE_EXCEEDED, hint: "The button was activated once."
error: XCTAssertEqual failed: ("First actions: 1; replacement actions: 0") is not equal to ("First actions: 0; replacement actions: 0")
     - a caller told about an expired deadline must not have a button activated behind it

Two things learned while tuning it, both in the fixture's comment: a repeating main-thread timer and a bare CABasicAnimation each left the app reading as idle, and the reverted path passed with them. Only a UIView animation counts as work in flight, so the fixture uses that and nothing else, with a 20 s backstop so a regressed run ends rather than waiting out XCTest.

At this head, runner built from the rebased tree: the nine alert XCTests passed 9/9 in two runs and the new test passed two further single runs (5.6 s, 6.1 s). A third suite run reported eight passed with zero failed and failed the session aggregate after Executed 5 tests, with 0 failures, the same xcodebuild post-test anomaly as on the previous head; this host is running several other simulators.

On your second point: agreed, and unchanged by this test. It guards the defect shape — deadline exhausted while the button lands later — not the ~13 s pre-synthesis stall itself, which still cannot be provoked on demand here. Whether that particular stall is gone stays open.

pnpm check:affected --run green at this head (xctest-selection, packaged-runner-swift, format, lint, typecheck, layering, fallow).

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 6a9e275, as a follow-up to the review at f94c74f. The new busy-app test gives the deadline fix a regression guard, but its last check can never pass on its own.

In RunnerTests+AlertObservationTests.swift#L82-L89, line 82 always expects First actions: 1, and the block after it runs only on ALERT_DEADLINE_EXCEEDED and expects First actions: 0. When that block runs, one of the two asserts must fail, so "no landed tap after a reported deadline" is never checked by itself, and a correct refusal also fails. Can the label check branch on response.ok: First actions: 1 when it is ok, otherwise First actions: 0 with the "must not have a button activated behind it" message, while keeping XCTAssertTrue(response.ok) and the elapsed bound? The comment at line 78 also says the fixture keeps its main thread busy, but it runs a repeating UIView animation and the main thread stays free. Please reword it.

The new test is not in the -only-testing list in .github/workflows/ios.yml, so it runs in the nightly full-suite lane, not in this PR's checks. One Smoke Tests job fails in the live iOS fixture run: live-snapshot-depth-frontier.ts reports that a regular depth-1 snapshot carries XCTest tree quality metadata. This PR changes the idle-wait wrappers around alert, gesture, type and swipe actions, not snapshot backend selection, so the failure looks unrelated, but a rerun should confirm it.

There are no conflicts. Next: fix the conditional assert and the fixture comment.

An alert button is read as hittable before it is tapped, so XCTest's own wait for the app to
idle before synthesizing that tap adds nothing the runner has not already checked. Its default
outlives the command, so the tap lands after the caller was told the alert timed out (#2546).

The helper that bounds that wait was named for the scroll path that owned it; gesture, type and
swipe already went through it, and alert activation now does too, so it is named for the wait it
bounds. Each caller states which of XCTest's two waits it gives up: the gesture, type and swipe
paths skip both, because their next step is a poll of their own, while alert activation drops only
the pre-event wait. Its verification reads the alert this tap replaced, and an alert that dismisses
in order to present an identical replacement passes through a moment with no alert at all — a first
read landing in that moment reported a dismissal nothing had proved.
Reverting the alert path to a plain `activateElement` kept every test green, so the #2546 failure had
no guard: the deadline tests returned before activation, and the ones that reached it used a 10 s
timeout on a fixture that had already settled.

The fixture now animates without end until an alert button is answered, which is the state that made
XCTest hold the event past the caller's deadline in the field. A test that reaches activation there
and is given 6 s reports what actually happens: the response comes back in well under a second of
activation, and if it ever reports `ALERT_DEADLINE_EXCEEDED` again the fixture says whether a button
was activated behind that answer. Reverting the seam costs the run 18 s and produces exactly the
original defect — `ALERT_DEADLINE_EXCEEDED` alongside `First actions: 1`.

Only a `UIView` animation counts here: a repeating main-thread timer and a `CABasicAnimation` both
left the app looking idle and the reverted path still passed.
…nswered

The label check ran twice over the same response: it always expected `First actions: 1`, and the block
after it expected `First actions: 0` only when the answer was a deadline failure, so the second could
never pass and a correct refusal failed anyway. Now the assertion follows the answer — an accepted
alert must show the button press, a refused one must not — and the comment said "main thread busy"
where the fixture runs a repeating animation with the main thread free.

The test also joins the targeted list in `.github/workflows/ios.yml`, which is where the alert
regressions it guards are selected.
@thymikee
thymikee force-pushed the fix/ios-alert-idle-timeout-2546 branch from 6a9e275 to 5dae723 Compare September 15, 2026 14:15
@thymikee

Copy link
Copy Markdown
Member Author

Fixed at 5dae723146 (rebased onto 5fb2860119).

The label check now follows the answer instead of asserting both shapes over one response:

let recordedActions = app.staticTexts["agent-device-alert-actions"].label
if response.ok {
  XCTAssertEqual(recordedActions, "First actions: 1; replacement actions: 0")
} else {
  XCTAssertEqual(
    recordedActions,
    "First actions: 0; replacement actions: 0",
    "a caller told about an expired deadline must not have a button activated behind it"
  )
}

XCTAssertTrue(response.ok) and the elapsed bound stay, so the refusal branch carries the no-landed-tap property on its own rather than fighting the line above it. You were right that it could never pass: the two asserts were complements over the same response, which also means a correct refusal used to fail the test. The comment now says the fixture keeps an animation in flight, which is what XCTest waits out; the main thread does stay free, and that is what lets the answered button end the window.

The test is in the -only-testing list in .github/workflows/ios.yml, next to the other alert regressions.

Re-ran the mutation at this head, runner built from the tree: reverting handleAlert to the plain activateElement call waits 18.19 s, answers ALERT_DEADLINE_EXCEEDED with hint "The button was activated once", and the refusal branch then fails with ("First actions: 1; replacement actions: 0") is not equal to ("First actions: 0; replacement actions: 0"). Green path: two single-test runs (7.7 s, 6.9 s) and the nine alert XCTests 9/9.

On the Smoke failure: agreed it is not this PR's — live-snapshot-depth-frontier.ts asserting tree quality metadata has nothing to do with the idle-wait wrappers, and main is green on that lane. The lane has been re-queued on this head and I will report what it returns rather than assuming.

Also worth knowing while you look at this PR: check:affected's mutation-model step is red on main on its own, so my local gate output for a workflow-file change stops there. Reproduced at 5fb2860119, fix in #2637.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 5dae723, as a follow-up to the review at 6a9e275. The label check now branches on response.ok, expecting First actions: 1 on success and First actions: 0 on a refusal, and the test comment now describes the in-flight animation. The runner code and the fixture app are unchanged since 6a9e275, so your Smoke run there, with the handleAlert revert that reproduces the 18.1 s ALERT_DEADLINE_EXCEEDED failure, still covers the fix, and ios.yml now runs the new test in this PR's checks.

Smoke Tests and Coverage are still running at this head; the earlier depth-frontier Smoke failure is outside the files this PR changes, so it looks unrelated. There are no conflicts. Ready for human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 15, 2026
@thymikee
thymikee merged commit 55d02e9 into main Sep 15, 2026
18 checks passed
@thymikee
thymikee deleted the fix/ios-alert-idle-timeout-2546 branch September 15, 2026 15:23
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-15 15:23 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant