| 1 | #!/bin/sh
|
| 2 | #
|
| 3 | # E2E harness for the nexo frontend: Maestro flows on a physical Android
|
| 4 | # device over adb (no emulator)
|
| 5 | #
|
| 6 | # POSIX sh on purpose: this runs on Debian/Ubuntu and on Alpine (busybox ash),
|
| 7 | # so keep it free of bashisms and of GNU-only tools
|
| 8 | #
|
| 9 | # The app talks to whatever API `EXPO_PUBLIC_API_BASE_URL` points at, so there is
|
| 10 | # no second API service to boot here: bring the normal stack up and point Metro
|
| 11 | # at it:
|
| 12 | #
|
| 13 | # docker compose up -d db valkey api
|
| 14 | # cd api && npm run seed # known e2e user (idempotent)
|
| 15 | # cd frontend && npx expo start # with .env.local set (see print-env)
|
| 16 | #
|
| 17 | # Usage: e2e.sh [test|print-env]
|
| 18 | # test prep the device (adb) and run the tagged Maestro flows
|
| 19 | # print-env print the EXPO_PUBLIC_* vars to set before `expo start`
|
| 20 | #
|
| 21 | # Optional env: HOST_IP (LAN IP of this machine) is auto-detected when unset
|
| 22 | set -eu
|
| 23 |
|
| 24 | SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
| 25 | ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
|
| 26 | FRONTEND="$ROOT/frontend"
|
| 27 |
|
| 28 | # The app under test: the dev build (`eas build --profile development`)
|
| 29 | APP_ID=${APP_ID:-com.eletrotupi.orbit}
|
| 30 | API_PORT=${API_PORT:-3000}
|
| 31 | EXPO_PORT=${EXPO_PORT:-8081}
|
| 32 | E2E_EMAIL=${E2E_EMAIL:-e2e@example.com}
|
| 33 | E2E_PASSWORD=${E2E_PASSWORD:-e2e-password}
|
| 34 |
|
| 35 | ANIMATION_KEYS="window_animation_scale transition_animation_scale animator_duration_scale"
|
| 36 | animation_state=""
|
| 37 |
|
| 38 | die() { echo "e2e: $*" >&2; exit 1; }
|
| 39 |
|
| 40 | # True for a plausible, non-loopback IPv4 address
|
| 41 | is_ipv4() {
|
| 42 | case "$1" in
|
| 43 | ""|127.*|0.0.0.0) return 1 ;;
|
| 44 | esac
|
| 45 | printf '%s\n' "$1" | awk -F. '
|
| 46 | NF == 4 { for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1; exit 0 }
|
| 47 | { exit 1 }
|
| 48 | '
|
| 49 | }
|
| 50 |
|
| 51 | detect_host_ip() {
|
| 52 | if [ -n "${HOST_IP:-}" ]; then
|
| 53 | echo "$HOST_IP"
|
| 54 | return 0
|
| 55 | fi
|
| 56 |
|
| 57 | addr=""
|
| 58 |
|
| 59 | # iproute2 (Debian/Ubuntu): `ip route get` names the interface the default
|
| 60 | # route leaves through
|
| 61 | if command -v ip >/dev/null 2>&1; then
|
| 62 | addr=$(ip route get 1 2>/dev/null | awk '/src/{for (i = 1; i <= NF; i++) if ($i == "src") {print $(i + 1); exit}}')
|
| 63 | if ! is_ipv4 "$addr"; then
|
| 64 | addr=$(ip -4 -o addr show scope global 2>/dev/null | awk '$4 ~ /\./ {sub(/\/.*/, "", $4); print $4; exit}')
|
| 65 | fi
|
| 66 | fi
|
| 67 |
|
| 68 | # busybox (Alpine): its `ip` has no `route get`, so fall back to ifconfig
|
| 69 | if ! is_ipv4 "$addr" && command -v ifconfig >/dev/null 2>&1; then
|
| 70 | addr=$(ifconfig 2>/dev/null | awk '/inet (addr:)?[0-9]/ {sub(/addr:/, "", $2); print $2; exit}')
|
| 71 | fi
|
| 72 |
|
| 73 | # GNU `hostname -I` / busybox `hostname -i`
|
| 74 | if ! is_ipv4 "$addr" && command -v hostname >/dev/null 2>&1; then
|
| 75 | for candidate in $(hostname -I 2>/dev/null) $(hostname -i 2>/dev/null); do
|
| 76 | if is_ipv4 "$candidate"; then
|
| 77 | addr=$candidate
|
| 78 | break
|
| 79 | fi
|
| 80 | done
|
| 81 | fi
|
| 82 |
|
| 83 | is_ipv4 "$addr" || die "could not detect LAN IP; export HOST_IP=<lan-ip>"
|
| 84 | echo "$addr"
|
| 85 | }
|
| 86 |
|
| 87 | # `settings put global` writes to the persisted settings provider, so animation
|
| 88 | # scales stay turned off across reboots and reinstalls. Remember what was there
|
| 89 | # first, and put it back on the way out
|
| 90 | remember_animations() {
|
| 91 | animation_state=""
|
| 92 | for key in $ANIMATION_KEYS; do
|
| 93 | value=$(adb shell settings get global "$key" 2>/dev/null | tr -d '[:space:]')
|
| 94 | animation_state="$animation_state $key=$value"
|
| 95 | done
|
| 96 | }
|
| 97 |
|
| 98 | restore_animations() {
|
| 99 | for entry in $animation_state; do
|
| 100 | key=${entry%%=*}
|
| 101 | value=${entry#*=}
|
| 102 | case "$value" in
|
| 103 | ""|null) adb shell settings delete global "$key" >/dev/null 2>&1 || true ;;
|
| 104 | *) adb shell settings put global "$key" "$value" >/dev/null 2>&1 || true ;;
|
| 105 | esac
|
| 106 | done
|
| 107 | }
|
| 108 |
|
| 109 | adb_wait_device() {
|
| 110 | i=0
|
| 111 | while [ "$i" -lt 15 ]; do
|
| 112 | if [ "$(adb get-state 2>/dev/null)" = "device" ]; then
|
| 113 | return 0
|
| 114 | fi
|
| 115 | sleep 1
|
| 116 | i=$((i + 1))
|
| 117 | done
|
| 118 | return 1
|
| 119 | }
|
| 120 |
|
| 121 | adb_prepare() {
|
| 122 | command -v adb >/dev/null 2>&1 || die "adb not found; install android-tools (Linux) or platform-tools"
|
| 123 | echo "e2e: waiting for an Android device (up to 15s)..."
|
| 124 |
|
| 125 | if ! adb_wait_device; then
|
| 126 | adb devices >&2 || true
|
| 127 | die "no Android device attached. Connect one over USB/wireless debugging (or set ANDROID_SERIAL when several are connected) and re-run"
|
| 128 | fi
|
| 129 |
|
| 130 | echo "e2e: adb devices:"
|
| 131 | adb devices
|
| 132 |
|
| 133 | # No `pm clear` here: each flow starts with Maestro `clearState`, which needs no
|
| 134 | # host-side help, and clearing here only re-triggered the expo-dev-client
|
| 135 | # first-run overlays. Granting the runtime permission is still worth it:
|
| 136 | # clearState resets it and the app asks for notifications right after login
|
| 137 | adb shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true
|
| 138 |
|
| 139 | # Disable animations for speed and stability, then put them back on exit
|
| 140 | remember_animations
|
| 141 | trap restore_animations EXIT HUP INT TERM
|
| 142 | for key in $ANIMATION_KEYS; do
|
| 143 | adb shell settings put global "$key" 0 >/dev/null
|
| 144 | done
|
| 145 | }
|
| 146 |
|
| 147 | run_maestro() {
|
| 148 | command -v maestro >/dev/null 2>&1 || die "maestro not found; install via https://get.maestro.mobile.dev"
|
| 149 | host_ip=$(detect_host_ip)
|
| 150 |
|
| 151 | (
|
| 152 | cd "$FRONTEND"
|
| 153 | maestro test e2e \
|
| 154 | --include-tags=smoke \
|
| 155 | --env HOST_IP="$host_ip" \
|
| 156 | --env PORT="$EXPO_PORT" \
|
| 157 | --env E2E_EMAIL="$E2E_EMAIL" \
|
| 158 | --env E2E_PASSWORD="$E2E_PASSWORD"
|
| 159 | )
|
| 160 | }
|
| 161 |
|
| 162 | print_env() {
|
| 163 | host_ip=$(detect_host_ip)
|
| 164 |
|
| 165 | cat <<EOF
|
| 166 | Put this in frontend/.env.local (gitignored) before starting Metro:
|
| 167 |
|
| 168 | EXPO_PUBLIC_API_BASE_URL=http://$host_ip:$API_PORT
|
| 169 | EXPO_PUBLIC_TOKEN_KEY=orbit-jwt
|
| 170 |
|
| 171 | npx expo start --port $EXPO_PORT
|
| 172 | EOF
|
| 173 | }
|
| 174 |
|
| 175 | case "${1:-}" in
|
| 176 | test)
|
| 177 | adb_prepare
|
| 178 | run_maestro
|
| 179 | ;;
|
| 180 | print-env)
|
| 181 | print_env
|
| 182 | ;;
|
| 183 | *)
|
| 184 | echo "usage: $0 [test|print-env]" >&2
|
| 185 | exit 1
|
| 186 | ;;
|
| 187 | esac
|