diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index 07adadc..5751c89 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -55,6 +55,12 @@ type Driver struct { LogEntries []driver.LogEntry MetricsData driver.Metrics Failures map[ActionKind]error + + // ForegroundResults is consumed one entry per ForegroundApp call (the + // last entry repeats). Empty yields "", which disables the runner's + // app-scope guard so tests that don't care are unaffected. + ForegroundResults []string + foregroundIndex int } func New() *Driver { @@ -96,6 +102,20 @@ func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool, _ m return nil } +func (d *Driver) ForegroundApp(_ context.Context) (string, error) { + d.mutex.Lock() + defer d.mutex.Unlock() + if len(d.ForegroundResults) == 0 { + return "", nil + } + index := d.foregroundIndex + if index >= len(d.ForegroundResults) { + index = len(d.ForegroundResults) - 1 + } + d.foregroundIndex++ + return d.ForegroundResults[index], nil +} + func (d *Driver) Terminate(ctx context.Context) error { if err := d.failure(ActionTerminate); err != nil { return err diff --git a/internal/runner/runner.go b/internal/runner/runner.go index bb0cb6a..1b49cf3 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -67,6 +67,14 @@ func Run(ctx context.Context, options Options) (Summary, error) { stepIndex++ stepStart := time.Now() + // Keep exploration scoped to the app under test. If a prior action + // backed out of (or otherwise left) the app, relaunch it before we + // observe or act, so properties never evaluate against a foreign app + // and actions never land outside the app. + if ensureForeground(ctx, options, logger, stepIndex) { + lastAction = nil + } + // Hierarchy, metrics, and logs are independent device reads — run // them concurrently so metrics+logs hide behind the hierarchy fetch. var tree *hierarchy.Tree @@ -263,6 +271,36 @@ func violationNames(verdicts map[string]ltl.Verdict) []string { return names } +// ensureForeground keeps the app under test in the foreground. When the driver +// can report the foreground app and it no longer matches the bundle under test, +// the app is relaunched. Returns true when a relaunch happened so the caller +// can drop the now-stale lastAction. Drivers without ForegroundChecker (web, +// iOS) are a no-op. +func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) bool { + checker, ok := options.Driver.(driver.ForegroundChecker) + if !ok || options.BundleID == "" { + return false + } + foreground, err := checker.ForegroundApp(ctx) + if err != nil { + logger.Warn("foreground check failed", "step", stepIndex, "err", err) + return false + } + if foreground == "" || foreground == options.BundleID { + return false + } + logger.Warn("app left foreground; relaunching", + "step", stepIndex, "foreground", foreground, "want", options.BundleID) + if err := options.Driver.Launch(ctx, options.BundleID, false, nil); err != nil { + logger.Warn("relaunch failed", "step", stepIndex, "err", err) + return false + } + idleCtx, cancel := context.WithTimeout(ctx, options.IdleTimeout) + _ = options.Driver.WaitForIdle(idleCtx, options.IdleTimeout) + cancel() + return true +} + func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error { switch action.Kind { case verifier.ActionKindTap: diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 2f7c54c..e06a2c9 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -426,3 +426,56 @@ func containsProperty(records []ViolationRecord, property string) bool { } return false } + +func TestRunner_RelaunchesWhenAppLeavesForeground(t *testing.T) { + state := newHarness(t) + // Always report a foreign app, so every step's guard must relaunch. + state.mock.ForegroundResults = []string{"com.android.chrome"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Run(ctx, Options{ + Duration: 100 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + BundleID: "app.folio", + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + relaunches := 0 + for _, a := range state.mock.Actions() { + if a.Kind == mockdriver.ActionLaunch && a.BundleID == "app.folio" && !a.ClearState { + relaunches++ + } + } + if relaunches == 0 { + t.Fatal("expected runner to relaunch app.folio when foreground escaped, got none") + } +} + +func TestRunner_NoRelaunchWhenAppInForeground(t *testing.T) { + state := newHarness(t) + state.mock.ForegroundResults = []string{"app.folio"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := Run(ctx, Options{ + Duration: 100 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + BundleID: "app.folio", + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + for _, a := range state.mock.Actions() { + if a.Kind == mockdriver.ActionLaunch { + t.Fatalf("expected no relaunch while app in foreground, got %v", a) + } + } +}