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:
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user