Merge branch 'clear-state-must-happen' into correctness-and-spec-skills

This commit is contained in:
pj committed 2026-08-16 00:22:59 +05:30
commit d977265b30
6 files changed
+283 -4

No files matched your search

+36 -2
View File
@@ -123,14 +123,17 @@ func antiFreezeCommands() [][]string {
// reinstalling it. This replaces `pm clear` for clear-state: ColorOS and other
// hardened OEM builds deny CLEAR_APP_USER_DATA even to the adb shell user, so a
// clear aborts the launch, whereas uninstall+install is always permitted.
// The uninstall is best effort so a not-installed app is not an error.
// A failed uninstall is not passed over: `install -r` keeps the app's data, so
// the reinstall would report a clear-state that never happened.
func ReinstallApp(ctx context.Context, serial, bundleID, apkPath string, stdout io.Writer) error {
adb, err := AdbBinary()
if err != nil {
return err
}
if output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "uninstall", bundleID)...).CombinedOutput(); err != nil {
fmt.Fprintf(stdout, "clear-state: uninstall %s skipped (%v: %s)\n", bundleID, err, strings.TrimSpace(string(output)))
if err := clearDataUninstallLeftBehind(ctx, adb, serial, bundleID, strings.TrimSpace(string(output)), stdout); err != nil {
return err
}
}
if output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "install", "-r", apkPath)...).CombinedOutput(); err != nil {
return fmt.Errorf("install %s: %w: %s", apkPath, err, strings.TrimSpace(string(output)))
@@ -138,6 +141,37 @@ func ReinstallApp(ctx context.Context, serial, bundleID, apkPath string, stdout
return nil
}
// clearDataUninstallLeftBehind reaches first-launch state after `adb uninstall`
// failed. The failure text cannot say why: an API 34 emulator answers
// "Failure [DELETE_FAILED_INTERNAL_ERROR]" both for a package that was never
// installed and for one it refuses to remove. So ask the package manager which
// happened. Nothing installed means nothing to clear. Still installed
// means the data survives the reinstall, and `pm clear` is the one remaining
// way to reach first-launch state; when that fails too, so does clear-state.
func clearDataUninstallLeftBehind(ctx context.Context, adb, serial, bundleID, uninstallOutput string, stdout io.Writer) error {
if !packageInstalled(ctx, adb, serial, bundleID) {
return nil
}
output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "shell", "pm", "clear", bundleID)...).CombinedOutput()
cleared := strings.TrimSpace(string(output))
if err != nil || !strings.Contains(cleared, "Success") {
return fmt.Errorf(
"clear-state: %s is still installed after `adb uninstall` said %q, and `pm clear` said %q: its data was not cleared",
bundleID, uninstallOutput, cleared,
)
}
fmt.Fprintf(stdout, "clear-state: uninstall %s said %q and left it installed; cleared its data with `pm clear` instead\n", bundleID, uninstallOutput)
return nil
}
// packageInstalled reports whether the package manager resolves an APK path for
// bundleID. The printed path is the signal rather than the exit status, which
// `adb shell` does not forward from devices below API 24.
func packageInstalled(ctx context.Context, adb, serial, bundleID string) bool {
output, _ := exec.CommandContext(ctx, adb, adbArgs(serial, "shell", "pm", "path", bundleID)...).Output()
return strings.HasPrefix(strings.TrimSpace(string(output)), "package:")
}
const threeButtonNavOverlay = "com.android.internal.systemui.navbar.threebutton"
// navModeOverlays are the system navigation-mode overlays. Only one is active at
+131
View File
@@ -441,3 +441,134 @@ func TestNavModeToRestore(t *testing.T) {
}
}
}
// scriptedAdb puts an adb under a fake SDK root that logs each invocation's
// arguments and answers from replies, a `case "$*" in` body. SDK lookup is
// isolated onto that root, so ReinstallApp runs its real command sequence
// against the script and the log holds what reached adb.
func scriptedAdb(t *testing.T, replies string) string {
t.Helper()
root := t.TempDir()
log := filepath.Join(root, "adb.log")
adb := filepath.Join(root, "platform-tools", "adb")
if err := os.MkdirAll(filepath.Dir(adb), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(adb), err)
}
script := "#!/bin/sh\necho \"$*\" >> " + log + "\ncase \"$*\" in\n" + replies + "\nesac\n"
if err := os.WriteFile(adb, []byte(script), 0o755); err != nil {
t.Fatalf("write %s: %v", adb, err)
}
isolateSDKLookup(t, root)
return log
}
func adbCalls(t *testing.T, log string) []string {
t.Helper()
contents, err := os.ReadFile(log)
if err != nil {
if os.IsNotExist(err) {
return nil
}
t.Fatalf("read %s: %v", log, err)
}
return strings.Split(strings.TrimSpace(string(contents)), "\n")
}
const (
uninstallFails = `"uninstall "*) echo "Failure [DELETE_FAILED_INTERNAL_ERROR]"; exit 1;;`
stillInstalled = `"shell pm path "*) echo "package:/data/app/app.example-1/base.apk";;`
notInstalled = `"shell pm path "*) exit 1;;`
installSucceeds = `"install "*) echo "Success";;`
)
func TestReinstallApp_RefusedUninstallClearsTheDataItLeftBehind(t *testing.T) {
log := scriptedAdb(t, strings.Join([]string{
uninstallFails,
stillInstalled,
`"shell pm clear "*) echo "Success";;`,
installSucceeds,
}, "\n"))
output := &strings.Builder{}
if err := ReinstallApp(t.Context(), "", "app.example", "/tmp/app.apk", output); err != nil {
t.Fatalf("ReinstallApp: %v", err)
}
want := []string{
"uninstall app.example",
"shell pm path app.example",
"shell pm clear app.example",
"install -r /tmp/app.apk",
}
if got := adbCalls(t, log); !slices.Equal(got, want) {
t.Fatalf("adb calls = %v, want %v", got, want)
}
if !strings.Contains(output.String(), "pm clear") {
t.Errorf("output %q does not say the data was cleared some other way", output.String())
}
}
func TestReinstallApp_RefusedUninstallThatCannotBeClearedIsFatal(t *testing.T) {
log := scriptedAdb(t, strings.Join([]string{
uninstallFails,
stillInstalled,
`"shell pm clear "*) echo "Failed"; exit 1;;`,
installSucceeds,
}, "\n"))
err := ReinstallApp(t.Context(), "", "app.example", "/tmp/app.apk", &strings.Builder{})
if err == nil {
t.Fatal("ReinstallApp reported success while app.example kept the data clear-state was asked to remove")
}
for _, want := range []string{"app.example", "DELETE_FAILED_INTERNAL_ERROR", "Failed"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not quote %q", err, want)
}
}
if slices.Contains(adbCalls(t, log), "install -r /tmp/app.apk") {
t.Error("installed over an app whose data survived, which is the reinstall reporting a clear it did not perform")
}
}
func TestReinstallApp_UninstallFailureWithNothingInstalledIsQuiet(t *testing.T) {
log := scriptedAdb(t, strings.Join([]string{
uninstallFails,
notInstalled,
installSucceeds,
}, "\n"))
output := &strings.Builder{}
if err := ReinstallApp(t.Context(), "", "app.example", "/tmp/app.apk", output); err != nil {
t.Fatalf("ReinstallApp: %v", err)
}
calls := adbCalls(t, log)
if !slices.Contains(calls, "install -r /tmp/app.apk") {
t.Fatalf("adb calls = %v, want the install to go ahead: a first run has no app to uninstall", calls)
}
if slices.Contains(calls, "shell pm clear app.example") {
t.Errorf("adb calls = %v, want no data clear: there was no app holding data", calls)
}
if output.String() != "" {
t.Errorf("output = %q, want nothing: a first run has no app to uninstall", output.String())
}
}
func TestReinstallApp_SuccessfulUninstallNeedsNoFallback(t *testing.T) {
log := scriptedAdb(t, strings.Join([]string{
`"uninstall "*) echo "Success";;`,
stillInstalled,
`"shell pm clear "*) echo "Success";;`,
installSucceeds,
}, "\n"))
if err := ReinstallApp(t.Context(), "", "app.example", "/tmp/app.apk", &strings.Builder{}); err != nil {
t.Fatalf("ReinstallApp: %v", err)
}
want := []string{"uninstall app.example", "install -r /tmp/app.apk"}
if got := adbCalls(t, log); !slices.Equal(got, want) {
t.Fatalf("adb calls = %v, want %v", got, want)
}
}
+7 -1
View File
@@ -224,8 +224,14 @@ func (d *Driver) respawnDevice(ctx context.Context) error {
// devicectlReinstall uninstalls then installs the app bundle via devicectl,
// keyed on the CoreDevice id. App lifecycle stays with devicectl: the runner's
// own install path is simulator-specific.
// A failed uninstall ends the reinstall: installing over an app keeps its data,
// so clear-state would be reported without happening. Uninstalling an app that
// is not installed exits 0 ("App uninstalled." on a paired iPhone running iOS
// 26.5), so there is no benign failure here to sort out from a real one.
func (d *Driver) devicectlReinstall(ctx context.Context) error {
_ = exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).Run()
if output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).CombinedOutput(); err != nil {
return fmt.Errorf("devicectl uninstall %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
}
output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app", "--device", d.coreDeviceID, d.appPath).CombinedOutput()
if err != nil {
return fmt.Errorf("devicectl install: %w: %s", err, strings.TrimSpace(string(output)))
@@ -7,6 +7,7 @@ import (
"net"
"os/exec"
"slices"
"strings"
"testing"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
@@ -182,6 +183,45 @@ func TestNewDeviceReinstallsOnceBeforeTheRunnerSession(t *testing.T) {
}
}
func TestDevicectlReinstallStopsWhenTheUninstallFails(t *testing.T) {
log := scriptedXcrun(t, `"devicectl device uninstall "*) echo "ERROR: Internal logic error: Connection was invalidated"; exit 1;;
"devicectl device install "*) :;;`)
d := &Driver{coreDeviceID: "CORE-DEVICE", bundleID: "app.example", appPath: "/tmp/Sample.app"}
err := d.devicectlReinstall(context.Background())
if err == nil {
t.Fatal("devicectlReinstall reported success while app.example kept the data clear-state was asked to remove")
}
for _, want := range []string{"app.example", "Connection was invalidated"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not quote %q", err, want)
}
}
calls := xcrunCalls(t, log)
if slices.ContainsFunc(calls, func(call string) bool { return strings.HasPrefix(call, "devicectl device install") }) {
t.Errorf("xcrun calls = %v: installing over the app carries its data into the run", calls)
}
}
func TestDevicectlReinstallProceedsWhenNothingIsInstalled(t *testing.T) {
log := scriptedXcrun(t, `"devicectl device uninstall "*) echo "App uninstalled.";;
"devicectl device install "*) :;;`)
d := &Driver{coreDeviceID: "CORE-DEVICE", bundleID: "app.example", appPath: "/tmp/Sample.app"}
if err := d.devicectlReinstall(context.Background()); err != nil {
t.Fatalf("devicectlReinstall: %v", err)
}
want := []string{
"devicectl device uninstall app --device CORE-DEVICE app.example",
"devicectl device install app --device CORE-DEVICE /tmp/Sample.app",
}
if got := xcrunCalls(t, log); !slices.Equal(got, want) {
t.Fatalf("xcrun calls = %v, want %v", got, want)
}
}
func TestDeviceClearStateWithoutAppPathWarnsOnce(t *testing.T) {
output := &bytes.Buffer{}
d := &Driver{output: output, deviceMode: true}
+7 -1
View File
@@ -615,8 +615,14 @@ func (d *Driver) clearAppState(ctx context.Context) error {
// simctlReinstall uninstalls and reinstalls the app bundle via simctl. App
// lifecycle stays with simctl: the companion's install RPC misreads current
// simulator targets' architectures and rejects valid bundles.
// A failed uninstall ends the reinstall: `simctl install` over an installed app
// carries its data container across, so clear-state would be reported without
// happening. Uninstalling an app that is not installed exits 0, so there is no
// benign failure here to sort out from a real one.
func (d *Driver) simctlReinstall(ctx context.Context) error {
_ = exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", d.udid, d.bundleID).Run()
if output, err := exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", d.udid, d.bundleID).CombinedOutput(); err != nil {
return fmt.Errorf("simctl uninstall %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
}
output, err := exec.CommandContext(ctx, "xcrun", "simctl", "install", d.udid, d.appPath).CombinedOutput()
if err != nil {
return fmt.Errorf("simctl install: %w: %s", err, strings.TrimSpace(string(output)))
@@ -329,6 +329,68 @@ func TestNewRejectsClearStateWithoutBundleID(t *testing.T) {
}
}
// scriptedXcrun puts an xcrun on PATH that logs each invocation's arguments and
// answers from replies, a `case "$*" in` body, so a reinstall runs its real
// command sequence and the log holds what reached the tool.
func scriptedXcrun(t *testing.T, replies string) string {
t.Helper()
directory := t.TempDir()
log := filepath.Join(directory, "xcrun.log")
script := "#!/bin/sh\necho \"$*\" >> " + log + "\ncase \"$*\" in\n" + replies + "\nesac\n"
if err := os.WriteFile(filepath.Join(directory, "xcrun"), []byte(script), 0o755); err != nil {
t.Fatalf("write xcrun: %v", err)
}
t.Setenv("PATH", directory)
return log
}
func xcrunCalls(t *testing.T, log string) []string {
t.Helper()
contents, err := os.ReadFile(log)
if err != nil {
if os.IsNotExist(err) {
return nil
}
t.Fatalf("read %s: %v", log, err)
}
return strings.Split(strings.TrimSpace(string(contents)), "\n")
}
func TestSimctlReinstallStopsWhenTheUninstallFails(t *testing.T) {
log := scriptedXcrun(t, `"simctl uninstall "*) echo "Simulator device failed to uninstall app.example."; echo "Uninstall prohibited."; exit 22;;
"simctl install "*) :;;`)
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", appPath: "/tmp/Sample.app"}
err := d.simctlReinstall(context.Background())
if err == nil {
t.Fatal("simctlReinstall reported success while app.example kept the data clear-state was asked to remove")
}
for _, want := range []string{"app.example", "Uninstall prohibited."} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not quote %q", err, want)
}
}
if calls := xcrunCalls(t, log); slices.Contains(calls, "simctl install SIM-UDID /tmp/Sample.app") {
t.Errorf("xcrun calls = %v: installing over the app carries its data into the run", calls)
}
}
func TestSimctlReinstallProceedsWhenNothingIsInstalled(t *testing.T) {
log := scriptedXcrun(t, `"simctl uninstall "*) :;;
"simctl install "*) :;;`)
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", appPath: "/tmp/Sample.app"}
if err := d.simctlReinstall(context.Background()); err != nil {
t.Fatalf("simctlReinstall: %v", err)
}
want := []string{"simctl uninstall SIM-UDID app.example", "simctl install SIM-UDID /tmp/Sample.app"}
if got := xcrunCalls(t, log); !slices.Equal(got, want) {
t.Fatalf("xcrun calls = %v, want %v", got, want)
}
}
func TestLaunchRejectsEnvironment(t *testing.T) {
d := newTestDriver(&fakeCompanion{accessibilityJSON: "[]"})
err := d.Launch(context.Background(), "", false, map[string]string{"K": "V"})