From e77f387a238767ec19f97f75c6c9f388d99ba1e7 Mon Sep 17 00:00:00 2001 From: mobile-kevin <306153527+mobile-kevin@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:53:15 +0200 Subject: [PATCH 1/2] fix(android): report a package that is not installed when terminating it am force-stop exits 0 for any package name, so terminating an app that is not installed reported success. Check pm path first and fail with ' not installed', the message the iOS device and simulator give. Terminating an installed app that is not running still succeeds. --- devices/android.go | 32 +++++++++++++++++++++++++++++ devices/android_apps_test.go | 40 +++++++++++++++++++++++++++++++++++- test/android.spec.ts | 33 +++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/devices/android.go b/devices/android.go index a38215b9..1b95ae34 100644 --- a/devices/android.go +++ b/devices/android.go @@ -509,7 +509,18 @@ func (d *AndroidDevice) LaunchApp(bundleID string, opts LaunchOptions) error { return nil } +// TerminateApp stops an app. `am force-stop` succeeds for any package name, so a +// package that is not installed is reported the way IOSDevice and SimulatorDevice +// report it. An installed app that is not running is still stopped without error. func (d *AndroidDevice) TerminateApp(bundleID string) error { + installed, err := d.isPackageInstalled(bundleID) + if err != nil { + return err + } + if !installed { + return fmt.Errorf("%s not installed", bundleID) + } + output, err := d.runAdbShell("am", "force-stop", bundleID) if err != nil { return fmt.Errorf("failed to terminate app %s: %v\nOutput: %s", bundleID, err, string(output)) @@ -1246,6 +1257,27 @@ func (d *AndroidDevice) Info() (*FullDeviceInfo, error) { }, nil } +// isPackageInstalled reports whether packageName is installed on the device. +func (d *AndroidDevice) isPackageInstalled(packageName string) (bool, error) { + output, err := d.runAdbShell("pm", "path", packageName) + return pmPathReportsInstalled(output, err) +} + +// pmPathReportsInstalled reads the result of `pm path `, which prints +// "package:" for an installed package and exits 1 with no output for +// one that is not installed. Any other failure, such as an offline device, is +// returned as an error rather than read as "not installed". +func pmPathReportsInstalled(output []byte, err error) (bool, error) { + text := strings.TrimSpace(string(output)) + if strings.HasPrefix(text, "package:") { + return true, nil + } + if err != nil && text != "" { + return false, fmt.Errorf("failed to check whether the package is installed: %v\nOutput: %s", err, text) + } + return false, nil +} + func (d *AndroidDevice) GetAppPath(packageName string) (string, error) { output, err := d.runAdbShell("pm", "path", packageName) if err != nil { diff --git a/devices/android_apps_test.go b/devices/android_apps_test.go index baa298c0..f02151fa 100644 --- a/devices/android_apps_test.go +++ b/devices/android_apps_test.go @@ -1,6 +1,44 @@ package devices -import "testing" +import ( + "errors" + "testing" +) + +func TestPmPathReportsAnInstalledPackage(t *testing.T) { + installed, err := pmPathReportsInstalled([]byte("package:/system_ext/priv-app/SettingsGoogle/SettingsGoogle.apk\n"), nil) + + if err != nil || !installed { + t.Fatalf("got installed=%v err=%v, want installed with no error", installed, err) + } +} + +func TestPmPathReportsAnInstalledSplitApkPackage(t *testing.T) { + output := []byte("package:/data/app/com.example/base.apk\npackage:/data/app/com.example/split_config.arm64_v8a.apk\n") + + installed, err := pmPathReportsInstalled(output, nil) + + if err != nil || !installed { + t.Fatalf("got installed=%v err=%v, want installed with no error", installed, err) + } +} + +func TestPmPathReportsAMissingPackageAsNotInstalled(t *testing.T) { + // pm path exits 1 and prints nothing for a package that is not installed + installed, err := pmPathReportsInstalled([]byte(""), errors.New("exit status 1")) + + if err != nil || installed { + t.Fatalf("got installed=%v err=%v, want not installed with no error", installed, err) + } +} + +func TestPmPathReportsAnAdbFailureAsAnError(t *testing.T) { + _, err := pmPathReportsInstalled([]byte("adb: device 'emulator-5554' not found"), errors.New("exit status 1")) + + if err == nil { + t.Fatal("an adb failure must not be read as 'not installed'") + } +} func TestParsePackageListerOutput(t *testing.T) { output := []byte(`[{"packageName":"com.mobilenext.devicekit","appName":"DeviceKit","version":"1.2.5","versionCode":10205}]`) diff --git a/test/android.spec.ts b/test/android.spec.ts index e6394478..a4740a56 100644 --- a/test/android.spec.ts +++ b/test/android.spec.ts @@ -195,6 +195,22 @@ test.describe('Android Tests', () => { await expectLauncherToBeInForeground(device!.id); }); + test('should report that a package which is not installed cannot be terminated', () => { + test.skip(!device, 'No Android device found'); + + const error = appsCommandError(device!.id, 'terminate', ABSENT_PACKAGE); + + expect(error).toContain(`${ABSENT_PACKAGE} not installed`); + }); + + test('should terminate an installed app that is not running without an error', () => { + test.skip(!device, 'No Android device found'); + + // force-stop is idempotent, and callers stop an app before relaunching it + terminateApp(device!.id, SETTINGS_PACKAGE); + terminateApp(device!.id, SETTINGS_PACKAGE); + }); + test('should handle launching app twice (idempotency)', async () => { test.skip(!device, 'No Android device found'); @@ -983,6 +999,23 @@ function terminateApp(deviceId: string, packageName: string): void { mobilecli(['apps', 'terminate', packageName, '--device', deviceId]); } +// runs an `apps` subcommand that is expected to fail and returns its error message +function appsCommandError(deviceId: string, subcommand: string, packageName: string): string { + try { + mobilecliJson(['apps', subcommand, packageName, '--device', deviceId]); + } catch (error: unknown) { + // a timeout or a missing binary fails without printing an envelope, and + // parsing that as json would bury the real cause + const stdout = (error as {stdout?: string}).stdout ?? ''; + if (stdout.trim() === '') { + throw error; + } + return expectErrorEnvelope(JSON.parse(stdout)); + } + + throw new Error(`apps ${subcommand} ${packageName} unexpectedly succeeded`); +} + // force-stops every package that can hold an activity in the settings task, so the // next launch starts from a clean root instead of resuming whatever was left open // agent subcommands report a missing agent with status "fail" and still exit 0, so From 5be917d48e45d2de40f006b4cc8f4217a3a62b6d Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Sun, 11 Oct 2026 15:00:48 +0200 Subject: [PATCH 2/2] fix(android): report an adb that could not run pm path instead of reading it as not installed --- devices/android.go | 12 ++++++++---- devices/android_apps_test.go | 28 +++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/devices/android.go b/devices/android.go index 1b95ae34..ee240090 100644 --- a/devices/android.go +++ b/devices/android.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "encoding/xml" + "errors" "fmt" "os" "os/exec" @@ -1265,15 +1266,18 @@ func (d *AndroidDevice) isPackageInstalled(packageName string) (bool, error) { // pmPathReportsInstalled reads the result of `pm path `, which prints // "package:" for an installed package and exits 1 with no output for -// one that is not installed. Any other failure, such as an offline device, is -// returned as an error rather than read as "not installed". +// one that is not installed. Any other failure, such as an offline device or +// an adb that could not be started, is returned as an error rather than read +// as "not installed". func pmPathReportsInstalled(output []byte, err error) (bool, error) { text := strings.TrimSpace(string(output)) if strings.HasPrefix(text, "package:") { return true, nil } - if err != nil && text != "" { - return false, fmt.Errorf("failed to check whether the package is installed: %v\nOutput: %s", err, text) + var exitErr *exec.ExitError + exitedWithoutAMatch := errors.As(err, &exitErr) && exitErr.ExitCode() == 1 && text == "" + if err != nil && !exitedWithoutAMatch { + return false, fmt.Errorf("failed to check whether the package is installed: %w\nOutput: %s", err, text) } return false, nil } diff --git a/devices/android_apps_test.go b/devices/android_apps_test.go index f02151fa..e4cc2763 100644 --- a/devices/android_apps_test.go +++ b/devices/android_apps_test.go @@ -1,7 +1,10 @@ package devices import ( + "context" "errors" + "fmt" + "os/exec" "testing" ) @@ -23,15 +26,38 @@ func TestPmPathReportsAnInstalledSplitApkPackage(t *testing.T) { } } +// processExiting returns the error of a process that exited with the given +// status, as adb's does when pm path reports a missing package. +func processExiting(t *testing.T, status int) error { + t.Helper() + err := exec.CommandContext(context.Background(), "sh", "-c", fmt.Sprintf("exit %d", status)).Run() + if err == nil { + t.Fatalf("expected the shell to exit with status %d", status) + } + return err +} + func TestPmPathReportsAMissingPackageAsNotInstalled(t *testing.T) { // pm path exits 1 and prints nothing for a package that is not installed - installed, err := pmPathReportsInstalled([]byte(""), errors.New("exit status 1")) + installed, err := pmPathReportsInstalled([]byte(""), processExiting(t, 1)) if err != nil || installed { t.Fatalf("got installed=%v err=%v, want not installed with no error", installed, err) } } +// adb that could not be started prints nothing either, and must not pass for +// a package that is not installed. +func TestPmPathReportsAnAdbThatCouldNotStartAsAnError(t *testing.T) { + adbNotFound := &exec.Error{Name: "adb", Err: exec.ErrNotFound} + + _, err := pmPathReportsInstalled([]byte(""), adbNotFound) + + if err == nil || !errors.Is(err, exec.ErrNotFound) { + t.Fatalf("expected the launch failure to be reported, got %v", err) + } +} + func TestPmPathReportsAnAdbFailureAsAnError(t *testing.T) { _, err := pmPathReportsInstalled([]byte("adb: device 'emulator-5554' not found"), errors.New("exit status 1"))