feat(folio): ask which android device to run on when none is named

This commit is contained in:
pj committed 2026-09-05 16:40:27 +05:30
1 parent 8c31e2bf6f
commit 419ed9d1c0
2 files changed
+116 -27

No files matched your search

+15 -8
View File
@@ -23,15 +23,17 @@ example sanderling runs its property-based specs against.
## Android ## Android
```sh ```sh
just install # asks which device, then builds + installs
ANDROID_DEVICE=emulator-5554 just install # build + install on that device ANDROID_DEVICE=emulator-5554 just install # build + install on that device
ANDROID_DEVICE=emulator-5554 just uninstall ANDROID_DEVICE=emulator-5554 just uninstall
just clean just clean
``` ```
`ANDROID_DEVICE` is the serial `adb devices` reports. Every recipe that `ANDROID_DEVICE` is the serial `adb devices` reports. Every recipe that
installs, uninstalls or fuzzes refuses to run without it, unless the only installs, uninstalls or fuzzes acts on that serial. Without it they print the
device adb can see is a single emulator on the local adb server. The refusal online devices and ask which one to use, and pick on their own only when the
prints what adb currently sees. one device adb can see is an emulator on the local adb server. With no terminal
to ask on, a CI job say, the ask becomes a refusal that prints what adb sees.
A run installs the app, clears its state and drives it, which is not something A run installs the app, clears its state and drives it, which is not something
to do to a handset that happens to be the one thing plugged in. An emulator is to do to a handset that happens to be the one thing plugged in. An emulator is
@@ -73,9 +75,9 @@ password: ledger123
ANDROID_DEVICE=emulator-5554 just test ANDROID_DEVICE=emulator-5554 just test
``` ```
The same naming rule as `just install` applies. If nothing is attached at all, The same naming rule as `just install` applies, and `just test` asks once for
`just test` boots a bootable AVD and runs against that. With multiple AVDs, the whole run. If nothing is attached at all, `just test` boots a bootable AVD
pick one: and runs against that. With multiple AVDs, pick one:
```sh ```sh
AVD=Pixel_7 just test AVD=Pixel_7 just test
@@ -90,14 +92,19 @@ DURATION=5m
The device does not have to be attached to this machine. `ADB_SERVER_SOCKET` The device does not have to be attached to this machine. `ADB_SERVER_SOCKET`
aims adb at another host's adb server, and `ANDROID_DEVICE` names the serial aims adb at another host's adb server, and `ANDROID_DEVICE` names the serial
that server reports. A remote server is shared, so `ANDROID_DEVICE` is required that server reports. A remote server is shared, so nothing there is ever picked
there even when it holds only one device: without being named or asked about, one device on it or twenty:
``` ```
ADB_SERVER_SOCKET=tcp:10.0.0.5:5037 ADB_SERVER_SOCKET=tcp:10.0.0.5:5037
ANDROID_DEVICE=emulator-5556 ANDROID_DEVICE=emulator-5556
``` ```
The older `ANDROID_ADB_SERVER_ADDRESS` / `ANDROID_ADB_SERVER_PORT` pair works
too, at the same precedence the adb CLI gives it. A remote server is never
auto-booted against: when it reports no device, `just test` says so rather than
starting a local emulator that server will never see.
Gradle only assembles the APK. The install goes through adb, which reads those Gradle only assembles the APK. The install goes through adb, which reads those
variables, so a remote server needs nothing else. Gradle's own `installDebug` variables, so a remote server needs nothing else. Gradle's own `installDebug`
cannot be used here: its adb client only ever dials loopback. cannot be used here: its adb client only ever dials loopback.
+101 -19
View File
@@ -39,9 +39,56 @@ _android-home:
echo "could not locate Android SDK (set ANDROID_HOME)" >&2 echo "could not locate Android SDK (set ANDROID_HOME)" >&2
exit 1 exit 1
# Ask which of a list of targets to act on. Callers set FOLIO_PICK_ITEMS to
# "value<TAB>label" lines and read one line back: "picked <value>", "cancelled"
# or "no-tty". The answer goes to stdout rather than an exit code so a caller
# reading it does not have to sieve just's own failure line out of the menu.
_pick header:
#!/usr/bin/env bash
set -euo pipefail
if ! { exec 3</dev/tty; } 2>/dev/null; then
echo "no-tty"
exit 0
fi
values=()
labels=()
while IFS=$'\t' read -r value label; do
[[ -n "$value" ]] || continue
values+=("$value")
labels+=("${label:-$value}")
done <<<"$FOLIO_PICK_ITEMS"
echo "{{header}}" >&2
for i in "${!values[@]}"; do
printf ' %d) %s\n' "$((i + 1))" "${labels[$i]}" >&2
done
while :; do
printf 'folio: number (enter to cancel): ' >&2
read -r -u 3 reply || break
[[ -n "$reply" ]] || break
if [[ "$reply" =~ ^[0-9]+$ ]] && (( reply >= 1 && reply <= ${#values[@]} )); then
echo "picked ${values[$((reply - 1))]}"
exit 0
fi
echo "folio: not one of the numbers above." >&2
done
echo "cancelled"
# Print where adb has been aimed when that is not this machine's default
# server, and nothing when it is. Read the way the adb CLI reads it, so the
# recipes that only ever guess at a local emulator can tell the difference.
_adb-server:
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "${ADB_SERVER_SOCKET:-}" ]]; then
echo "${ADB_SERVER_SOCKET}"
elif [[ -n "${ANDROID_ADB_SERVER_ADDRESS:-}${ANDROID_ADB_SERVER_PORT:-}" ]]; then
echo "tcp:${ANDROID_ADB_SERVER_ADDRESS:-localhost}:${ANDROID_ADB_SERVER_PORT:-5037}"
fi
# Ensure an Android device is online. If none is connected and no AVD is # Ensure an Android device is online. If none is connected and no AVD is
# provided, boot the first AVD whose system image is actually installed # provided, boot the first AVD whose system image is actually installed
# (headless) and wait for it to finish booting. # (headless) and wait for it to finish booting. A remote adb server is left
# alone: an emulator booted here would never appear on it.
_ensure-device: _ensure-device:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -50,6 +97,11 @@ _ensure-device:
if "$adb" devices | awk 'NR>1 && $2=="device"{f=1} END{exit !f}'; then if "$adb" devices | awk 'NR>1 && $2=="device"{f=1} END{exit !f}'; then
exit 0 exit 0
fi fi
server="$(just _adb-server)"
if [[ -n "$server" ]]; then
echo "no device is online on the adb server at $server, and an emulator booted here would not appear on it" >&2
exit 1
fi
if [[ -n "{{avd}}" ]]; then if [[ -n "{{avd}}" ]]; then
exit 0 # sanderling boots the named AVD itself exit 0 # sanderling boots the named AVD itself
fi fi
@@ -79,15 +131,16 @@ _ensure-device:
echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2 echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2
exit 1 exit 1
# Print the serial every device-affecting recipe must act on, or refuse. A run # Print the serial every device-affecting recipe must act on. A run installs
# installs the app, clears its state and fuzzes it, so the target is never # the app, clears its state and fuzzes it, so the target is never inferred from
# inferred from "whatever adb resolved to": the one case it picks on its own is # "whatever adb resolved to": the one case it picks on its own is a single
# a single emulator on the local adb server, which is cheap to rebuild. # emulator on the local adb server, which is cheap to rebuild. Anything else is
# asked about when there is a terminal to ask on, and refused when there is not.
_require-device: _require-device:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
adb="$(just _android-home)/platform-tools/adb" adb="$(just _android-home)/platform-tools/adb"
listing="$("$adb" devices)" listing="$("$adb" devices -l)"
online="$(echo "$listing" | awk 'NR>1 && $2=="device"{print $1}')" online="$(echo "$listing" | awk 'NR>1 && $2=="device"{print $1}')"
count="$(printf '%s' "$online" | grep -c . || true)" count="$(printf '%s' "$online" | grep -c . || true)"
@@ -106,6 +159,12 @@ _require-device:
exit 1 exit 1
} }
model_of() {
printf '%s\n' "$listing" | awk -v serial="$1" '$1 == serial {
for (i = 3; i <= NF; i++) if ($i ~ /^model:/) { sub(/^model:/, "", $i); print $i; exit }
}'
}
if [[ -n "{{android_device}}" ]]; then if [[ -n "{{android_device}}" ]]; then
if printf '%s\n' "$online" | grep -qxF "{{android_device}}"; then if printf '%s\n' "$online" | grep -qxF "{{android_device}}"; then
echo "{{android_device}}" echo "{{android_device}}"
@@ -116,13 +175,33 @@ _require-device:
if [[ "$count" -eq 0 ]]; then if [[ "$count" -eq 0 ]]; then
refuse "no device is online." refuse "no device is online."
fi fi
if [[ -z "${ADB_SERVER_SOCKET:-}" && "$count" -eq 1 && "$online" =~ ^emulator-[0-9]+$ ]]; then if [[ -z "$(just _adb-server)" && "$count" -eq 1 && "$online" =~ ^emulator-[0-9]+$ ]]; then
echo "$online" echo "$online"
exit 0 exit 0
fi fi
refuse "refusing to install on and fuzz a device nobody named. A run installs
the app, clears its state and drives it, so the only target it picks on its items="$(printf '%s\n' "$online" | while read -r serial; do
own is a single emulator on the local adb server." [[ -n "$serial" ]] || continue
model="$(model_of "$serial")"
printf '%s\t%s%s\n' "$serial" "$serial" "${model:+ $model}"
done)"
answer="$(FOLIO_PICK_ITEMS="$items" just _pick "folio: a run installs the app, clears its state and drives it. Pick the device:")"
case "$answer" in
"picked "*)
serial="${answer#picked }"
echo "folio: using $serial (ANDROID_DEVICE=$serial in examples/folio/.env skips this)" >&2
echo "$serial"
exit 0
;;
cancelled)
echo "folio: cancelled." >&2
exit 1
;;
esac
refuse "refusing to install on and fuzz a device nobody named, and there is no
terminal here to ask on. A run installs the app, clears its state and drives
it, so the only target it picks on its own is a single emulator on the local
adb server."
# Run folio's own unit tests. Named test-unit because `test` is the fuzz run. # Run folio's own unit tests. Named test-unit because `test` is the fuzz run.
test-unit: test-unit:
@@ -138,15 +217,16 @@ build:
export ANDROID_HOME="$(just _android-home)" export ANDROID_HOME="$(just _android-home)"
./gradlew :app:androidApp:assembleDebug ./gradlew :app:androidApp:assembleDebug
# Build and install the folio APK on a running emulator/device. Gradle only # Build and install the folio APK on a running emulator/device, on the serial
# assembles: adb does the install because it reads ADB_SERVER_SOCKET, so the # given or the one picked by _require-device. Gradle only assembles: adb does
# device may live on a remote adb server, which AGP's loopback-only client # the install because it reads ADB_SERVER_SOCKET, so the device may live on a
# cannot reach. # remote adb server, which AGP's loopback-only client cannot reach.
install: _ensure-device install serial="": _ensure-device
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
export ANDROID_HOME="$(just _android-home)" export ANDROID_HOME="$(just _android-home)"
serial="$(just _require-device)" serial="{{serial}}"
[[ -n "$serial" ]] || serial="$(just _require-device)"
export ANDROID_SERIAL="$serial" export ANDROID_SERIAL="$serial"
./gradlew :app:androidApp:assembleDebug ./gradlew :app:androidApp:assembleDebug
"$ANDROID_HOME/platform-tools/adb" install -r "{{apk}}" "$ANDROID_HOME/platform-tools/adb" install -r "{{apk}}"
@@ -217,12 +297,13 @@ ios-device:
# Run 'sanderling test' against the folio app. Uses a connected device if one is # Run 'sanderling test' against the folio app. Uses a connected device if one is
# online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD. # online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD.
# Depends on install so the run always fuzzes the current build, matching # Installs before it runs so the run always fuzzes the current build, matching
# test-ios which rebuilds and reinstalls the app first. # test-ios which rebuilds and reinstalls the app first.
test: install test: _ensure-device
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
serial="$(just _require-device)" serial="$(just _require-device)"
just install "$serial"
device_flag=(--device "$serial") device_flag=(--device "$serial")
if [[ -n "{{avd}}" ]]; then if [[ -n "{{avd}}" ]]; then
device_flag+=(--avd "{{avd}}") device_flag+=(--avd "{{avd}}")
@@ -239,10 +320,11 @@ test: install
# Run 'sanderling test' with the LLM action generator instead of the seeded # Run 'sanderling test' with the LLM action generator instead of the seeded
# fuzzer. Needs OPENROUTER_API_KEY (or OPENAI_API_KEY) in the environment; the # fuzzer. Needs OPENROUTER_API_KEY (or OPENAI_API_KEY) in the environment; the
# model is configured by generator = llm({...}) in spec.ts. # model is configured by generator = llm({...}) in spec.ts.
test-llm: install test-llm: _ensure-device
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
serial="$(just _require-device)" serial="$(just _require-device)"
just install "$serial"
device_flag=(--device "$serial") device_flag=(--device "$serial")
if [[ -n "{{avd}}" ]]; then if [[ -n "{{avd}}" ]]; then
device_flag+=(--avd "{{avd}}") device_flag+=(--avd "{{avd}}")