Files
opendance-windows/build/integration/probe-ws.gd
T
zijunchiang 0ffb2d43bb 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 产物,故不新发版本。
2026-09-17 05:32:24 +08:00

176 lines
5.7 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)