#!/usr/bin/env bash # ============================================================ # OpenDance Studio Windows 客户端 —— **WebSocket 契约一致性验收**(T-WIN-020) # # contracts/websocket-keypoints.md v0.2.0 × 发布包里的 PoseServer # # 为什么需要它:本仓的 `tests/test_pose_server.gd` 用的是**同引擎内的客户端** # (Godot 自己的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。 # 同引擎自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义、 # 子协议协商)——**必须有一个独立实现的客户端来对照**: # * `build/integration/probe-ws.gd`:把**发布包里**的 PoseServer 跑起来 # (只加载包内代码),把连接/控制/帧/断开事件汇总成 JSON 报告; # * `build/integration/ws-client.mjs`:Node 实现的契约一致性客户端, # 逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景并断言; # * 本脚本:编排两者,并把**服务端侧**的事实(丢帧计数、malformed、去重事件、 # 关闭原因)也一并核对 —— 客户端只能看到自己那一半。 # # 用法: # GODOT_BIN=/path/to/godot bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3.zip # GODOT_BIN=... bash build/accept-ws.sh dist-release/opendance-windows-v0.2.3 # 已解压目录 # ... --skip-slow # 跳过「保活 19s」与「静默超时 15s」两个耗时场景 # # 可选环境变量: # PORT 监听端口(默认 18765) # WORK_DIR 工作目录(默认 mktemp -d) # ============================================================ set -uo pipefail PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$PROJECT_DIR" TARGET="${1:-}" SKIP_SLOW="" for arg in "$@"; do case "$arg" in --skip-slow) SKIP_SLOW="--skip-slow" ;; "$TARGET") : ;; *) echo "✗ 未知参数:$arg(只支持 --skip-slow)" >&2; exit 2 ;; esac done PORT="${PORT:-18765}" WORK_DIR="${WORK_DIR:-$(mktemp -d)}" mkdir -p "$WORK_DIR" REPORT="$WORK_DIR/ws-report.json" STOP_FILE="$WORK_DIR/ws-stop" PROBE_LOG="$WORK_DIR/probe-ws.log" FAILED=0 log() { printf '\033[1;34m[accept-ws]\033[0m %s\n' "$*"; } fail() { printf '\033[1;31m[accept-ws] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; } if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then echo "用法:GODOT_BIN=... bash build/accept-ws.sh <发布包 zip 或已解压目录> [--skip-slow]" >&2 exit 2 fi # ---- 1. 取包内 pck ---- if [ -f "$TARGET" ]; then log "解压发布包…" if ! unzip -qq -o "$TARGET" -d "$WORK_DIR" 2>/dev/null; then command -v 7z >/dev/null 2>&1 && 7z x -y -o"$WORK_DIR" "$TARGET" >/dev/null || { fail "解压失败"; exit 1; } fi PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)" else PACKAGE_DIR="$TARGET" fi PCK="$PACKAGE_DIR/OpenDance.pck" [ -f "$PCK" ] || { fail "找不到包内 pck:$PCK"; exit 1; } log "包内 pck:$PCK" # ---- 2. 启探针(包内 PoseServer)---- GODOT_BIN="${GODOT_BIN:-}" if [ -z "$GODOT_BIN" ]; then for candidate in godot godot4 Godot; do command -v "$candidate" >/dev/null 2>&1 && { GODOT_BIN="$candidate"; break; } done fi [ -n "$GODOT_BIN" ] || { fail "未找到 Godot(用 GODOT_BIN=<路径> 指定,需 4.7.x)"; exit 1; } rm -f "$REPORT" "$STOP_FILE" log "启动包内 PoseServer 探针(端口 $PORT)…" nohup "$GODOT_BIN" --headless --main-pack "$PCK" \ --script "$PROJECT_DIR/build/integration/probe-ws.gd" \ -- --port "$PORT" --duration-ms 180000 --report "$REPORT" --stop-file "$STOP_FILE" \ >"$PROBE_LOG" 2>&1 & PROBE_PID=$! cleanup() { kill "$PROBE_PID" 2>/dev/null || true; } trap cleanup EXIT READY=0 for _ in $(seq 1 60); do grep -q "\[ws\] listening" "$PROBE_LOG" 2>/dev/null && { READY=1; break; } kill -0 "$PROBE_PID" 2>/dev/null || break sleep 0.5 done if [ "$READY" != 1 ]; then fail "探针 30 秒内未开始监听" tail -20 "$PROBE_LOG" | sed 's/^/ /' exit 1 fi grep "\[ws\] listening" "$PROBE_LOG" | sed 's/^/ /' # ---- 3. 跑契约一致性客户端 ---- log "契约一致性客户端(Node 独立实现)…" CLIENT_LOG="$WORK_DIR/ws-client.log" if timeout 300 node "$PROJECT_DIR/build/integration/ws-client.mjs" \ --url "ws://127.0.0.1:$PORT/pose" $SKIP_SLOW >"$CLIENT_LOG" 2>&1; then CLIENT_OK=1 else CLIENT_OK=0 fi sed 's/^/ /' "$CLIENT_LOG" [ "$CLIENT_OK" = 1 ] || fail "契约一致性客户端未全通过" # ---- 4. 收尾并取服务端侧报告 ---- touch "$STOP_FILE" for _ in $(seq 1 40); do kill -0 "$PROBE_PID" 2>/dev/null || break sleep 0.5 done if [ ! -f "$REPORT" ]; then fail "探针没有产出报告($REPORT)" tail -20 "$PROBE_LOG" | sed 's/^/ /' exit 1 fi # ---- 5. 服务端侧事实核对 ---- log "服务端侧事实核对(客户端看不到的那一半):" node -e ' const fs = require("node:fs"); const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const skipSlow = process.argv[2] === "1"; let bad = 0; const say = (ok, text) => { console.log(` ${ok ? "✓" : "✗"} ${text}`); if (!ok) bad = 1; }; say(r.server.silent_timeout_ms === 15000 && r.server.heartbeat_interval_ms === 5000, `服务端声明 silentTimeoutMs=${r.server.silent_timeout_ms} / heartbeatIntervalMs=${r.server.heartbeat_interval_ms}`); say(r.connected.length >= 6, `收到 ${r.connected.length} 次连接(握手完成)`); say(r.framesReceived >= 72, `收到 ${r.framesReceived} 个关键点帧`); say(r.lastFrameId >= 74, `最大 frame_id = ${r.lastFrameId}`); say(r.frameIdRegressions === 0, `frame_id 无回退(回退 ${r.frameIdRegressions} 次)`); // 客户端故意跳号 60→63,服务端应按 §8 计入丢帧 say(r.server.frames_dropped >= 3, `丢帧检测:frames_dropped=${r.server.frames_dropped}(期望 ≥3,来自故意的 60→63 跳号)`); // 客户端故意发 100 字节非法帧,服务端应计数且不崩 say(r.server.malformed_frames === 1, `非法帧计数:malformed_frames=${r.server.malformed_frames}(期望 1)`); // 控制帧分类计数 const c = r.controls; say((c.hello ?? 0) >= 6, `hello=${c.hello ?? 0};ping=${c.ping ?? 0};stats=${c.stats ?? 0};bye=${c.bye ?? 0}`); say((c.ping ?? 0) >= (skipSlow ? 1 : 8), `应用层 ping 计数=${c.ping ?? 0}(保活场景)`); // 去重事件 say(Array.isArray(r.deviceReplaced) && r.deviceReplaced.includes("aaaa000000000001"), `device_replaced 事件:${JSON.stringify(r.deviceReplaced)}`); // 断开原因:替换(1000) / 静默超时 / bye(1000) / 协议错误(1002) const reasons = r.disconnected.map((x) => x.reason).join(" | "); say(reasons.includes("被同一设备的新连接替换"), "服务端记录了「同设备替换」断开原因"); say(reasons.includes("user_stop"), "服务端记录了 bye 的 reason"); say(reasons.includes("code=1002"), "服务端记录了协议不匹配的 1002 关闭"); if (!skipSlow) { say(reasons.includes("静默超时"), "服务端记录了静默超时踢人"); // 写这条时第一版写成了 !reasons.includes("静默超时 1") —— 恰好把「消息里带 15001 ms」 // 这件正确的事判成了失败。断言要写成**正向的模式匹配**,别用容易反转语义的否定式。 say(/静默超时 \d+ ms(阈值 \d+ ms/.test(reasons), "静默超时消息带实测毫秒数与阈值(可核对):" + (reasons.match(/静默超时 [^|]*/) ?? [""])[0].trim()); } process.exit(bad); ' "$REPORT" "$([ -n "$SKIP_SLOW" ] && echo 1 || echo 0)" || fail "服务端侧事实核对未通过" # 探针日志里不允许出现脚本错误(WARNING 是故意的负例产生的,允许) if grep -qE "SCRIPT ERROR|Parse Error" "$PROBE_LOG"; then fail "探针日志出现脚本错误" grep -E "SCRIPT ERROR|Parse Error" "$PROBE_LOG" | head -5 | sed 's/^/ /' else echo " ✓ 探针日志无脚本错误(负例产生的 WARNING 属预期)" fi echo "" if [ "$FAILED" -ne 0 ]; then echo "============================================================" echo " WebSocket 契约一致性验收失败(工作目录:$WORK_DIR)" echo "============================================================" exit 1 fi echo "============================================================" echo " WebSocket 契约一致性验收全部通过(工作目录:$WORK_DIR)" echo "============================================================"