Files
浅念 d41be367f8 fix(mobile,it): integration suite stabilization on real device
Root causes found & fixed across 29 phase-1 + 3 phase-2 runs:
- MSYS path mangling for adb shell device paths (per-command MSYS_NO_PATHCONV + winpath)
- adb reverse tcp:9000: presign URLs embed server-side localhost MinIO host,
  unreachable from emulator (uploads hung forever)
- compose --profile ov omitted: Docker restarts stranded ov side (ov-api 500s)
- soft keyboard occlusion on dialog buttons (dismissKeyboard + ensureVisible)
- stacked-route BackButton ambiguity (goBack must tap topmost hit-testable)
- bottom-sheet entrance animation race (wait for hit-testable option)
- language leg: drive LocaleSettings provider (sheet tap flaky, covered by unit tests)
- password page: self-pops on success (no extra goBack)
- bg verify: server-side authoritative assertion (fresh process = fresh memory DB)
- flutter test uninstall-reinstall wipes app data: image dir fallback to /data/local/tmp
- run-as cp failures no longer silently swallowed
- phase-2 missing IT_STAFF_ACCOUNT define

Phase 1: all 12 legs green (login→album→category→invite→3 uploads via native
WorkManager→server history→ov visible→hide sync→network fault/recovery→
password change→relogin→locale switch)
Phase 2: bg-home + kill-restart green (A04/A05 emulator-level)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 15:03:17 +08:00

