test(ws): WebSocket 契约一致性验收(独立实现 × 包内 PoseServer)+ 故意不合规的负例服务端

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 产物,故不新发版本。
This commit is contained in:
2026-09-17 05:32:24 +08:00
parent 6238873312
commit 0ffb2d43bb
8 changed files with 904 additions and 2 deletions
+175
View File
@@ -0,0 +1,175 @@
extends SceneTree
## WebSocket 契约一致性探针(T-WIN-020)
##
## 用法(由 build/accept-ws.sh 调用,也可手工执行):
## godot --headless --main-pack <包内 OpenDance.pck> \
## --script /绝对路径/build/integration/probe-ws.gd \
## -- --port 18765 --duration-ms 60000 --report /tmp/ws-report.json
##
## 为什么需要它:`tests/test_pose_server.gd` 用的是**同引擎内的客户端**
## (Godot 自己写的 WebSocketPeer),而真实对端是 **Android(Kotlin/OkHttp)**。
## 同引擎内的自测会共享同样的假设(超时判定、分包、文本/二进制边界、关闭语义),
## 因此**必须有一个独立实现的客户端**来对照 —— 这就是本探针 + Node 客户端的组合:
## * 本探针:把**发布包里的** PoseServer 跑起来(只加载包内代码),
## 把收到的连接/控制/帧/断开事件汇总成 JSON 报告;
## * `build/integration/ws-client.mjs`:Node 端按契约 v0.2.0 逐条跑场景并断言。
##
## 探针自身不做判定(只收集事实),判定在 Node 客户端与 accept-ws.sh 里,避免
## 「用被测实现来判定被测实现」。
const DEFAULT_PORT := 18765
const DEFAULT_DURATION_MS := 60_000
var _server: Node = null
var _started := false
var _done := false
var _start_ms := 0
var _port := DEFAULT_PORT
var _duration_ms := DEFAULT_DURATION_MS
var _report_path := "/tmp/opendance-ws-report.json"
## 可选:客户端跑完后 touch 这个文件,探针提前收尾(比固定时长更快、更稳)
var _stop_file := ""
var _events: Array = []
var _connected: Array = []
var _disconnected: Array = []
var _replaced: Array = []
var _controls := {}
var _frames := 0
var _last_frame_id := -1
var _frame_id_regressions := 0
var _first_frame_ms := 0
var _last_frame_ms := 0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
match args[i]:
"--port":
_port = int(args[i + 1]); i += 2
"--duration-ms":
_duration_ms = int(args[i + 1]); i += 2
"--report":
_report_path = args[i + 1]; i += 2
"--stop-file":
_stop_file = args[i + 1]; i += 2
_:
i += 1
func _process(_delta: float) -> bool:
if not _started:
_started = true
_start_ms = Time.get_ticks_msec()
_start_server()
if _done:
return true
var elapsed := Time.get_ticks_msec() - _start_ms
if elapsed >= _duration_ms:
_finish()
elif _stop_file != "" and FileAccess.file_exists(_stop_file):
_finish()
return _done
func _start_server() -> void:
var script: GDScript = load("res://src/net/pose_server.gd")
if script == null:
print("[ws] ✗ 无法加载 res://src/net/pose_server.gd")
_finish(2)
return
_server = script.new()
_server.name = "ProbePoseServer"
root.add_child(_server)
_server.listening.connect(_on_listening)
_server.listen_failed.connect(_on_listen_failed)
_server.client_connected.connect(_on_client_connected)
_server.client_disconnected.connect(_on_client_disconnected)
_server.device_replaced.connect(_on_device_replaced)
_server.frame_received.connect(_on_frame_received)
_server.control_received.connect(_on_control_received)
var err: int = _server.listen("127.0.0.1", _port)
if err != OK:
print("[ws] ✗ listen(%d) 失败:%s" % [_port, error_string(err)])
_finish(2)
func _on_listening(port: int) -> void:
# accept-ws.sh 靠这一行判断「服务端已就绪」
print("[ws] listening on 127.0.0.1:%d(契约 §1 端点)" % port)
func _on_listen_failed(port: int, reason: String) -> void:
print("[ws] ✗ 监听 %d 失败:%s" % [port, reason])
func _now_ms() -> int:
return Time.get_ticks_msec() - _start_ms
func _on_client_connected(client_id: int, device: String) -> void:
_connected.append({"id": client_id, "device": device, "atMs": _now_ms()})
_events.append({"atMs": _now_ms(), "kind": "connected", "id": client_id, "device": device})
func _on_client_disconnected(client_id: int, reason: String) -> void:
_disconnected.append({"id": client_id, "reason": reason, "atMs": _now_ms()})
_events.append({"atMs": _now_ms(), "kind": "disconnected", "id": client_id, "reason": reason})
func _on_device_replaced(device_id: String) -> void:
_replaced.append(device_id)
_events.append({"atMs": _now_ms(), "kind": "device_replaced", "deviceId": device_id})
func _on_frame_received(frame) -> void:
_frames += 1
if _first_frame_ms == 0:
_first_frame_ms = _now_ms()
_last_frame_ms = _now_ms()
if _last_frame_id >= 0 and frame.frame_id <= _last_frame_id:
_frame_id_regressions += 1
_last_frame_id = frame.frame_id
func _on_control_received(client_id: int, message: Dictionary) -> void:
var type := str(message.get("type", "(无 type)"))
_controls[type] = int(_controls.get(type, 0)) + 1
_events.append({"atMs": _now_ms(), "kind": "control", "id": client_id, "type": type})
func _finish(code: int = 0) -> void:
if _done:
return
_done = true
var report := {
"port": _port,
"durationMs": _now_ms(),
"server": _server.snapshot() if _server != null else {},
"connected": _connected,
"disconnected": _disconnected,
"deviceReplaced": _replaced,
"controls": _controls,
"framesReceived": _frames,
"lastFrameId": _last_frame_id,
"frameIdRegressions": _frame_id_regressions,
"firstFrameAtMs": _first_frame_ms,
"lastFrameAtMs": _last_frame_ms,
"events": _events,
}
if _server != null:
_server.stop()
var file := FileAccess.open(_report_path, FileAccess.WRITE)
if file == null:
print("[ws] ✗ 无法写入报告 %s" % _report_path)
else:
file.store_string(JSON.stringify(report, " "))
file.close()
print("[ws] 报告已写入 %s(连接 %d、帧 %d、控制帧 %s、替换 %d)" % [
_report_path, _connected.size(), _frames, JSON.stringify(_controls), _replaced.size()])
quit(code)