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:
@@ -0,0 +1,96 @@
|
||||
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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WebSocket 契约一致性客户端(T-WIN-020)
|
||||
*
|
||||
* 独立的 Node 实现,按 contracts/websocket-keypoints.md **v0.2.0** 逐条跑场景,
|
||||
* 对发布包里的 Windows `PoseServer` 做契约一致性验证。
|
||||
*
|
||||
* 为什么不用 Godot 自己的客户端:同引擎内的自测会共享同样的假设
|
||||
* (超时判定、分包、文本/二进制边界、关闭语义、子协议协商)。
|
||||
* 真实对端是 Android(Kotlin/OkHttp),所以必须用**另一套实现**来对照。
|
||||
*
|
||||
* 用法:
|
||||
* node build/integration/ws-client.mjs --url ws://127.0.0.1:18765/pose [--skip-slow]
|
||||
*
|
||||
* 场景(对应契约章节):
|
||||
* §1/§3.2 子协议协商 + hello → welcome(含 silentTimeoutMs / heartbeatIntervalMs)
|
||||
* §1.1 heartbeatIntervalMs 必须 < silentTimeoutMs / 2
|
||||
* §2 239 字节二进制帧(magic/version/flags/frame_id/33×7B),含 flags 位
|
||||
* §3.4 stats 文本帧
|
||||
* §3.6 应用层 ping → pong(sentAtMs 原样回传)
|
||||
* §5 同 deviceId 新连接替换旧连接:旧连接收到 close(1000, "replaced…")
|
||||
* §1.1 只发 ping 时连接**不会**被静默超时踢掉(19 秒对照,--skip-slow 可跳过)
|
||||
* §1.1/§7 完全不发数据 → 服务端 close(1001, "timeout")
|
||||
* §3.5 bye → 服务端 close(1000)
|
||||
* §3.1/§6 协议版本不匹配 → bye(reason=protocol_mismatch) + close(1002)
|
||||
* §2 非法二进制帧 → 服务端不崩且计入 malformed(由探针报告核对)
|
||||
*
|
||||
* 退出码:0 全通过 / 1 有失败 / 2 用法或连接问题。
|
||||
*/
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function argValue(name, fallback) {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback;
|
||||
}
|
||||
const URL_ = argValue('--url', 'ws://127.0.0.1:18765/pose');
|
||||
const SUBPROTOCOL = 'opendance.pose.v1';
|
||||
const SKIP_SLOW = args.includes('--skip-slow');
|
||||
|
||||
const MAGIC = 0x4f44;
|
||||
const FRAME_BYTES = 239;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
|
||||
function check(name, ok, detail = '') {
|
||||
if (ok) {
|
||||
passed += 1;
|
||||
console.log(` ✓ ${name}`);
|
||||
} else {
|
||||
failed += 1;
|
||||
failures.push(`${name}${detail ? ` — ${detail}` : ''}`);
|
||||
console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 构造契约 §2 的 239 字节关键点帧 */
|
||||
function buildFrame({ frameId = 0, flags = 0, mirrored = false, lowConf = false, upperBodyOnly = false } = {}) {
|
||||
const buf = new ArrayBuffer(FRAME_BYTES);
|
||||
const view = new DataView(buf);
|
||||
view.setUint16(0, MAGIC, true); // magic(小端)
|
||||
view.setUint8(2, 0x01); // version
|
||||
let f = flags;
|
||||
if (mirrored) f |= 0x01;
|
||||
if (lowConf) f |= 0x02;
|
||||
if (upperBodyOnly) f |= 0x04;
|
||||
view.setUint8(3, f);
|
||||
view.setUint32(4, frameId >>> 0, true);
|
||||
for (let i = 0; i < 33; i += 1) {
|
||||
const o = 8 + i * 7;
|
||||
// 造一组确定性的归一化坐标:x 从 -0.9 到 0.9,y 随索引摆动,z 小量
|
||||
const x = Math.round((-0.9 + (1.8 * i) / 32) * 32767);
|
||||
const y = Math.round((0.5 * Math.sin(i) + 0.1) * 32767);
|
||||
const z = Math.round((0.02 * i - 0.3) * 32767);
|
||||
view.setInt16(o, Math.max(-32768, Math.min(32767, x)), true);
|
||||
view.setInt16(o + 2, Math.max(-32768, Math.min(32767, y)), true);
|
||||
view.setInt16(o + 4, Math.max(-32768, Math.min(32767, z)), true);
|
||||
view.setUint8(o + 6, i === 5 ? 100 : 240); // 故意放一个低置信度点(conf<128)
|
||||
}
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/** 打开一条连接,返回 { ws, next(type), closeInfo } */
|
||||
function connect({ deviceId = '', device = 'Node 契约客户端', protocol = SUBPROTOCOL, hello = true } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(URL_, [SUBPROTOCOL]);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const inbox = [];
|
||||
const waiters = [];
|
||||
const state = { closeInfo: null, error: null };
|
||||
|
||||
const push = (msg) => {
|
||||
const w = waiters.shift();
|
||||
if (w) w(msg);
|
||||
else inbox.push(msg);
|
||||
};
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
if (typeof ev.data === 'string') {
|
||||
try {
|
||||
push(JSON.parse(ev.data));
|
||||
} catch {
|
||||
push({ type: '(非 JSON 文本)', raw: ev.data });
|
||||
}
|
||||
} else {
|
||||
push({ type: '(二进制)', byteLength: ev.data.byteLength });
|
||||
}
|
||||
});
|
||||
ws.addEventListener('close', (ev) => {
|
||||
state.closeInfo = { code: ev.code, reason: ev.reason };
|
||||
const w = waiters.shift();
|
||||
if (w) w({ type: '(closed)', ...state.closeInfo });
|
||||
else inbox.push({ type: '(closed)', ...state.closeInfo });
|
||||
});
|
||||
ws.addEventListener('error', (ev) => {
|
||||
state.error = ev?.message ?? 'error';
|
||||
});
|
||||
|
||||
const opened = new Promise((res, rej) => {
|
||||
ws.addEventListener('open', res);
|
||||
const t = setTimeout(() => rej(new Error('连接超时(3s)')), 3000);
|
||||
t.unref?.();
|
||||
});
|
||||
|
||||
opened
|
||||
.then(() => {
|
||||
if (hello) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello', protocol, deviceId, device,
|
||||
appVersion: '0.2.0', cameraFacing: 'front', targetFps: 30, resolution: '640x480',
|
||||
}));
|
||||
}
|
||||
resolve({
|
||||
ws,
|
||||
state,
|
||||
next: (timeoutMs = 3000) => new Promise((res) => {
|
||||
if (inbox.length) return res(inbox.shift());
|
||||
const t = setTimeout(() => res({ type: '(超时)', timeoutMs }), timeoutMs);
|
||||
t.unref?.();
|
||||
waiters.push((m) => {
|
||||
clearTimeout(t);
|
||||
res(m);
|
||||
});
|
||||
}),
|
||||
async closed(timeoutMs = 3000) {
|
||||
if (state.closeInfo) return state.closeInfo;
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (state.closeInfo) return state.closeInfo;
|
||||
await sleep(50);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
function badFrame() {
|
||||
// 契约要求恰好 239 字节;这里故意发 100 字节
|
||||
return new Uint8Array(100).fill(0x7f);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n== WebSocket 契约一致性(websocket-keypoints v0.2.0)@ ${URL_} ==\n`);
|
||||
|
||||
// ---------- §1/§3.2 握手 ----------
|
||||
console.log('[1] 子协议协商 + hello → welcome(§1 / §3.1 / §3.2)');
|
||||
const a = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
||||
const welcome = await a.next(3000);
|
||||
check('收到 welcome', welcome?.type === 'welcome', JSON.stringify(welcome));
|
||||
check('welcome.protocol 与契约一致', welcome?.protocol === SUBPROTOCOL, String(welcome?.protocol));
|
||||
check('welcome.sessionId 非空', typeof welcome?.sessionId === 'string' && welcome.sessionId.length > 0);
|
||||
check('welcome.expectedFps = 30', welcome?.expectedFps === 30, String(welcome?.expectedFps));
|
||||
const silent = Number(welcome?.silentTimeoutMs);
|
||||
const heartbeat = Number(welcome?.heartbeatIntervalMs);
|
||||
check('welcome 声明 silentTimeoutMs', Number.isFinite(silent) && silent > 0, String(welcome?.silentTimeoutMs));
|
||||
check('welcome 声明 heartbeatIntervalMs', Number.isFinite(heartbeat) && heartbeat > 0, String(welcome?.heartbeatIntervalMs));
|
||||
check('heartbeatIntervalMs < silentTimeoutMs / 2(§1.1 硬性)',
|
||||
heartbeat < silent / 2, `心跳 ${heartbeat} ms,超时 ${silent} ms`);
|
||||
check('子协议已协商成功(未回落到空串)', a.ws.protocol === SUBPROTOCOL, `实际 '${a.ws.protocol}'`);
|
||||
|
||||
// ---------- §2 二进制帧 ----------
|
||||
console.log('\n[2] 239 字节二进制帧(§2)');
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
a.ws.send(buildFrame({ frameId: i, mirrored: i % 2 === 0, lowConf: i % 5 === 0, upperBodyOnly: false }));
|
||||
await sleep(8);
|
||||
}
|
||||
// 故意跳号(丢包检测:§8 应进入 degraded 并可自动恢复)
|
||||
for (let i = 63; i < 75; i += 1) {
|
||||
a.ws.send(buildFrame({ frameId: i }));
|
||||
await sleep(8);
|
||||
}
|
||||
check('已发送 72 帧(含跳号 60→63)', true);
|
||||
a.ws.send(badFrame());
|
||||
check('已发送 1 个非法长度帧(100 字节,服务端应计入 malformed 且不崩)', true);
|
||||
await sleep(200);
|
||||
|
||||
// ---------- §3.4 / §3.6 ----------
|
||||
console.log('\n[3] stats 与 ping/pong(§3.4 / §3.6)');
|
||||
a.ws.send(JSON.stringify({ type: 'stats', fps: 29.6, dropped: 3, latencyMs: 42, confidenceAvg: 0.93 }));
|
||||
const sentAt = 1730000000000;
|
||||
a.ws.send(JSON.stringify({ type: 'ping', sentAtMs: sentAt }));
|
||||
const pong = await a.next(3000);
|
||||
check('收到 pong', pong?.type === 'pong', JSON.stringify(pong));
|
||||
check('pong.sentAtMs 原样回传', pong?.sentAtMs === sentAt, String(pong?.sentAtMs));
|
||||
check('pong.serverAtMs 已填写', Number.isFinite(Number(pong?.serverAtMs)) && Number(pong?.serverAtMs) > 0,
|
||||
String(pong?.serverAtMs));
|
||||
|
||||
// ---------- §5 设备去重 ----------
|
||||
console.log('\n[4] 同 deviceId 替换旧连接(§5)');
|
||||
const a2 = await connect({ deviceId: 'aaaa000000000001', device: 'Pixel 8' });
|
||||
const a2welcome = await a2.next(3000);
|
||||
check('新连接收到 welcome', a2welcome?.type === 'welcome', JSON.stringify(a2welcome));
|
||||
const oldClose = await a.closed(4000);
|
||||
check('旧连接被服务端关闭', oldClose !== null, '旧连接仍在打开');
|
||||
check('旧连接关闭码为 1000(契约 §5)', oldClose?.code === 1000, String(oldClose?.code));
|
||||
check('旧连接关闭原因为 replaced by new connection',
|
||||
String(oldClose?.reason ?? '').includes('replaced'), String(oldClose?.reason));
|
||||
check('新连接保持打开', a2.ws.readyState === WebSocket.OPEN, String(a2.ws.readyState));
|
||||
|
||||
// ---------- §1.1 保活:只发 ping 不会被踢 ----------
|
||||
console.log('\n[5] 保活:只发应用层 ping,连接不应被静默超时踢掉(§1.1)');
|
||||
if (SKIP_SLOW) {
|
||||
console.log(' · --skip-slow:跳过 19 秒对照');
|
||||
} else {
|
||||
const k = await connect({ deviceId: 'bbbb000000000002', device: 'Keepalive' });
|
||||
const kw = await k.next(3000);
|
||||
check('保活连接已握手', kw?.type === 'welcome', JSON.stringify(kw));
|
||||
const deadline = Date.now() + (silent + 4000); // 超过静默超时 4 秒
|
||||
let pings = 0;
|
||||
while (Date.now() < deadline) {
|
||||
if (k.ws.readyState !== WebSocket.OPEN) break;
|
||||
k.ws.send(JSON.stringify({ type: 'ping', sentAtMs: Date.now() }));
|
||||
pings += 1;
|
||||
await sleep(Math.max(500, Math.floor(heartbeat / 2)));
|
||||
}
|
||||
check(`持续 ${Math.round((silent + 4000) / 100) / 10}s 发 ping(${pings} 次)后连接仍打开`,
|
||||
k.ws.readyState === WebSocket.OPEN,
|
||||
`readyState=${k.ws.readyState}(静默超时 ${silent} ms,期望心跳 ${heartbeat} ms)`);
|
||||
k.ws.close(1000, 'done');
|
||||
await k.closed(2000);
|
||||
}
|
||||
|
||||
// ---------- §1.1 / §7 静默超时 ----------
|
||||
console.log('\n[6] 完全不发数据 → close(1001, "timeout")(§1.1 / §7)');
|
||||
if (SKIP_SLOW) {
|
||||
console.log(' · --skip-slow:跳过静默超时对照');
|
||||
} else {
|
||||
const s = await connect({ deviceId: 'cccc000000000003', device: 'Silent' });
|
||||
const sw = await s.next(3000);
|
||||
check('静默连接已握手', sw?.type === 'welcome', JSON.stringify(sw));
|
||||
const t0 = Date.now();
|
||||
const closeInfo = await s.closed(silent + 6000);
|
||||
const elapsed = Date.now() - t0;
|
||||
check('服务端关闭了静默连接', closeInfo !== null, `${elapsed} ms 内未关闭`);
|
||||
check('关闭码为 1001(going away)', closeInfo?.code === 1001, String(closeInfo?.code));
|
||||
check('关闭原因为 timeout', String(closeInfo?.reason ?? '').includes('timeout'), String(closeInfo?.reason));
|
||||
check(`关闭时机接近 silentTimeoutMs(实测 ${elapsed} ms,阈值 ${silent} ms)`,
|
||||
closeInfo !== null && elapsed >= silent * 0.8 && elapsed <= silent + 4000, `${elapsed} ms`);
|
||||
}
|
||||
|
||||
// ---------- §3.5 bye ----------
|
||||
console.log('\n[7] bye → 服务端 close(1000)(§3.5)');
|
||||
const b = await connect({ deviceId: 'dddd000000000004', device: 'Bye' });
|
||||
await b.next(3000);
|
||||
b.ws.send(JSON.stringify({ type: 'bye', reason: 'user_stop' }));
|
||||
const byeClose = await b.closed(4000);
|
||||
check('服务端关闭了连接', byeClose !== null);
|
||||
check('关闭码为 1000', byeClose?.code === 1000, String(byeClose?.code));
|
||||
|
||||
// ---------- §3.1 / §6 协议不匹配 ----------
|
||||
console.log('\n[8] 协议版本不匹配 → bye + close(1002)(§3.1 / §6)');
|
||||
const c = await connect({ deviceId: 'eeee000000000005', device: 'Old', protocol: 'opendance.pose.v0' });
|
||||
const byeMsg = await c.next(3000);
|
||||
check('收到 bye', byeMsg?.type === 'bye', JSON.stringify(byeMsg));
|
||||
check('bye.reason 说明协议不匹配', String(byeMsg?.reason ?? '').includes('protocol_mismatch'), String(byeMsg?.reason));
|
||||
const mismatchClose = await c.closed(4000);
|
||||
check('关闭码为 1002(协议错误)', mismatchClose?.code === 1002, String(mismatchClose?.code));
|
||||
|
||||
// ---------- 收尾 ----------
|
||||
console.log('\n[9] 收尾');
|
||||
a2.ws.close(1000, 'done');
|
||||
await a2.closed(2000);
|
||||
await sleep(300);
|
||||
|
||||
console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`);
|
||||
if (failed > 0) {
|
||||
console.log('失败项:');
|
||||
for (const f of failures) console.log(` - ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('全部通过 ✅');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('✗ 客户端异常:', err?.message ?? err);
|
||||
process.exit(2);
|
||||
});
|
||||
Reference in New Issue
Block a user