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 filesParents
468cadaChanged 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
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
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