taler-monitoring/android-test/run-goa-gui-chain.sh
Hernâni Marques 5a5431bfa9
taler-monitoring v1.13.12
Multi-phase global SUMMARY, warn-filter dimmed context, SUMMARY chrome
as meta; FP stage host-agent via francpaysan-stage-user (stagepaysan);
monpages GOA + FP stage.
2026-07-19 04:22:10 +02:00

366 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
# GOA (and optionally stage) GUI chain on Android — adapted from
# taler-android branch **dev/hernani-inference/gui-workflows**
# (`scripts/goa-chain-emu.sh`, docs/gui-workflows.md).
#
# Linux + macOS: uses adb + uiautomator only (no Homebrew hard dependency).
# Upstream scripts are documented as macOS-only for SDK install paths.
#
# Flow:
# 1) API mint withdraw (explorer) → VIEW taler://withdraw → GUI taps
# 2) Merchant template POST → VIEW taler://pay → GUI taps
#
# Usage:
# ./run-goa-gui-chain.sh
# STACK=stage ./run-goa-gui-chain.sh
# WITHDRAW_AMOUNTS='GOA:10' PAY_LIMIT=2 ./run-goa-gui-chain.sh
# PKG=net.taler.wallet.fdroid.debug ./run-goa-gui-chain.sh --no-clear
#
# Env: ANDROID_SERIAL, PKG, EXP_PW_FILE, BANK, MERCHANT, INSTANCE,
# WITHDRAW_AMOUNTS, PRODUCTS, PAY_LIMIT, SHOTDIR, PAUSE
#
# See GUI-AUTOMATION-NOTES.md
#
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
CLEAR=1
for a in "$@"; do
case "$a" in
--no-clear) CLEAR=0 ;;
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
esac
done
die() { echo "ERROR: $*" >&2; exit 1; }
step() { echo; echo "======== $* ========"; }
info() { echo " $*"; }
pause() { sleep "${1:-${PAUSE:-0.8}}"; }
# Platform flags + headless emulator defaults (see GUI-AUTOMATION-NOTES.md)
# shellcheck source=lib_android_env.sh
. "$ROOT/lib_android_env.sh"
[ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; }
[ "${AUTO_GUI}" = "1" ] || { echo "skipped: AUTO_GUI=${AUTO_GUI} (chain is GUI)"; exit 0; }
command -v adb >/dev/null || die "adb not found"
command -v python3 >/dev/null || die "python3 required"
STACK="${STACK:-goa}"
case "$STACK" in
goa|hacktivism)
PKG="${PKG:-net.taler.wallet.fdroid.debug}"
BANK="${BANK:-https://bank.hacktivism.ch}"
MERCHANT="${MERCHANT:-https://taler.hacktivism.ch}"
INSTANCE=$(printf '%s' "${INSTANCE:-goa-shop}" | tr '[:upper:]' '[:lower:]')
DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-goa-demo-cp4zqk}" | tr '[:upper:]' '[:lower:]')
DONATE_TEMPLATE="${DONATE_TEMPLATE:-goa-free}"
DEFAULT_AMOUNTS=("GOA:10" "GOA:20")
DEFAULT_PAYS=("product:orbit-sticker" "product:nebula-coffee" "product:voidwave-playlist" "donate:GOA:12")
CUR_HINT=GOA
;;
stage|testpaysan)
PKG="${PKG:-net.taler.wallet.fdroid.debug}"
BANK="${BANK:-https://stage.bank.lefrancpaysan.ch}"
MERCHANT="${MERCHANT:-https://stage.monnaie.lefrancpaysan.ch}"
INSTANCE=$(printf '%s' "${INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]')
DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]')
DONATE_TEMPLATE="${DONATE_TEMPLATE:-don-panier-libre}"
DEFAULT_AMOUNTS=("TESTPAYSAN:10" "TESTPAYSAN:20")
DEFAULT_PAYS=("product:panier-legumes" "product:fromage-chevre" "product:oeufs-6")
CUR_HINT=TESTPAYSAN
;;
*) die "STACK must be goa|stage (got $STACK)" ;;
esac
ACT="${ACT:-$PKG/net.taler.wallet.main.MainActivity}"
EXP_USER="${EXP_USER:-explorer}"
PAY_LIMIT="${PAY_LIMIT:-4}"
PAUSE="${PAUSE:-0.8}"
SHOTDIR="${SHOTDIR:-$ROOT/out-gui-chain/$(date +%Y%m%d-%H%M%S)-$STACK}"
mkdir -p "$SHOTDIR"
# Explorer password (same search as taler-monitoring ladder)
if [[ -z "${EXP_PW_FILE:-}" ]]; then
for cand in \
"${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \
"${HOME}/.config/taler-landing/bank-explorer-password.txt"
do
[[ -f "$cand" ]] && EXP_PW_FILE="$cand" && break
done
fi
# stage: try stagepaysan secret via ssh if missing
if [[ ! -f "${EXP_PW_FILE:-}" && "$STACK" = "stage" ]]; then
for host in ${INSIDE_SSH:+"$INSIDE_SSH"} ${FRANCPAYSAN_SSH:+"$FRANCPAYSAN_SSH"}; do
tmp=$(mktemp)
if ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" \
'tr -d "\n\r" </mnt/data/stagepaysan/bank/secrets/bank-explorer-password.txt' \
>"$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then
EXP_PW_FILE="$tmp"
info "explorer password via $host"
break
fi
rm -f "$tmp"
done
fi
[[ -n "${EXP_PW_FILE:-}" && -f "$EXP_PW_FILE" ]] || die "set EXP_PW_FILE (explorer password)"
# Headless AVD by default (no host window). WINDOWED=1 for a visible emulator.
SERIAL="${SERIAL:-${ANDROID_SERIAL:-}}"
android_ensure_device || die "no adb device (./start-android-emulator.sh --wait)"
export ANDROID_SERIAL="$SERIAL"
info "ANDROID_SERIAL=$ANDROID_SERIAL STACK=$STACK PKG=$PKG SHOTDIR=$SHOTDIR headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU"
ADB=(adb -s "$ANDROID_SERIAL")
adb_sh() { "${ADB[@]}" shell "$@"; }
shot() {
local n="$1"
adb_sh screencap -p "/sdcard/demo-$n.png" 2>/dev/null || true
"${ADB[@]}" pull "/sdcard/demo-$n.png" "$SHOTDIR/$n.png" >/dev/null 2>&1 || true
info "[shot] $SHOTDIR/$n.png"
}
tap_text() {
local label="$1"
adb_sh uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || return 1
"${ADB[@]}" pull /sdcard/ui.xml "$SHOTDIR/ui.xml" >/dev/null 2>&1 || return 1
SERIAL="$ANDROID_SERIAL" python3 - "$label" "$SHOTDIR/ui.xml" <<'PY'
import os, re, subprocess, sys
from pathlib import Path
label, path = sys.argv[1], sys.argv[2]
serial = os.environ.get("SERIAL") or os.environ.get("ANDROID_SERIAL") or ""
xml = Path(path).read_text(errors="replace")
# ANR dismiss
if "isn't responding" in xml or "reagiert nicht" in xml.lower():
for lab in ("Wait", "Warten"):
for m in re.finditer(rf'text="{lab}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml):
x = (int(m.group(1))+int(m.group(3)))//2
y = (int(m.group(2))+int(m.group(4)))//2
subprocess.run(["adb","-s",serial,"shell","input","tap",str(x),str(y)], check=False)
print(f"anr-wait @{x},{y}")
sys.exit(0)
for pat in [
rf'text="{re.escape(label)}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
rf'text="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
rf'content-desc="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
]:
for m in re.finditer(pat, xml, re.I):
chunk = xml[max(0, m.start()-120):m.end()]
if 'enabled="false"' in chunk:
continue
x = (int(m.group(1))+int(m.group(3)))//2
y = (int(m.group(2))+int(m.group(4)))//2
print(f"tap {label!r} @{x},{y}")
cmd = ["adb","shell","input","tap",str(x),str(y)]
if serial:
cmd = ["adb","-s",serial,"shell","input","tap",str(x),str(y)]
subprocess.run(cmd, check=False)
sys.exit(0)
print("miss", label)
sys.exit(1)
PY
}
mint_withdraw() {
local amount="$1"
EXP_USER="$EXP_USER" EXP_PW_FILE="$EXP_PW_FILE" BANK="$BANK" \
python3 - "$amount" <<'PY'
import base64, json, os, ssl, sys, urllib.request
from pathlib import Path
amount = sys.argv[1]
bank = os.environ["BANK"].rstrip("/")
user = os.environ["EXP_USER"]
pw = Path(os.environ["EXP_PW_FILE"]).read_text().strip()
auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
ctx = ssl.create_default_context()
def req(method, url, data=None, headers=None):
h = dict(headers or {})
body = None
if data is not None:
body = json.dumps(data).encode()
h["Content-Type"] = "application/json"
r = urllib.request.Request(url, data=body, headers=h, method=method)
with urllib.request.urlopen(r, context=ctx, timeout=25) as resp:
return json.load(resp)
tok = req(
"POST",
f"{bank}/accounts/{user}/token",
{"scope": "readwrite", "duration": {"d_us": 3600_000_000}},
{"Authorization": auth},
)["access_token"]
wd = req(
"POST",
f"{bank}/accounts/{user}/withdrawals",
{"amount": amount},
{"Authorization": f"Bearer {tok}"},
)
print(wd.get("taler_withdraw_uri") or wd.get("talerWithdrawUri") or "")
PY
}
mint_pay() {
local mode="$1"
MERCHANT="$MERCHANT" INSTANCE="$INSTANCE" \
DONATE_INSTANCE="$DONATE_INSTANCE" DONATE_TEMPLATE="$DONATE_TEMPLATE" \
python3 - "$mode" <<'PY'
import json, os, ssl, sys, urllib.error, urllib.request
mode = sys.argv[1]
merchant = os.environ["MERCHANT"].rstrip("/")
inst = os.environ["INSTANCE"]
d_inst = os.environ["DONATE_INSTANCE"]
d_tmpl = os.environ["DONATE_TEMPLATE"]
ctx = ssl.create_default_context()
def post(url, data):
body = json.dumps(data).encode()
r = urllib.request.Request(url, data=body, method="POST",
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(r, context=ctx, timeout=25) as resp:
return resp.status, json.load(resp)
except urllib.error.HTTPError as e:
raw = e.read() if hasattr(e, "read") else b""
try:
return getattr(e, "code", None), json.loads(raw)
except Exception as err:
raise SystemExit(f"POST {url} failed: {e} body={raw[:200]!r}") from err
if mode.startswith("product:"):
pid = mode.split(":", 1)[1]
st, d = post(f"{merchant}/instances/{inst}/templates/{pid}", {})
use_inst = inst
elif mode.startswith("donate:"):
amt = mode.split(":", 1)[1]
st, d = post(f"{merchant}/instances/{d_inst}/templates/{d_tmpl}", {"amount": amt})
use_inst = d_inst
else:
raise SystemExit(f"bad mode {mode!r}")
if "taler_pay_uri" in d:
print(d["taler_pay_uri"]); raise SystemExit(0)
oid, tok = d.get("order_id"), d.get("token")
if not oid or not tok:
raise SystemExit(f"no order: {d!r}"[:200])
host = merchant.replace("https://","").replace("http://","").split("/")[0]
print(f"taler://pay/{host}/instances/{use_inst}/{oid}/?c={tok}")
PY
}
click_through() {
local labels=("$@")
local i lab misses=0
# Prefer dismissing ANR before hunting Confirm (low-RAM hosts thrash System UI)
for i in 1 2 3 4 5 6; do
if tap_text "Wait" 2>/dev/null || tap_text "Warten" 2>/dev/null; then
info "dismissed ANR (Wait)"
pause 2.0
misses=0
continue
fi
local hit=0
for lab in "${labels[@]}"; do
if tap_text "$lab" 2>/dev/null; then
hit=1
misses=0
pause 0.9
break
fi
done
if [[ "$hit" -eq 0 ]]; then
misses=$((misses + 1))
# stop early if hierarchy has nothing useful (avoid burning order deadlines)
[[ "$misses" -ge 3 ]] && break
fi
pause 0.5
done
}
if [[ -n "${WITHDRAW_AMOUNTS:-}" ]]; then
# shellcheck disable=SC2206
AMOUNTS=($WITHDRAW_AMOUNTS)
else
AMOUNTS=("${DEFAULT_AMOUNTS[@]}")
fi
if [[ -n "${PRODUCTS:-}" ]]; then
PAYS=()
for p in $PRODUCTS; do PAYS+=("product:$p"); done
else
PAYS=("${DEFAULT_PAYS[@]}")
fi
############################
step "0 device + wallet ($STACK / $CUR_HINT)"
adb_sh input keyevent KEYCODE_WAKEUP 2>/dev/null || true
adb_sh wm dismiss-keyguard 2>/dev/null || true
if [[ "$CLEAR" -eq 1 ]]; then
info "pm clear $PKG"
adb_sh pm clear "$PKG" >/dev/null 2>&1 || true
fi
adb_sh am force-stop "$PKG" 2>/dev/null || true
pause 0.4
# Prefer explicit MainActivity (from gui-workflows); fall back to VIEW launcher
if ! adb_sh am start -n "$ACT" >/dev/null 2>&1; then
info "MainActivity path failed — monkey launch"
adb_sh monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
fi
pause 2.0
shot "00-start"
############################
step "2 withdraw chain"
idx=0
for AMT in "${AMOUNTS[@]}"; do
idx=$((idx + 1))
step "2.$idx withdraw $AMT"
URI="$(mint_withdraw "$AMT" | head -1)"
[[ -n "$URI" ]] || die "mint failed for $AMT"
# wallet / monitoring QR rules: strip default ports
URI=$(printf '%s' "$URI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g')
info "URI=$URI"
echo "$URI" >>"$SHOTDIR/withdraw-uris.txt"
adb_sh am start -a android.intent.action.VIEW -d "$URI" "$PKG" >/dev/null
pause 1.5
shot "w${idx}-open"
click_through "Accept" "I accept" "Accept terms" "Confirm" "Continue" "Withdraw" "Next" "OK" "Agree" "Bestätigen" "Abheben" "Akzeptieren"
pause 3.0
shot "w${idx}-after"
adb_sh am start -n "$ACT" >/dev/null 2>&1 || true
pause 0.6
tap_text "Assets" 2>/dev/null || true
pause 0.4
shot "w${idx}-assets"
done
############################
step "3 pay chain (limit=$PAY_LIMIT)"
pidx=0
for PAY in "${PAYS[@]}"; do
pidx=$((pidx + 1))
[[ "$pidx" -gt "$PAY_LIMIT" ]] && break
step "3.$pidx pay $PAY"
if ! PAYURI="$(mint_pay "$PAY" | head -1)"; then
info "mint_pay failed for $PAY — skip"
continue
fi
[[ -n "$PAYURI" ]] || { info "empty pay uri — skip"; continue; }
PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g')
info "PAYURI=$PAYURI"
echo "$PAYURI" >>"$SHOTDIR/pay-uris.txt"
adb_sh am start -a android.intent.action.VIEW -d "$PAYURI" "$PKG" >/dev/null
pause 1.5
shot "p${pidx}-open"
click_through "Pay" "Confirm" "Accept" "Continue" "OK" "Next" "Bezahlen" "Bestätigen" "Payer"
pause 1.5
shot "p${pidx}-after"
adb_sh am start -n "$ACT" >/dev/null 2>&1 || true
pause 0.5
done
step "4 done → $SHOTDIR"
ls -la "$SHOTDIR" | tail -40
adb_sh dumpsys window 2>/dev/null | grep mCurrentFocus | head -1 || true
info "PKG=$PKG amounts=${AMOUNTS[*]} pays(limit)=$PAY_LIMIT"
echo "See GUI-AUTOMATION-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)"