From 73736ab26f1adc91aeeeb3f6f49dd1fd886debe7 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:49:31 +0530 Subject: [PATCH 1/2] fix(ios): the runner must not answer ok for a launch that failed XCTest records a refused launch as a test issue that never throws, so the companion returned ok for an app that never started. Check the state the app actually reached and report the refusal instead. --- companion/Sources/AppLifecycle.swift | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/companion/Sources/AppLifecycle.swift b/companion/Sources/AppLifecycle.swift index 065abc5..9ebffb9 100644 --- a/companion/Sources/AppLifecycle.swift +++ b/companion/Sources/AppLifecycle.swift @@ -9,6 +9,7 @@ enum AppLifecycle { } static func launch(bundleIdentifier: String, foregroundIfRunning: Bool) throws { + var reached = XCUIApplication.State.unknown try onMainCatching { let application = XCUIApplication(bundleIdentifier: bundleIdentifier) if foregroundIfRunning { @@ -18,6 +19,17 @@ enum AppLifecycle { } else { application.launch() } + reached = application.state + } + // A refused launch is recorded as a test issue that never throws, so + // without this the runner answers ok for an app that is not running + // and the host learns nothing until its own bound expires. + switch reached { + case .runningForeground, .runningBackground, .runningBackgroundSuspended: + return + default: + throw LifecycleError.failed( + "\(bundleIdentifier) is \(name(of: reached)) after launch") } } @@ -54,6 +66,21 @@ enum AppLifecycle { return result } + private static func name(of state: XCUIApplication.State) -> String { + switch state { + case .runningForeground: + return "foreground" + case .runningBackground: + return "background" + case .runningBackgroundSuspended: + return "suspended" + case .notRunning: + return "not running" + default: + return "unknown" + } + } + // onMainCatching runs automation work on the main thread and converts a // framework assertion into a thrown error so the server survives it. private static func onMainCatching(_ work: @escaping () -> Void) throws { From a7e94c85dfd13df7bd30a5f224d4851275250466 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 20:49:35 +0530 Subject: [PATCH 2/2] test(ios): a refusal the runner names costs no session restart The session restart is for a launch that never answers. A launch that reports the app's state has already said what a fresh session would. --- internal/driver/ioscompanion/driver_test.go | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/driver/ioscompanion/driver_test.go b/internal/driver/ioscompanion/driver_test.go index 07316c2..e69433c 100644 --- a/internal/driver/ioscompanion/driver_test.go +++ b/internal/driver/ioscompanion/driver_test.go @@ -1076,6 +1076,50 @@ func TestLaunchKeepsTheSessionWhenTheCallersOwnDeadlineExpires(t *testing.T) { } } +// refusedLaunchCompanion answers a launch the way the runner does once it +// checks the app's state after activating it: promptly, naming the app and the +// state it reached, over a session that is still serving. +type refusedLaunchCompanion struct { + fakeCompanion + attempts int +} + +func (r *refusedLaunchCompanion) Launch(context.Context, string, bool) error { + r.attempts++ + return errors.New(`runner launch: failed("com.example.app is not running after launch")`) +} + +// TestLaunchKeepsTheSessionWhenTheRunnerNamesTheRefusal separates a launch that +// answers from a launch that never does. The session restart is the only +// recovery from a wedged session, and it costs a cold start; a runner that +// reports the app's state has already said what a fresh session would say, so +// restarting to hear it again only delays the error and hides the app under it. +func TestLaunchKeepsTheSessionWhenTheRunnerNamesTheRefusal(t *testing.T) { + companion := &refusedLaunchCompanion{} + output := &bytes.Buffer{} + d := newTestDriver(companion) + d.output = output + restarts := 0 + d.restart = func(context.Context) error { + restarts++ + return nil + } + + err := d.Launch(context.Background(), "", false, nil) + if err == nil || !strings.Contains(err.Error(), "com.example.app is not running after launch") { + t.Fatalf("err = %v, want the runner's refusal reaching the caller intact", err) + } + if restarts != 0 { + t.Fatalf("session restarts = %d, want 0: a refusal the runner reported is not a wedged session", restarts) + } + if companion.attempts != 1 { + t.Fatalf("launch attempts = %d, want 1: relaunching an app the runner just refused cannot launch it", companion.attempts) + } + if strings.Contains(output.String(), "restarting the session") { + t.Fatalf("the driver announced a recovery it must not spend here; output was %q", output.String()) + } +} + // newLockTestOptions builds New options that dial a seamed companion, so the // device-lock tests exercise New without spawning anything. func newLockTestOptions(t *testing.T, udid string) Options {