mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +00:00
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.
This commit is contained in:
1 parent
f9aa44f7e8
commit
da7557ac0b
2 files changed
+133
-12
No files matched your search
+58
-12
@@ -371,12 +371,44 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger,
|
|||||||
logger.Warn("foreground check failed", "step", stepIndex, "err", err)
|
logger.Warn("foreground check failed", "step", stepIndex, "err", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if foreground == "" || foreground == options.BundleID {
|
if foreground != "" && foreground != options.BundleID {
|
||||||
return false
|
|
||||||
}
|
|
||||||
logger.Warn("app left foreground; relaunching",
|
logger.Warn("app left foreground; relaunching",
|
||||||
"step", stepIndex, "foreground", foreground, "want", options.BundleID)
|
"step", stepIndex, "foreground", foreground, "want", options.BundleID)
|
||||||
return bringToForeground(ctx, options, logger, stepIndex)
|
// 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
|
||||||
|
}
|
||||||
|
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
|
// 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
|
// report the focused window, the gate additionally waits for that window to
|
||||||
// name the app, which only happens once it is genuinely drawn.
|
// name the app, which only happens once it is genuinely drawn.
|
||||||
func waitForForeground(ctx context.Context, options Options, logger *slog.Logger) {
|
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)
|
checker, ok := options.Driver.(driver.ForegroundChecker)
|
||||||
if !ok || options.BundleID == "" {
|
if !ok || options.BundleID == "" {
|
||||||
return
|
return
|
||||||
@@ -406,16 +452,16 @@ func waitForForeground(ctx context.Context, options Options, logger *slog.Logger
|
|||||||
}
|
}
|
||||||
foreground, err := checker.ForegroundApp(ctx)
|
foreground, err := checker.ForegroundApp(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("foreground check failed before first step", "err", err)
|
logger.Warn("foreground check failed", "step", stepIndex, "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if foreground == "" {
|
if foreground == "" {
|
||||||
return // foreground unknowable (e.g. iOS); don't block the run
|
return // foreground unknowable (e.g. iOS); don't block the run
|
||||||
}
|
}
|
||||||
if foreground != options.BundleID {
|
if foreground != options.BundleID {
|
||||||
logger.Warn("app not in foreground at start; bringing it forward",
|
logger.Warn("app not in foreground; bringing it forward",
|
||||||
"foreground", foreground, "want", options.BundleID, "attempt", attempt)
|
"step", stepIndex, "foreground", foreground, "want", options.BundleID, "attempt", attempt)
|
||||||
bringToForeground(ctx, options, logger, 0)
|
bringToForeground(ctx, options, logger, stepIndex)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !hasFocus {
|
if !hasFocus {
|
||||||
@@ -423,18 +469,18 @@ func waitForForeground(ctx context.Context, options Options, logger *slog.Logger
|
|||||||
}
|
}
|
||||||
focused, err := focusChecker.FocusedWindowApp(ctx)
|
focused, err := focusChecker.FocusedWindowApp(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("focus check failed before first step", "err", err)
|
logger.Warn("focus check failed", "step", stepIndex, "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if focused == options.BundleID {
|
if focused == options.BundleID {
|
||||||
return // window is drawn; safe to observe
|
return // window is drawn; safe to observe
|
||||||
}
|
}
|
||||||
logger.Warn("app resumed but window not yet drawn; waiting",
|
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)
|
settleForForeground(ctx, options)
|
||||||
}
|
}
|
||||||
logger.Warn("app never reached foreground before first step; proceeding anyway",
|
logger.Warn("app never reached foreground; proceeding anyway",
|
||||||
"want", options.BundleID)
|
"step", stepIndex, "want", options.BundleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bringToForeground returns the app under test to the foreground. It first
|
// bringToForeground returns the app under test to the foreground. It first
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user