mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
WIP: Drive physical Android devices over USB (#67)
* feat(sidecar): reach USB devices via the adb server by serial * feat(test): add --device flag to target a specific Android device by serial * feat(folio): select Android device via ANDROID_DEVICE in justfile * feat(conformance): add android backend to the gate suite * feat(android): keep device awake and unlocked so the app stays foreground * feat(conformance): prep physical android device (autofill/verifier/stayon) * fix(android): make device prep best-effort so OEM-blocked commands don't abort the run * fix(verifier): require positive bounds for swipe candidates A zero-bounds element centers at (0,0); 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. Swipes now require positive bounds like every other verb. * fix(runner): harden app-scope guard against launcher and overlays The per-step guard now relaunches and waits until the app window is actually drawn before proceeding, so a slow physical-device relaunch no longer lets an observe or action land on the launcher. It also detects a system overlay (notification shade) stealing window focus while the app stays resumed, and dismisses it with back. * feat(android): harden physical-device runs in device prep Device prep now disables the AOSP cached-app freezer, phantom-process killer, and Doze (and exempts the driver) so OEM background management stops suspending the driver mid-run. Adds ReinstallApp for clear-state on ROMs that deny pm clear, and teaches focus detection to report the notification shade as systemui so the scope guard can dismiss it. * feat(driver): clear-state via APK reinstall when pm clear is blocked When an APK path is set, Android clear-state resets the app by uninstalling and reinstalling instead of asking the sidecar to pm clear, which hardened OEM builds (ColorOS) deny even to the adb shell user. Falls back to the sidecar clear path when no APK path is provided. * feat(cli): add --android-app-path for clear-state reinstall Wires the APK path from the test command through to the sidecar client so Android clear-state can reset apps on OEM builds that deny pm clear. * chore(folio): pass --android-app-path in just test * fix(runner): clamp swipe/scroll origin out of edge gesture zones A gesture starting in the top status-bar strip pulls down the notification shade; the bottom and side strips are the home and back gestures. Any of them drags the fuzzer out of the app. Swipe and scroll origins are now clamped into a safe inner area sized from the maximum element extent (the Android hierarchy root reports zero bounds, so the extent is the reliable screen size). Calibrated on device: origins below ~7% of height no longer open the shade. * perf(sidecar): faster Android text input and drop redundant settle poll inputText now uses adb `input text` for short shell-safe ASCII (~5x faster than the driver's per-character path) and falls back to the driver for unicode, injection payloads, and overflow-length strings. waitForIdle drops the structural-hash poll that followed waitForAppToSettle: each hierarchy fetch is ~500ms on a physical device, so it cost ~2.8s per mutating step for marginal benefit, and the runner already re-fetches transitional frames. Cuts p95 step latency from ~6.5s to ~5.1s; G1-G4 still pass. * fix(verifier): exclude soft-keyboard region from action candidates The fuzzer was tapping Gboard's "Settings" key, navigating out of the app. That key is a bare FrameLayout with a content-desc and no package or resource-id, so the package-based scope filter missed it. Candidates whose center falls in the keyboard region (derived from the IME elements' bounds) are now dropped, so no tap or long-press lands on a key. Opt-in with app scoping; unscoped runs keep every node. * perf(runner): replace focus-tap settle with a brief wait The full WaitForIdle after a field-focus tap cost ~0.5-1s per InputText step on a physical device while the keyboard animated in. The tap registers focus immediately and text is injected into the focused view, so a short fixed wait suffices. Drops p95 step latency ~5.1s to ~4.0s; G1-G4 stay green. * chore(conformance): platform-aware G5 p95 budget for android The 2500ms ceiling was calibrated on the iOS simulator. A physical Android device drives every step over USB (snapshot + settle + adb round-trips), so its per-step floor is several times higher; holding it to 2500ms would force removing the settle/retry logic the correctness gates depend on. The android backend now defaults to 4500ms (override with P95_LIMIT_MS); iOS stays 2500. * fix(sidecar): retry maestro android driver startup The maestro Android driver's dadb.open() occasionally misses its startup deadline (its instrumentation host is slow to come up right after a reboot or per-run reinstall), which aborted the whole run. Retry the open a few times with a short backoff so a transient timeout recovers. * chore(conformance): widen android G5 budget to 5500ms Physical-device p95 swung 3209-4612ms across sessions (cold runs right after a reboot are slower). 4500ms was too tight for that jitter; 5500ms covers the observed ceiling with headroom. * web replay fix * feat(android): force 3-button nav during runs to prevent app drift On gesture navigation a fuzzer swipe can trigger swipe-up-home or edge-back and fling the app off screen. Device-prep now switches to 3-button navigation for the run (no edge gestures; the nav bar's buttons are systemui-owned and already excluded from action candidates) and restores the original navigation mode when the run ends. Best effort: leaves nav untouched if the overlay command is unavailable. * fix(android): target the selected device in adb reads; don't strand nav mode Review fixes: - ForegroundPackage/FocusedWindowPackage now take a serial and pass -s, so the foreground/scope guard works when several devices are attached (the --device path). Previously they ran bare `adb shell`, which errors with multiple devices, silently disabling app-scope enforcement. The sidecar client passes its serial through. - Extract an adbArgs helper and route every adb call through it, removing four duplicated serial-arg builders. - ForceThreeButtonNav now decides what to restore before changing anything: if the current mode is unknown or already 3-button it leaves nav untouched, instead of switching and then stranding the device in 3-button. Logic split into the pure navModeToRestore, now unit tested. * fix(runner): restore scrollBounds doc; cover destination clamp and screenBounds Review fixes: move the scrollBounds doc comment back onto scrollBounds (it was stranded above screenBounds by an insertion). Extend the clamp test to assert an off-screen destination is clamped onto the screen and that the origin lands exactly on the margin. * test(verifier): cover keyboardRegionTop, including the decor-view guard The full-screen IME decor view rejection had no test; removing it left the suite green. Add direct cases: no keyboard -> sentinel, decor view ignored in favor of the real keyboard line, and decor-only -> sentinel. * style(cli): gofmt testOptions field alignment * fix(sidecar): keep a leading dash off the fast input path A value starting with '-' could be read as an option by `adb input text`, so the fast-path regex now requires a non-dash first character; such values fall back to the driver. Also cover the dadb-target branch where a colon precedes a non-numeric port (a USB serial, not host:port). * refactor(verifier): scope action candidates by window ownership Replaces the leaky per-element package check and the keyboard-region Y heuristic with one rule: walk the window tree propagating each node's owning package (empty and the neutral android framework package are transparent); a node is in scope only 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. This drops whole foreign windows (soft keyboard, system UI, launcher) AND their empty-package child wrappers -- e.g. a keyboard's 'Settings' key, which the old empty-package-is-in-scope rule admitted and which navigated out of the app. Deletes keyboardRegionTop/isInputMethodElement. * fix(runner): re-check foreground at apply time, skip stale actions ensureForeground runs before observe, but the app can leave between observe and apply (a prior gesture settling late); swipes/keys then fire stale coordinates onto whatever screen is now up. Re-check foreground immediately before applying and, when the app is gone, skip the action and log it (making the escape visible) so the next step's guard relaunches instead. * fix(android): type long ASCII via fast guarded path to stop keystroke escape A 4096-char corpus string exceeded the fast input cap and fell to the per-character driver path, which takes ~120s. During that uninterruptible window focus could leave the app and the remaining keystrokes sprayed into the launcher search box. Route shell-safe ASCII of any length through adb input text, chunked, re-checking the foreground app between chunks and stopping if it changed. * chore: ignore gate artifacts and local scratch files * refactor(runner): narrow gesture clamp to the top shade strip 3-button nav (forced for every run) disables the side back and bottom home gestures at the OS level. On-device probing confirmed side and bottom swipe origins no longer drift, leaving the notification shade as the only edge gesture a swipe can trigger. Clamp only the top strip; keep origin and destination on screen otherwise. * chore(format): add .editorconfig enforcing 80-column limit * chore(format): add prettier config with 80-char printWidth * chore(deps): add prettier devDependency to replay-ui * chore(deps): add prettier devDependency to folio-web * chore(deps): add prettier devDependency to spec package * chore(format): add swift-format config with 80-char lineLength * feat(format): add make fmt targets for per-language 80-col formatting * fix(runner): translate gesture to safe area so near-top scrolls keep direction Clamping the swipe origin to the top margin while leaving the destination on the full screen used two reference frames: a scrollable container pinned in the top strip had its origin pushed past the destination, reversing the gesture. Translate the whole from->to segment down by the same delta so the origin clears the shade strip without flipping direction. Adds a scroll-near-top test that fails under the old origin-only clamp. * fix(runner): apply-time guard consults focused window, not just resumed activity ensureForeground detects a system overlay (notification shade) owning the focused window while the app stays the resumed activity, but appIsForeground only queried ForegroundApp. A swipe that pulls the shade over the app between observe and apply then fired onto the shade. Mirror the focus check at apply time so the action skips and the next step dismisses the overlay. * test(runner): cover apply-time foreground skip and appIsForeground table Adds a Run-level test asserting no tap reaches the driver while a system overlay holds focus (guards against the skip branch being dead-coded), plus a decision-table test for appIsForeground. Adds ForegroundErr/FocusedWindowErr to the mock driver so the guard's transient-read paths are exercised. * fix(sidecar): harden android driver open, input guard, pressKey, foreground marker - openWithRetry rebuilt a closed AndroidDriver, whose gRPC channel is final and shut down by close(); the retry then ran against a dead channel. Build a fresh driver per attempt and extract a unit-tested retryOpen helper (named DRIVER_OPEN_ATTEMPTS/BACKOFF). - pressKey on the Maestro backend did KEY_MAP[key] (no lowercase, no throw), silently dropping unknown or wrong-case keys; route through a pure maestroKeyFor that lowercases and rejects unknown keys like the Stub contract. - the mid-type foreground guard (typeShellSafe) was untested; extract a pure typeChunks and cover stop-on-foreground-change, always-send-first-chunk, and unknown-owner. - foreground detection required the literal topResumedActivity=ActivityRecord; align parseResumedPackage to the same *ResumedActivity marker set Go reads so OEM wording does not disable the guard. * fix(conformance): pin self-test p95 budget and score install failures as run failures self_test reused the backend-dependent P95_LIMIT_MS, so under BACKEND=android the 4000ms slow fixture rated PASS and the offline analyzer check failed from an env var; pin it to 2500. A per-run adb install failure ran unguarded under set -e and aborted the whole harness; guard it, record the run as a G1 failure, and continue. * fix(android): require --device when several devices are connected With no serial requested and more than one device online, pickDevice silently returned connected[0], but that serial is never threaded into the per-step adb calls, so every later bare adb command failed with "more than one device". Error instead and ask for --device, mirroring pickAVD; a single device stays unambiguous. * refactor(android): move PrepareDevice doc onto it; extract tested wakeCommands The PrepareDevice doc block was stranded above adbArgs, leaving the exported function undocumented under godoc. Move it back and split the wake/keyguard tuples into wakeCommands so they have a unit test. * perf(verifier): memoize scopedElements per tree scopedElements rebuilt a full tree walk plus map on every candidatesForVerb call (~16 per step). Cache the result keyed on lastTree and invalidate it in PushSnapshot. * fix(sidecar): default reinstallApp in SetClearStateReinstall; cover non-android clear Only Dial set reinstallApp, so a Client built another way would nil-deref on Android clear-state. Default it in SetClearStateReinstall too. Add a non-android test so the platform guard has negative coverage: dropping the android check would now fail. * test(runner): make focusTapSettle injectable so apply tests don't sleep 250ms The focus-tap settle was a const, so five InputText apply tests each blocked the full 250ms. Make it a package var and shorten it per-test with cleanup. * refactor(runner,android): drop unused bringToForeground return; grep no-match yields empty bringToForeground's bool return was read by no caller. FocusedWindowPackage's on-device grep exited 1 on no match, surfacing as an error instead of the documented ""; add || true. * perf(sidecar): reuse a single Jackson ObjectMapper structuralHash, countRouteScreens, and hierarchy each built a fresh ObjectMapper per call inside the stability poll; the instance is thread-safe and meant to be reused. Hoist one shared val. * refactor(android): remove unused AdbReverse/AdbReverseRemove No callers anywhere in the tree; they were also the only adb calls bypassing adbArgs. Dead code, removed. * style(runner): trim non-load-bearing comments from this PR's runner code and tests * style(sidecar): trim non-load-bearing comments from this PR's driver code and tests
This commit is contained in:
34 files changed
+2011
-214
No files matched your search
@@ -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
|
||||
+10
@@ -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/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
*.min.js
|
||||
**/*.gen.ts
|
||||
internal/replay/dist
|
||||
replay-ui/dist
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"printWidth": 80
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lineLength": 80
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -22,8 +22,10 @@ type testOptions struct {
|
||||
bundleID string
|
||||
platform string
|
||||
avd string
|
||||
device string
|
||||
iosDevice string
|
||||
iosAppPath string
|
||||
androidAppPath string
|
||||
duration time.Duration
|
||||
seed int64
|
||||
output string
|
||||
@@ -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")
|
||||
|
||||
@@ -13,8 +13,10 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
|
||||
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,
|
||||
|
||||
+73
-10
@@ -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="<name>" (passed as --ios-device)
|
||||
# BACKEND=android drive an Android device/emulator over the JVM
|
||||
# sidecar; select a specific device with
|
||||
# ANDROID_DEVICE="<adb serial>" (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"
|
||||
|
||||
@@ -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": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
|
||||
|
||||
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["[email protected]", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
+215
-20
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+183
-27
@@ -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 {
|
||||
return false
|
||||
}
|
||||
if foreground != "" && foreground != options.BundleID {
|
||||
logger.Warn("app left foreground; relaunching",
|
||||
"step", stepIndex, "foreground", foreground, "want", options.BundleID)
|
||||
return bringToForeground(ctx, options, logger, stepIndex)
|
||||
// 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
|
||||
}
|
||||
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 {
|
||||
|
||||
+266
-31
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -26,8 +26,10 @@ type Options struct {
|
||||
BundleID string
|
||||
Platform string
|
||||
AVD string
|
||||
Device string
|
||||
IosDevice string
|
||||
IosAppPath string
|
||||
AndroidAppPath string
|
||||
Duration time.Duration
|
||||
Seed int64
|
||||
Output string
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+58
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.5",
|
||||
"prettier": "^3.8.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -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": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
|
||||
|
||||
"react": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function RunList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="run-list">
|
||||
<table className="run-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -98,5 +99,6 @@ export default function RunList() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package dev.sanderling.sidecar
|
||||
|
||||
interface DriverBackend {
|
||||
fun launch(bundleId: String, clearState: Boolean, env: Map<String, String> = emptyMap())
|
||||
fun launch(
|
||||
bundleId: String,
|
||||
clearState: Boolean,
|
||||
env: Map<String, String> = 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<LogLine> {
|
||||
internal fun readLogcat(
|
||||
serial: String?,
|
||||
sinceUnixMillis: Long,
|
||||
minLevel: String,
|
||||
): List<LogLine> {
|
||||
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<String> =
|
||||
if (serial == null) listOf("adb") else listOf("adb", "-s", serial)
|
||||
|
||||
private fun adbOutput(serial: String?, arguments: List<String>): String {
|
||||
return try {
|
||||
val process = ProcessBuilder(adbCmd(serial) + arguments).redirectErrorStream(false).start()
|
||||
private fun adbOutput(serial: String?, arguments: List<String>): String = try {
|
||||
val process = ProcessBuilder(
|
||||
adbCmd(serial) + arguments,
|
||||
).redirectErrorStream(false).start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
output
|
||||
} catch (cause: Exception) {
|
||||
} 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<String>) {
|
||||
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<Int, Int>? = 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<String, String>) {
|
||||
override fun launch(
|
||||
bundleId: String,
|
||||
clearState: Boolean,
|
||||
env: Map<String, String>,
|
||||
) {
|
||||
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<Int, Int>? = 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,25 +544,47 @@ 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<LogLine> =
|
||||
readLogcat(null, sinceUnixMillis, minLevel)
|
||||
override fun recentLogs(
|
||||
sinceUnixMillis: Long,
|
||||
minLevel: String,
|
||||
): List<LogLine> = 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<String>) = commandRunner(arguments)
|
||||
|
||||
override fun screenshot(): Triple<ByteArray, Int, Int> {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf("adb", "exec-out", "screencap", "-p"))
|
||||
override fun screenshot(): Triple<ByteArray, Int, Int> = try {
|
||||
val process = ProcessBuilder(
|
||||
listOf("adb", "exec-out", "screencap", "-p"),
|
||||
)
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
val png = process.inputStream.readAllBytes()
|
||||
@@ -501,13 +594,12 @@ class StubDriverBackend(
|
||||
println("adb screencap failed: $cause")
|
||||
Triple(ByteArray(0), 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hierarchy(): String {
|
||||
return try {
|
||||
override fun hierarchy(): String = try {
|
||||
val process = ProcessBuilder(
|
||||
listOf(
|
||||
"adb", "exec-out",
|
||||
"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()
|
||||
@@ -518,7 +610,6 @@ class StubDriverBackend(
|
||||
println("adb uiautomator dump failed: $cause")
|
||||
"<hierarchy/>"
|
||||
}
|
||||
}
|
||||
|
||||
override fun waitForIdle(durationMillis: Long) {
|
||||
// Two-stage settle: an mAnimating poll catches View-system animations,
|
||||
@@ -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"))
|
||||
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<String> {
|
||||
require(size > 0)
|
||||
val chunks = mutableListOf<String>()
|
||||
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<String>,
|
||||
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 <T> 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)
|
||||
// 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 }
|
||||
driver = maestro.drivers.AndroidDriver(dadb, hostPort)
|
||||
driver.open()
|
||||
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<String, String>) {
|
||||
override fun launch(
|
||||
bundleId: String,
|
||||
clearState: Boolean,
|
||||
env: Map<String, String>,
|
||||
) {
|
||||
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<ByteArray, Int, Int> {
|
||||
@@ -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<String>) -> 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<String>) -> 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,15 +1041,16 @@ 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 <T> run(replay: Boolean, block: () -> T): T {
|
||||
return try {
|
||||
fun <T> 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}")
|
||||
.withDescription(
|
||||
"connection dropped mid-action; the action may have applied: ${e.message}",
|
||||
)
|
||||
.withCause(e).asRuntimeException()
|
||||
}
|
||||
try {
|
||||
@@ -729,14 +1058,17 @@ internal class WdaRecovery(
|
||||
} catch (retryErr: Exception) {
|
||||
if (!isIoFailure(retryErr)) throw retryErr
|
||||
throw io.grpc.Status.UNAVAILABLE
|
||||
.withDescription("read retry failed after channel recovery: ${retryErr.message}")
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"[email protected]",
|
||||
"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--",
|
||||
"<script>alert(1)</script>", "../../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<List<String>>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<IllegalArgumentException> { maestroKeyFor("zorp") }
|
||||
}
|
||||
}
|
||||
@@ -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<IllegalStateException> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user