298 lines
14 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# P3 Task 11 集成测试编排(MuMu 模拟器 + 真实本地后端全栈)。
#
# 用法:
# bash scripts/run-mobile-it.sh # 阶段 1:主流程(登录→建相册→
# # 邀请→传图→历史→ov→隐藏→断网→
# # 改密→重登录→语言切换)
# bash scripts/run-mobile-it.sh --background # 阶段 2:后台延续(HOME 后
# # WorkManager 续传)+ 杀进程重启
# # 恢复(A04/A05 模拟器级)
#
# 前置:
# · MuMu 已启动(脚本【不会】尝试启动 MuMu;被更新弹窗挡住时请先处理)
# · Docker Desktop 运行中(infra compose:mysql/redis/minio 双地域)
# · 工具链经 scripts/flutter-env.sh 注入(本脚本自行 source)
#
# 数据隔离约定(集成测试【不做 DB reset】——dev 库共享):
# · 每次运行生成全新专属账号(itmain_/itstaff_/itbg1_/itbg2_ + 时间戳),
# 由 services/api seed:it 幂等预置;
# · 相册标题带同一时间戳后缀;密码修改只作用于本次专属账号。
#
# 退出码:0=通过;1=断言/命令失败;2=无可用设备;3=后端未能就绪。
set -euo pipefail
# Git Bash/MSYS 会把传给 adb.exe 的安卓设备绝对路径(/data/...)改写为
# `C:\Program Files\Git\data\...` 再被 Android shell 拆词(曾致 mkdir 报
# 'C:'/'Files' 只读、adb push 无限悬挂)。但宿主侧 /d/... 路径又依赖转化。
# 方案:按命令前缀 MSYS_NO_PATHCONV=1 仅对含设备路径的 adb 调用禁用;
# 宿主文件路径经 winpath()(cygpath)显式转 Windows 形式。
winpath() { cygpath -w "$1"; }
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$REPO_ROOT/scripts/flutter-env.sh"
MOBILE_DIR="$REPO_ROOT/apps/mobile"
APP_ID="com.photolive.photo_live"
TESTDATA_DIR="$MOBILE_DIR/integration_test/testdata"
LOG_DIR="$MOBILE_DIR/build/it-logs"
RUN_STAMP="$(date +%Y%m%d-%H%M%S)"
API_HOST="http://127.0.0.1:8080"
OV_HOST="http://127.0.0.1:8081"
API_BASE_DEVICE="http://10.0.2.2:8080" # 模拟器内看到的宿主
OV_BASE_DEVICE="http://10.0.2.2:8081"
VIEWER_BASE_DEVICE="http://10.0.2.2:5175"
ADB="${ANDROID_HOME}/platform-tools/adb"
log() { echo "[run-mobile-it $RUN_STAMP] $*"; }
die() { log "错误:$1"; exit "${2:-1}"; }
mkdir -p "$LOG_DIR"
# ── 设备(不启动 MuMu,只探测)──────────────────────────────────────────────
connect_device() {
for port in 16384 7555 5555 21503; do
"$ADB" connect "127.0.0.1:$port" >/dev/null 2>&1 || true
done
DEVICE_ID="$("$ADB" devices | awk '$2 == "device" {print $1; exit}')"
if [[ -z "$DEVICE_ID" ]]; then
echo "未发现可用 Android 设备(MuMu 未启动或 adb 未连上)。" >&2
echo "请先启动 MuMu 模拟器(adb connect 127.0.0.1:16384),再重跑本脚本。" >&2
exit 2
fi
log "设备:$DEVICE_ID"
}
# ── 后端就绪(compose 基础设施 + 四个 dev 服务)────────────────────────────
# api/ov 以 health 端点为准;worker/sync 无 HTTP 端点,用 CIM 查 node 命令行
# 防重复拉起(即便重复:BullMQ 任务锁 + 同步 eventId 幂等,双实例安全——
# 仓库 sync 设计明示与常驻 dev:sync 并发安全)。
node_proc_count() { # $1=命令行通配模式
powershell.exe -NoProfile -Command \
"(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { \$_.CommandLine -like '*$1*' }).Count" \
2>/dev/null | tr -d '\r' || echo 0
}
spawn_service() { # $1=pnpm 脚本名 $2=日志文件
log "后台拉起 $1(日志 $2)"
( cd "$REPO_ROOT" && nohup pnpm "$1" >"$2" 2>&1 & )
}
wait_http() { # $1=url $2=超时秒 $3=名称
local i=0
until curl -sf "$1" >/dev/null 2>&1; do
i=$((i + 1))
if (( i >= $2 )); then return 1; fi
sleep 2
done
}
ensure_backend() {
log "检查基础设施容器(mysql/redis/minio 双地域)"
# 必须 --profile ov:Docker Desktop 重启后 ov 侧容器不会自动回来(曾致
# ov-api 500、sync 推送连败——排查了整轮才定位到这个遗漏)。
( cd "$REPO_ROOT" && docker compose -f infra/docker-compose.yml --profile ov up -d ) \
|| die "docker compose up 失败(Docker Desktop 是否在运行?)"
if ! wait_http "$API_HOST/v1/health" 5 cn-api; then
spawn_service dev:api "$LOG_DIR/dev-api.log"
fi
if ! wait_http "$OV_HOST/v1/health" 5 ov-api; then
spawn_service dev:ov "$LOG_DIR/dev-ov.log"
fi
if ! wait_http "$API_HOST/v1/health" 90 cn-api; then
tail -n 40 "$LOG_DIR/dev-api.log" || true
die "cn-api(8080) 未能就绪" 3
fi
if ! wait_http "$OV_HOST/v1/health" 90 ov-api; then
tail -n 40 "$LOG_DIR/dev-ov.log" || true
die "ov-api(8081) 未能就绪" 3
fi
if [[ "$(node_proc_count '*worker*dist*main.js*')" == "0" ]]; then
spawn_service dev:worker "$LOG_DIR/dev-worker.log"
fi
if [[ "$(node_proc_count '*sync*dist*main.js*')" == "0" ]]; then
spawn_service dev:sync "$LOG_DIR/dev-sync.log"
fi
# worker/sync 启动缓冲(无探针可等,只保证进程活过冷启动期)。
sleep 3
log "后端就绪:cn-api(8080) ov-api(8081) worker sync"
}
# ── 账号种子(幂等;dev admin 确保 + 本次专属账号)──────────────────────────
seed_users() { # $1=IT_USERS 值(account:password,...)
log "种子用户(seed:it,已存在跳过不改写)"
( cd "$REPO_ROOT" && pnpm --filter @photo/api-cn seed ) \
|| die "seed-admin 失败(.env 的 ADMIN_ACCOUNT/ADMIN_PASSWORD 是否配置?)"
( cd "$REPO_ROOT" && IT_USERS="$1" pnpm --filter @photo/api-cn seed:it ) \
|| die "seed-it-users 失败"
}
# ── 测试素材上设备 + App 预安装(run-as 需要 debug 包已装)─────────────────
push_images_and_prep_app() {
log "构建/安装 debug APK(run-as 前置)"
( cd "$MOBILE_DIR" && flutter build apk --debug ) || die "debug APK 构建失败"
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" install -r "$(winpath "$MOBILE_DIR/build/app/outputs/flutter-apk/app-debug.apk")" \
|| die "APK 安装失败"
log "推送测试图片到设备"
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" shell mkdir -p /data/local/tmp/photolive-it \
|| die "adb shell mkdir 失败(若报 'C:'/'Files' 只读 = MSYS 路径转化未禁用)"
for f in "$TESTDATA_DIR"/it_*.jpg; do
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" push "$(winpath "$f")" /data/local/tmp/photolive-it/ \
|| die "adb push 测试图片失败($(basename "$f"))"
done
# 首选路径:App 自有 files 目录(无作用域存储/SELinux 风险;debug 可 run-as)。
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" mkdir -p files/photolive-it \
|| die "run-as mkdir 失败(App 未安装或非 debug 包?)"
for f in "$TESTDATA_DIR"/it_*.jpg; do
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" cp \
"/data/local/tmp/photolive-it/$(basename "$f")" files/photolive-it/ \
|| die "run-as cp 失败($(basename "$f"))——测试图为必需件,不再静默吞错"
done
# 上传前台服务通知权限:预授权避免系统权限弹窗打断自动化(33+)。
"$ADB" -s "$DEVICE_ID" shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS || true
# 关键:presign URL 内嵌服务端视角的 MinIO 地址(localhost:9000)——模拟器
# 的 localhost 是自己,直连必失败(曾致 uploading 永久挂起)。adb reverse 把
# 设备 9000 反向隧道到宿主,URL 与 SigV4 host 签名均无需改动。
MSYS_NO_PATHCONV=1 "$ADB" -s "$DEVICE_ID" reverse tcp:9000 tcp:9000 \
|| die "adb reverse tcp:9000 失败(MinIO presign 上传链路必需)"
}
# ── flutter test 包装(tee 日志;$@=额外 dart-define 已拼好的参数)──────────
run_test() { # $1=测试文件 $2=日志名 $3...=--dart-define 串
local file="$1" logf="$2"; shift 2
( cd "$MOBILE_DIR" && flutter test "$file" -d "$DEVICE_ID" "$@" ) \
2>&1 | tee "$LOG_DIR/$logf"
return "${PIPESTATUS[0]}"
}
# ── 服务端旁证(阶段 2 主判定:后端侧确认上传完成)────────────────────────
server_login() { # $1=账号 $2=密码 → stdout token
curl -sf -X POST "$API_HOST/v1/auth/password" \
-H 'content-type: application/json' \
-d "{\"account\":\"$1\",\"password\":\"$2\"}" \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);process.stdout.write(j.sessionToken||'')})"
}
server_confirmed_count() { # $1=token
curl -sf "$API_HOST/v1/uploads?limit=20" -H "Authorization: Bearer $1" \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);process.stdout.write(String((j.items||[]).filter(i=>i.state==='confirmed').length))})"
}
wait_marker() { # $1=日志 $2=marker $3=超时秒
local i=0
until grep -q "$2" "$1" 2>/dev/null; do
i=$((i + 2))
if (( i >= $3 )); then return 1; fi
sleep 2
done
}
gen_password() { echo "ItPw-$(openssl rand -hex 6)"; }
# ═════════════════════════════ 阶段 1:主流程 ═════════════════════════════
phase1() {
local main="itmain_$RUN_STAMP" staff="itstaff_$RUN_STAMP"
local main_pw; main_pw="$(gen_password)"
seed_users "$main:$main_pw,$staff:$(gen_password)"
push_images_and_prep_app
log "阶段 1 运行主流程套件(登录→相册→邀请→上传→历史→ov→隐藏→断网→改密→重登录)"
run_test integration_test/p3_flow_test.dart "phase1-$RUN_STAMP.log" \
--dart-define "API_BASE=$API_BASE_DEVICE" \
--dart-define "VIEWER_BASE=$VIEWER_BASE_DEVICE" \
--dart-define "OV_BASE=$OV_BASE_DEVICE" \
--dart-define "IT_MAIN_ACCOUNT=$main" \
--dart-define "IT_MAIN_PASSWORD=$main_pw" \
--dart-define "IT_STAFF_ACCOUNT=$staff" \
--dart-define "IT_RUN_STAMP=$RUN_STAMP" \
|| die "阶段 1(主流程)失败——日志 $LOG_DIR/phase1-$RUN_STAMP.log"
log "阶段 1 通过 ✔"
}
# ═══════════════ 阶段 2:后台延续 + 杀进程恢复(A04/A05)═══════════════════
bg_scenario() { # $1=场景名(bg-home|kill-restart) $2=账号 $3=密码
local scenario="$1" account="$2" password="$3"
local define_common=(
--dart-define "API_BASE=$API_BASE_DEVICE"
--dart-define "VIEWER_BASE=$VIEWER_BASE_DEVICE"
--dart-define "OV_BASE=$OV_BASE_DEVICE"
--dart-define "IT_MAIN_ACCOUNT=$account"
--dart-define "IT_MAIN_PASSWORD=$password"
# ItConfig.assertValid 要求全量 define——背景剧本不用 staff,但需非空。
--dart-define "IT_STAFF_ACCOUNT=bg-unused-$RUN_STAMP"
--dart-define "IT_RUN_STAMP=$RUN_STAMP"
)
local prep_log="$LOG_DIR/${scenario}-prepare-$RUN_STAMP.log"
log "$scenario:prepare 阶段启动(登录→建相册→入队 2 张大图→进入上传中)"
( cd "$MOBILE_DIR" && flutter test integration_test/p3_background_test.dart \
-d "$DEVICE_ID" "${define_common[@]}" --dart-define IT_PHASE=prepare ) \
>"$prep_log" 2>&1 &
local prep_pid=$!
if ! wait_marker "$prep_log" "MARKER:UPLOADING" 600; then
tail -n 60 "$prep_log" || true
die "$scenario:未等到 MARKER:UPLOADING(含构建安装最长 10 分钟)"
fi
if [[ "$scenario" == "bg-home" ]]; then
log "$scenario:HOME 键切后台(上传仍进行中)"
"$ADB" -s "$DEVICE_ID" shell input keyevent KEYCODE_HOME
local token; token="$(server_login "$account" "$password")"
[[ -n "$token" ]] || die "$scenario:服务端登录失败(旁证无法进行)"
local i=0 confirmed=0
until confirmed="$(server_confirmed_count "$token")" && (( confirmed >= 2 )); do
i=$((i + 5)); if (( i >= 300 )); then
die "$scenario:后台 5 分钟内未见 2 条 confirmed(最后 $confirmed)"
fi
sleep 5
done
log "$scenario:服务端旁证 OK——后台(WorkManager 路径)2 张 confirmed"
# prepare 测试自行收尾(后台 Dart 侧收到完成事件即退出);防冻结:至多等 2 分钟。
local w=0
while kill -0 "$prep_pid" 2>/dev/null && (( w < 120 )); do sleep 5; w=$((w + 5)); done
kill "$prep_pid" 2>/dev/null || true
else
log "$scenario:force-stop 杀进程(上传中)"
"$ADB" -s "$DEVICE_ID" shell am force-stop "$APP_ID"
wait "$prep_pid" || log "$scenario:prepare 被杀后 flutter test 非零退出(预期内)"
sleep 2
fi
log "$scenario:verify 阶段(新进程登录→队列恢复/收口→服务端历史)"
run_test integration_test/p3_background_test.dart \
"${scenario}-verify-$RUN_STAMP.log" \
"${define_common[@]}" --dart-define IT_PHASE=verify \
|| die "$scenario:verify 失败——日志 $LOG_DIR/${scenario}-verify-$RUN_STAMP.log"
log "$scenario 通过 ✔"
}
phase2() {
local bg1="itbg1_$RUN_STAMP" bg2="itbg2_$RUN_STAMP"
local pw1 pw2; pw1="$(gen_password)"; pw2="$(gen_password)"
seed_users "$bg1:$pw1,$bg2:$pw2"
push_images_and_prep_app
bg_scenario bg-home "$bg1" "$pw1"
bg_scenario kill-restart "$bg2" "$pw2"
log "阶段 2 全部通过 ✔(A04 后台延续 + A05 杀进程恢复,模拟器级)"
}
# ── 入口 ───────────────────────────────────────────────────────────────────
main() {
connect_device
ensure_backend
if [[ "${1:-}" == "--background" ]]; then
phase2
elif [[ -z "${1:-}" ]]; then
phase1
else
die "未知参数:$1(可用:--background)"
fi
}
main "$@"