Files
opendance-windows/build/integration/probe-ws-bad.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

97 lines
3.2 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
## 反例服务端(T-WIN-020 的**负例**夹具):故意不遵守 websocket-keypoints v0.2.0。
##
## 用途:证明 `build/integration/ws-client.mjs` 的断言**真的会失败**。
## 一个只跑通过用例的检查器,无法区分「契约被遵守」与「断言写得太松」。
##
## 故意违反的条款:
## * §3.2 welcome **不声明** silentTimeoutMs / heartbeatIntervalMs(v0.2.0 硬性要求);
## * §3.2 welcome 不带 sessionId;
## * §3.6 收到应用层 ping **不回** pong;
## * §5 不做 deviceId 去重(第二个同 deviceId 连接不会踢掉第一个)。
##
## 用法:与 probe-ws.gd 相同(`--port` / `--duration-ms` / `--report` / `--stop-file`),
## 但本文件只记录最小事实。期望结果:ws-client.mjs 出现多项 ✗ 并以非 0 退出。
const DEFAULT_PORT := 18770
const DEFAULT_DURATION_MS := 60_000
var _tcp := TCPServer.new()
var _peers: Array = []
var _started := false
var _done := false
var _start_ms := 0
var _port := DEFAULT_PORT
var _duration_ms := DEFAULT_DURATION_MS
var _report_path := ""
var _stop_file := ""
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()
var err := _tcp.listen(_port, "127.0.0.1")
if err != OK:
print("[bad-ws] ✗ listen 失败:%s" % error_string(err))
quit(2)
return true
print("[bad-ws] listening on 127.0.0.1:%d(**故意不合规**的服务端)" % _port)
while _tcp.is_connection_available():
var conn := _tcp.take_connection()
if conn == null:
continue
var peer := WebSocketPeer.new()
peer.supported_protocols = PackedStringArray(["opendance.pose.v1"])
if peer.accept_stream(conn) == OK:
_peers.append(peer)
for peer in _peers:
peer.poll()
while peer.get_available_packet_count() > 0:
# peer 来自无类型数组,返回值类型推不出来,这里显式标注
var packet: PackedByteArray = peer.get_packet()
if peer.was_string_packet():
var text: String = packet.get_string_from_utf8()
var parsed: Variant = JSON.parse_string(text)
if typeof(parsed) == TYPE_DICTIONARY and str(parsed.get("type", "")) == "hello":
# 违规 1/2/3:缺保活字段、缺 sessionId、且此后不回 pong
peer.send_text(JSON.stringify({
"type": "welcome",
"protocol": "opendance.pose.v1",
"expectedFps": 30,
}))
if _done:
return true
var elapsed := Time.get_ticks_msec() - _start_ms
if elapsed >= _duration_ms or (_stop_file != "" and FileAccess.file_exists(_stop_file)):
if _report_path != "":
var file := FileAccess.open(_report_path, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify({"server": "bad-ws", "peers": _peers.size()}, " "))
file.close()
print("[bad-ws] 收尾(连接 %d,未做去重、未回 pong)" % _peers.size())
_done = true
quit(0)
return _done