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 产物,故不新发版本。
97 lines
3.2 KiB
GDScript
97 lines
3.2 KiB
GDScript
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
|