eletrotupi / tcc/ commit / e25731e

frontend: port the e2e harness script to make it more POSIX-ish

Also patch a small bug that made device animations permanently disabled,
so now it stashes and roll it back afterwards
Pedro Lucas Porcellis porcellis@eletrotupi.com · 1 month ago e25731e72ced51f01450344fc2bc3f7a95b430f0 Browse files
Parents 468cada
Changed files
3
Additions
+109
Deletions
-37
frontend/e2e/README.md
1 1
# Mobile E2E (Maestro on a physical Android device)
2 2

3 3
Smoke coverage for the core journey: **login → register a mood → logout**. Runs
4 4
on a real Android device over `adb` (no emulator), against a dev build of the
5 5
app talking to the normal local API
29 29
cd frontend
30 30
npm run test:e2e          # adb prep + the tagged smoke flows
31 31
npm run test:e2e:env      # print the EXPO_PUBLIC_* values Metro needs
32 32
```
33 33

34 +
`scripts/e2e.sh` is POSIX `sh`, so it runs unchanged on Debian/Ubuntu and on
35 +
Alpine (busybox ash). It turns the device's animation scales off for the run and
36 +
restores the previous values on exit — that setting is persisted on the device,
37 +
so it is never left behind.
38 +

34 39
A single flow, while iterating (a few minutes faster than the whole suite):
35 40

36 41
```sh
37 42
cd frontend
43 +
npm run test:e2e:env      # prints HOST_IP, the one machine-specific value
44 +

38 45
maestro test e2e/mood.yaml \
39 -
  --env HOST_IP=$(ip route get 1 | awk '/src/{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}') \
40 -
  --env PORT=8081 --env E2E_EMAIL=e2e@example.com --env E2E_PASSWORD=e2e-password
46 +
  --env HOST_IP=<lan-ip> --env PORT=8081 \
47 +
  --env E2E_EMAIL=e2e@example.com --env E2E_PASSWORD=e2e-password
