From 3f8c6018f646e9f967f5848359258b26eef59cc9 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 10 Jun 2026 21:53:21 +0530 Subject: [PATCH] fix(runner): re-check foreground at apply time, skip stale actions ensureForeground runs before observe, but the app can leave between observe and apply (a prior gesture settling late); swipes/keys then fire stale coordinates onto whatever screen is now up. Re-check foreground immediately before applying and, when the app is gone, skip the action and log it (making the escape visible) so the next step's guard relaunches instead. --- internal/runner/runner.go | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 9476454..4cfcf61 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -225,7 +225,17 @@ func Run(ctx context.Context, options Options) (Summary, error) { } applySkipped := false - if nextErr == nil { + if nextErr == nil && !appIsForeground(ctx, options) { + // The app left the foreground between observe and apply (a prior + // action's gesture settling late, or an async navigation). The + // chosen action's coordinates reference a tree that no longer + // applies, so firing it would act on whatever screen is now up. + // Skip it and record the escape; the next step's guard relaunches. + logger.Warn("app not in foreground at action time; skipping (relaunch next step)", + "step", stepIndex, "action", nextAction.Kind) + applySkipped = true + lastAction = nil + } else if nextErr == nil { if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil { if isWDADrop(err) { return summary, fmt.Errorf("step %d: the iOS XCTest runner could not be restarted - re-run the test: %w", stepIndex, err) @@ -411,6 +421,25 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, return true } +// appIsForeground reports whether the app under test currently owns the +// foreground. It is the apply-time half of the scope guard: ensureForeground +// runs before observe, but the app can leave between observe and apply (a prior +// gesture settling late, an async navigation), and swipes/keys carry stale +// coordinates with no selector to re-resolve. An absent capability or an unknown +// foreground returns true so the run is never blocked where the signal is +// unavailable (web, iOS, a transient read). +func appIsForeground(ctx context.Context, options Options) bool { + checker, ok := options.Driver.(driver.ForegroundChecker) + if !ok || options.BundleID == "" { + return true + } + foreground, err := checker.ForegroundApp(ctx) + if err != nil || foreground == "" { + return true + } + return foreground == options.BundleID +} + // foregroundReadyAttempts bounds how many times waitForForeground tries to // bring the app forward before the first step, so a stuck system dialog can // never hang the run.