tests/test_pose_server.gd 用的是**同引擎内的客户端**(Godot WebSocketPeer),而真实
对端是 Android(Kotlin/OkHttp)——同引擎自测会共享同样的假设(超时判定、分包、
文本/二进制边界、关闭语义、子协议协商),于是「测试全绿」不等于「Android 连得上」。
契约 §7 也写明:静默超时要用保活避免,而不是靠重连掩盖;这句话只有用独立实现的
客户端实测才算验过。
新增三个文件:
* build/integration/probe-ws.gd —— 把**包内** PoseServer 跑起来(--main-pack +
只加载包内代码),把连接/控制帧/帧/断开事件汇总成 JSON 报告;自身不做判定
(避免「用被测实现判定被测实现」)。支持 --stop-file 提前收尾。
* build/integration/ws-client.mjs —— Node 实现的契约一致性客户端(用内置
WebSocket,零依赖),逐条跑 §1 §1.1 §2 §3.1-§3.6 §5 §6 §7 的场景。
* build/accept-ws.sh —— 编排:解包 → 起探针 → 等监听 → 跑客户端 → 收报告 →
核对客户端看不到的那一半(frames_dropped / malformed_frames / 控制帧计数 /
device_replaced / 关闭原因 / 静默超时消息带毫秒数与阈值)。
实测(**已发布**的 v0.2.3 资产重新下载后跑):客户端 30 通过 / 0 失败 + 服务端侧
16 项事实全过。关键两条:
* 保活对照:只发应用层 ping 持续 19 秒(> 静默超时 15 秒)连接**仍保持打开**;
* 静默超时:完全不发数据 → close(1001,"timeout"),实测 15011 ms(阈值 15000 ms);
另有:子协议协商、welcome 声明保活参数且 heartbeat(5000) < silentTimeout(15000)/2、
pong 原样回传 sentAtMs、同 deviceId 替换旧连接(1000 "replaced by new connection")、
bye → 1000、协议不匹配 → bye + 1002、72 帧含故意跳号(服务端 frames_dropped=3)
与故意 100 字节非法帧(malformed_frames=1)。
反向验证:新增**故意不合规的服务端** build/integration/probe-ws-bad.gd
(welcome 不声明保活字段、不带 sessionId、收到 ping 不回 pong、不做 deviceId 去重),
对同一客户端 → 14 项 ✗、退出码 1,证明断言不是空转。
踩坑记录(已写入任务文档):
1. --script 模式下从无类型数组取 peer,peer.get_packet() 返回类型推不出来,
var x := … 直接 Parse Error;
2. 复核静默超时消息时写了否定式断言 !includes("静默超时 1"),把正确行为判成失败 ——
断言应写成正向模式匹配;
3. 探针原本只能按固定时长跑,编排脚本得猜时间;改成客户端跑完 touch stop 文件。
顺带:package-release.sh 结尾把三条发布前验收(产物自证 / WS 契约一致性 / 跨端集成)
一次列全。不影响已发布的 v0.2.3 产物,故不新发版本。
187 lines
8.2 KiB
Bash
Executable File
187 lines
8.2 KiB
Bash
Executable File
#!/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 "============================================================"
|