#!/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 "$@"