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

305 lines
13 KiB
JavaScript
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.
#!/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);
});