From da7557ac0b5d78dedb01a884ea3659ed214f25cb Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 10 Jun 2026 15:20:18 +0530 Subject: [PATCH] fix(runner): harden app-scope guard against launcher and overlays The per-step guard now relaunches and waits until the app window is actually drawn before proceeding, so a slow physical-device relaunch no longer lets an observe or action land on the launcher. It also detects a system overlay (notification shade) stealing window focus while the app stays resumed, and dismisses it with back. --- internal/runner/runner.go | 70 +++++++++++++++++++++++++------ internal/runner/runner_test.go | 75 ++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index e060133..253b5af 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -371,12 +371,44 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, logger.Warn("foreground check failed", "step", stepIndex, "err", err) return false } - if foreground == "" || foreground == options.BundleID { + if foreground != "" && foreground != options.BundleID { + logger.Warn("app left foreground; relaunching", + "step", stepIndex, "foreground", foreground, "want", options.BundleID) + // Relaunch and confirm the app is genuinely back on screen before the + // step observes or acts. A single relaunch returns before the window + // draws on a slow physical device, which would let the observe and the + // next action land on the launcher (its type-to-search swallows + // InputText). awaitForeground re-checks the foreground and focused + // window, so it never acts outside the app no matter how slow the + // relaunch settles. + awaitForeground(ctx, options, logger, stepIndex) + return true + } + // The app is the resumed activity, but a system overlay can still own the + // focused window while the app stays resumed: a fuzzer swipe starting in the + // status bar pulls the notification shade over the app. The resumed-activity + // signal misses this, so observing or acting would land on the shade. + // Dismiss it with back (which collapses the shade) so the next observe sees + // the app again. + focusChecker, hasFocus := options.Driver.(driver.FocusedWindowChecker) + if !hasFocus { return false } - logger.Warn("app left foreground; relaunching", - "step", stepIndex, "foreground", foreground, "want", options.BundleID) - return bringToForeground(ctx, options, logger, stepIndex) + focused, err := focusChecker.FocusedWindowApp(ctx) + if err != nil { + logger.Warn("focus check failed", "step", stepIndex, "err", err) + return false + } + if focused == "" || focused == options.BundleID { + return false + } + logger.Warn("system window obscuring app; dismissing", + "step", stepIndex, "focused", focused, "want", options.BundleID) + if err := options.Driver.PressKey(ctx, "back"); err != nil { + logger.Warn("dismiss overlay failed", "step", stepIndex, "err", err) + } + settleForForeground(ctx, options) + return true } // foregroundReadyAttempts bounds how many times waitForForeground tries to @@ -395,6 +427,20 @@ const foregroundReadyAttempts = 8 // report the focused window, the gate additionally waits for that window to // name the app, which only happens once it is genuinely drawn. func waitForForeground(ctx context.Context, options Options, logger *slog.Logger) { + awaitForeground(ctx, options, logger, 0) +} + +// awaitForeground brings the app under test forward when it is not already +// resumed and blocks until its window is actually drawn, bounded by +// foregroundReadyAttempts so a stuck system dialog can never hang the run. It +// re-checks the foreground each iteration and only presses back + relaunches +// while the app is genuinely absent, so once the app is resumed it polls the +// focused-window signal instead of mashing back (which would re-exit the app +// from its root screen). Shared by the pre-run startup gate (stepIndex 0) and +// the per-step scope guard so neither lets an observe or action land outside +// the app. Drivers without ForegroundChecker (web) and an unknown foreground +// both skip the gate. +func awaitForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) { checker, ok := options.Driver.(driver.ForegroundChecker) if !ok || options.BundleID == "" { return @@ -406,16 +452,16 @@ func waitForForeground(ctx context.Context, options Options, logger *slog.Logger } foreground, err := checker.ForegroundApp(ctx) if err != nil { - logger.Warn("foreground check failed before first step", "err", err) + logger.Warn("foreground check failed", "step", stepIndex, "err", err) return } if foreground == "" { return // foreground unknowable (e.g. iOS); don't block the run } if foreground != options.BundleID { - logger.Warn("app not in foreground at start; bringing it forward", - "foreground", foreground, "want", options.BundleID, "attempt", attempt) - bringToForeground(ctx, options, logger, 0) + logger.Warn("app not in foreground; bringing it forward", + "step", stepIndex, "foreground", foreground, "want", options.BundleID, "attempt", attempt) + bringToForeground(ctx, options, logger, stepIndex) continue } if !hasFocus { @@ -423,18 +469,18 @@ func waitForForeground(ctx context.Context, options Options, logger *slog.Logger } focused, err := focusChecker.FocusedWindowApp(ctx) if err != nil { - logger.Warn("focus check failed before first step", "err", err) + logger.Warn("focus check failed", "step", stepIndex, "err", err) return } if focused == options.BundleID { return // window is drawn; safe to observe } logger.Warn("app resumed but window not yet drawn; waiting", - "focused", focused, "want", options.BundleID, "attempt", attempt) + "step", stepIndex, "focused", focused, "want", options.BundleID, "attempt", attempt) settleForForeground(ctx, options) } - logger.Warn("app never reached foreground before first step; proceeding anyway", - "want", options.BundleID) + logger.Warn("app never reached foreground; proceeding anyway", + "step", stepIndex, "want", options.BundleID) } // bringToForeground returns the app under test to the foreground. It first diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index ad27702..8d5ed6d 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "os" "path/filepath" @@ -1764,3 +1765,77 @@ func TestRunner_WaitsForWindowDrawnBeforeFirstAction(t *testing.T) { } } } + +// TestAwaitForeground_RelaunchesThenWaitsForWindow locks the per-step scope +// guard's recovery: after the app leaves to the launcher, it must relaunch AND +// keep polling the focused window until it names the app, so the step never +// observes or acts while the launcher is on screen (where InputText would land +// in the launcher's type-to-search filter). A single fire-and-forget relaunch, +// which returns before the window draws on a slow physical device, is the bug +// this guards against. +func TestAwaitForeground_RelaunchesThenWaitsForWindow(t *testing.T) { + m := mockdriver.New() + // Foreground: launcher on the first poll (still gone), then the app. Focus: + // the launcher window lingers one extra poll before the app's window draws. + m.ForegroundResults = []string{"com.android.launcher", "app.folio"} + m.FocusedWindowResults = []string{"com.android.launcher", "app.folio"} + + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn})) + options := Options{BundleID: "app.folio", Driver: m, IdleTimeout: 10 * time.Millisecond} + + awaitForeground(context.Background(), options, logger, 7) + + relaunches, backs := 0, 0 + for _, a := range m.Actions() { + switch { + case a.Kind == mockdriver.ActionLaunch && a.BundleID == "app.folio" && !a.ClearState: + relaunches++ + case a.Kind == mockdriver.ActionPressKey && a.Key == "back": + backs++ + } + } + if relaunches != 1 { + t.Fatalf("expected exactly one relaunch while the app was gone, got %d", relaunches) + } + if backs != 1 { + t.Fatalf("expected one back-press to dismiss a possible dialog before relaunch, got %d", backs) + } + // The window lagged one poll behind the resumed activity, so the focused + // window must have been queried at least twice before the gate returned. + if calls := m.FocusedWindowCalls(); calls < 2 { + t.Fatalf("expected the guard to poll the focused window until drawn (>=2), got %d", calls) + } +} + +// TestEnsureForeground_DismissesSystemOverlay locks the shade fix: when the app +// is still the resumed activity but a system overlay (notification shade) holds +// the focused window, the guard must dismiss it with back rather than relaunch +// or act on the obscured app. +func TestEnsureForeground_DismissesSystemOverlay(t *testing.T) { + m := mockdriver.New() + // Resumed activity stays the app; the focused window is the shade. + m.ForegroundResults = []string{"app.folio"} + m.FocusedWindowResults = []string{"com.android.systemui"} + + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn})) + options := Options{BundleID: "app.folio", Driver: m, IdleTimeout: 10 * time.Millisecond} + + if !ensureForeground(context.Background(), options, logger, 5) { + t.Fatal("expected the guard to act on the focus-stealing overlay") + } + backs, relaunches := 0, 0 + for _, a := range m.Actions() { + switch { + case a.Kind == mockdriver.ActionPressKey && a.Key == "back": + backs++ + case a.Kind == mockdriver.ActionLaunch: + relaunches++ + } + } + if backs != 1 { + t.Fatalf("expected one back-press to collapse the shade, got %d", backs) + } + if relaunches != 0 { + t.Fatalf("a resumed-but-obscured app must not be relaunched, got %d relaunches", relaunches) + } +}