diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..521a218 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +max_line_length = 80 + +[*.{kt,kts}] +ktlint_code_style = intellij_idea + +[*.md] +max_line_length = off diff --git a/.gitignore b/.gitignore index a42af9c..1857ba2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,16 @@ local.properties # macOS .DS_Store +__MACOSX/ + +# Gate run artifacts (regenerable; conformance/runs is the current location) +examples/folio/sanderling/gates/ + +# Local scratch (talk archive, ad-hoc screenshots) +talk.zip +examples/folio/folio_web.png +examples/folio/folio_web_favicon.png +examples/folio/run-list-scrolled-bottom.png # Go /bin/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..886ddd1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +dist +build +*.min.js +**/*.gen.ts +internal/replay/dist +replay-ui/dist diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..1c5e966 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 80 +} diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..8d00228 --- /dev/null +++ b/.swift-format @@ -0,0 +1,4 @@ +{ + "version": 1, + "lineLength": 80 +} diff --git a/Makefile b/Makefile index a474602..19b5faa 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,9 @@ DOCS_TEMPLATE := docs/_template/page.html REPLAY_DIST := internal/replay/dist WEB_DIST := replay-ui/dist -.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-companion test-kotlin test-spec-api web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry +GOLINES := $(shell $(GO) env GOPATH)/bin/golines + +.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-companion test-kotlin test-spec-api web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry fmt fmt-go fmt-kotlin fmt-ts fmt-swift bootstrap: $(GO) mod download @@ -80,6 +82,25 @@ $(COMPANION_EMBED): $(COMPANION_PREPARE) $(RUNNER_EMBED): $(RUNNER_SRC) $(RUNNER_PREPARE) $(RUNNER_PREPARE) +# Each language enforces an 80-column limit through its own formatter config: +# Go via golines flags (gofmt has no width option), Kotlin via .editorconfig +# (ktlint), TypeScript/JS via .prettierrc.json, Swift via .swift-format. +fmt: fmt-go fmt-kotlin fmt-ts fmt-swift + +fmt-go: + $(GOLINES) -m 80 --ignore-generated -w ./internal ./cmd + +fmt-kotlin: + ktlint -F "sidecar/src/**/*.kt" "sidecar/src/**/*.kts" + +fmt-ts: + cd replay-ui && bunx prettier --write "src/**/*.{ts,tsx}" + cd examples/folio-web && bunx prettier --write "src/**/*.{ts,tsx}" + cd pkg/spec && npx --yes prettier --write "src/**/*.ts" "test/**/*.ts" + +fmt-swift: + xcrun swift-format format -i -r companion/Sources + test: test-go test-spec-api web-typecheck web-test test-go: diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index 33d8589..db63e4b 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -18,16 +18,18 @@ import ( var Version = "dev" type testOptions struct { - spec string - bundleID string - platform string - avd string - iosDevice string - iosAppPath string - duration time.Duration - seed int64 - output string - clearData bool + spec string + bundleID string + platform string + avd string + device string + iosDevice string + iosAppPath string + androidAppPath string + duration time.Duration + seed int64 + output string + clearData bool } const topUsage = `sanderling is a property-based UI fuzzer for mobile apps. @@ -52,8 +54,10 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)") flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web") flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected") + flagSet.StringVar(&options.device, "device", "", "Android device serial (from `adb devices`) to target when several are connected") flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id") flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS clear-state reinstall (simulator: simctl; device: devicectl)") + flagSet.StringVar(&options.androidAppPath, "android-app-path", "", "path to the .apk for Android clear-state reinstall; required to reset apps on OEM builds that deny `pm clear`") flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces") diff --git a/cmd/sanderling/test_run.go b/cmd/sanderling/test_run.go index f8407d1..b1c4764 100644 --- a/cmd/sanderling/test_run.go +++ b/cmd/sanderling/test_run.go @@ -9,15 +9,17 @@ import ( func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error { return testrun.Execute(ctx, testrun.Options{ - Spec: options.spec, - BundleID: options.bundleID, - Platform: options.platform, - AVD: options.avd, - IosDevice: options.iosDevice, - IosAppPath: options.iosAppPath, - Duration: options.duration, - Seed: options.seed, - Output: options.output, - ClearData: options.clearData, + Spec: options.spec, + BundleID: options.bundleID, + Platform: options.platform, + AVD: options.avd, + Device: options.device, + IosDevice: options.iosDevice, + IosAppPath: options.iosAppPath, + AndroidAppPath: options.androidAppPath, + Duration: options.duration, + Seed: options.seed, + Output: options.output, + ClearData: options.clearData, }, stdout) } diff --git a/conformance/gates.sh b/conformance/gates.sh index 947b82d..10626b6 100755 --- a/conformance/gates.sh +++ b/conformance/gates.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Scripted conformance gate for the iOS simulator driver. Runs five serial, +# Scripted conformance gate for the mobile drivers. Runs five serial, # non-overlapping 3-minute fuzz runs against the folio example app # (examples/folio) and scores five gates (G1..G5) over the captured traces and # output. Exits non-zero if any gate fails. @@ -9,17 +9,23 @@ # BACKEND=device drive an attached physical iPhone via the # driver's runner-only device path; select it # with IOS_DEVICE="" (passed as --ios-device) +# BACKEND=android drive an Android device/emulator over the JVM +# sidecar; select a specific device with +# ANDROID_DEVICE="" (passed as --device) # # Usage: # ./gates.sh run the simulator gates # BACKEND=device IOS_DEVICE="iPhone" ./gates.sh +# BACKEND=android ANDROID_DEVICE="663c91b1" ./gates.sh # ./gates.sh --self-test run the offline analyzer tests only # # Tunables (environment): # RUNS=5 number of serial runs # DURATION=3m per-run fuzz duration # SEED=0 fuzz seed -# P95_LIMIT_MS=2500 G5 p95 step-latency ceiling in milliseconds +# P95_LIMIT_MS G5 p95 step-latency ceiling in ms (default 2500 for iOS, +# 5500 for the android backend's higher and more variable +# per-step USB cost, especially cold right after a reboot) # SANDERLING=sanderling binary to invoke set -euo pipefail @@ -31,12 +37,23 @@ BACKEND="${BACKEND:-simulator}" RUNS="${RUNS:-5}" DURATION="${DURATION:-3m}" SEED="${SEED:-0}" -P95_LIMIT_MS="${P95_LIMIT_MS:-2500}" +# The p95 ceiling is backend-specific: a physical Android device drives every +# step over USB (snapshot + settle + adb round-trips), so its per-step floor is +# several times the iOS simulator's in-process cost. 2500ms was calibrated on +# the simulator; holding a physical device to it would force ripping out the +# settle/retry logic the correctness gates depend on. Override with P95_LIMIT_MS. +if [[ "$BACKEND" == "android" ]]; then + P95_LIMIT_MS="${P95_LIMIT_MS:-5500}" +else + P95_LIMIT_MS="${P95_LIMIT_MS:-2500}" +fi SANDERLING="${SANDERLING:-sanderling}" IOS_DEVICE="${IOS_DEVICE:-iPhone 17 Pro}" +ANDROID_DEVICE="${ANDROID_DEVICE:-}" bundle_id="app.folio" spec_path="${folio_directory}/sanderling/spec.ts" +android_apk="${folio_directory}/app/androidApp/build/outputs/apk/debug/androidApp-debug.apk" # The built app bundle differs by SDK: the simulator build lands under # Debug-iphonesimulator, the device build under Debug-iphoneos. if [[ "$BACKEND" == "device" ]]; then @@ -45,6 +62,11 @@ else ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app" fi +# Run adb against the selected Android device, or the only one if unset. +adb_target() { + if [[ -n "$ANDROID_DEVICE" ]]; then adb -s "$ANDROID_DEVICE" "$@"; else adb "$@"; fi +} + # The companion binary, embedded for simulator runs. Referenced by file name # only for the orphan-process check; prose elsewhere says "the companion". companion_process_name="idb_companion" @@ -197,6 +219,12 @@ print(samples[rank - 1]) # dies with sanderling, so it leaves no process to check. Empty output is clean. orphan_processes() { local found="" + if [[ "$BACKEND" == "android" ]]; then + # sanderling SIGTERMs the JVM sidecar on shutdown; a survivor is an orphan. + if pgrep -f "sanderling-sidecar.*\.jar" >/dev/null 2>&1; then found+="sidecar "; fi + printf '%s' "$found" + return + fi if pgrep -f "$companion_process_name" >/dev/null 2>&1; then found+="companion " fi @@ -223,15 +251,32 @@ invoke_sanderling() { local output_log="$2" local exit_status_file="$3" - # Both backends pass --ios-app-path so each run reinstalls the current build - # for a clean clear-state start (device install via devicectl, simulator via - # simctl). The device backend selects the connected iPhone by name; the - # simulator backend boots IOS_DEVICE if nothing is booted. - local target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app") + local platform target_flags=() + if [[ "$BACKEND" == "android" ]]; then + # pm clear is blocked on some OEM ROMs, so a fresh install (which wipes + # /data/data) provides the clean clear-state start; --clear-data=false + # then skips the sidecar's pm clear. Mirrors the iOS per-run reinstall. + platform=android + adb_target uninstall "$bundle_id" >/dev/null 2>&1 || true + # A transient install hiccup must score this run as a failure, not abort the + # whole harness under `set -e` and discard the other runs' data. + if ! adb_target install "$android_apk" >"$output_log" 2>&1; then + echo "adb install failed for ${android_apk}; recording run as a failure" >>"$output_log" + printf '1' >"$exit_status_file" + return + fi + target_flags=(--clear-data=false) + [[ -n "$ANDROID_DEVICE" ]] && target_flags+=(--device "$ANDROID_DEVICE") + else + # The iOS backends pass --ios-app-path so each run reinstalls the current + # build for a clean start (device via devicectl, simulator via simctl). + platform=ios + target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app") + fi local status=0 "$SANDERLING" test \ - --platform ios \ + --platform "$platform" \ --spec "$spec_path" \ --bundle-id "$bundle_id" \ "${target_flags[@]}" \ @@ -255,7 +300,20 @@ collect_run_artifacts() { } run_gates() { - if [[ "$BACKEND" == "simulator" ]]; then + if [[ "$BACKEND" == "android" ]]; then + # Build only; invoke_sanderling reinstalls per run via adb (gradle's ddmlib + # install is flaky on some physical devices). + echo "preparing folio android build" + ( cd "$folio_directory" && just build >/dev/null ) + # A physical device, unlike an emulator, lets system UI steal the foreground + # from the app the fuzzer is exploring. Keep the screen on so it never + # re-locks, silence the autofill save-password prompt that pops over the + # login form, and stop Play Protect from intercepting the per-run reinstall. + # The device must already be unlocked (a secure lock cannot be opened here). + adb_target shell svc power stayon true >/dev/null 2>&1 || true + adb_target shell settings put secure autofill_service null >/dev/null 2>&1 || true + adb_target shell settings put global verifier_verify_adb_installs 0 >/dev/null 2>&1 || true + elif [[ "$BACKEND" == "simulator" ]]; then echo "preparing folio build for the simulator backend" ( cd "$folio_directory" && just ios >/dev/null ) else @@ -351,6 +409,11 @@ run_gates() { self_test() { local testdata="${script_directory}/testdata" local failures=0 + # The self-test fixtures (g5-slow-p95 = 4000ms) were calibrated against the + # 2500ms ceiling, so pin it here. Without this the backend-dependent default + # (5500ms under BACKEND=android) would rate the slow fixture as a PASS and the + # offline, device-free analyzer check would fail purely from an env var. + local P95_LIMIT_MS=2500 assert() { local label="$1" expected="$2" actual="$3" diff --git a/examples/folio-web/bun.lock b/examples/folio-web/bun.lock index c20b23f..d6c8452 100644 --- a/examples/folio-web/bun.lock +++ b/examples/folio-web/bun.lock @@ -18,6 +18,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "prettier": "^3.8.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.0", "vite": "^8.0.4", @@ -359,6 +360,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], diff --git a/examples/folio-web/package.json b/examples/folio-web/package.json index e0d552d..51e7c33 100644 --- a/examples/folio-web/package.json +++ b/examples/folio-web/package.json @@ -23,6 +23,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "prettier": "^3.8.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.0", "vite": "^8.0.4" diff --git a/examples/folio/justfile b/examples/folio/justfile index 3a394bd..df14b86 100644 --- a/examples/folio/justfile +++ b/examples/folio/justfile @@ -3,6 +3,7 @@ set dotenv-load := true sanderling := env_var_or_default("SANDERLING", "sanderling") avd := env_var_or_default("AVD", "") +android_device := env_var_or_default("ANDROID_DEVICE", "") duration := env_var_or_default("DURATION", "1m") seed := env_var_or_default("SEED", "0") output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs") @@ -77,11 +78,20 @@ _ensure-device: echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2 exit 1 +# Build the folio debug APK without installing it. +build: + #!/usr/bin/env bash + set -euo pipefail + export ANDROID_HOME="$(just _android-home)" + ./gradlew :app:androidApp:assembleDebug + # Build and install the folio APK on a running emulator/device. install: _ensure-device #!/usr/bin/env bash set -euo pipefail export ANDROID_HOME="$(just _android-home)" + # ANDROID_SERIAL routes the install to the chosen device when several attach. + [[ -n "{{android_device}}" ]] && export ANDROID_SERIAL="{{android_device}}" ./gradlew :app:androidApp:installDebug # Remove the folio APK from the connected device. @@ -153,10 +163,15 @@ test: install if [[ -n "{{avd}}" ]]; then avd_flag=(--avd "{{avd}}") fi + if [[ -n "{{android_device}}" ]]; then + avd_flag+=(--device "{{android_device}}") + fi + apk="{{justfile_directory()}}/app/androidApp/build/outputs/apk/debug/androidApp-debug.apk" "{{sanderling}}" test \ --spec "{{justfile_directory()}}/sanderling/spec.ts" \ --bundle-id app.folio \ "${avd_flag[@]}" \ + --android-app-path "$apk" \ --duration "{{duration}}" \ --seed "{{seed}}" \ --output "{{output}}" diff --git a/internal/android/android.go b/internal/android/android.go index 784e2f7..b8038a2 100644 --- a/internal/android/android.go +++ b/internal/android/android.go @@ -17,17 +17,22 @@ import ( // EnsureDevice makes sure an Android device is ready for adb commands. // Resolution order: -// - if an adb device is already online, use it; +// - if serial is set, require that exact device to be online; +// - else if an adb device is already online, use it; // - else if avdName is set, validate and boot it; // - else if exactly one AVD exists locally, boot it; // - else fail with a helpful message listing the available AVDs. -func EnsureDevice(ctx context.Context, avdName string, stdout io.Writer) error { +func EnsureDevice(ctx context.Context, serial, avdName string, stdout io.Writer) error { devices, err := listAdbDevices(ctx) if err != nil { return fmt.Errorf("list adb devices: %w", err) } - if len(devices) > 0 { - fmt.Fprintf(stdout, "using connected device: %s\n", devices[0]) + chosen, found, err := pickDevice(serial, devices) + if err != nil { + return err + } + if found { + fmt.Fprintf(stdout, "using connected device: %s\n", chosen) return nil } avds, err := listAVDs(ctx) @@ -49,23 +54,168 @@ func EnsureDevice(ctx context.Context, avdName string, stdout io.Writer) error { return nil } -// AdbReverse sets up adb reverse forwarding for a local abstract socket. -func AdbReverse(socket string, port int) error { - adb, err := AdbBinary() - if err != nil { - return err +// adbArgs prepends the device selector when a serial is set, so every adb +// invocation targets the chosen device. Without it, `adb` fails on a host with +// more than one device attached, which silently disables anything that reads +// adb output (the foreground/scope guard). +func adbArgs(serial string, args ...string) []string { + if serial == "" { + return args } - command := exec.Command(adb, "reverse", "localabstract:"+socket, fmt.Sprintf("tcp:%d", port)) - return command.Run() + return append([]string{"-s", serial}, args...) } -// AdbReverseRemove removes an adb reverse forwarding rule. -func AdbReverseRemove(socket string) error { +// wakeCommands keep the screen on and unlocked. A secure lock (PIN/password) +// cannot be dismissed here and must be unlocked out of band. +func wakeCommands() [][]string { + return [][]string{ + {"svc", "power", "stayon", "true"}, + {"input", "keyevent", "KEYCODE_WAKEUP"}, + {"wm", "dismiss-keyguard"}, + } +} + +// PrepareDevice wakes and unlocks the device and disables the background +// freezers that would suspend the driver. Best effort: some OEM builds kill +// these commands (e.g. HyperOS SIGKILLs `svc power stayon`), so a failure is +// logged and skipped rather than aborting the run. +func PrepareDevice(ctx context.Context, serial string, stdout io.Writer) error { adb, err := AdbBinary() if err != nil { return err } - return exec.Command(adb, "reverse", "--remove", "localabstract:"+socket).Run() + for _, shellCommand := range append(wakeCommands(), antiFreezeCommands()...) { + args := adbArgs(serial, append([]string{"shell"}, shellCommand...)...) + if err := exec.CommandContext(ctx, adb, args...).Run(); err != nil { + fmt.Fprintf(stdout, "device prep: skipping `adb %s` (%v)\n", strings.Join(shellCommand, " "), err) + } + } + return nil +} + +// driverPackages are the on-device native-driver instrumentation packages that +// the platform and OEM background freezers must not suspend mid-run. +var driverPackages = []string{"dev.mobile.maestro", "dev.mobile.maestro.test"} + +// antiFreezeCommands turns off the background-process freezers that suspend the +// driver between actions. Android 12+ adds a cached-app freezer and a +// phantom-process killer; OEM builds (e.g. OnePlus/Oppo ColorOS OSense) add +// their own. Left on, they freeze the driver instrumentation while the app is +// foreground and the run stalls. set_sync_disabled_for_tests keeps the +// device_config writes from being reverted by server-side sync. All best effort: +// the caller skips and logs any command an OEM build rejects. +func antiFreezeCommands() [][]string { + commands := [][]string{ + {"device_config", "set_sync_disabled_for_tests", "persistent"}, + {"device_config", "put", "activity_manager_native_boot", "use_freezer", "false"}, + {"device_config", "put", "activity_manager_native_boot", "freeze_exempt_inst_pkg", strings.Join(driverPackages, ",")}, + {"settings", "put", "global", "settings_enable_monitor_phantom_procs", "false"}, + {"device_config", "put", "activity_manager", "max_phantom_processes", "2147483647"}, + {"dumpsys", "deviceidle", "disable"}, + } + for _, pkg := range driverPackages { + commands = append(commands, []string{"dumpsys", "deviceidle", "whitelist", "+" + pkg}) + } + return commands +} + +// ReinstallApp resets an app to first-launch state by uninstalling and +// 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. +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 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))) + } + return nil +} + +const threeButtonNavOverlay = "com.android.internal.systemui.navbar.threebutton" + +// navModeOverlays are the system navigation-mode overlays. Only one is active at +// a time; the active one is restored after the run. +var navModeOverlays = []string{ + "com.android.internal.systemui.navbar.gestural", + threeButtonNavOverlay, + "com.android.internal.systemui.navbar.twobutton", +} + +// ForceThreeButtonNav switches the device to 3-button navigation for the run, so +// the fuzzer's swipes cannot trigger the gesture-nav home/back actions and fling +// the app off screen (the nav bar's own buttons are systemui-owned and already +// dropped from action candidates). It returns a function that restores the +// original navigation mode. Best effort: on any failure it leaves navigation +// untouched and returns a no-op restore. +func ForceThreeButtonNav(ctx context.Context, serial string, stdout io.Writer) func() { + adb, err := AdbBinary() + if err != nil { + return func() {} + } + // Decide before changing anything: if the current mode is unknown (an OEM + // overlay, or a parse failure) or already 3-button, there is nothing to + // restore, so leave navigation untouched rather than stranding the device in + // 3-button after the run. + restore := navModeToRestore(enabledNavOverlay(ctx, adb, serial)) + if restore == "" { + return func() {} + } + if err := navOverlayCommand(ctx, adb, serial, threeButtonNavOverlay).Run(); err != nil { + fmt.Fprintf(stdout, "device prep: skipping 3-button nav (%v)\n", err) + return func() {} + } + return func() { + if err := navOverlayCommand(context.Background(), adb, serial, restore).Run(); err != nil { + fmt.Fprintf(stdout, "device prep: could not restore nav mode %s (%v)\n", restore, err) + } + } +} + +// navModeToRestore returns the navigation overlay to restore after forcing +// 3-button nav, or "" when nothing should change: an unknown current mode (not +// restorable) or one that is already 3-button. +func navModeToRestore(original string) string { + if original == "" || original == threeButtonNavOverlay { + return "" + } + return original +} + +// enabledNavOverlay returns the currently active navigation-mode overlay, or "" +// when it cannot be determined. +func enabledNavOverlay(ctx context.Context, adb, serial string) string { + output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "shell", "cmd", "overlay", "list")...).Output() + if err != nil { + return "" + } + return parseEnabledNavOverlay(string(output)) +} + +// parseEnabledNavOverlay reads `cmd overlay list` output and returns the +// enabled ("[x]") navigation-mode overlay package. +func parseEnabledNavOverlay(overlayList string) string { + for line := range strings.SplitSeq(overlayList, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "[x]") { + continue + } + package_ := strings.TrimSpace(strings.TrimPrefix(trimmed, "[x]")) + if slices.Contains(navModeOverlays, package_) { + return package_ + } + } + return "" +} + +func navOverlayCommand(ctx context.Context, adb, serial, overlay string) *exec.Cmd { + return exec.CommandContext(ctx, adb, adbArgs(serial, "shell", "cmd", "overlay", "enable-exclusive", overlay)...) } // EnvWithAndroidPlatformTools returns env with the directory containing adb @@ -191,6 +341,29 @@ func parseAVDList(output string) []string { return avds } +// pickDevice resolves which connected device to drive. A requested serial must +// be online. With no request: a single connected device is used; more than one +// is ambiguous and errors asking for --device, because the chosen serial is not +// threaded into the per-step adb calls, so silently picking one would leave +// every later bare `adb` command failing with "more than one device". No device +// connected returns found=false so the caller falls back to booting an AVD. +func pickDevice(requested string, connected []string) (serial string, found bool, err error) { + if requested != "" { + if !slices.Contains(connected, requested) { + return "", false, fmt.Errorf("device %q is not connected (online devices: %s)", requested, strings.Join(connected, ", ")) + } + return requested, true, nil + } + switch len(connected) { + case 0: + return "", false, nil + case 1: + return connected[0], true, nil + default: + return "", false, fmt.Errorf("%d devices connected (%s); select one with --device", len(connected), strings.Join(connected, ", ")) + } +} + func pickAVD(requested string, available []string) (string, error) { if requested != "" { if !slices.Contains(available, requested) { @@ -239,13 +412,13 @@ func waitForBoot(ctx context.Context, timeout time.Duration) error { } // ForegroundPackage returns the package of the currently resumed activity on -// the connected device, or "" when it cannot be determined. -func ForegroundPackage(ctx context.Context) (string, error) { +// the given device, or "" when it cannot be determined. +func ForegroundPackage(ctx context.Context, serial string) (string, error) { adb, err := AdbBinary() if err != nil { return "", err } - output, err := exec.CommandContext(ctx, adb, "shell", "dumpsys", "activity", "activities").Output() + output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "shell", "dumpsys", "activity", "activities")...).Output() if err != nil { return "", err } @@ -257,12 +430,16 @@ func ForegroundPackage(ctx context.Context) (string, error) { // Unlike ForegroundPackage, this reflects what is actually on screen: // ResumedActivity flips to a newly launched app before its first frame renders, // while mCurrentFocus only names the app once its window is up. -func FocusedWindowPackage(ctx context.Context) (string, error) { +func FocusedWindowPackage(ctx context.Context, serial string) (string, error) { adb, err := AdbBinary() if err != nil { return "", err } - output, err := exec.CommandContext(ctx, adb, "shell", "dumpsys", "window").Output() + // Grep the focus line on-device: the full dumpsys window output is large and + // this runs on the per-step scope guard, so transferring it whole would add + // latency to every step. `|| true` keeps a no-match (grep exit 1) from + // surfacing as an error so it yields "" per the contract. + output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "shell", "dumpsys window | grep mCurrentFocus || true")...).Output() if err != nil { return "", err } @@ -288,9 +465,22 @@ func parseForegroundPackage(dumpsys string) string { return "" } +// systemUIPackage is the owner reported for system overlays (notification +// shade, quick settings) that take window focus without a package/activity +// component name. The scope guard treats it as "not the app" and dismisses it. +const systemUIPackage = "com.android.systemui" + +// systemOverlayWindowNames are the mCurrentFocus window names for the system +// panels a fuzzer gesture can pull over the app (a swipe from the status bar +// opens NotificationShade). They own focus while the app stays the resumed +// activity, so the resumed-activity signal alone misses them. +var systemOverlayWindowNames = []string{"NotificationShade", "ShadePanel", "QuickSettings", "VolumeUiDialog"} + // parseFocusedWindowPackage extracts the focused-window package from // `dumpsys window` output by reading the mCurrentFocus component name. A -// "mCurrentFocus=null" line (no focused window) yields "". +// "mCurrentFocus=null" line (no focused window) yields "". A system overlay +// (e.g. the notification shade) yields systemUIPackage so callers can tell it +// apart from the app and from "no focus". func parseFocusedWindowPackage(dumpsys string) string { for line := range strings.SplitSeq(dumpsys, "\n") { if !strings.Contains(line, "mCurrentFocus") { @@ -299,6 +489,11 @@ func parseFocusedWindowPackage(dumpsys string) string { if match := resumedActivityPackage.FindStringSubmatch(line); match != nil { return match[1] } + for _, overlay := range systemOverlayWindowNames { + if strings.Contains(line, overlay) { + return systemUIPackage + } + } } return "" } diff --git a/internal/android/android_test.go b/internal/android/android_test.go index 70f950d..01a017e 100644 --- a/internal/android/android_test.go +++ b/internal/android/android_test.go @@ -2,9 +2,22 @@ package android import ( "reflect" + "slices" + "strings" "testing" ) +func TestWakeCommands(t *testing.T) { + want := [][]string{ + {"svc", "power", "stayon", "true"}, + {"input", "keyevent", "KEYCODE_WAKEUP"}, + {"wm", "dismiss-keyguard"}, + } + if got := wakeCommands(); !reflect.DeepEqual(got, want) { + t.Errorf("wakeCommands() = %v, want %v", got, want) + } +} + func TestParseAdbDevices_OnlineOnly(t *testing.T) { output := `List of devices attached emulator-5554 device @@ -28,6 +41,47 @@ func TestParseAdbDevices_Empty(t *testing.T) { } } +func TestPickDevice_RequestedOnline(t *testing.T) { + serial, found, err := pickDevice("physical-abc", []string{"emulator-5554", "physical-abc"}) + if err != nil || !found || serial != "physical-abc" { + t.Fatalf("got (%q, %v, %v), want (physical-abc, true, nil)", serial, found, err) + } +} + +func TestPickDevice_RequestedNotConnected(t *testing.T) { + _, found, err := pickDevice("physical-abc", []string{"emulator-5554"}) + if err == nil { + t.Fatal("expected error for a serial that is not connected") + } + if found { + t.Fatal("found must be false when the requested device is absent") + } +} + +func TestPickDevice_NoRequestSingleDeviceUsesIt(t *testing.T) { + serial, found, err := pickDevice("", []string{"emulator-5554"}) + if err != nil || !found || serial != "emulator-5554" { + t.Fatalf("got (%q, %v, %v), want (emulator-5554, true, nil)", serial, found, err) + } +} + +func TestPickDevice_NoRequestMultipleDevicesErrors(t *testing.T) { + serial, found, err := pickDevice("", []string{"emulator-5554", "physical-abc"}) + if err == nil { + t.Fatal("expected an error asking for --device when several devices are connected") + } + if found || serial != "" { + t.Fatalf("ambiguous selection must not pick a device, got (%q, %v)", serial, found) + } +} + +func TestPickDevice_NoneConnectedFallsBackToAVD(t *testing.T) { + serial, found, err := pickDevice("", nil) + if err != nil || found || serial != "" { + t.Fatalf("got (%q, %v, %v), want (\"\", false, nil)", serial, found, err) + } +} + func TestParseAVDList_DropsInfoLines(t *testing.T) { output := `INFO | Storing crashdata in: /tmp/x Medium_Phone_API_36.0 @@ -154,6 +208,21 @@ func TestParseFocusedWindowPackage(t *testing.T) { dumpsys: " some unrelated dumpsys window output\n", want: "", }, + { + name: "notification shade focused", + dumpsys: " mCurrentFocus=Window{885e289 u0 NotificationShade}", + want: "com.android.systemui", + }, + { + name: "quick settings focused", + dumpsys: " mCurrentFocus=Window{abc u0 QuickSettings}", + want: "com.android.systemui", + }, + { + name: "volume dialog focused", + dumpsys: " mCurrentFocus=Window{abc u0 VolumeUiDialog}", + want: "com.android.systemui", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -163,3 +232,102 @@ func TestParseFocusedWindowPackage(t *testing.T) { }) } } + +func TestParseEnabledNavOverlay(t *testing.T) { + cases := []struct { + name string + listing string + want string + }{ + { + name: "gesture nav active", + listing: "[ ] com.android.internal.systemui.navbar.threebutton\n[x] com.android.internal.systemui.navbar.gestural\n[ ] com.android.internal.systemui.navbar.transparent", + want: "com.android.internal.systemui.navbar.gestural", + }, + { + name: "three-button active", + listing: "[x] com.android.internal.systemui.navbar.threebutton\n[ ] com.android.internal.systemui.navbar.gestural", + want: "com.android.internal.systemui.navbar.threebutton", + }, + { + name: "two-button active", + listing: "[x] com.android.internal.systemui.navbar.twobutton\n[ ] com.android.internal.systemui.navbar.gestural", + want: "com.android.internal.systemui.navbar.twobutton", + }, + { + name: "ignores enabled non-nav overlays", + listing: "[x] com.some.other.overlay\n[ ] com.android.internal.systemui.navbar.gestural", + want: "", + }, + { + name: "no overlay enabled", + listing: "[ ] com.android.internal.systemui.navbar.gestural\n[ ] com.android.internal.systemui.navbar.threebutton", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := parseEnabledNavOverlay(tc.listing); got != tc.want { + t.Errorf("parseEnabledNavOverlay = %q, want %q", got, tc.want) + } + }) + } +} + +func TestAntiFreezeCommands_DisablesFreezersAndExemptsDriver(t *testing.T) { + commands := antiFreezeCommands() + has := func(want ...string) bool { + return slices.ContainsFunc(commands, func(c []string) bool { return slices.Equal(c, want) }) + } + for _, want := range [][]string{ + {"device_config", "set_sync_disabled_for_tests", "persistent"}, + {"device_config", "put", "activity_manager_native_boot", "use_freezer", "false"}, + {"settings", "put", "global", "settings_enable_monitor_phantom_procs", "false"}, + {"dumpsys", "deviceidle", "disable"}, + } { + if !has(want...) { + t.Errorf("anti-freeze commands missing exact command %v", want) + } + } + + // The freezer exemption must be one device_config command whose final + // argument lists every driver package, not just the package string + // appearing somewhere among the commands. + exemption := findCommand(commands, "device_config", "put", "activity_manager_native_boot", "freeze_exempt_inst_pkg") + if exemption == nil { + t.Fatalf("no freeze_exempt_inst_pkg command found in %v", commands) + } + value := exemption[len(exemption)-1] + for _, pkg := range driverPackages { + if !strings.Contains(value, pkg) { + t.Errorf("freeze_exempt_inst_pkg value %q missing driver package %q", value, pkg) + } + if !has("dumpsys", "deviceidle", "whitelist", "+"+pkg) { + t.Errorf("driver package %q not whitelisted from doze", pkg) + } + } +} + +// findCommand returns the first command whose leading tokens equal prefix. +func findCommand(commands [][]string, prefix ...string) []string { + for _, c := range commands { + if len(c) >= len(prefix) && slices.Equal(c[:len(prefix)], prefix) { + return c + } + } + return nil +} + +func TestNavModeToRestore(t *testing.T) { + cases := map[string]string{ + "com.android.internal.systemui.navbar.gestural": "com.android.internal.systemui.navbar.gestural", + "com.android.internal.systemui.navbar.twobutton": "com.android.internal.systemui.navbar.twobutton", + "com.android.internal.systemui.navbar.threebutton": "", // already 3-button: nothing to change + "": "", // unknown current mode: must not switch what cannot be restored + } + for original, want := range cases { + if got := navModeToRestore(original); got != want { + t.Errorf("navModeToRestore(%q) = %q, want %q", original, got, want) + } + } +} diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index b0e8f27..f438a31 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -73,6 +73,11 @@ type Driver struct { ForegroundResults []string foregroundIndex int + // ForegroundErr and FocusedWindowErr, when set, make the respective check + // return that error so tests can cover the guard's transient-read paths. + ForegroundErr error + FocusedWindowErr error + // FocusedWindowResults is consumed one entry per FocusedWindowApp call // (the last entry repeats). When empty, FocusedWindowApp mirrors the // last ForegroundApp result, so the startup gate treats the window as @@ -125,6 +130,9 @@ func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool, _ m func (d *Driver) ForegroundApp(_ context.Context) (string, error) { d.mutex.Lock() defer d.mutex.Unlock() + if d.ForegroundErr != nil { + return "", d.ForegroundErr + } if len(d.ForegroundResults) == 0 { d.lastForeground = "" return "", nil @@ -142,6 +150,9 @@ func (d *Driver) FocusedWindowApp(_ context.Context) (string, error) { d.mutex.Lock() defer d.mutex.Unlock() d.focusedWindowCalls++ + if d.FocusedWindowErr != nil { + return "", d.FocusedWindowErr + } if len(d.FocusedWindowResults) == 0 { return d.lastForeground, nil } diff --git a/internal/driver/sidecar/client.go b/internal/driver/sidecar/client.go index 82f68a9..0214b29 100644 --- a/internal/driver/sidecar/client.go +++ b/internal/driver/sidecar/client.go @@ -4,6 +4,7 @@ package sidecar import ( "context" "fmt" + "io" "time" "google.golang.org/grpc" @@ -18,6 +19,19 @@ type Client struct { connection *grpc.ClientConn stub driverpb.DriverClient platform string + + // serial, apkPath, and output drive the Android clear-state reinstall: when + // apkPath is set, Launch resets state by uninstalling and reinstalling the + // APK instead of asking the sidecar to `pm clear`, which hardened OEM builds + // deny. Empty apkPath leaves the legacy `pm clear` path in place. + serial string + apkPath string + output io.Writer + + // reinstallApp resets an Android app to first-launch state. A seam so tests + // exercise the clear-state branch without a connected device; defaults to + // android.ReinstallApp. + reinstallApp func(ctx context.Context, serial, bundleID, apkPath string, output io.Writer) error } // SetPlatform records the target platform so capability methods (e.g. @@ -25,13 +39,25 @@ type Client struct { // Dial. func (c *Client) SetPlatform(platform string) { c.platform = platform } +// SetClearStateReinstall makes Android clear-state reset the app by reinstalling +// the APK at apkPath (uninstall+install) rather than `pm clear`. serial targets +// the device when several are connected; output receives progress lines. +func (c *Client) SetClearStateReinstall(serial, apkPath string, output io.Writer) { + c.serial = serial + c.apkPath = apkPath + c.output = output + if c.reinstallApp == nil { + c.reinstallApp = android.ReinstallApp + } +} + // ForegroundApp reports the foreground package. Only Android is supported (via // adb); other platforms return "" so the runner skips app-scope enforcement. func (c *Client) ForegroundApp(ctx context.Context) (string, error) { if c.platform != "android" { return "", nil } - return android.ForegroundPackage(ctx) + return android.ForegroundPackage(ctx, c.serial) } // FocusedWindowApp reports the package owning the focused window. Only Android @@ -41,7 +67,7 @@ func (c *Client) FocusedWindowApp(ctx context.Context) (string, error) { if c.platform != "android" { return "", nil } - return android.FocusedWindowPackage(ctx) + return android.FocusedWindowPackage(ctx, c.serial) } // Dial connects to the sidecar gRPC server at the given address. @@ -51,7 +77,11 @@ func Dial(address string) (*Client, error) { if err != nil { return nil, fmt.Errorf("dial sidecar: %w", err) } - return &Client{connection: connection, stub: driverpb.NewDriverClient(connection)}, nil + return &Client{ + connection: connection, + stub: driverpb.NewDriverClient(connection), + reinstallApp: android.ReinstallApp, + }, nil } func (c *Client) Close() error { return c.connection.Close() } @@ -76,9 +106,19 @@ func (c *Client) WaitForHealth(ctx context.Context, pollInterval time.Duration) } func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error { + sidecarClearState := clearState + if clearState && c.platform == "android" && c.apkPath != "" { + if c.output != nil { + fmt.Fprintf(c.output, "clear-state: reinstalling %s from %s\n", bundleID, c.apkPath) + } + if err := c.reinstallApp(ctx, c.serial, bundleID, c.apkPath, c.output); err != nil { + return fmt.Errorf("clear-state reinstall: %w", err) + } + sidecarClearState = false + } _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{ BundleId: bundleID, - ClearState: clearState, + ClearState: sidecarClearState, Env: env, }) return err diff --git a/internal/driver/sidecar/client_test.go b/internal/driver/sidecar/client_test.go index 46182ac..2cee6c6 100644 --- a/internal/driver/sidecar/client_test.go +++ b/internal/driver/sidecar/client_test.go @@ -2,6 +2,7 @@ package sidecar import ( "context" + "io" "net" "strings" "sync" @@ -300,6 +301,96 @@ func TestClient_LaunchAndTerminate(t *testing.T) { } } +func TestClient_LaunchClearStateReinstallsOnAndroid(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + client.SetPlatform("android") + client.SetClearStateReinstall("serial123", "/tmp/app.apk", io.Discard) + + var got struct { + serial, bundleID, apkPath string + } + called := 0 + client.reinstallApp = func(_ context.Context, serial, bundleID, apkPath string, _ io.Writer) error { + called++ + got.serial, got.bundleID, got.apkPath = serial, bundleID, apkPath + return nil + } + + if err := client.Launch(context.Background(), "app.folio", true, nil); err != nil { + t.Fatal(err) + } + if called != 1 { + t.Fatalf("reinstall called %d times, want 1", called) + } + if got.serial != "serial123" || got.bundleID != "app.folio" || got.apkPath != "/tmp/app.apk" { + t.Errorf("reinstall args wrong: %+v", got) + } + // The sidecar must not also clear: the host reinstall already reset state. + if state.fake.clearState { + t.Error("sidecar clearState should be false after host reinstall") + } + if state.fake.launchedBundleID != "app.folio" { + t.Errorf("launched bundle wrong: %q", state.fake.launchedBundleID) + } +} + +func TestClient_LaunchClearStateReinstallFailureAborts(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + client.SetPlatform("android") + client.SetClearStateReinstall("", "/tmp/app.apk", io.Discard) + client.reinstallApp = func(_ context.Context, _, _, _ string, _ io.Writer) error { + return context.DeadlineExceeded + } + + if err := client.Launch(context.Background(), "app.folio", true, nil); err == nil { + t.Fatal("expected launch to fail when reinstall fails") + } + if state.fake.launchedBundleID == "app.folio" { + t.Error("sidecar launch should not be called after a failed reinstall") + } +} + +func TestClient_LaunchClearStateWithoutApkPathUsesSidecarClear(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + client.SetPlatform("android") + client.reinstallApp = func(_ context.Context, _, _, _ string, _ io.Writer) error { + t.Fatal("reinstall should not run without an apk path") + return nil + } + + if err := client.Launch(context.Background(), "app.folio", true, nil); err != nil { + t.Fatal(err) + } + if !state.fake.clearState { + t.Error("without an apk path the sidecar clearState path must remain") + } +} + +func TestClient_LaunchClearStateNonAndroidUsesSidecarClear(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + client.SetPlatform("ios") + client.SetClearStateReinstall("", "/tmp/app.app", io.Discard) + client.reinstallApp = func(_ context.Context, _, _, _ string, _ io.Writer) error { + t.Fatal("reinstall must not run on a non-android platform") + return nil + } + + if err := client.Launch(context.Background(), "app.folio", true, nil); err != nil { + t.Fatal(err) + } + if !state.fake.clearState { + t.Error("non-android clear-state must forward clearState to the sidecar") + } +} + func TestClient_TapAndTapSelector(t *testing.T) { state := newHarness(t) client, _ := Dial(state.address) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index e060133..c5274c1 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -225,8 +225,18 @@ func Run(ctx context.Context, options Options) (Summary, error) { } applySkipped := false - if nextErr == nil { - if err := applyAction(ctx, options.Driver, nextAction, tree, options.IdleTimeout); err != nil { + if nextErr == nil && !appIsForeground(ctx, options) { + // The app left the foreground between observe and apply (a prior + // action's gesture settling late, or an async navigation). The + // chosen action's coordinates reference a tree that no longer + // applies, so firing it would act on whatever screen is now up. + // Skip it and record the escape; the next step's guard relaunches. + logger.Warn("app not in foreground at action time; skipping (relaunch next step)", + "step", stepIndex, "action", nextAction.Kind) + applySkipped = true + lastAction = nil + } else if nextErr == nil { + if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil { if isWDADrop(err) { return summary, fmt.Errorf("step %d: the iOS XCTest runner could not be restarted - re-run the test: %w", stepIndex, err) } @@ -371,12 +381,76 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, logger.Warn("foreground check failed", "step", stepIndex, "err", err) return false } - if foreground == "" || foreground == options.BundleID { + if foreground != "" && foreground != options.BundleID { + logger.Warn("app left foreground; relaunching", + "step", stepIndex, "foreground", foreground, "want", options.BundleID) + // Relaunch and confirm the app is genuinely back on screen before the + // step observes or acts. A single relaunch returns before the window + // draws on a slow physical device, which would let the observe and the + // next action land on the launcher (its type-to-search swallows + // InputText). awaitForeground re-checks the foreground and focused + // window, so it never acts outside the app no matter how slow the + // relaunch settles. + awaitForeground(ctx, options, logger, stepIndex) + return true + } + // The app is the resumed activity, but a system overlay can still own the + // focused window while the app stays resumed: a fuzzer swipe starting in the + // status bar pulls the notification shade over the app. The resumed-activity + // signal misses this, so observing or acting would land on the shade. + // Dismiss it with back (which collapses the shade) so the next observe sees + // the app again. + focusChecker, hasFocus := options.Driver.(driver.FocusedWindowChecker) + if !hasFocus { return false } - logger.Warn("app left foreground; relaunching", - "step", stepIndex, "foreground", foreground, "want", options.BundleID) - return bringToForeground(ctx, options, logger, stepIndex) + focused, err := focusChecker.FocusedWindowApp(ctx) + if err != nil { + logger.Warn("focus check failed", "step", stepIndex, "err", err) + return false + } + if focused == "" || focused == options.BundleID { + return false + } + logger.Warn("system window obscuring app; dismissing", + "step", stepIndex, "focused", focused, "want", options.BundleID) + if err := options.Driver.PressKey(ctx, "back"); err != nil { + logger.Warn("dismiss overlay failed", "step", stepIndex, "err", err) + } + settleForForeground(ctx, options) + return true +} + +// appIsForeground reports whether the app under test currently owns the +// foreground. It is the apply-time half of the scope guard: ensureForeground +// runs before observe, but the app can leave between observe and apply (a prior +// gesture settling late, an async navigation), and swipes/keys carry stale +// coordinates with no selector to re-resolve. An absent capability or an unknown +// foreground returns true so the run is never blocked where the signal is +// unavailable (web, iOS, a transient read). +func appIsForeground(ctx context.Context, options Options) bool { + checker, ok := options.Driver.(driver.ForegroundChecker) + if !ok || options.BundleID == "" { + return true + } + foreground, err := checker.ForegroundApp(ctx) + if err != nil || foreground == "" { + return true + } + if foreground != options.BundleID { + return false + } + // A system overlay can own the focused window while the app stays resumed, + // so mirror ensureForeground's focus check rather than act on the overlay. + focusChecker, ok := options.Driver.(driver.FocusedWindowChecker) + if !ok { + return true + } + focused, err := focusChecker.FocusedWindowApp(ctx) + if err != nil || focused == "" { + return true + } + return focused == options.BundleID } // foregroundReadyAttempts bounds how many times waitForForeground tries to @@ -384,6 +458,12 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, // never hang the run. const foregroundReadyAttempts = 8 +// focusTapSettle is the pause after tapping a field to focus it, before typing. +// Long enough for focus to land, short enough to avoid the ~500ms-1s full +// settle the keyboard's open animation would otherwise cost every InputText +// step on a physical device. +var focusTapSettle = 250 * time.Millisecond + // waitForForeground blocks until the app under test is actually on screen, so // the first observe never captures a leftover screen or a freshly-booted // device's system dialog (e.g. Android's "set a screen lock" prompt). Drivers @@ -395,6 +475,20 @@ const foregroundReadyAttempts = 8 // report the focused window, the gate additionally waits for that window to // name the app, which only happens once it is genuinely drawn. func waitForForeground(ctx context.Context, options Options, logger *slog.Logger) { + awaitForeground(ctx, options, logger, 0) +} + +// awaitForeground brings the app under test forward when it is not already +// resumed and blocks until its window is actually drawn, bounded by +// foregroundReadyAttempts so a stuck system dialog can never hang the run. It +// re-checks the foreground each iteration and only presses back + relaunches +// while the app is genuinely absent, so once the app is resumed it polls the +// focused-window signal instead of mashing back (which would re-exit the app +// from its root screen). Shared by the pre-run startup gate (stepIndex 0) and +// the per-step scope guard so neither lets an observe or action land outside +// the app. Drivers without ForegroundChecker (web) and an unknown foreground +// both skip the gate. +func awaitForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) { checker, ok := options.Driver.(driver.ForegroundChecker) if !ok || options.BundleID == "" { return @@ -406,16 +500,16 @@ func waitForForeground(ctx context.Context, options Options, logger *slog.Logger } foreground, err := checker.ForegroundApp(ctx) if err != nil { - logger.Warn("foreground check failed before first step", "err", err) + logger.Warn("foreground check failed", "step", stepIndex, "err", err) return } if foreground == "" { return // foreground unknowable (e.g. iOS); don't block the run } if foreground != options.BundleID { - logger.Warn("app not in foreground at start; bringing it forward", - "foreground", foreground, "want", options.BundleID, "attempt", attempt) - bringToForeground(ctx, options, logger, 0) + logger.Warn("app not in foreground; bringing it forward", + "step", stepIndex, "foreground", foreground, "want", options.BundleID, "attempt", attempt) + bringToForeground(ctx, options, logger, stepIndex) continue } if !hasFocus { @@ -423,34 +517,32 @@ func waitForForeground(ctx context.Context, options Options, logger *slog.Logger } focused, err := focusChecker.FocusedWindowApp(ctx) if err != nil { - logger.Warn("focus check failed before first step", "err", err) + logger.Warn("focus check failed", "step", stepIndex, "err", err) return } if focused == options.BundleID { return // window is drawn; safe to observe } logger.Warn("app resumed but window not yet drawn; waiting", - "focused", focused, "want", options.BundleID, "attempt", attempt) + "step", stepIndex, "focused", focused, "want", options.BundleID, "attempt", attempt) settleForForeground(ctx, options) } - logger.Warn("app never reached foreground before first step; proceeding anyway", - "want", options.BundleID) + logger.Warn("app never reached foreground; proceeding anyway", + "step", stepIndex, "want", options.BundleID) } // bringToForeground returns the app under test to the foreground. It first // presses BACK to dismiss any modal system dialog (a relaunch alone does not -// close one), then relaunches and waits for the UI to settle. Returns true -// when the relaunch itself succeeded. -func bringToForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) bool { +// close one), then relaunches and waits for the UI to settle. +func bringToForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) { if err := options.Driver.PressKey(ctx, "back"); err != nil { logger.Warn("dismiss key before relaunch failed", "step", stepIndex, "err", err) } if err := options.Driver.Launch(ctx, options.BundleID, false, nil); err != nil { logger.Warn("relaunch failed", "step", stepIndex, "err", err) - return false + return } settleForForeground(ctx, options) - return true } // settleForForeground waits one idle window for the UI to settle, bounding the @@ -461,7 +553,7 @@ func settleForForeground(ctx context.Context, options Options) { cancel() } -func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree, idleTimeout time.Duration) error { +func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.Action, tree *hierarchy.Tree) error { switch action.Kind { case verifier.ActionKindTap: x, y, ok := resolveCoordinates(action, tree) @@ -491,6 +583,7 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A return drv.LongPress(ctx, x, y) case verifier.ActionKindScroll: fromX, fromY, toX, toY := scrollEndpoints(action, tree) + fromX, fromY, toX, toY = clampGestureToSafeArea(fromX, fromY, toX, toY, screenBounds(tree)) duration := time.Duration(action.DurationMillis) * time.Millisecond if duration <= 0 { duration = 300 * time.Millisecond @@ -509,13 +602,19 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A } tapped = true } - // The focus tap raises the keyboard. Settle before sending key - // events so the keyboard animation cannot race them into the wrong - // field (or drop them entirely). + // The focus tap raises the keyboard. The tap registers focus + // immediately and the text is injected into the focused view (not typed + // on the visible keyboard), so a brief pause is enough for focus to land + // rather than a full settle, which costs ~500ms-1s per InputText step on + // a physical device while the keyboard animates in. if tapped { - idleCtx, idleCancel := context.WithTimeout(ctx, idleTimeout) - _ = drv.WaitForIdle(idleCtx, idleTimeout) - idleCancel() + timer := time.NewTimer(focusTapSettle) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } } // InputText replaces the field's content: erase what the target // holds before typing. Appending instead lets repeated draws grow @@ -535,7 +634,8 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A if duration <= 0 { duration = 250 * time.Millisecond } - return drv.Swipe(ctx, action.FromX, action.FromY, action.ToX, action.ToY, duration) + fromX, fromY, toX, toY := clampGestureToSafeArea(action.FromX, action.FromY, action.ToX, action.ToY, screenBounds(tree)) + return drv.Swipe(ctx, fromX, fromY, toX, toY, duration) case verifier.ActionKindPressKey: if action.Key == "" { return nil @@ -659,6 +759,62 @@ func scrollEndpoints(action verifier.Action, tree *hierarchy.Tree) (fromX, fromY return cx, cy, toX, toY } +// screenBounds returns the device screen rectangle as the maximum extent across +// all elements. The hierarchy root often reports zero bounds on Android, so the +// extent (driven by full-screen containers and the navigation bar) is the +// reliable screen size. Returns a zero rectangle when unknown. +func screenBounds(tree *hierarchy.Tree) hierarchy.Bounds { + if tree == nil { + return hierarchy.Bounds{} + } + var bounds hierarchy.Bounds + for _, element := range tree.Elements { + if element.Bounds.Right > bounds.Right { + bounds.Right = element.Bounds.Right + } + if element.Bounds.Bottom > bounds.Bottom { + bounds.Bottom = element.Bounds.Bottom + } + } + return bounds +} + +// clampGestureToSafeArea keeps a swipe's origin below the top status strip, +// where a downward drag pulls the notification shade over the app. Runs force +// 3-button navigation (ForceThreeButtonNav), which disables the side back and +// bottom home gestures at the OS level; on-device probing confirmed side and +// bottom origins then no longer drift, so the shade is the only edge gesture a +// swipe can still trigger. Origin and destination are otherwise only kept on +// screen. With an unknown screen size the coordinates pass through unchanged. +func clampGestureToSafeArea(fromX, fromY, toX, toY int, screen hierarchy.Bounds) (int, int, int, int) { + width, height := screen.Width(), screen.Height() + if width <= 0 || height <= 0 { + return fromX, fromY, toX, toY + } + // Translate the whole segment when the origin is in the top margin, rather + // than clamping the origin alone, which could push it past the destination + // and reverse a near-top scroll. + marginY := height / 12 + if shortfall := (screen.Top + marginY) - fromY; shortfall > 0 { + fromY += shortfall + toY += shortfall + } + clamp := func(value, low, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value + } + fromX = clamp(fromX, screen.Left, screen.Right) + fromY = clamp(fromY, screen.Top, screen.Bottom) + toX = clamp(toX, screen.Left, screen.Right) + toY = clamp(toY, screen.Top, screen.Bottom) + return fromX, fromY, toX, toY +} + // scrollBounds returns the container bounds for an authored Scroll: the node // named by On when it resolves, otherwise the root (whole-screen) bounds. func scrollBounds(action verifier.Action, tree *hierarchy.Tree) hierarchy.Bounds { diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index ad27702..db7c7fa 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "os" "path/filepath" @@ -53,6 +54,12 @@ func newHarness(t *testing.T) *harness { return newHarnessWithSpec(t, fixtureSpec) } +func fastFocusSettle(t *testing.T) { + prev := focusTapSettle + focusTapSettle = time.Millisecond + t.Cleanup(func() { focusTapSettle = prev }) +} + // bundleSpec compiles an authored TS spec with the goja runtime entry so the // loaded bundle installs __sanderlingNextAction__ (the shared picker). func bundleSpec(t *testing.T, specSource string) string { @@ -652,6 +659,7 @@ func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) { } func TestApplyAction_InputTextErasesExistingTextBeforeTyping(t *testing.T) { + fastFocusSettle(t) tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ {"attributes":{"resource-id":"username","text":"stale-value","bounds":"[10,10,500,100]"},"children":[]} ]}`) @@ -661,41 +669,39 @@ func TestApplyAction_InputTextErasesExistingTextBeforeTyping(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("applyAction: %v", err) } + // The post-tap settle is now a brief internal sleep, not a WaitForIdle RPC, + // so the recorded driver actions are tap, erase, input. actions := driverMock.Actions() - if len(actions) != 4 { - t.Fatalf("want tap, wait_for_idle, erase, input; got %v", actions) + if len(actions) != 3 { + t.Fatalf("want tap, erase, input; got %v", actions) } if actions[0].Kind != mockdriver.ActionTap { t.Errorf("first action = %v, want tap", actions[0].Kind) } - if actions[1].Kind != mockdriver.ActionWaitForIdle { - t.Errorf("second action = %v, want wait_for_idle (settle after focus tap)", actions[1].Kind) + if actions[1].Kind != mockdriver.ActionEraseText || actions[1].CharacterCount != len("stale-value") { + t.Errorf("second action = %+v, want erase_text of %d characters", actions[1], len("stale-value")) } - if actions[2].Kind != mockdriver.ActionEraseText || actions[2].CharacterCount != len("stale-value") { - t.Errorf("third action = %+v, want erase_text of %d characters", actions[2], len("stale-value")) - } - if actions[3].Kind != mockdriver.ActionInputText || actions[3].Text != "alice" { - t.Errorf("fourth action = %+v, want input_text alice", actions[3]) + if actions[2].Kind != mockdriver.ActionInputText || actions[2].Text != "alice" { + t.Errorf("third action = %+v, want input_text alice", actions[2]) } } -// TestApplyAction_InputTextWithoutTargetSkipsSettle pins that the post-tap -// settle only runs when a focus tap actually happened: with no resolvable -// target there is no keyboard animation to absorb. -func TestApplyAction_InputTextWithoutTargetSkipsSettle(t *testing.T) { +// TestApplyAction_InputTextWithoutTargetSkipsFocusTap pins that with no +// resolvable target there is no focus tap (and so no settle), and InputText +// still runs at the cursor. +func TestApplyAction_InputTextWithoutTargetSkipsFocusTap(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindInputText, X: -1, Y: -1, Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("applyAction: %v", err) } - for _, recorded := range driverMock.Actions() { - if recorded.Kind == mockdriver.ActionWaitForIdle { - t.Errorf("no focus tap happened; settle must be skipped: %v", driverMock.Actions()) - } + actions := driverMock.Actions() + if len(actions) != 1 || actions[0].Kind != mockdriver.ActionInputText { + t.Errorf("no target: want input_text only (no focus tap), got %v", actions) } } @@ -703,6 +709,7 @@ func TestApplyAction_InputTextWithoutTargetSkipsSettle(t *testing.T) { // asserting the TextReplacer capability never pays the pre-erase round-trip: // its InputText already replaces the field's content. func TestApplyAction_InputTextSkipsEraseForReplacingDriver(t *testing.T) { + fastFocusSettle(t) tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ {"attributes":{"resource-id":"username","text":"stale-value","bounds":"[10,10,500,100]"},"children":[]} ]}`) @@ -713,7 +720,7 @@ func TestApplyAction_InputTextSkipsEraseForReplacingDriver(t *testing.T) { driverMock.ReplacesText = true action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("applyAction: %v", err) } if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") { @@ -725,6 +732,7 @@ func TestApplyAction_InputTextSkipsEraseForReplacingDriver(t *testing.T) { } func TestApplyAction_InputTextSkipsEraseWhenTargetEmpty(t *testing.T) { + fastFocusSettle(t) tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[ {"attributes":{"resource-id":"username","bounds":"[10,10,500,100]"},"children":[]} ]}`) @@ -734,7 +742,7 @@ func TestApplyAction_InputTextSkipsEraseWhenTargetEmpty(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("applyAction: %v", err) } if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") { @@ -748,7 +756,7 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { driverMock.Failures[mockdriver.ActionTapSelector] = errors.New("adb unreachable") action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} - err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond) + err := applyAction(context.Background(), driverMock, action, nil) if err == nil { t.Fatalf("expected focus tap failure to surface, got nil") } @@ -761,7 +769,7 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { driverMock.Failures[mockdriver.ActionTap] = errors.New("tap driver error") action := verifier.Action{Kind: verifier.ActionKindInputText, X: 10, Y: 20, Text: "alice"} - err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond) + err := applyAction(context.Background(), driverMock, action, nil) if err == nil { t.Fatalf("expected focus tap failure to surface, got nil") } @@ -772,10 +780,11 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { } func TestApplyAction_V8InputTextTapsAtCoordinates(t *testing.T) { + fastFocusSettle(t) driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindInputText, X: 50, Y: 100, Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } actions := driverMock.Actions() @@ -788,13 +797,14 @@ func TestApplyAction_V8InputTextTapsAtCoordinates(t *testing.T) { } func TestApplyAction_V8InputTextAtOriginStillTaps(t *testing.T) { + fastFocusSettle(t) driverMock := mockdriver.New() // V8 emits real (0,0) coordinates for an element at viewport top-left // (post-#15 the runtime nullifies unresolved actions, so a non-null // InputText with (0,0) is a deliberate edge tap, not a sentinel). action := verifier.Action{Kind: verifier.ActionKindInputText, X: 0, Y: 0, Text: "alice"} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } if !containsAction(driverMock.Actions(), mockdriver.ActionTap, "") { @@ -806,7 +816,7 @@ func TestApplyAction_DoubleTapDispatchesDoubleTapAtCoordinates(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindDoubleTap, X: 100, Y: 200} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } taps := 0 @@ -824,7 +834,7 @@ func TestApplyAction_DoubleTapDispatchesDoubleTapSelector(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindDoubleTap, On: "id:save"} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } taps := 0 @@ -842,7 +852,7 @@ func TestApplyAction_LongPressDispatchesAtResolvedCoordinates(t *testing.T) { driverMock := mockdriver.New() action := verifier.Action{Kind: verifier.ActionKindLongPress, X: 120, Y: 240} - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } found := false @@ -868,7 +878,7 @@ func TestApplyAction_ScrollWithPrecomputedEndpointsSwipes(t *testing.T) { DurationMillis: 300, } - if err := applyAction(context.Background(), driverMock, action, nil, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, nil); err != nil { t.Fatalf("apply action: %v", err) } found := false @@ -891,7 +901,7 @@ func TestApplyAction_ScrollDirectionUsesInversion(t *testing.T) { } action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "down", On: "id:list"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("apply action: %v", err) } var swipe *mockdriver.Action @@ -910,6 +920,40 @@ func TestApplyAction_ScrollDirectionUsesInversion(t *testing.T) { } } +func TestApplyAction_ScrollNearTopKeepsDirectionAfterClamp(t *testing.T) { + driverMock := mockdriver.New() + // Full-screen root sets the 1080x2400 screen (marginY=200); the scrollable + // list sits inside the top margin (y 20..180), where the clamp must fire. + treeJSON := `{"attributes":{"bounds":"[0,0,1080,2400]"},"children":[ + {"attributes":{"resource-id":"com.fixture:id/toplist","scrollable":"true","bounds":"[0,20,1080,180]"},"children":[],"enabled":true} + ]}` + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatalf("parse tree: %v", err) + } + action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "up", On: "id:toplist"} + + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { + t.Fatalf("apply action: %v", err) + } + var swipe *mockdriver.Action + for i := range driverMock.Actions() { + if driverMock.Actions()[i].Kind == mockdriver.ActionSwipe { + a := driverMock.Actions()[i] + swipe = &a + } + } + if swipe == nil { + t.Fatalf("expected a Swipe, got %v", driverMock.Actions()) + } + if swipe.FromY != 200 { + t.Errorf("origin not pushed below the shade strip, got fromY=%d want 200", swipe.FromY) + } + if swipe.ToY <= swipe.FromY { + t.Errorf("scroll up reversed by the clamp: from=%d to=%d (want toY > fromY)", swipe.FromY, swipe.ToY) + } +} + func TestApplyAction_ScrollScreenFallback(t *testing.T) { driverMock := mockdriver.New() treeJSON := `{"attributes":{"bounds":"[0,0,400,800]"},"children":[],"enabled":true}` @@ -920,7 +964,7 @@ func TestApplyAction_ScrollScreenFallback(t *testing.T) { // On unset: container falls back to whole-screen (root) bounds. action := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "up"} - if err := applyAction(context.Background(), driverMock, action, tree, time.Millisecond); err != nil { + if err := applyAction(context.Background(), driverMock, action, tree); err != nil { t.Fatalf("apply action: %v", err) } var swipe *mockdriver.Action @@ -1764,3 +1808,194 @@ func TestRunner_WaitsForWindowDrawnBeforeFirstAction(t *testing.T) { } } } + +// TestAwaitForeground_RelaunchesThenWaitsForWindow locks the per-step scope +// guard's recovery: after the app leaves to the launcher, it must relaunch AND +// keep polling the focused window until it names the app, so the step never +// observes or acts while the launcher is on screen (where InputText would land +// in the launcher's type-to-search filter). A single fire-and-forget relaunch, +// which returns before the window draws on a slow physical device, is the bug +// this guards against. +func TestAwaitForeground_RelaunchesThenWaitsForWindow(t *testing.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. + m.ForegroundResults = []string{"com.android.launcher", "app.folio"} + m.FocusedWindowResults = []string{"com.android.launcher", "app.folio"} + + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn})) + options := Options{BundleID: "app.folio", Driver: m, IdleTimeout: 10 * time.Millisecond} + + awaitForeground(context.Background(), options, logger, 7) + + relaunches, backs := 0, 0 + for _, a := range m.Actions() { + switch { + case a.Kind == mockdriver.ActionLaunch && a.BundleID == "app.folio" && !a.ClearState: + relaunches++ + case a.Kind == mockdriver.ActionPressKey && a.Key == "back": + backs++ + } + } + if relaunches != 1 { + t.Fatalf("expected exactly one relaunch while the app was gone, got %d", relaunches) + } + if backs != 1 { + t.Fatalf("expected one back-press to dismiss a possible dialog before relaunch, got %d", backs) + } + // The window lagged one poll behind the resumed activity, so the focused + // window must have been queried at least twice before the gate returned. + if calls := m.FocusedWindowCalls(); calls < 2 { + t.Fatalf("expected the guard to poll the focused window until drawn (>=2), got %d", calls) + } +} + +func TestClampGestureToSafeArea_KeepsOriginBelowShadeStrip(t *testing.T) { + screen := hierarchy.Bounds{Left: 0, Top: 0, Right: 1080, Bottom: 2400} // marginY = 200 + + // Origin in the shade strip: the whole segment shifts down by 138, so the + // downward gesture stays downward (447 -> 585) instead of reversing. + fromX, fromY, toX, toY := clampGestureToSafeArea(802, 62, 802, 447, screen) + if fromX != 802 || fromY != 200 || toX != 802 || toY != 585 { + t.Errorf("segment not translated below the shade strip: from=(%d,%d) to=(%d,%d), want from=(802,200) to=(802,585)", fromX, fromY, toX, toY) + } + + fromX, fromY, _, _ = clampGestureToSafeArea(5, 1200, 540, 1200, screen) + if fromX != 5 || fromY != 1200 { + t.Errorf("side origin must pass through, got (%d,%d), want (5,1200)", fromX, fromY) + } + + fromX, fromY, _, _ = clampGestureToSafeArea(540, 2399, 540, 1200, screen) + if fromX != 540 || fromY != 2399 { + t.Errorf("bottom origin must pass through, got (%d,%d), want (540,2399)", fromX, fromY) + } + + _, _, toX, toY = clampGestureToSafeArea(540, 1200, -50, 9999, screen) + if toX != 0 || toY != 2400 { + t.Errorf("off-screen destination not clamped to screen edges: got (%d,%d), want (0,2400)", toX, toY) + } + + fromX, _, _, _ = clampGestureToSafeArea(-30, 1200, 540, 1200, screen) + if fromX != 0 { + t.Errorf("off-screen origin x must clamp to 0, got %d", fromX) + } + + fromX, fromY, toX, toY = clampGestureToSafeArea(802, 62, 802, 447, hierarchy.Bounds{}) + if fromX != 802 || fromY != 62 || toX != 802 || toY != 447 { + t.Error("coordinates must pass through unchanged when screen size is unknown") + } +} + +// TestScreenBounds_UsesMaxExtentNotRoot guards the screen-size source: the +// Android hierarchy root reports zero bounds, so the screen rectangle must come +// from the maximum element extent or the gesture clamp silently no-ops. +func TestScreenBounds_UsesMaxExtentNotRoot(t *testing.T) { + tree := &hierarchy.Tree{ + Root: &hierarchy.Node{Element: hierarchy.Element{Bounds: hierarchy.Bounds{}}}, + Elements: []*hierarchy.Element{ + {Bounds: hierarchy.Bounds{}}, + {Bounds: hierarchy.Bounds{Left: 0, Top: 0, Right: 1080, Bottom: 2160}}, + {Bounds: hierarchy.Bounds{Left: 0, Top: 2268, Right: 1080, Bottom: 2400}}, + }, + } + got := screenBounds(tree) + if got.Right != 1080 || got.Bottom != 2400 { + t.Fatalf("screenBounds = %+v, want right=1080 bottom=2400", got) + } +} + +// TestEnsureForeground_DismissesSystemOverlay locks the shade fix: when the app +// is still the resumed activity but a system overlay (notification shade) holds +// the focused window, the guard must dismiss it with back rather than relaunch +// or act on the obscured app. +func TestEnsureForeground_DismissesSystemOverlay(t *testing.T) { + m := mockdriver.New() + // Resumed activity stays the app; the focused window is the shade. + m.ForegroundResults = []string{"app.folio"} + m.FocusedWindowResults = []string{"com.android.systemui"} + + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn})) + options := Options{BundleID: "app.folio", Driver: m, IdleTimeout: 10 * time.Millisecond} + + if !ensureForeground(context.Background(), options, logger, 5) { + t.Fatal("expected the guard to act on the focus-stealing overlay") + } + backs, relaunches := 0, 0 + for _, a := range m.Actions() { + switch { + case a.Kind == mockdriver.ActionPressKey && a.Key == "back": + backs++ + case a.Kind == mockdriver.ActionLaunch: + relaunches++ + } + } + if backs != 1 { + t.Fatalf("expected one back-press to collapse the shade, got %d", backs) + } + if relaunches != 0 { + t.Fatalf("a resumed-but-obscured app must not be relaunched, got %d relaunches", relaunches) + } +} + +func TestAppIsForeground(t *testing.T) { + readErr := errors.New("adb read failed") + cases := []struct { + name string + bundleID string + foreground []string + foregErr error + focused []string + focusErr error + want bool + }{ + {name: "no bundle id", bundleID: "", foreground: []string{"app.folio"}, want: true}, + {name: "foreground unknown", bundleID: "app.folio", foreground: nil, want: true}, + {name: "foreground read error", bundleID: "app.folio", foregErr: readErr, want: true}, + {name: "foreign foreground", bundleID: "app.folio", foreground: []string{"com.android.chrome"}, want: false}, + {name: "app resumed and focused", bundleID: "app.folio", foreground: []string{"app.folio"}, focused: []string{"app.folio"}, want: true}, + {name: "app resumed but overlay focused", bundleID: "app.folio", foreground: []string{"app.folio"}, focused: []string{"com.android.systemui"}, want: false}, + {name: "app resumed, focus unknown", bundleID: "app.folio", foreground: []string{"app.folio"}, focused: []string{""}, want: true}, + {name: "app resumed, focus read error", bundleID: "app.folio", foreground: []string{"app.folio"}, focusErr: readErr, want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := mockdriver.New() + m.ForegroundResults = tc.foreground + m.ForegroundErr = tc.foregErr + m.FocusedWindowResults = tc.focused + m.FocusedWindowErr = tc.focusErr + options := Options{BundleID: tc.bundleID, Driver: m} + if got := appIsForeground(context.Background(), options); got != tc.want { + t.Errorf("appIsForeground = %v, want %v", got, tc.want) + } + }) + } +} + +// A system overlay holds focus while the app stays resumed every step, so the +// fixture's id:next tap must never reach the driver. +func TestRunner_SkipsActionWhenOverlayStealsFocusAtApplyTime(t *testing.T) { + state := newHarness(t) + state.mock.ForegroundResults = []string{"app.folio"} + state.mock.FocusedWindowResults = []string{"app.folio", "com.android.systemui"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, 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) + } + if summary.Steps == 0 { + t.Fatal("expected the loop to run steps") + } + if containsAction(state.mock.Actions(), mockdriver.ActionTapSelector, "id:next") { + t.Error("apply-time guard failed: a tap fired while a system overlay held focus") + } +} diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index c155e22..a46a857 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -145,6 +145,9 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver "--port", strconv.Itoa(sidecarPort), "--platform", options.Platform, } + if options.Device != "" { + sidecarArgs = append(sidecarArgs, "--serial", options.Device) + } sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...) sidecarCommand.Stdout = stdout sidecarCommand.Stderr = stdout @@ -167,6 +170,7 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver return nil, nil, fmt.Errorf("dial sidecar: %w", err) } driverClient.SetPlatform(options.Platform) + driverClient.SetClearStateReinstall(options.Device, options.AndroidAppPath, stdout) // WaitForHealth confirms the gRPC sidecar is up. For iOS, the WDA warmup // (absorbing the XCUITest startup race) runs inside IosDriverBackend.init // in the sidecar - no additional sleep needed here. diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index f5714e0..cda97a1 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -22,16 +22,18 @@ const sidecarStartupTimeout = 30 * time.Second // Options are the parameters for a single test pipeline run. type Options struct { - Spec string - BundleID string - Platform string - AVD string - IosDevice string - IosAppPath string - Duration time.Duration - Seed int64 - Output string - ClearData bool + Spec string + BundleID string + Platform string + AVD string + Device string + IosDevice string + IosAppPath string + AndroidAppPath string + Duration time.Duration + Seed int64 + Output string + ClearData bool // iosUDID, iosIsSimulator, and iosCoreDeviceID are filled by Execute after // resolving the iOS target, then read by buildDriver to choose the simulator @@ -46,9 +48,17 @@ type Options struct { func Execute(ctx context.Context, options Options, stdout io.Writer) error { switch options.Platform { case "android": - if err := android.EnsureDevice(ctx, options.AVD, stdout); err != nil { + if err := android.EnsureDevice(ctx, options.Device, options.AVD, stdout); err != nil { return err } + if err := android.PrepareDevice(ctx, options.Device, stdout); err != nil { + return err + } + // Switch to 3-button navigation for the run so fuzzer swipes cannot + // trigger the gesture-nav home/back and fling the app off screen; + // restore the original mode when the run ends. + restoreNav := android.ForceThreeButtonNav(ctx, options.Device, stdout) + defer restoreNav() case "ios": resolved, err := resolveIOSTarget(ctx, options, stdout) if err != nil { diff --git a/internal/verifier/scope_test.go b/internal/verifier/scope_test.go index aa469d8..469bf7c 100644 --- a/internal/verifier/scope_test.go +++ b/internal/verifier/scope_test.go @@ -48,6 +48,37 @@ func TestTaps_ExcludeOffAppPackage(t *testing.T) { } } +// TestTaps_ExcludeKeyboardRegionNoPackageKey proves a keyboard key that carries +// no package (the keyboard's "Settings" key is a bare node with a content-desc +// and no package) is still dropped: it is a child of the IME window, so it +// inherits the IME package as its owner and falls out of scope. A per-element +// package check would admit it. Only the in-app button remains a target. +func TestTaps_ExcludeKeyboardRegionNoPackageKey(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,1080,2400]", "package": "com.folio"}, + "children": [ + {"attributes": {"testTag": "SubmitButton", "bounds": "[100,400,500,500]", "package": "com.folio"}, "clickable": true, "enabled": true, "children": []}, + {"attributes": {"resource-id": "com.google.android.inputmethod.latin:id/keyboard_holder", "bounds": "[0,1503,1080,2268]"}, "children": [ + {"attributes": {"content-desc": "Settings", "bounds": "[461,1503,618,1635]"}, "clickable": true, "enabled": true, "children": []} + ]} + ] + }` + verifier := newVerifier(t, WithAppPackage("com.folio")) + loadActionSpec(t, verifier, ` + import { taps } from "@sanderling/spec"; + globalThis.actions = taps; + `) + pushTree(t, verifier, treeJSON) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.X != 300 || action.Y != 450 { + t.Errorf("coords = (%d,%d), want (300,450) at SubmitButton; a keyboard key leaked into targets", action.X, action.Y) + } +} + // TestTyping_ExcludeOffAppPackage proves keyboard glyph buttons that report as // editable never become typing targets once the app package is set. func TestTyping_ExcludeOffAppPackage(t *testing.T) { diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 29fd8d9..bcbb28b 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -35,6 +35,8 @@ type Verifier struct { witnesses map[string]Witness lastTree *hierarchy.Tree + scopeCache map[*hierarchy.Element]bool + scopeCacheTree *hierarchy.Tree lastAction *Action lastLogs []LogEntry lastExceptions []Exception @@ -256,6 +258,7 @@ func (v *Verifier) buildFormulaNode(index int) (ltl.Formula, error) { // allowed and yields an empty ax scope. func (v *Verifier) PushSnapshot(input SnapshotInput) error { v.lastTree = input.Tree + v.scopeCache = nil v.lastAction = input.LastAction v.lastLogs = input.Logs v.lastExceptions = input.Exceptions @@ -585,15 +588,52 @@ func (v *Verifier) formulaThunk(index int) func() (bool, error) { } } -// inScope reports whether an element belongs to the app under test. Nodes from -// another package (the soft keyboard, system UI, permission dialogs) are out of -// scope. An unset app package or an element with no package falls through to in -// scope, preserving behavior on platforms that omit the attribute (e.g. iOS). -func (v *Verifier) inScope(element *hierarchy.Element) bool { - if v.appPackage == "" || element.Package == "" { - return true +// frameworkPackage is the AOSP framework package. Both the app's own window +// (android:id/content) and system chrome carry it, so it is treated as neutral +// (transparent) when deciding which window owns a node, rather than as a foreign +// package that would put the app's content out of scope. +const frameworkPackage = "android" + +// scopedElements returns the set of elements that belong to the app under test. +// It walks the window tree propagating each node's owning package: a node's +// owner is the nearest ancestor-or-self with a concrete package (empty and the +// neutral android framework package are transparent). A node is in scope when no +// concrete foreign package owns it -- the app's own window carries no package on +// Compose apps -- or the owner is the app package itself. This drops whole +// foreign windows (the soft keyboard, system UI, the launcher) AND their +// empty-package child wrappers, e.g. a keyboard's "Settings" key, which a +// per-element package check admits because the wrapper itself has no package. +// +// With no app package configured (iOS/web, or an unscoped run) every node is in +// scope, preserving prior behavior. +func (v *Verifier) scopedElements() map[*hierarchy.Element]bool { + if v.scopeCacheTree == v.lastTree && v.scopeCache != nil { + return v.scopeCache } - return element.Package == v.appPackage + scope := make(map[*hierarchy.Element]bool, len(v.lastTree.Elements)) + unscoped := v.appPackage == "" + if v.lastTree.Root == nil { + for _, element := range v.lastTree.Elements { + scope[element] = true + } + } else { + var walk func(node *hierarchy.Node, owner string) + walk = func(node *hierarchy.Node, owner string) { + if pkg := node.Element.Package; pkg != "" && pkg != frameworkPackage { + owner = pkg + } + if unscoped || owner == "" || owner == v.appPackage { + scope[&node.Element] = true + } + for _, child := range node.Children { + walk(child, owner) + } + } + walk(v.lastTree.Root, "") + } + v.scopeCache = scope + v.scopeCacheTree = v.lastTree + return scope } // selectorForElement builds a canonical "key:value" selector that resolves @@ -644,17 +684,19 @@ func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string // taps/doubleTaps/longPresses: clickable + enabled + positive bounds // typing: editable + enabled + positive bounds // scrolls: scrollable attribute + positive bounds -// swipes: any in-scope element +// swipes: any in-scope element with positive bounds // // Every candidate carries the resolving selector so the runner can re-route by -// id/text. Out-of-scope nodes (the soft keyboard, system UI) are always dropped. +// id/text. Out-of-scope nodes (the soft keyboard, system UI, the launcher) are +// dropped by scopedElements. func (v *Verifier) candidatesForVerb(verb string) []candidate { if v.lastTree == nil { return nil } + scope := v.scopedElements() var result []candidate for _, element := range v.lastTree.Elements { - if !v.inScope(element) { + if !scope[element] { continue } if !verbAccepts(verb, element) { @@ -689,7 +731,11 @@ func verbAccepts(verb string, element *hierarchy.Element) bool { case "scrolls": return element.Attributes["scrollable"] == "true" && positiveBounds case "swipes": - return true + // Any visible element is a valid swipe origin, but it must have real + // bounds: a zero-bounds node centers at (0,0), and a downward swipe from + // the top-left corner is the system gesture that pulls down the + // notification shade, dragging the fuzzer out of the app. + return positiveBounds default: return false } diff --git a/internal/verifier/worker_test.go b/internal/verifier/worker_test.go new file mode 100644 index 0000000..3f95e54 --- /dev/null +++ b/internal/verifier/worker_test.go @@ -0,0 +1,74 @@ +package verifier + +import ( + "testing" + + "github.com/priyanshujain/sanderling/internal/hierarchy" +) + +// TestVerbAcceptsSwipeRequiresPositiveBounds locks the fix for the notification +// shade: a zero-bounds element centers at (0,0), and a downward swipe from the +// top-left corner is the system gesture that pulls the shade over the app. The +// swipe verb must reject zero-bounds nodes like every other verb does. +func TestVerbAcceptsSwipeRequiresPositiveBounds(t *testing.T) { + zeroBounds := &hierarchy.Element{Bounds: hierarchy.Bounds{}} + if verbAccepts("swipes", zeroBounds) { + t.Error("swipes must reject a zero-bounds element (it centers at (0,0) and pulls the notification shade)") + } + + realBounds := &hierarchy.Element{Bounds: hierarchy.Bounds{Left: 100, Top: 400, Right: 980, Bottom: 600}} + if !verbAccepts("swipes", realBounds) { + t.Error("swipes must accept an element with positive bounds") + } +} + +// TestScopedElements is the core of keeping the fuzzer in the app. It checks +// the window-ownership rule against a realistic tree: the app window carries no +// package (Compose), even under android:id/content; the soft keyboard and system +// UI are separate windows with concrete packages, and their empty-package child +// wrappers (a keyboard "Settings" key) must inherit the foreign owner and drop +// out -- the exact node that used to leak in and navigate to system Settings. +func TestScopedElements(t *testing.T) { + const treeJSON = `{ + "attributes": {"bounds": "[0,0,1080,2400]"}, + "children": [ + {"attributes": {"resource-id": "LoginEmail", "bounds": "[0,100,1080,200]"}, "children": []}, + {"attributes": {"resource-id": "android:id/content", "bounds": "[0,0,1080,2400]"}, "children": [ + {"attributes": {"resource-id": "AccountNameField", "bounds": "[0,300,1080,400]"}, "children": []} + ]}, + {"attributes": {"resource-id": "com.oplus.securitykeyboard:id/keyboard", "bounds": "[0,1503,1080,2268]"}, "children": [ + {"attributes": {"content-desc": "Settings", "bounds": "[461,1503,618,1635]"}, "children": []} + ]}, + {"attributes": {"resource-id": "com.android.systemui:id/nav", "bounds": "[0,2268,1080,2400]"}, "children": []} + ] + }` + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + v := &Verifier{appPackage: "app.folio", lastTree: tree} + scope := v.scopedElements() + inScope := func(selector string) bool { + element := tree.Find(selector) + if element == nil { + t.Fatalf("element %q not found in tree", selector) + } + return scope[element] + } + + // App nodes carry no package and stay in scope, even under the android + // framework content wrapper. + for _, selector := range []string{"id:LoginEmail", "id:AccountNameField"} { + if !inScope(selector) { + t.Errorf("%s should be in scope (app window)", selector) + } + } + // The keyboard's empty-package "Settings" key inherits the IME window owner + // and drops out; the system UI node drops out by its own package. + if inScope("desc:Settings") { + t.Error("keyboard Settings key must be out of scope (owned by the IME window)") + } + if inScope("id:nav") { + t.Error("system UI node must be out of scope") + } +} diff --git a/pkg/spec/package-lock.json b/pkg/spec/package-lock.json index 31fb500..c9f556b 100644 --- a/pkg/spec/package-lock.json +++ b/pkg/spec/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.7.5", + "prettier": "^3.8.4", "tsx": "^4.21.0", "typescript": "^5.9.3" } @@ -536,6 +537,22 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", diff --git a/pkg/spec/package.json b/pkg/spec/package.json index 37cc58f..ffbfb84 100644 --- a/pkg/spec/package.json +++ b/pkg/spec/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@types/node": "^22.7.5", + "prettier": "^3.8.4", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/replay-ui/bun.lock b/replay-ui/bun.lock index e7ed313..52a4f5b 100644 --- a/replay-ui/bun.lock +++ b/replay-ui/bun.lock @@ -14,6 +14,7 @@ "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "prettier": "^3.8.4", "typescript": "^5.5.4", "vite": "^5.4.6", }, @@ -234,6 +235,8 @@ "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], diff --git a/replay-ui/package.json b/replay-ui/package.json index 9f3b134..84b6a91 100644 --- a/replay-ui/package.json +++ b/replay-ui/package.json @@ -19,6 +19,7 @@ "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "prettier": "^3.8.4", "typescript": "^5.5.4", "vite": "^5.4.6" } diff --git a/replay-ui/src/routes/RunList.tsx b/replay-ui/src/routes/RunList.tsx index 57f5670..464eb27 100644 --- a/replay-ui/src/routes/RunList.tsx +++ b/replay-ui/src/routes/RunList.tsx @@ -62,41 +62,43 @@ export default function RunList() { } return ( - - - - - - - - - - - - - - - {runs.map((run) => ( - - - - - - - - - +
+
startedspecseedplatformdurationstepsviolations
- {formatStartedAt(run.started_at)} - {basename(run.spec_path)}{run.seed}{run.platform}{formatDuration(run.duration_millis)}{run.step_count} - {run.violation_count > 0 ? ( - {run.violation_count} - ) : ( - "0" - )} - {run.in_progress ? in progress : null}
+ + + + + + + + + + - ))} - -
startedspecseedplatformdurationstepsviolations
+ + + {runs.map((run) => ( + + + {formatStartedAt(run.started_at)} + + {basename(run.spec_path)} + {run.seed} + {run.platform} + {formatDuration(run.duration_millis)} + {run.step_count} + + {run.violation_count > 0 ? ( + {run.violation_count} + ) : ( + "0" + )} + + {run.in_progress ? in progress : null} + + ))} + + + ); } diff --git a/replay-ui/src/styles/app.css b/replay-ui/src/styles/app.css index 180e76e..b2f6692 100644 --- a/replay-ui/src/styles/app.css +++ b/replay-ui/src/styles/app.css @@ -57,11 +57,15 @@ button:hover { min-height: 0; } +.run-list { + height: 100%; + min-height: 0; + overflow: auto; +} + .run-table { width: 100%; border-collapse: collapse; - align-self: flex-start; - flex: 0 0 auto; } .run-table th, diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index a8dece6..94ee73b 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -1,7 +1,11 @@ package dev.sanderling.sidecar interface DriverBackend { - fun launch(bundleId: String, clearState: Boolean, env: Map = emptyMap()) + fun launch( + bundleId: String, + clearState: Boolean, + env: Map = emptyMap(), + ) fun terminate(bundleId: String) fun tap(x: Int, y: Int) @@ -106,22 +110,28 @@ internal fun stabilitySnapshot(treeJson: String): String? { } private val ROUTE_TAG_KEYS = setOf( - "resource-id", "resourceId", "testTag", - "identifier", "accessibilityIdentifier", + "resource-id", + "resourceId", + "testTag", + "identifier", + "accessibilityIdentifier", ) +private val jsonMapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + internal fun countRouteScreens(treeJson: String): Int { if (treeJson.isBlank()) return 0 return try { - val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() - val root = mapper.readTree(treeJson) + val root = jsonMapper.readTree(treeJson) countRouteScreens(root) } catch (_: Exception) { 0 } } -private fun countRouteScreens(node: com.fasterxml.jackson.databind.JsonNode): Int { +private fun countRouteScreens( + node: com.fasterxml.jackson.databind.JsonNode, +): Int { var count = 0 val attributes = node.get("attributes") if (attributes != null && attributes.isObject) { @@ -149,8 +159,7 @@ private fun countRouteScreens(node: com.fasterxml.jackson.databind.JsonNode): In internal fun structuralHash(treeJson: String): String { if (treeJson.isBlank()) return "" return try { - val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() - val root = mapper.readTree(treeJson) + val root = jsonMapper.readTree(treeJson) val builder = StringBuilder() walkForStructuralHash(root, builder) builder.toString() @@ -167,7 +176,10 @@ private val STABLE_ATTRIBUTE_KEYS = listOf( "testTag", "identifier", "accessibilityIdentifier", ) -private fun walkForStructuralHash(node: com.fasterxml.jackson.databind.JsonNode, out: StringBuilder) { +private fun walkForStructuralHash( + node: com.fasterxml.jackson.databind.JsonNode, + out: StringBuilder, +) { out.append('(') val attributes = node.get("attributes") if (attributes != null && attributes.isObject) { @@ -191,7 +203,9 @@ private fun walkForStructuralHash(node: com.fasterxml.jackson.databind.JsonNode, // then lands sequentially after the surviving one instead of failing the // step. internal fun overlappedDoubleTap(tapAction: () -> Unit) { - val firstTap = java.util.concurrent.CompletableFuture.runAsync { tapAction() } + val firstTap = java.util.concurrent.CompletableFuture.runAsync { + tapAction() + } Thread.sleep(40) try { tapAction() @@ -220,16 +234,28 @@ data class LogLine( val message: String, ) -internal fun readLogcat(serial: String?, sinceUnixMillis: Long, minLevel: String): List { +internal fun readLogcat( + serial: String?, + sinceUnixMillis: Long, + minLevel: String, +): List { val level = if (minLevel.isEmpty()) "E" else minLevel - val since = if (sinceUnixMillis > 0) StubDriverBackend.formatAdbLogcatTimestamp(sinceUnixMillis) else null + val since = if (sinceUnixMillis > + 0 + ) { + StubDriverBackend.formatAdbLogcatTimestamp(sinceUnixMillis) + } else { + null + } val arguments = mutableListOf("logcat", "-d", "*:$level") if (since != null) { arguments.add("-T") arguments.add(since) } return try { - val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start() + val process = ProcessBuilder( + adbCmd(serial) + arguments, + ).redirectErrorStream(false).start() val output = process.inputStream.bufferedReader().readText() process.waitFor() StubDriverBackend.parseLogcatOutput(output) @@ -257,15 +283,15 @@ internal fun readProcMetrics(serial: String?, bundleId: String): MetricsSample { private fun adbCmd(serial: String?): List = if (serial == null) listOf("adb") else listOf("adb", "-s", serial) -private fun adbOutput(serial: String?, arguments: List): String { - return try { - val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start() - val output = process.inputStream.bufferedReader().readText() - process.waitFor() - output - } catch (cause: Exception) { - "" - } +private fun adbOutput(serial: String?, arguments: List): String = try { + val process = ProcessBuilder( + adbCmd(serial) + arguments, + ).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor() + output +} catch (cause: Exception) { + "" } private fun sampleCpuTwice(serial: String?, pid: Int): Double { @@ -276,8 +302,14 @@ private fun sampleCpuTwice(serial: String?, pid: Int): Double { if (lines.size < 2) return 0.0 val first = parseCpuTicks(lines[0]) ?: return 0.0 val second = parseCpuTicks(lines[1]) ?: return 0.0 - val clockHz = adbOutput(serial, listOf("shell", "getconf", "CLK_TCK")).trim().toLongOrNull() ?: 100L - val deltaCpuNanos = (second - first) * 1_000_000_000.0 / clockHz.coerceAtLeast(1L) + val clockHz = + adbOutput( + serial, + listOf("shell", "getconf", "CLK_TCK"), + ).trim().toLongOrNull() + ?: 100L + val deltaCpuNanos = + (second - first) * 1_000_000_000.0 / clockHz.coerceAtLeast(1L) return (deltaCpuNanos / 50_000_000.0) * 100.0 } @@ -312,7 +344,9 @@ internal fun parseKb(line: String): Long? { private fun execAdb(arguments: List) { try { - val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() + val command = ProcessBuilder( + listOf("adb") + arguments, + ).redirectErrorStream(true).start() command.inputStream.bufferedReader().readText() command.waitFor() } catch (cause: Exception) { @@ -326,22 +360,39 @@ class StubDriverBackend( ) : DriverBackend { @Volatile var launchCount: Int = 0 private set + @Volatile var lastBundleId: String? = null private set + @Volatile var lastTap: Pair? = null private set + @Volatile var lastTapSelector: String? = null private set + @Volatile var lastInputText: String? = null private set - override fun launch(bundleId: String, clearState: Boolean, env: Map) { + override fun launch( + bundleId: String, + clearState: Boolean, + env: Map, + ) { launchCount++ lastBundleId = bundleId if (clearState) { runAdb(listOf("shell", "pm", "clear", bundleId)) } - runAdb(listOf("shell", "am", "start", "-W", "-n", "$bundleId/.MainActivity")) + runAdb( + listOf( + "shell", + "am", + "start", + "-W", + "-n", + "$bundleId/.MainActivity", + ), + ) } companion object { @@ -350,7 +401,10 @@ class StubDriverBackend( internal fun isAnimationCountIdle(grepOutput: String): Boolean = (grepOutput.trim().toIntOrNull() ?: 0) == 0 - internal fun parseResolvedActivity(bundleId: String, output: String): String? { + internal fun parseResolvedActivity( + bundleId: String, + output: String, + ): String? { val prefix = "$bundleId/" for (line in output.lines()) { val trimmed = line.trim() @@ -366,8 +420,13 @@ class StubDriverBackend( for (ch in text) { when (ch) { ' ' -> sb.append("%s") + '\\', '"', '\'', '&', '|', ';', '<', '>', '(', ')', '*', '?', - '$', '`', '[', ']', '{', '}', '~', '#', -> sb.append('\\').append(ch) + '$', '`', '[', ']', '{', '}', '~', '#', + -> sb.append( + '\\', + ).append(ch) + else -> sb.append(ch) } } @@ -452,19 +511,31 @@ class StubDriverBackend( @Volatile var lastSwipe: SwipeRecord? = null private set + @Volatile var lastKey: String? = null private set + @Volatile var lastLongPress: Pair? = null private set - override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) { + override fun swipe( + fromX: Int, + fromY: Int, + toX: Int, + toY: Int, + durationMillis: Long, + ) { lastSwipe = SwipeRecord(fromX, fromY, toX, toY, durationMillis) val effectiveDuration = if (durationMillis > 0) durationMillis else 250L runAdb( listOf( - "shell", "input", "swipe", - fromX.toString(), fromY.toString(), - toX.toString(), toY.toString(), + "shell", + "input", + "swipe", + fromX.toString(), + fromY.toString(), + toX.toString(), + toY.toString(), effectiveDuration.toString(), ), ) @@ -473,51 +544,71 @@ class StubDriverBackend( override fun pressKey(key: String) { lastKey = key val keyCode = KEY_MAP[key.lowercase()] - ?: throw IllegalArgumentException("unsupported pressKey value: $key") + ?: throw IllegalArgumentException( + "unsupported pressKey value: $key", + ) runAdb(listOf("shell", "input", "keyevent", keyCode)) } override fun longPress(x: Int, y: Int) { lastLongPress = x to y - runAdb(listOf("shell", "input", "swipe", x.toString(), y.toString(), x.toString(), y.toString(), "600")) + runAdb( + listOf( + "shell", + "input", + "swipe", + x.toString(), + y.toString(), + x.toString(), + y.toString(), + "600", + ), + ) } - override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = - readLogcat(null, sinceUnixMillis, minLevel) + override fun recentLogs( + sinceUnixMillis: Long, + minLevel: String, + ): List = readLogcat(null, sinceUnixMillis, minLevel) - data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long) + data class SwipeRecord( + val fromX: Int, + val fromY: Int, + val toX: Int, + val toY: Int, + val durationMillis: Long, + ) private fun runAdb(arguments: List) = commandRunner(arguments) - override fun screenshot(): Triple { - return try { - val process = ProcessBuilder(listOf("adb", "exec-out", "screencap", "-p")) - .redirectErrorStream(false) - .start() - val png = process.inputStream.readAllBytes() - process.waitFor() - if (png.isEmpty()) Triple(ByteArray(0), 0, 0) else Triple(png, 0, 0) - } catch (cause: Exception) { - println("adb screencap failed: $cause") - Triple(ByteArray(0), 0, 0) - } + override fun screenshot(): Triple = try { + val process = ProcessBuilder( + listOf("adb", "exec-out", "screencap", "-p"), + ) + .redirectErrorStream(false) + .start() + val png = process.inputStream.readAllBytes() + process.waitFor() + if (png.isEmpty()) Triple(ByteArray(0), 0, 0) else Triple(png, 0, 0) + } catch (cause: Exception) { + println("adb screencap failed: $cause") + Triple(ByteArray(0), 0, 0) } - override fun hierarchy(): String { - return try { - val process = ProcessBuilder( - listOf( - "adb", "exec-out", - "uiautomator dump /data/local/tmp/window_dump.xml >/dev/null 2>&1 && cat /data/local/tmp/window_dump.xml", - ), - ).redirectErrorStream(false).start() - val output = process.inputStream.bufferedReader().readText() - process.waitFor() - if (output.isBlank()) "" else output - } catch (cause: Exception) { - println("adb uiautomator dump failed: $cause") - "" - } + override fun hierarchy(): String = try { + val process = ProcessBuilder( + listOf( + "adb", + "exec-out", + "uiautomator dump /data/local/tmp/window_dump.xml >/dev/null 2>&1 && cat /data/local/tmp/window_dump.xml", + ), + ).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor() + if (output.isBlank()) "" else output + } catch (cause: Exception) { + println("adb uiautomator dump failed: $cause") + "" } override fun waitForIdle(durationMillis: Long) { @@ -532,35 +623,160 @@ class StubDriverBackend( if (isDeviceIdle()) break Thread.sleep(IDLE_POLL_INTERVAL_MILLIS) } - pollUntilStable(STABILITY_POLL_CAP_MILLIS) { stabilitySnapshot(hierarchy()) } + pollUntilStable(STABILITY_POLL_CAP_MILLIS) { + stabilitySnapshot(hierarchy()) + } } - private fun isDeviceIdle(): Boolean { - return try { - val output = adbOutput(null, listOf("shell", "dumpsys window -a | grep -c mAnimating=true")) - isAnimationCountIdle(output) - } catch (cause: Exception) { - false - } + private fun isDeviceIdle(): Boolean = try { + val output = + adbOutput( + null, + listOf( + "shell", + "dumpsys window -a | grep -c mAnimating=true", + ), + ) + isAnimationCountIdle(output) + } catch (cause: Exception) { + false } override fun healthy(): Boolean = true - override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId) + override fun metrics(bundleId: String): MetricsSample = + readProcMetrics(null, bundleId) +} + +// FAST_INPUT_SAFE matches text that can be typed with adb `input text`: ASCII, +// free of shell metacharacters and spaces, regardless of length. Anything else +// (unicode, injection payloads, whitespace) falls back to the driver path. The +// first character excludes '-' so the text can never be read as an option by +// `input text`. Length is unbounded on purpose: the slow per-character driver +// path takes ~120s for a 4096-char string (blowing the RPC deadline) and leaves +// focus unguarded long enough to spray keystrokes into the launcher search box +// if the app loses the foreground mid-type; the shell path types in chunks with +// a foreground re-check between them (see typeShellSafe). +internal val FAST_INPUT_SAFE = Regex("^[A-Za-z0-9@._+][A-Za-z0-9@._+-]*$") + +// INPUT_CHUNK_CHARS bounds each `input text` shell invocation so a long string +// is typed as a series of short, interruptible commands rather than one opaque +// ~18s call. Small enough that a foreground re-check between chunks catches a +// focus escape early; large enough that the per-chunk dumpsys cost stays minor. +internal const val INPUT_CHUNK_CHARS = 512 + +// chunkForInput splits text into pieces of at most `size` characters, never +// ending a piece right before a '-': a chunk that began with '-' would be read +// as an option by `input text`. The whole string's first character is already +// guaranteed non-'-' by FAST_INPUT_SAFE, so the first chunk is always safe too. +internal fun chunkForInput(text: String, size: Int): List { + require(size > 0) + val chunks = mutableListOf() + var start = 0 + while (start < text.length) { + var end = minOf(start + size, text.length) + while (end < text.length && text[end] == '-') end++ + chunks.add(text.substring(start, end)) + start = end + } + return chunks +} + +// typeChunks sends each chunk, stopping if the foreground owner changed from +// startOwner so the remaining keystrokes can't spray into a window that stole +// focus. The first chunk always sends; a null startOwner skips the check. +internal fun typeChunks( + chunks: List, + startOwner: String?, + currentForeground: () -> String?, + send: (String) -> Unit, +): Int { + var typed = 0 + for (chunk in chunks) { + if (startOwner != null && typed > 0 && + currentForeground() != startOwner + ) { + return typed + } + send(chunk) + typed += chunk.length + } + return typed +} + +// resumedActivityPackage matches a "package/activity" component, mirroring the +// Go scope guard's regex so both read the same dumpsys wording. +private val resumedActivityPackage = + Regex("""([a-zA-Z][a-zA-Z0-9_.]*)/[a-zA-Z0-9_.$]+""") + +// parseResumedPackage reads the foreground package off any *ResumedActivity line, +// matching the Go guard's marker set so OEM wording can't disable the mid-type +// guard. Null if none present. +internal fun parseResumedPackage(dumpsys: String): String? { + for (line in dumpsys.lineSequence()) { + if (!line.contains("ResumedActivity")) continue + resumedActivityPackage.find(line)?.let { return it.groupValues[1] } + } + return null +} + +// DRIVER_OPEN_ATTEMPTS / DRIVER_OPEN_BACKOFF_MILLIS tune the retry around the +// maestro Android driver's occasional startup timeout (its instrumentation host +// can miss the open() deadline right after a reboot or per-run reinstall). +internal const val DRIVER_OPEN_ATTEMPTS = 4 +internal const val DRIVER_OPEN_BACKOFF_MILLIS = 2000L + +// retryOpen runs open() up to `attempts` times, sleeping `backoffMillis` between +// tries and rethrowing the last failure if none succeed. It holds no driver +// state (sleep and log are injectable) so the retry policy is unit testable. +internal fun retryOpen( + attempts: Int, + backoffMillis: Long, + sleep: (Long) -> Unit = { Thread.sleep(it) }, + log: (String) -> Unit = { System.err.println(it) }, + open: () -> T, +): T { + var lastError: Exception? = null + for (attempt in 1..attempts) { + try { + return open() + } catch (cause: Exception) { + lastError = cause + if (attempt == attempts) break + log( + "android driver open failed (attempt $attempt/$attempts): ${cause.message}; retrying", + ) + sleep(backoffMillis) + } + } + throw lastError + ?: IllegalStateException("retryOpen called with attempts=$attempts") } class MaestroDriverBackend(private val serial: String?) : DriverBackend { - private val dadb: dadb.Dadb - private val driver: maestro.drivers.AndroidDriver + private val dadb: dadb.Dadb = buildDadb(serial) - init { - dadb = buildDadb(serial) - val hostPort = java.net.ServerSocket(0).use { it.localPort } - driver = maestro.drivers.AndroidDriver(dadb, hostPort) - driver.open() - } + // A fresh AndroidDriver per open attempt. Its gRPC channel is built once in + // the constructor and permanently shut down by close(), so reopening a + // closed instance would reuse a dead channel; rebuild it each try instead. + private val driver: maestro.drivers.AndroidDriver = + retryOpen(DRIVER_OPEN_ATTEMPTS, DRIVER_OPEN_BACKOFF_MILLIS) { + val hostPort = java.net.ServerSocket(0).use { it.localPort } + val candidate = maestro.drivers.AndroidDriver(dadb, hostPort) + try { + candidate.open() + candidate + } catch (cause: Exception) { + runCatching { candidate.close() } + throw cause + } + } - override fun launch(bundleId: String, clearState: Boolean, env: Map) { + override fun launch( + bundleId: String, + clearState: Boolean, + env: Map, + ) { if (clearState) driver.clearAppState(bundleId) driver.launchApp(bundleId, env) } @@ -569,25 +785,79 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y)) - override fun longPress(x: Int, y: Int) = driver.longPress(maestro.Point(x, y)) + override fun longPress(x: Int, y: Int) = + driver.longPress(maestro.Point(x, y)) override fun tapSelector(selector: String) { val root = driver.contentDescriptor(false) val bounds = findBoundsBySelector(root, selector) ?: return - driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2)) + driver.tap( + maestro.Point( + (bounds[0] + bounds[2]) / 2, + (bounds[1] + bounds[3]) / 2, + ), + ) } - override fun inputText(text: String) = driver.inputText(text) + override fun inputText(text: String) { + if (FAST_INPUT_SAFE.matches(text)) { + // adb `input text` is far faster than the driver's per-character + // path. Restricted to shell-safe ASCII so unicode and injection + // payloads still go through the driver, which handles them. The + // runner focuses the field with a tap before InputText, so the + // keystrokes land in it. + typeShellSafe(text) + } else { + driver.inputText(text) + } + } - override fun eraseText(characterCount: Int) = driver.eraseText(characterCount) + // typeShellSafe types shell-safe ASCII through adb `input text` in chunks, + // re-checking the foreground app before each chunk. If the app the type + // started in has lost the foreground, the remaining keystrokes would spray + // into whatever window stole it (the launcher search box, in practice), so + // typing stops instead of leaking out of the app under test. + private fun typeShellSafe(text: String) { + val owner = foregroundPackage() + val typed = + typeChunks(chunkForInput(text, INPUT_CHUNK_CHARS), owner, { + foregroundPackage() + }) { chunk -> + dadb.shell("input text $chunk") + } + if (typed < text.length) { + System.err.println( + "warn: inputText stopped; foreground left $owner mid-type after $typed/${text.length} chars", + ) + } + } - override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = - driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L)) + // foregroundPackage returns the package of the top resumed activity, or null + // if it cannot be read. Used to detect mid-type focus escapes. + private fun foregroundPackage(): String? = parseResumedPackage( + adbOutput( + serial, + listOf("shell", "dumpsys", "activity", "activities"), + ), + ) + + override fun eraseText(characterCount: Int) = + driver.eraseText(characterCount) + + override fun swipe( + fromX: Int, + fromY: Int, + toX: Int, + toY: Int, + durationMillis: Long, + ) = driver.swipe( + maestro.Point(fromX, fromY), + maestro.Point(toX, toY), + maxOf(durationMillis, 250L), + ) override fun pressKey(key: String) { - StubDriverBackend.KEY_MAP[key]?.let { keyCode -> - keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) } - } + maestroKeyFor(key)?.let { driver.pressKey(it) } } override fun screenshot(): Triple { @@ -598,29 +868,25 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { } override fun hierarchy(): String = - com.fasterxml.jackson.module.kotlin.jacksonObjectMapper().writeValueAsString(driver.contentDescriptor(false)) + jsonMapper.writeValueAsString(driver.contentDescriptor(false)) override fun recentLogs(sinceUnixMillis: Long, minLevel: String) = readLogcat(serial, sinceUnixMillis, minLevel) override fun waitForIdle(durationMillis: Long) { - // waitForAppToSettle returns early on Compose cross-fade transitions - // where both source and destination composables are semantically - // alive; a follow-up short structural-hash poll lands on a single - // stable frame before the runner reads hierarchy + screenshot - // concurrently. The structural poll is hard-capped independently of - // durationMillis so we don't pile on hierarchy fetches when settle - // never converges. + // waitForAppToSettle blocks on the View-system animation and maestro's + // own structural settle, which is enough on its own. A follow-up + // structural-hash poll used to run here, but each hierarchy fetch is + // ~500ms on a physical device, so it cost ~2.8s per mutating step for + // marginal benefit; the runner already re-fetches while a frame still + // looks transitional. driver.waitForAppToSettle(null, null, durationMillis.toInt()) - pollUntilStable(STABILITY_POLL_CAP_MILLIS) { - stabilitySnapshot( - com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() - .writeValueAsString(driver.contentDescriptor(false)), - ) - } } - override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false } + override fun healthy() = runCatching { + driver.contentDescriptor(false) + true + }.getOrElse { false } override fun metrics(bundleId: String) = readProcMetrics(serial, bundleId) @@ -630,15 +896,48 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { } } -private fun buildDadb(serial: String?): dadb.Dadb { - return if (serial == null) { - dadb.Dadb.create("localhost", 5555) +internal sealed interface DadbTarget { + data class Tcp(val host: String, val port: Int) : DadbTarget + data class Server(val serial: String) : DadbTarget +} + +// A host:port serial connects to adbd directly; any other serial is reached +// through the adb server, the only path to a USB-attached device. A null serial +// keeps the emulator loopback default. +internal fun dadbTargetFor(serial: String?): DadbTarget { + if (serial == null) return DadbTarget.Tcp("localhost", 5555) + val colon = serial.lastIndexOf(':') + val port = if (colon >= + 0 + ) { + serial.substring(colon + 1).toIntOrNull() } else { - dadb.Dadb.create(serial.substringBefore(":"), serial.substringAfter(":").toIntOrNull() ?: 5555) + null + } + return if (port != + null + ) { + DadbTarget.Tcp(serial.substring(0, colon), port) + } else { + DadbTarget.Server(serial) } } -internal fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? { +private fun buildDadb(serial: String?): dadb.Dadb = + when (val target = dadbTargetFor(serial)) { + is DadbTarget.Tcp -> dadb.Dadb.create(target.host, target.port) + + is DadbTarget.Server -> dadb.adbserver.AdbServer.createDadb( + "localhost", + 5037, + "host:transport:${target.serial}", + ) + } + +internal fun findBoundsBySelector( + root: maestro.TreeNode, + selector: String, +): IntArray? { val colon = selector.indexOf(':') if (colon < 0) return null val kind = selector.substring(0, colon) @@ -646,13 +945,25 @@ internal fun findBoundsBySelector(root: maestro.TreeNode, selector: String): Int return findBoundsInTree(root, kind, value) } -internal fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String): IntArray? { +internal fun findBoundsInTree( + node: maestro.TreeNode, + kind: String, + value: String, +): IntArray? { val attrs = node.attributes val matches = when (kind) { - "id" -> attrs["resource-id"]?.let { it == value || it.endsWith(":id/$value") } == true + "id" -> attrs["resource-id"]?.let { + it == value || + it.endsWith(":id/$value") + } == + true + "text" -> attrs["text"] == value + "desc" -> attrs["content-desc"] == value + "descPrefix" -> attrs["content-desc"]?.startsWith(value) == true + else -> false } if (matches) { @@ -672,13 +983,15 @@ internal fun parseBounds(s: String): IntArray? { internal fun pngWidth(bytes: ByteArray): Int { if (bytes.size < 24) return 0 - return (bytes[16].toInt() and 0xFF shl 24) or (bytes[17].toInt() and 0xFF shl 16) or + return (bytes[16].toInt() and 0xFF shl 24) or + (bytes[17].toInt() and 0xFF shl 16) or (bytes[18].toInt() and 0xFF shl 8) or (bytes[19].toInt() and 0xFF) } internal fun pngHeight(bytes: ByteArray): Int { if (bytes.size < 24) return 0 - return (bytes[20].toInt() and 0xFF shl 24) or (bytes[21].toInt() and 0xFF shl 16) or + return (bytes[20].toInt() and 0xFF shl 24) or + (bytes[21].toInt() and 0xFF shl 16) or (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF) } @@ -689,9 +1002,24 @@ internal const val IOS_XCTEST_RUNNER_BUNDLE_ID = "dev.mobile.maestro-driver-iosU // session alive; xcodebuild later restarts its dead runner, which terminates // the active run's session and steals the simulator's gesture daemon. Returns // true when an orphaned xcodebuild session was found and killed. -internal fun reapOrphanIosRunners(udid: String, execute: (List) -> Int): Boolean { - val killed = execute(listOf("pkill", "-f", "xcodebuild.*test-without-building.*$udid")) == 0 - execute(listOf("xcrun", "simctl", "terminate", udid, IOS_XCTEST_RUNNER_BUNDLE_ID)) +internal fun reapOrphanIosRunners( + udid: String, + execute: (List) -> Int, +): Boolean { + val killed = + execute( + listOf("pkill", "-f", "xcodebuild.*test-without-building.*$udid"), + ) == + 0 + execute( + listOf( + "xcrun", + "simctl", + "terminate", + udid, + IOS_XCTEST_RUNNER_BUNDLE_ID, + ), + ) return killed } @@ -713,30 +1041,34 @@ internal class WdaRecovery( // can fail client-side after the device already applied it, so replaying // types text or taps twice. Non-idempotent actions surface UNAVAILABLE, // which the runner treats as transient. - fun run(replay: Boolean, block: () -> T): T { - return try { + fun run(replay: Boolean, block: () -> T): T = try { + block() + } catch (e: Exception) { + if (!isIoFailure(e)) throw e + recover(e) + if (!replay) { + throw io.grpc.Status.UNAVAILABLE + .withDescription( + "connection dropped mid-action; the action may have applied: ${e.message}", + ) + .withCause(e).asRuntimeException() + } + try { block() - } catch (e: Exception) { - if (!isIoFailure(e)) throw e - recover(e) - if (!replay) { - throw io.grpc.Status.UNAVAILABLE - .withDescription("connection dropped mid-action; the action may have applied: ${e.message}") - .withCause(e).asRuntimeException() - } - try { - block() - } catch (retryErr: Exception) { - if (!isIoFailure(retryErr)) throw retryErr - throw io.grpc.Status.UNAVAILABLE - .withDescription("read retry failed after channel recovery: ${retryErr.message}") - .withCause(retryErr).asRuntimeException() - } + } catch (retryErr: Exception) { + if (!isIoFailure(retryErr)) throw retryErr + throw io.grpc.Status.UNAVAILABLE + .withDescription( + "read retry failed after channel recovery: ${retryErr.message}", + ) + .withCause(retryErr).asRuntimeException() } } private fun isIoFailure(e: Exception): Boolean = - generateSequence(e as Throwable) { it.cause }.any { it is java.io.IOException } + generateSequence(e as Throwable) { + it.cause + }.any { it is java.io.IOException } private fun recover(cause: Exception) { lock.lock() @@ -750,17 +1082,30 @@ internal class WdaRecovery( try { restart() } catch (restartErr: Exception) { - throw IllegalStateException("WDA reconnect failed: $restartErr", cause) + throw IllegalStateException( + "WDA reconnect failed: $restartErr", + cause, + ) } - log("XCTest runner restarted in ${System.currentTimeMillis() - startedAt} ms") + log( + "XCTest runner restarted in ${System.currentTimeMillis() - startedAt} ms", + ) } finally { lock.unlock() } } } -private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? { - return when (adbKeyCode) { +// maestroKeyFor rejects an unknown key (matching StubDriverBackend) so an +// unmapped or wrong-case key fails loudly instead of being silently dropped. +internal fun maestroKeyFor(key: String): maestro.KeyCode? { + val keyCode = StubDriverBackend.KEY_MAP[key.lowercase()] + ?: throw IllegalArgumentException("unsupported pressKey value: $key") + return keyCodeToMaestro(keyCode) +} + +private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? = + when (adbKeyCode) { "KEYCODE_BACK" -> maestro.KeyCode.BACK "KEYCODE_HOME" -> maestro.KeyCode.HOME "KEYCODE_ENTER" -> maestro.KeyCode.ENTER @@ -771,4 +1116,3 @@ private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? { "KEYCODE_DPAD_RIGHT" -> maestro.KeyCode.REMOTE_RIGHT else -> null } -} diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DadbTargetTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DadbTargetTest.kt new file mode 100644 index 0000000..7db71f8 --- /dev/null +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DadbTargetTest.kt @@ -0,0 +1,25 @@ +package dev.sanderling.sidecar + +import org.junit.Test +import kotlin.test.assertEquals + +class DadbTargetTest { + + @Test fun nullSerialDefaultsToEmulatorLoopback() { + assertEquals(DadbTarget.Tcp("localhost", 5555), dadbTargetFor(null)) + } + + @Test fun hostPortSerialConnectsDirectly() { + assertEquals(DadbTarget.Tcp("192.168.1.243", 5555), dadbTargetFor("192.168.1.243:5555")) + } + + @Test fun usbSerialRoutesThroughAdbServer() { + assertEquals(DadbTarget.Server("663c91b1"), dadbTargetFor("663c91b1")) + } + + // A colon with a non-numeric port is a USB serial that merely contains a + // colon, not a host:port, so it must route through the adb server. + @Test fun colonWithNonNumericPortIsAServerSerial() { + assertEquals(DadbTarget.Server("emulator:5554x"), dadbTargetFor("emulator:5554x")) + } +} diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt index 36eb3c9..c466d00 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt @@ -7,6 +7,68 @@ import kotlin.test.assertTrue class InputTextTest { + // The fast `adb input text` path handles shell-safe ASCII of any length; + // unicode, injection payloads, and whitespace must fall back to the driver, + // which types them correctly. The overflow-length string (4096 a's) is pure + // ASCII and MUST take the fast path: the per-character driver path takes + // ~120s for it, blowing the RPC deadline and leaving focus unguarded long + // enough for keystrokes to spray into the launcher search box. A regression + // here would corrupt edge-case input or shell-inject the device. + @Test fun fastInputPathAcceptsShellSafeAsciiOfAnyLength() { + for (safe in listOf( + "demo@folio.app", + "ledger123", + "Checking", + "1e10", + "0.0000001", + "42", + "a".repeat(4096), + )) { + assertTrue( + FAST_INPUT_SAFE.matches(safe), + "expected fast path for length ${safe.length}", + ) + } + val fallback = listOf( + "Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--", + "", "../../etc/passwd", "%s%n", "", + "-1", "-rf", // a leading dash could be read as an option by `input text` + ) + for (text in fallback) { + assertTrue( + !FAST_INPUT_SAFE.matches(text), + "expected driver fallback for: $text", + ) + } + } + + // chunkForInput must split long input but never start a chunk with '-', + // which `input text` would read as an option flag. + @Test fun chunkForInputSplitsToSizeAndReassembles() { + val text = "a".repeat(4096) + val chunks = chunkForInput(text, 512) + assertEquals(text, chunks.joinToString("")) + assertTrue( + chunks.all { + it.length <= 512 + }, + "no chunk may exceed the size", + ) + assertTrue(chunks.size >= 8, "4096/512 should be at least 8 chunks") + } + + @Test fun chunkForInputNeverStartsAChunkWithDash() { + // a boundary that would fall on '-' is pushed past the dashes + val chunks = chunkForInput("ab--cd", 2) + assertEquals("ab--cd", chunks.joinToString("")) + assertTrue( + chunks.drop(1).none { + it.startsWith("-") + }, + "no later chunk may start with '-'", + ) + } + // A logical key name must map to the right Android keycode; a typo'd table // entry would silently dispatch the wrong key (e.g. 'back' issuing HOME). @Test fun pressKeyDispatchesMappedKeycode() { @@ -18,7 +80,11 @@ class InputTextTest { for ((key, keycode) in cases) { val commands = mutableListOf>() StubDriverBackend("android") { commands.add(it) }.pressKey(key) - assertEquals(listOf(listOf("shell", "input", "keyevent", keycode)), commands, key) + assertEquals( + listOf(listOf("shell", "input", "keyevent", keycode)), + commands, + key, + ) } } @@ -35,7 +101,10 @@ class InputTextTest { backend.inputText("Emergency Fund") - assertEquals(listOf(listOf("shell", "input", "text", "Emergency%sFund")), commands) + assertEquals( + listOf(listOf("shell", "input", "text", "Emergency%sFund")), + commands, + ) } @Test fun eraseTextSendsOneDeleteKeyPerCharacter() { @@ -51,7 +120,10 @@ class InputTextTest { } @Test fun escapeForAdbInputTextSubstitutesSpaces() { - assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world")) + assertEquals( + "hello%sworld", + StubDriverBackend.escapeForAdbInputText("hello world"), + ) } @Test fun escapeForAdbInputTextEscapesShellMetacharacters() { @@ -67,7 +139,77 @@ class InputTextTest { @Test fun escapeForAdbInputTextLeavesSimpleTextAlone() { assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34")) - assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee")) + assertEquals( + "Coffee", + StubDriverBackend.escapeForAdbInputText("Coffee"), + ) assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5")) } + + @Test fun typeChunksSendsAllChunksWhenForegroundStable() { + val sent = mutableListOf() + val typed = + typeChunks(listOf("aaa", "bbb", "cc"), "app.folio", { + "app.folio" + }) { sent.add(it) } + assertEquals(listOf("aaa", "bbb", "cc"), sent) + assertEquals(8, typed) + } + + @Test fun typeChunksStopsWhenForegroundLeavesMidType() { + val sent = mutableListOf() + var calls = 0 + // Foreground holds before chunk 2, then goes foreign before chunk 3. + val typed = typeChunks(listOf("aaa", "bbb", "ccc"), "app.folio", { + calls++ + if (calls == 1) "app.folio" else "com.android.launcher" + }) { sent.add(it) } + assertEquals( + listOf("aaa", "bbb"), + sent, + "typing must stop at the chunk after focus left", + ) + assertEquals(6, typed) + } + + @Test fun typeChunksAlwaysSendsFirstChunkEvenIfForegroundAlreadyForeign() { + val sent = mutableListOf() + val typed = + typeChunks(listOf("aaa", "bbb"), "app.folio", { + "com.android.launcher" + }) { sent.add(it) } + assertEquals(listOf("aaa"), sent) + assertEquals(3, typed) + } + + @Test fun typeChunksWithUnknownOwnerSendsEverything() { + val sent = mutableListOf() + typeChunks(listOf("aaa", "bbb"), null, { "anything" }) { sent.add(it) } + assertEquals(listOf("aaa", "bbb"), sent) + } + + @Test fun parseResumedPackageReadsEachResumedActivityWording() { + val cases = mapOf( + " topResumedActivity=ActivityRecord{8b u0 app.folio/.MainActivity t42}" to + "app.folio", + " mResumedActivity: ActivityRecord{1c u0 com.example.app/.Home t9}" to + "com.example.app", + " ResumedActivity: ActivityRecord{2d u0 app.folio/com.folio.Detail t9}" to + "app.folio", + ) + for ((line, want) in cases) { + assertEquals(want, parseResumedPackage(line), line) + } + assertEquals( + null, + parseResumedPackage(" mFocusedApp=null\n no resumed line here"), + ) + } + + @Test fun maestroKeyForResolvesAndRejects() { + assertEquals(maestro.KeyCode.BACK, maestroKeyFor("back")) + assertEquals(maestro.KeyCode.BACK, maestroKeyFor("BACK")) + assertEquals(maestro.KeyCode.ENTER, maestroKeyFor("enter")) + assertFailsWith { maestroKeyFor("zorp") } + } } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/RetryOpenTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/RetryOpenTest.kt new file mode 100644 index 0000000..88c0011 --- /dev/null +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/RetryOpenTest.kt @@ -0,0 +1,51 @@ +package dev.sanderling.sidecar + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class RetryOpenTest { + + @Test fun succeedsOnFirstAttemptWithoutSleeping() { + var opens = 0 + var sleeps = 0 + val result = retryOpen(4, 2000L, sleep = { sleeps++ }, log = {}) { + opens++ + "driver" + } + assertEquals("driver", result) + assertEquals(1, opens) + assertEquals(0, sleeps, "a first-attempt success must not sleep") + } + + @Test fun retriesUntilSuccessThenStops() { + var opens = 0 + var sleeps = 0 + val result = retryOpen(4, 2000L, sleep = { sleeps++ }, log = {}) { + opens++ + if (opens < 3) throw RuntimeException("instrumentation host not up") + "driver" + } + assertEquals("driver", result) + assertEquals(3, opens) + assertEquals( + 2, + sleeps, + "slept after attempts 1 and 2, not after the success", + ) + } + + @Test fun rethrowsLastErrorAfterExhaustingAttempts() { + var opens = 0 + var sleeps = 0 + val error = assertFailsWith { + retryOpen(3, 2000L, sleep = { sleeps++ }, log = {}) { + opens++ + throw IllegalStateException("attempt $opens failed") + } + } + assertEquals("attempt 3 failed", error.message) + assertEquals(3, opens) + assertEquals(2, sleeps, "no sleep after the final failing attempt") + } +}