test(mobile): integration suite against real backend on mumu

P3 Task 11 (WRITE & COMPILE phase; device run pending MuMu availability).

- integration_test/p3_flow_test.dart: login -> album (zh+en) -> category
  -> invite -> enqueue 3 real JPEGs -> queue completed -> server history
  (sourceDeviceId) -> ov public API sees 3 -> hide via API + UI sync ->
  DIO fault-gate offline (waitingNetwork -> retry -> 4 completed) ->
  password change (session kept) -> relogin -> in-app locale smoke.
- integration_test/p3_background_test.dart: IT_PHASE=prepare/verify spec
  for HOME-background continuation and force-stop kill-restart recovery
  (2 x ~14MB noise JPEGs give a deterministic mid-flight window).
- helpers: dart-define config, fault-injecting DIO adapter, app harness
  (picker/notification/locale provider overrides), UI step library with
  live-binding polling (no pumpAndSettle).
- scripts/run-mobile-it.sh: device detect (exit 2), backend ensure
  (compose + api/ov/worker/sync, exit 3), idempotent per-run user seed
  (no DB reset), APK build/install + adb push of testdata, phase 1 /
  --background phase 2 orchestration with server-side confirmed-count
  truth via curl.
- services/api seed:it script (create-if-absent staff accounts).
- debug-only network security config: cleartext HTTP permitted to
  10.0.2.2/127.0.0.1/localhost only (release unaffected).
- testdata: 3 x ~120KB + 2 x ~14MB generated JPEGs.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 05:35:16 +08:00
parent 8f3966d7b5
commit fb3c278e50
18 changed files with 1409 additions and 3 deletions
+277
View File
@@ -0,0 +1,277 @@
#!/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
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=名 $2=pnpm 脚本 $3=日志文件
log "后台拉起 $1(日志 $3)"
( cd "$REPO_ROOT" && nohup pnpm "$2" >"$3" 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 双地域)"
( cd "$REPO_ROOT" && docker compose -f infra/docker-compose.yml 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 构建失败"
"$ADB" -s "$DEVICE_ID" install -r "$MOBILE_DIR/build/app/outputs/flutter-apk/app-debug.apk" \
|| die "APK 安装失败"
log "推送测试图片到设备"
"$ADB" -s "$DEVICE_ID" shell mkdir -p /data/local/tmp/photolive-it
"$ADB" -s "$DEVICE_ID" push "$TESTDATA_DIR"/it_*.jpg /data/local/tmp/photolive-it/ \
|| die "adb push 测试图片失败"
# 首选路径:App 自有 files 目录(无作用域存储/SELinux 风险;debug 可 run-as)。
"$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" mkdir -p files/photolive-it || true
for f in "$TESTDATA_DIR"/it_*.jpg; do
"$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" cp \
"/data/local/tmp/photolive-it/$(basename "$f")" files/photolive-it/ || true
done
# 上传前台服务通知权限:预授权避免系统权限弹窗打断自动化(33+)。
"$ADB" -s "$DEVICE_ID" shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS || true
}
# ── 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"
--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 "$@"