d41be367f8
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>
298 lines
14 KiB
Bash
298 lines
14 KiB
Bash
#!/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 "$@"
|