fix(runner): budget the foreground gate in time, not in polls

Eight polls is not a budget. Each poll costs whatever the driver's idle wait
happens to take, so the same launch cleared the gate on one device and
exhausted it on another: across 80 runs of one app, the gate reported "app
never reached foreground" on 38 of 40 Android 14 runs and 0 of 40 Android 16
runs, and it was wrong every time. On API 34 settleForForeground returned in
~100ms, so the eight polls gave up 1.2s into a launch whose window drew at
~1.9s; on API 36 the same eight polls spanned 3s and covered it. The Android 14
runs then spent their first step on the launch animation instead of the app,
which is the one-step offset that came out of that campaign looking like a
platform difference.

The gate now polls for a fixed 15s at a 250ms floor, so its verdict is the same
duration on every device, and a verdict of "not in front" ends the run instead
of warning and carrying on: a run that never got its app on screen holds no
evidence about the app, and the trace records why at step 0.
This commit is contained in:
pj committed 2026-08-17 14:35:29 +05:30
1 parent 1ebb8b191f
commit a7ed433706
2 files changed
+176 -51

No files matched your search

+25 -3
View File
@@ -82,6 +82,18 @@ func fastFocusSettle(t *testing.T) {
t.Cleanup(func() { focusTapSettle = prev })
}
// fastForegroundGate shrinks the startup gate's wall-clock budget so a test that
// drives it to exhaustion takes milliseconds. The budget stays a duration, which
// is the property under test.
func fastForegroundGate(t *testing.T) {
budget, interval := foregroundReadyBudget, foregroundPollInterval
foregroundReadyBudget = 200 * time.Millisecond
foregroundPollInterval = time.Millisecond
t.Cleanup(func() {
foregroundReadyBudget, foregroundPollInterval = budget, interval
})
}
func mustDispatch(t *testing.T, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) {
t.Helper()
skipped, err := applyAction(context.Background(), drv, action, tree)
@@ -2117,9 +2129,12 @@ func containsProperty(records []ViolationRecord, property string) bool {
}
func TestRunner_RelaunchesWhenAppLeavesForeground(t *testing.T) {
fastForegroundGate(t)
state := newHarness(t)
// Always report a foreign app, so every step's guard must relaunch.
state.mock.ForegroundResults = []string{"com.android.chrome"}
// The app is in front when the run starts and a foreign app every time a
// step looks, so the startup gate passes and every step's guard must
// relaunch.
state.mock.ForegroundResults = []string{"app.folio", "com.android.chrome"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -2173,6 +2188,7 @@ func TestRunner_NoRelaunchWhenAppInForeground(t *testing.T) {
// brings the app forward (back-press + relaunch) before any tap fires when the
// device boots showing a system dialog.
func TestRunner_WaitsForForegroundBeforeFirstAction(t *testing.T) {
fastForegroundGate(t)
state := newHarness(t)
// First the device shows a system setup screen, then the app is on top.
state.mock.ForegroundResults = []string{"com.google.android.setupwizard", "app.folio"}
@@ -2221,6 +2237,7 @@ func TestRunner_WaitsForForegroundBeforeFirstAction(t *testing.T) {
// signal rather than relaunching, and only proceed once the window names the
// app.
func TestRunner_WaitsForWindowDrawnBeforeFirstAction(t *testing.T) {
fastForegroundGate(t)
state := newHarness(t)
// The app is resumed immediately, but its window lags: the outgoing
// settings screen stays focused for two checks before the app draws.
@@ -2263,6 +2280,7 @@ func TestRunner_WaitsForWindowDrawnBeforeFirstAction(t *testing.T) {
// which returns before the window draws on a slow physical device, is the bug
// this guards against.
func TestAwaitForeground_RelaunchesThenWaitsForWindow(t *testing.T) {
fastForegroundGate(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.
@@ -2363,11 +2381,15 @@ func TestEnsureForeground_DismissesSystemOverlay(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn}))
options := Options{BundleID: "app.folio", Driver: m, IdleTimeout: 10 * time.Millisecond}
got := ensureForeground(context.Background(), options, logger, 5)
got, inScope := ensureForeground(context.Background(), options, logger, 5)
if got != foregroundOverlayDismissed {
t.Fatalf("the guard reported %v, want foregroundOverlayDismissed; "+
"an obscured app is not a relaunched one", got)
}
if !inScope {
t.Fatal("the guard reported the app out of scope; a dismissed overlay leaves " +
"the app resumed, and marking the step unmet would hide the real ones")
}
backs, relaunches := 0, 0
for _, a := range m.Actions() {
switch {