41 48
```
42 49

43 50
Three flows take roughly 8 minutes: every flow clears app state, so each one
44 51
pays for a cold bundle load and the dev-client startup dance
45 52

103 110

104 111
- **The formSheet opens at its half detent.** `entry/mood-components` uses
105 112
  `sheetInitialDetentIndex: 1` of `[0.25, 0.5, 1]`, so only the first rows of
106 113
  the component list are mounted
107 114
- The soft keyboard covers `button-login`; the flow calls `hideKeyboard` first
frontend/package.json modified+2 -2
frontend/package.json
1 1
{
2 2
  "name": "orbit",
3 3
  "main": "expo-router/entry",
4 4
  "version": "0.0.0",
5 5
  "scripts": {
6 6
    "start": "expo start",
7 7
    "reset-project": "node ./scripts/reset-project.js",
8 8
    "android": "expo run:android",
9 9
    "lint": "expo lint",
10 10
    "ios": "expo run:ios",
11 -
    "test:e2e": "bash scripts/e2e.sh test",
12 -
    "test:e2e:env": "bash scripts/e2e.sh print-env"
11 +
    "test:e2e": "sh scripts/e2e.sh test",
12 +
    "test:e2e:env": "sh scripts/e2e.sh print-env"
13 13
  },
14 14
  "dependencies": {
15 15
    "@expo-google-fonts/inter": "^0.4.2",
16 16
    "@expo/vector-icons": "^15.0.3",
17 17
    "@react-native-async-storage/async-storage": "2.2.0",
64 64
    "eslint-config-expo": "~57.0.2",
65 65
    "typescript": "~6.0.3"
66 66
  },
67 67
  "private": true
68 68
}
frontend/scripts/e2e.sh modified+98 -33
frontend/scripts/e2e.sh
1 -
#!/usr/bin/env bash
1 +
#!/bin/sh
2 2
#
3 3
# E2E harness for the nexo frontend: Maestro flows on a physical Android
4 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
5 8
#
6 9
# The app talks to whatever API `EXPO_PUBLIC_API_BASE_URL` points at, so there is
7 10
# no second API service to boot here: bring the normal stack up and point Metro
8 11
# at it:
9 12
#
14 17
# Usage: e2e.sh [test|print-env]
15 18
#   test       prep the device (adb) and run the tagged Maestro flows
16 19
#   print-env  print the EXPO_PUBLIC_* vars to set before `expo start`
17 20
#
18 21
# Optional env: HOST_IP (LAN IP of this machine) is auto-detected when unset
19 -
set -euo pipefail
22 +
set -eu
20 23

21 -
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
24 +
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
25 +
ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
22 26
FRONTEND="$ROOT/frontend"
23 27

24 28
# The app under test: the dev build (`eas build --profile development`)
25 -
APP_ID="${APP_ID:-com.eletrotupi.orbit}"
26 -
EXPO_PORT="${EXPO_PORT:-8081}"
27 -
E2E_EMAIL="${E2E_EMAIL:-e2e@example.com}"
28 -
E2E_PASSWORD="${E2E_PASSWORD:-e2e-password}"
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=""
29 37

30 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 +
}
31 50

32 51
detect_host_ip() {
33 -
  if [[ -n "${HOST_IP:-}" ]]; then
52 +
  if [ -n "${HOST_IP:-}" ]; then
34 53
    echo "$HOST_IP"
35 -
    return
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
36 66
  fi
37 67

38 -
  local ip=""
39 -
  ip="$(ip route get 1 2>/dev/null | awk '/src/{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')"
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
40 72

41 -
  [[ -z "$ip" ]] && ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
42 -
  [[ -z "$ip" ]] && die "could not detect LAN IP; export HOST_IP=<lan-ip>"
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
43 82

44 -
  echo "$ip"
83 +
  is_ipv4 "$addr" || die "could not detect LAN IP; export HOST_IP=<lan-ip>"
84 +
  echo "$addr"
45 85
}
46 86

47 -
adb_prepare() {
48 -
  command -v adb >/dev/null || die "adb not found; install android-tools"
49 -
  echo "e2e: waiting for an Android device (up to 15s)..."
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 +
}
50 97

51 -
  local i state=""
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 +
}
52 108

53 -
  for i in $(seq 1 15); do
54 -
    state="$(adb get-state 2>/dev/null)"
55 -
    [[ "$state" == "device" ]] && break
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
56 115
    sleep 1
116 +
    i=$((i + 1))
57 117
  done
118 +
  return 1
119 +
}
58 120

59 -
  if [[ "$state" != "device" ]]; then
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
60 126
    adb devices >&2 || true
61 127
    die "no Android device attached. Connect one over USB/wireless debugging (or set ANDROID_SERIAL when several are connected) and re-run"
62 128
  fi
63 129

64 130
  echo "e2e: adb devices:"
65 -

66 131
  adb devices
67 132

68 133
  # No `pm clear` here: each flow starts with Maestro `clearState`, which needs no
69 134
  # host-side help, and clearing here only re-triggered the expo-dev-client
70 135
  # first-run overlays. Granting the runtime permission is still worth it:
71 136
  # clearState resets it and the app asks for notifications right after login
72 137
  adb shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true
73 -
  # Disable animations for speed and stability
74 138

75 -
  adb shell settings put global window_animation_scale 0 >/dev/null
76 -
  adb shell settings put global transition_animation_scale 0 >/dev/null
77 -
  adb shell settings put global animator_duration_scale 0 >/dev/null
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
78 145
}
79 146

80 147
run_maestro() {
81 -
  command -v maestro >/dev/null || die "maestro not found; install via https://get.maestro.mobile.dev"
82 -
  local host_ip
83 -
  host_ip="$(detect_host_ip)"
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)
84 150

85 151
  (
86 152
    cd "$FRONTEND"
87 153
    maestro test e2e \
88 154
      --include-tags=smoke \
92 158
      --env E2E_PASSWORD="$E2E_PASSWORD"
93 159
  )
94 160
}
95 161

96 162
print_env() {
97 -
  local host_ip
98 -
  host_ip="$(detect_host_ip)"
163 +
  host_ip=$(detect_host_ip)
99 164

100 165
  cat <<EOF
101 166
Put this in frontend/.env.local (gitignored) before starting Metro:
102 167

103 -
  EXPO_PUBLIC_API_BASE_URL=http://$host_ip:3000
168 +
  EXPO_PUBLIC_API_BASE_URL=http://$host_ip:$API_PORT
104 169
  EXPO_PUBLIC_TOKEN_KEY=orbit-jwt
105 170

106 171
  npx expo start --port $EXPO_PORT
107 172
EOF
108 173
}
118 183
  *)
119 184
    echo "usage: $0 [test|print-env]" >&2
120 185
    exit 1
121 186
    ;;
122 187
esac