rol-websocket-channel 1.4.2 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/{MQTT-API /346/226/260/345/242/236/346/226/207/344/273/266/345/212/237/350/203/275.md" → MQTT-API 5-6.md } +89 -1
  2. package/dist/index.js +617 -617
  3. package/dist/message-handler.js +515 -503
  4. package/dist/src/admin/cli.js +43 -43
  5. package/dist/src/admin/jsonrpc.js +60 -60
  6. package/dist/src/admin/lib/fs.js +30 -30
  7. package/dist/src/admin/lib/paths.js +80 -80
  8. package/dist/src/admin/methods/admin.js +60 -60
  9. package/dist/src/admin/methods/agents-extended.js +251 -251
  10. package/dist/src/admin/methods/artifacts.js +736 -642
  11. package/dist/src/admin/methods/artifacts.test.js +210 -191
  12. package/dist/src/admin/methods/cron.js +250 -250
  13. package/dist/src/admin/methods/index.js +104 -102
  14. package/dist/src/admin/methods/mem9.js +309 -270
  15. package/dist/src/admin/methods/mem9.test.js +34 -0
  16. package/dist/src/admin/methods/memory.js +363 -363
  17. package/dist/src/admin/methods/models-extended.js +190 -190
  18. package/dist/src/admin/methods/models.js +195 -195
  19. package/dist/src/admin/methods/pairing.js +268 -268
  20. package/dist/src/admin/methods/sessions-extended.js +215 -215
  21. package/dist/src/admin/methods/sessions.js +75 -75
  22. package/dist/src/admin/methods/skills-extended.js +157 -157
  23. package/dist/src/admin/methods/skills-toggle.js +183 -183
  24. package/dist/src/admin/methods/skills.js +528 -528
  25. package/dist/src/admin/methods/system.js +271 -180
  26. package/dist/src/admin/methods/usage.js +1170 -1170
  27. package/dist/src/admin/types.js +1 -1
  28. package/dist/src/mqtt/connection-manager.js +209 -209
  29. package/dist/src/mqtt/index.js +5 -5
  30. package/dist/src/mqtt/mqtt-client.js +110 -110
  31. package/dist/src/mqtt/mqtt.test.js +418 -418
  32. package/dist/src/mqtt/types.js +2 -2
  33. package/dist/src/shared/context.js +24 -24
  34. package/dist/src/shared/wrapper.js +23 -23
  35. package/message-handler.ts +15 -1
  36. package/openclaw.plugin.json +73 -0
  37. package/package.json +1 -1
  38. package/src/admin/methods/artifacts.test.ts +35 -0
  39. package/src/admin/methods/artifacts.ts +140 -2
  40. package/src/admin/methods/index.ts +3 -1
  41. package/src/admin/methods/mem9.test.ts +39 -0
  42. package/src/admin/methods/mem9.ts +48 -1
  43. package/src/admin/methods/system.ts +129 -1
@@ -1,418 +1,418 @@
1
- /**
2
- * MQTT 模块测试
3
- *
4
- * 测试内容:
5
- * 1. 工具函数单元测试(parseUsernameFromTopic、generateClientId、getSubscribeTopic)
6
- * 2. 全局连接生命周期测试(使用 Mock MQTT 客户端注入)
7
- * 3. 订阅 / 发布集成测试
8
- *
9
- * 运行:npm test
10
- */
11
- import { test, describe, beforeEach, afterEach } from "node:test";
12
- import assert from "node:assert/strict";
13
- import { EventEmitter } from "node:events";
14
- import { parseUsernameFromTopic, generateClientId, getSubscribeTopic, createGlobalMqttConnection, closeGlobalConnection, isGlobalConnected, getGlobalConnection, publishGlobalMessage, _setMqttConnectFn, } from "./connection-manager.js";
15
- import { GlobalMqttClient } from "./mqtt-client.js";
16
- // ─────────────────────────────────────────────────────────────────────────────
17
- // Mock MQTT 客户端
18
- // 模拟 mqtt.js 的 MqttClient API(EventEmitter + subscribe / publish / end)
19
- // ─────────────────────────────────────────────────────────────────────────────
20
- class MockMqttClient extends EventEmitter {
21
- constructor() {
22
- super(...arguments);
23
- this.connected = false;
24
- this.clientId = "";
25
- this.username = "";
26
- this.subscribedTopics = [];
27
- this.publishedMessages = [];
28
- this.endCalled = false;
29
- }
30
- /** 模拟 broker 在 delayMs 毫秒后接受连接 */
31
- simulateConnect(delayMs = 10) {
32
- setTimeout(() => {
33
- this.connected = true;
34
- this.emit("connect");
35
- }, delayMs);
36
- }
37
- /** 模拟 broker 推送一条消息给客户端 */
38
- simulateMessage(topic, payload) {
39
- this.emit("message", topic, payload);
40
- }
41
- // ── mqtt.js 兼容 API ───────────────────────────────────────────────────────
42
- subscribe(topic, cb) {
43
- this.subscribedTopics.push(topic);
44
- cb(null);
45
- }
46
- publish(topic, message) {
47
- this.publishedMessages.push({ topic, message });
48
- }
49
- end(_force) {
50
- this.connected = false;
51
- this.endCalled = true;
52
- this.emit("close");
53
- }
54
- }
55
- /**
56
- * 创建注入用的 mock connect 工厂。
57
- * 返回的函数签名与 mqtt.connect() 相同,会把连接选项写回 mockClient 以便断言。
58
- */
59
- function makeMockConnectFn(mockClient) {
60
- return (_url, opts) => {
61
- mockClient.clientId = opts.clientId ?? "";
62
- mockClient.username = opts.username ?? "";
63
- return mockClient;
64
- };
65
- }
66
- // ─────────────────────────────────────────────────────────────────────────────
67
- // 测试辅助
68
- // ─────────────────────────────────────────────────────────────────────────────
69
- const waitMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
70
- // ─────────────────────────────────────────────────────────────────────────────
71
- // 1. parseUsernameFromTopic — 单元测试
72
- // ─────────────────────────────────────────────────────────────────────────────
73
- describe("parseUsernameFromTopic", () => {
74
- test("从完整的 announcement topic 中正确解析 user_name", () => {
75
- const topic = "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot";
76
- assert.equal(parseUsernameFromTopic(topic), "0b9a784d-e044-4c6f-9024-ef8799c131d1");
77
- });
78
- test("只有两段的 announcement topic 也能解析", () => {
79
- assert.equal(parseUsernameFromTopic("announcement/myuser"), "myuser");
80
- });
81
- test("不同 agent_id 解析出相同 user_name", () => {
82
- const base = "announcement/same-user";
83
- assert.equal(parseUsernameFromTopic(`${base}/agent-A/bot`), "same-user");
84
- assert.equal(parseUsernameFromTopic(`${base}/agent-B/bot`), "same-user");
85
- });
86
- test("非 announcement 前缀 → default_name", () => {
87
- assert.equal(parseUsernameFromTopic("some/other/topic"), "default_name");
88
- });
89
- test("空字符串 → default_name", () => {
90
- assert.equal(parseUsernameFromTopic(""), "default_name");
91
- });
92
- test("announcement 后无用户名段 → default_name", () => {
93
- assert.equal(parseUsernameFromTopic("announcement/"), "default_name");
94
- });
95
- test("announcement 后仅有空白 → default_name", () => {
96
- assert.equal(parseUsernameFromTopic("announcement/ "), "default_name");
97
- });
98
- test("null / undefined 输入 → default_name(边界情况)", () => {
99
- // @ts-expect-error 故意传非法类型以测试边界
100
- assert.equal(parseUsernameFromTopic(null), "default_name");
101
- // @ts-expect-error 故意传非法类型以测试边界
102
- assert.equal(parseUsernameFromTopic(undefined), "default_name");
103
- });
104
- });
105
- // ─────────────────────────────────────────────────────────────────────────────
106
- // 2. generateClientId — 单元测试
107
- // ─────────────────────────────────────────────────────────────────────────────
108
- describe("generateClientId", () => {
109
- test('以 "mqtt_client_" 开头', () => {
110
- const id = generateClientId();
111
- assert.ok(id.startsWith("mqtt_client_"), `期望 mqtt_client_ 前缀,实际得到: ${id}`);
112
- });
113
- test("长度大于 16 个字符", () => {
114
- const id = generateClientId();
115
- assert.ok(id.length > 16, `ID 过短: ${id}`);
116
- });
117
- test("连续 100 次调用生成的 ID 几乎全部唯一", () => {
118
- const ids = new Set(Array.from({ length: 100 }, () => generateClientId()));
119
- assert.ok(ids.size >= 90, `碰撞过多:100 次中仅有 ${ids.size} 个唯一 ID`);
120
- });
121
- test("只包含字母、数字和下划线", () => {
122
- for (let i = 0; i < 30; i++) {
123
- const id = generateClientId();
124
- assert.match(id, /^[a-z0-9_]+$/i, `ID 含非法字符: ${id}`);
125
- }
126
- });
127
- });
128
- // ─────────────────────────────────────────────────────────────────────────────
129
- // 3. getSubscribeTopic — 单元测试
130
- // ─────────────────────────────────────────────────────────────────────────────
131
- describe("getSubscribeTopic", () => {
132
- test("完整 announcement topic → announcement/{user_name}/#", () => {
133
- const topic = "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot";
134
- assert.equal(getSubscribeTopic(topic), "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/#");
135
- });
136
- test("不同 agent_id 产生相同的通配符订阅 topic(不区分 id)", () => {
137
- const t1 = "announcement/user123/agent-aaa/bot";
138
- const t2 = "announcement/user123/agent-bbb/bot";
139
- assert.equal(getSubscribeTopic(t1), "announcement/user123/#");
140
- assert.equal(getSubscribeTopic(t1), getSubscribeTopic(t2));
141
- });
142
- test("非 announcement topic → 原 topic + /#", () => {
143
- assert.equal(getSubscribeTopic("some/topic"), "some/topic/#");
144
- });
145
- test("已以 # 结尾的 topic 不重复添加通配符", () => {
146
- assert.equal(getSubscribeTopic("some/topic/#"), "some/topic/#");
147
- });
148
- test("末尾有斜杠的 topic → 补 # 而非 /#", () => {
149
- assert.equal(getSubscribeTopic("some/topic/"), "some/topic/#");
150
- });
151
- });
152
- // ─────────────────────────────────────────────────────────────────────────────
153
- // 4. 全局连接生命周期测试(Mock 注入)
154
- // ─────────────────────────────────────────────────────────────────────────────
155
- describe("全局连接生命周期(createGlobalMqttConnection / closeGlobalConnection)", () => {
156
- let mockClient;
157
- beforeEach(() => {
158
- mockClient = new MockMqttClient();
159
- _setMqttConnectFn(makeMockConnectFn(mockClient));
160
- closeGlobalConnection(); // 清理上次残留
161
- });
162
- afterEach(() => {
163
- closeGlobalConnection();
164
- _setMqttConnectFn(null);
165
- });
166
- // ── 连接对象字段 ──────────────────────────────────────────────────────────
167
- test("createGlobalMqttConnection 返回的连接对象包含正确字段", async () => {
168
- const topic = "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot";
169
- const conn = await createGlobalMqttConnection("mqtt://localhost:1883", topic, {});
170
- assert.equal(conn.topic, topic, "topic 字段");
171
- assert.equal(conn.username, "0b9a784d-e044-4c6f-9024-ef8799c131d1", "username 字段");
172
- assert.equal(conn.subscribeTopic, "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/#", "subscribeTopic 字段");
173
- });
174
- // ── clientId / username ───────────────────────────────────────────────────
175
- test("连接时传递随机生成的 clientId", async () => {
176
- await createGlobalMqttConnection("mqtt://localhost:1883", "announcement/user/agent/bot", {});
177
- assert.ok(mockClient.clientId.startsWith("mqtt_client_"), `期望 mqtt_client_ 前缀,实际: ${mockClient.clientId}`);
178
- });
179
- test("连接时 username 使用从 topic 解析的用户名", async () => {
180
- await createGlobalMqttConnection("mqtt://localhost:1883", "announcement/myuser/agent/bot", {});
181
- assert.equal(mockClient.username, "myuser");
182
- });
183
- // ── isGlobalConnected ─────────────────────────────────────────────────────
184
- test("连接前 isGlobalConnected() = false,成功连接后 = true", async () => {
185
- assert.equal(isGlobalConnected(), false);
186
- const onConnectPromise = new Promise((resolve) => {
187
- createGlobalMqttConnection("mqtt://localhost:1883", "announcement/u/a/bot", {
188
- onConnect: resolve,
189
- });
190
- });
191
- mockClient.simulateConnect(20);
192
- await onConnectPromise;
193
- assert.equal(isGlobalConnected(), true);
194
- });
195
- // ── 订阅 topic ────────────────────────────────────────────────────────────
196
- test("连接后自动订阅正确的通配符 topic", async () => {
197
- const onConnectPromise = new Promise((resolve) => {
198
- createGlobalMqttConnection("mqtt://localhost:1883", "announcement/testuser/agent/bot", { onConnect: resolve });
199
- });
200
- mockClient.simulateConnect(10);
201
- await onConnectPromise;
202
- assert.ok(mockClient.subscribedTopics.includes("announcement/testuser/#"), `订阅 topic 错误,实际订阅: ${mockClient.subscribedTopics.join(", ")}`);
203
- });
204
- // ── 复用已有连接 ──────────────────────────────────────────────────────────
205
- test("连接已存在时重复调用 createGlobalMqttConnection 复用同一对象", async () => {
206
- const onConnectPromise = new Promise((resolve) => {
207
- createGlobalMqttConnection("mqtt://localhost:1883", "announcement/u/a/bot", { onConnect: resolve });
208
- });
209
- mockClient.simulateConnect(10);
210
- await onConnectPromise;
211
- const conn1 = getGlobalConnection();
212
- // 第二次调用
213
- const conn2 = await createGlobalMqttConnection("mqtt://localhost:1883", "announcement/u/a/bot", {});
214
- assert.strictEqual(conn1, conn2, "应返回同一个连接对象引用");
215
- });
216
- // ── closeGlobalConnection ─────────────────────────────────────────────────
217
- test("closeGlobalConnection 后 isGlobalConnected() = false 且 getGlobalConnection() = null", async () => {
218
- const onConnectPromise = new Promise((resolve) => {
219
- createGlobalMqttConnection("mqtt://localhost:1883", "announcement/u/a/bot", { onConnect: resolve });
220
- });
221
- mockClient.simulateConnect(10);
222
- await onConnectPromise;
223
- assert.equal(isGlobalConnected(), true);
224
- closeGlobalConnection();
225
- assert.equal(isGlobalConnected(), false);
226
- assert.equal(getGlobalConnection(), null);
227
- assert.equal(mockClient.endCalled, true);
228
- });
229
- });
230
- // ─────────────────────────────────────────────────────────────────────────────
231
- // 5. GlobalMqttClient 订阅 & 发布测试(Mock 注入)
232
- // ─────────────────────────────────────────────────────────────────────────────
233
- describe("GlobalMqttClient — 订阅 & 发布", () => {
234
- let mockClient;
235
- beforeEach(() => {
236
- mockClient = new MockMqttClient();
237
- _setMqttConnectFn(makeMockConnectFn(mockClient));
238
- closeGlobalConnection();
239
- });
240
- afterEach(() => {
241
- closeGlobalConnection();
242
- _setMqttConnectFn(null);
243
- });
244
- // ─────────────────────────────────────────────────────────────────────────
245
- // 辅助:快速建立一个已连接的 GlobalMqttClient
246
- // ─────────────────────────────────────────────────────────────────────────
247
- async function makeConnectedClient(mqttTopic, onMessage, abortController) {
248
- let client;
249
- const onConnectPromise = new Promise((resolve) => {
250
- client = new GlobalMqttClient({
251
- mqttUrl: "mqtt://localhost:1883",
252
- mqttTopic,
253
- abortSignal: abortController.signal,
254
- onMessage,
255
- onConnect: resolve,
256
- });
257
- });
258
- // connect() 挂起到 abort,在后台跑
259
- client.connect().catch(() => { });
260
- mockClient.simulateConnect(10);
261
- await onConnectPromise;
262
- return client;
263
- }
264
- // ── publish ───────────────────────────────────────────────────────────────
265
- test("连接成功后 publish() 返回 true 且消息到达 broker", async () => {
266
- const ac = new AbortController();
267
- const client = await makeConnectedClient("announcement/user/agent/bot", async () => { }, ac);
268
- const topic = "announcement/user/agent/bot";
269
- const payload = JSON.stringify({ type: "message", content: "hello" });
270
- const ok = client.publish(topic, payload);
271
- assert.equal(ok, true);
272
- assert.equal(mockClient.publishedMessages.length, 1);
273
- assert.equal(mockClient.publishedMessages[0].topic, topic);
274
- assert.deepEqual(JSON.parse(mockClient.publishedMessages[0].message), {
275
- type: "message",
276
- content: "hello",
277
- });
278
- ac.abort();
279
- });
280
- test("未连接时 publish() 返回 false", () => {
281
- const ac = new AbortController();
282
- const client = new GlobalMqttClient({
283
- mqttUrl: "mqtt://localhost:1883",
284
- mqttTopic: "announcement/user/agent/bot",
285
- abortSignal: ac.signal,
286
- onMessage: async () => { },
287
- });
288
- // 没有调用 connect(),连接未建立
289
- const ok = client.publish("announcement/user/agent/bot", "test");
290
- assert.equal(ok, false);
291
- ac.abort();
292
- });
293
- test("连续 publish 多条消息,全部到达 broker,顺序正确", async () => {
294
- const ac = new AbortController();
295
- const client = await makeConnectedClient("announcement/userC/agent/bot", async () => { }, ac);
296
- const topic = "announcement/userC/agent/bot";
297
- client.publish(topic, "first");
298
- client.publish(topic, "second");
299
- client.publish(topic, "third");
300
- assert.equal(mockClient.publishedMessages.length, 3);
301
- assert.deepEqual(mockClient.publishedMessages.map((m) => m.message), ["first", "second", "third"]);
302
- ac.abort();
303
- });
304
- // ── 订阅 / 接收消息 ───────────────────────────────────────────────────────
305
- test("订阅的 topic 是通配符形式(不区分 agent_id)", async () => {
306
- const ac = new AbortController();
307
- await makeConnectedClient("announcement/userA/agentX/bot", async () => { }, ac);
308
- assert.ok(mockClient.subscribedTopics.includes("announcement/userA/#"), `订阅 topic 不正确,实际: ${mockClient.subscribedTopics.join(", ")}`);
309
- ac.abort();
310
- });
311
- test("broker 推送消息时 onMessage 回调被正确触发", async () => {
312
- const ac = new AbortController();
313
- const received = [];
314
- const client = await makeConnectedClient("announcement/userA/agentX/bot", async (topic, payload) => {
315
- received.push({ topic, payload: payload.toString() });
316
- }, ac);
317
- mockClient.simulateMessage("announcement/userA/agentX/bot", Buffer.from(JSON.stringify({ type: "message", content: "world" })));
318
- await waitMs(30);
319
- assert.equal(received.length, 1);
320
- assert.equal(received[0].topic, "announcement/userA/agentX/bot");
321
- assert.deepEqual(JSON.parse(received[0].payload), {
322
- type: "message",
323
- content: "world",
324
- });
325
- ac.abort();
326
- });
327
- test("通配符订阅能收到同一 user_name 下不同 agent_id 的消息", async () => {
328
- const ac = new AbortController();
329
- const receivedTopics = [];
330
- const client = await makeConnectedClient("announcement/userB/agent-001/bot", async (topic) => {
331
- receivedTopics.push(topic);
332
- }, ac);
333
- // 来自不同 agent_id 的消息(通配符 announcement/userB/# 都应匹配)
334
- mockClient.simulateMessage("announcement/userB/agent-001/bot", Buffer.from("msg-from-agent-001"));
335
- mockClient.simulateMessage("announcement/userB/agent-002/bot", Buffer.from("msg-from-agent-002"));
336
- mockClient.simulateMessage("announcement/userB/agent-003/command", Buffer.from("msg-from-agent-003"));
337
- await waitMs(30);
338
- assert.equal(receivedTopics.length, 3);
339
- assert.ok(receivedTopics.includes("announcement/userB/agent-001/bot"));
340
- assert.ok(receivedTopics.includes("announcement/userB/agent-002/bot"));
341
- assert.ok(receivedTopics.includes("announcement/userB/agent-003/command"));
342
- ac.abort();
343
- });
344
- test("连续收到多条消息,每条都触发 onMessage,顺序正确", async () => {
345
- const ac = new AbortController();
346
- const payloads = [];
347
- await makeConnectedClient("announcement/userC/agent/bot", async (_topic, payload) => {
348
- payloads.push(payload.toString());
349
- }, ac);
350
- for (const msg of ["alpha", "beta", "gamma"]) {
351
- mockClient.simulateMessage("announcement/userC/agent/bot", Buffer.from(msg));
352
- }
353
- await waitMs(30);
354
- assert.deepEqual(payloads, ["alpha", "beta", "gamma"]);
355
- ac.abort();
356
- });
357
- // ── getSubscribeTopic / getUsername ───────────────────────────────────────
358
- test("getUsername() 返回从 topic 中解析的用户名", () => {
359
- const ac = new AbortController();
360
- const client = new GlobalMqttClient({
361
- mqttUrl: "mqtt://localhost:1883",
362
- mqttTopic: "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot",
363
- abortSignal: ac.signal,
364
- onMessage: async () => { },
365
- });
366
- assert.equal(client.getUsername(), "0b9a784d-e044-4c6f-9024-ef8799c131d1");
367
- ac.abort();
368
- });
369
- test("getSubscribeTopic() 返回通配符形式的订阅 topic", () => {
370
- const ac = new AbortController();
371
- const client = new GlobalMqttClient({
372
- mqttUrl: "mqtt://localhost:1883",
373
- mqttTopic: "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot",
374
- abortSignal: ac.signal,
375
- onMessage: async () => { },
376
- });
377
- assert.equal(client.getSubscribeTopic(), "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/#");
378
- ac.abort();
379
- });
380
- // ── AbortController / 生命周期 ────────────────────────────────────────────
381
- test("abort 触发后 isConnected() = false,connect() Promise 正常 resolve", async () => {
382
- const ac = new AbortController();
383
- let client;
384
- const onConnectPromise = new Promise((resolve) => {
385
- client = new GlobalMqttClient({
386
- mqttUrl: "mqtt://localhost:1883",
387
- mqttTopic: "announcement/userD/agent/bot",
388
- abortSignal: ac.signal,
389
- onMessage: async () => { },
390
- onConnect: resolve,
391
- });
392
- });
393
- const done = client.connect(); // 挂起中
394
- mockClient.simulateConnect(10);
395
- await onConnectPromise;
396
- assert.equal(client.isConnected(), true);
397
- ac.abort();
398
- await done; // 应在 abort 后正常 resolve(不应 reject)
399
- assert.equal(client.isConnected(), false);
400
- });
401
- // ── publishGlobalMessage 直接 API ─────────────────────────────────────────
402
- test("publishGlobalMessage() 使用全局连接发布消息", async () => {
403
- const ac = new AbortController();
404
- await makeConnectedClient("announcement/userE/agent/bot", async () => { }, ac);
405
- const ok = publishGlobalMessage("announcement/userE/agent/bot", JSON.stringify({ type: "ping" }));
406
- assert.equal(ok, true);
407
- assert.equal(mockClient.publishedMessages.length, 1);
408
- assert.deepEqual(JSON.parse(mockClient.publishedMessages[0].message), {
409
- type: "ping",
410
- });
411
- ac.abort();
412
- });
413
- test("publishGlobalMessage() 在无连接时返回 false", () => {
414
- // 没有任何连接
415
- const ok = publishGlobalMessage("some/topic", "data");
416
- assert.equal(ok, false);
417
- });
418
- });
1
+ /**
2
+ * MQTT 模块测试
3
+ *
4
+ * 测试内容:
5
+ * 1. 工具函数单元测试(parseUsernameFromTopic、generateClientId、getSubscribeTopic)
6
+ * 2. 全局连接生命周期测试(使用 Mock MQTT 客户端注入)
7
+ * 3. 订阅 / 发布集成测试
8
+ *
9
+ * 运行:npm test
10
+ */
11
+ import { test, describe, beforeEach, afterEach } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { EventEmitter } from "node:events";
14
+ import { parseUsernameFromTopic, generateClientId, getSubscribeTopic, createGlobalMqttConnection, closeGlobalConnection, isGlobalConnected, getGlobalConnection, publishGlobalMessage, _setMqttConnectFn, } from "./connection-manager.js";
15
+ import { GlobalMqttClient } from "./mqtt-client.js";
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // Mock MQTT 客户端
18
+ // 模拟 mqtt.js 的 MqttClient API(EventEmitter + subscribe / publish / end)
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ class MockMqttClient extends EventEmitter {
21
+ constructor() {
22
+ super(...arguments);
23
+ this.connected = false;
24
+ this.clientId = "";
25
+ this.username = "";
26
+ this.subscribedTopics = [];
27
+ this.publishedMessages = [];
28
+ this.endCalled = false;
29
+ }
30
+ /** 模拟 broker 在 delayMs 毫秒后接受连接 */
31
+ simulateConnect(delayMs = 10) {
32
+ setTimeout(() => {
33
+ this.connected = true;
34
+ this.emit("connect");
35
+ }, delayMs);
36
+ }
37
+ /** 模拟 broker 推送一条消息给客户端 */
38
+ simulateMessage(topic, payload) {
39
+ this.emit("message", topic, payload);
40
+ }
41
+ // ── mqtt.js 兼容 API ───────────────────────────────────────────────────────
42
+ subscribe(topic, cb) {
43
+ this.subscribedTopics.push(topic);
44
+ cb(null);
45
+ }
46
+ publish(topic, message) {
47
+ this.publishedMessages.push({ topic, message });
48
+ }
49
+ end(_force) {
50
+ this.connected = false;
51
+ this.endCalled = true;
52
+ this.emit("close");
53
+ }
54
+ }
55
+ /**
56
+ * 创建注入用的 mock connect 工厂。
57
+ * 返回的函数签名与 mqtt.connect() 相同,会把连接选项写回 mockClient 以便断言。
58
+ */
59
+ function makeMockConnectFn(mockClient) {
60
+ return (_url, opts) => {
61
+ mockClient.clientId = opts.clientId ?? "";
62
+ mockClient.username = opts.username ?? "";
63
+ return mockClient;
64
+ };
65
+ }
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+ // 测试辅助
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+ const waitMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ // 1. parseUsernameFromTopic — 单元测试
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+ describe("parseUsernameFromTopic", () => {
74
+ test("从完整的 announcement topic 中正确解析 user_name", () => {
75
+ const topic = "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot";
76
+ assert.equal(parseUsernameFromTopic(topic), "0b9a784d-e044-4c6f-9024-ef8799c131d1");
77
+ });
78
+ test("只有两段的 announcement topic 也能解析", () => {
79
+ assert.equal(parseUsernameFromTopic("announcement/myuser"), "myuser");
80
+ });
81
+ test("不同 agent_id 解析出相同 user_name", () => {
82
+ const base = "announcement/same-user";
83
+ assert.equal(parseUsernameFromTopic(`${base}/agent-A/bot`), "same-user");
84
+ assert.equal(parseUsernameFromTopic(`${base}/agent-B/bot`), "same-user");
85
+ });
86
+ test("非 announcement 前缀 → default_name", () => {
87
+ assert.equal(parseUsernameFromTopic("some/other/topic"), "default_name");
88
+ });
89
+ test("空字符串 → default_name", () => {
90
+ assert.equal(parseUsernameFromTopic(""), "default_name");
91
+ });
92
+ test("announcement 后无用户名段 → default_name", () => {
93
+ assert.equal(parseUsernameFromTopic("announcement/"), "default_name");
94
+ });
95
+ test("announcement 后仅有空白 → default_name", () => {
96
+ assert.equal(parseUsernameFromTopic("announcement/ "), "default_name");
97
+ });
98
+ test("null / undefined 输入 → default_name(边界情况)", () => {
99
+ // @ts-expect-error 故意传非法类型以测试边界
100
+ assert.equal(parseUsernameFromTopic(null), "default_name");
101
+ // @ts-expect-error 故意传非法类型以测试边界
102
+ assert.equal(parseUsernameFromTopic(undefined), "default_name");
103
+ });
104
+ });
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+ // 2. generateClientId — 单元测试
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+ describe("generateClientId", () => {
109
+ test('以 "mqtt_client_" 开头', () => {
110
+ const id = generateClientId();
111
+ assert.ok(id.startsWith("mqtt_client_"), `期望 mqtt_client_ 前缀,实际得到: ${id}`);
112
+ });
113
+ test("长度大于 16 个字符", () => {
114
+ const id = generateClientId();
115
+ assert.ok(id.length > 16, `ID 过短: ${id}`);
116
+ });
117
+ test("连续 100 次调用生成的 ID 几乎全部唯一", () => {
118
+ const ids = new Set(Array.from({ length: 100 }, () => generateClientId()));
119
+ assert.ok(ids.size >= 90, `碰撞过多:100 次中仅有 ${ids.size} 个唯一 ID`);
120
+ });
121
+ test("只包含字母、数字和下划线", () => {
122
+ for (let i = 0; i < 30; i++) {
123
+ const id = generateClientId();
124
+ assert.match(id, /^[a-z0-9_]+$/i, `ID 含非法字符: ${id}`);
125
+ }
126
+ });
127
+ });
128
+ // ─────────────────────────────────────────────────────────────────────────────
129
+ // 3. getSubscribeTopic — 单元测试
130
+ // ─────────────────────────────────────────────────────────────────────────────
131
+ describe("getSubscribeTopic", () => {
132
+ test("完整 announcement topic → announcement/{user_name}/#", () => {
133
+ const topic = "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot";
134
+ assert.equal(getSubscribeTopic(topic), "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/#");
135
+ });
136
+ test("不同 agent_id 产生相同的通配符订阅 topic(不区分 id)", () => {
137
+ const t1 = "announcement/user123/agent-aaa/bot";
138
+ const t2 = "announcement/user123/agent-bbb/bot";
139
+ assert.equal(getSubscribeTopic(t1), "announcement/user123/#");
140
+ assert.equal(getSubscribeTopic(t1), getSubscribeTopic(t2));
141
+ });
142
+ test("非 announcement topic → 原 topic + /#", () => {
143
+ assert.equal(getSubscribeTopic("some/topic"), "some/topic/#");
144
+ });
145
+ test("已以 # 结尾的 topic 不重复添加通配符", () => {
146
+ assert.equal(getSubscribeTopic("some/topic/#"), "some/topic/#");
147
+ });
148
+ test("末尾有斜杠的 topic → 补 # 而非 /#", () => {
149
+ assert.equal(getSubscribeTopic("some/topic/"), "some/topic/#");
150
+ });
151
+ });
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+ // 4. 全局连接生命周期测试(Mock 注入)
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ describe("全局连接生命周期(createGlobalMqttConnection / closeGlobalConnection)", () => {
156
+ let mockClient;
157
+ beforeEach(() => {
158
+ mockClient = new MockMqttClient();
159
+ _setMqttConnectFn(makeMockConnectFn(mockClient));
160
+ closeGlobalConnection(); // 清理上次残留
161
+ });
162
+ afterEach(() => {
163
+ closeGlobalConnection();
164
+ _setMqttConnectFn(null);
165
+ });
166
+ // ── 连接对象字段 ──────────────────────────────────────────────────────────
167
+ test("createGlobalMqttConnection 返回的连接对象包含正确字段", async () => {
168
+ const topic = "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot";
169
+ const conn = await createGlobalMqttConnection("mqtt://localhost:1883", topic, {});
170
+ assert.equal(conn.topic, topic, "topic 字段");
171
+ assert.equal(conn.username, "0b9a784d-e044-4c6f-9024-ef8799c131d1", "username 字段");
172
+ assert.equal(conn.subscribeTopic, "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/#", "subscribeTopic 字段");
173
+ });
174
+ // ── clientId / username ───────────────────────────────────────────────────
175
+ test("连接时传递随机生成的 clientId", async () => {
176
+ await createGlobalMqttConnection("mqtt://localhost:1883", "announcement/user/agent/bot", {});
177
+ assert.ok(mockClient.clientId.startsWith("mqtt_client_"), `期望 mqtt_client_ 前缀,实际: ${mockClient.clientId}`);
178
+ });
179
+ test("连接时 username 使用从 topic 解析的用户名", async () => {
180
+ await createGlobalMqttConnection("mqtt://localhost:1883", "announcement/myuser/agent/bot", {});
181
+ assert.equal(mockClient.username, "myuser");
182
+ });
183
+ // ── isGlobalConnected ─────────────────────────────────────────────────────
184
+ test("连接前 isGlobalConnected() = false,成功连接后 = true", async () => {
185
+ assert.equal(isGlobalConnected(), false);
186
+ const onConnectPromise = new Promise((resolve) => {
187
+ createGlobalMqttConnection("mqtt://localhost:1883", "announcement/u/a/bot", {
188
+ onConnect: resolve,
189
+ });
190
+ });
191
+ mockClient.simulateConnect(20);
192
+ await onConnectPromise;
193
+ assert.equal(isGlobalConnected(), true);
194
+ });
195
+ // ── 订阅 topic ────────────────────────────────────────────────────────────
196
+ test("连接后自动订阅正确的通配符 topic", async () => {
197
+ const onConnectPromise = new Promise((resolve) => {
198
+ createGlobalMqttConnection("mqtt://localhost:1883", "announcement/testuser/agent/bot", { onConnect: resolve });
199
+ });
200
+ mockClient.simulateConnect(10);
201
+ await onConnectPromise;
202
+ assert.ok(mockClient.subscribedTopics.includes("announcement/testuser/#"), `订阅 topic 错误,实际订阅: ${mockClient.subscribedTopics.join(", ")}`);
203
+ });
204
+ // ── 复用已有连接 ──────────────────────────────────────────────────────────
205
+ test("连接已存在时重复调用 createGlobalMqttConnection 复用同一对象", async () => {
206
+ const onConnectPromise = new Promise((resolve) => {
207
+ createGlobalMqttConnection("mqtt://localhost:1883", "announcement/u/a/bot", { onConnect: resolve });
208
+ });
209
+ mockClient.simulateConnect(10);
210
+ await onConnectPromise;
211
+ const conn1 = getGlobalConnection();
212
+ // 第二次调用
213
+ const conn2 = await createGlobalMqttConnection("mqtt://localhost:1883", "announcement/u/a/bot", {});
214
+ assert.strictEqual(conn1, conn2, "应返回同一个连接对象引用");
215
+ });
216
+ // ── closeGlobalConnection ─────────────────────────────────────────────────
217
+ test("closeGlobalConnection 后 isGlobalConnected() = false 且 getGlobalConnection() = null", async () => {
218
+ const onConnectPromise = new Promise((resolve) => {
219
+ createGlobalMqttConnection("mqtt://localhost:1883", "announcement/u/a/bot", { onConnect: resolve });
220
+ });
221
+ mockClient.simulateConnect(10);
222
+ await onConnectPromise;
223
+ assert.equal(isGlobalConnected(), true);
224
+ closeGlobalConnection();
225
+ assert.equal(isGlobalConnected(), false);
226
+ assert.equal(getGlobalConnection(), null);
227
+ assert.equal(mockClient.endCalled, true);
228
+ });
229
+ });
230
+ // ─────────────────────────────────────────────────────────────────────────────
231
+ // 5. GlobalMqttClient 订阅 & 发布测试(Mock 注入)
232
+ // ─────────────────────────────────────────────────────────────────────────────
233
+ describe("GlobalMqttClient — 订阅 & 发布", () => {
234
+ let mockClient;
235
+ beforeEach(() => {
236
+ mockClient = new MockMqttClient();
237
+ _setMqttConnectFn(makeMockConnectFn(mockClient));
238
+ closeGlobalConnection();
239
+ });
240
+ afterEach(() => {
241
+ closeGlobalConnection();
242
+ _setMqttConnectFn(null);
243
+ });
244
+ // ─────────────────────────────────────────────────────────────────────────
245
+ // 辅助:快速建立一个已连接的 GlobalMqttClient
246
+ // ─────────────────────────────────────────────────────────────────────────
247
+ async function makeConnectedClient(mqttTopic, onMessage, abortController) {
248
+ let client;
249
+ const onConnectPromise = new Promise((resolve) => {
250
+ client = new GlobalMqttClient({
251
+ mqttUrl: "mqtt://localhost:1883",
252
+ mqttTopic,
253
+ abortSignal: abortController.signal,
254
+ onMessage,
255
+ onConnect: resolve,
256
+ });
257
+ });
258
+ // connect() 挂起到 abort,在后台跑
259
+ client.connect().catch(() => { });
260
+ mockClient.simulateConnect(10);
261
+ await onConnectPromise;
262
+ return client;
263
+ }
264
+ // ── publish ───────────────────────────────────────────────────────────────
265
+ test("连接成功后 publish() 返回 true 且消息到达 broker", async () => {
266
+ const ac = new AbortController();
267
+ const client = await makeConnectedClient("announcement/user/agent/bot", async () => { }, ac);
268
+ const topic = "announcement/user/agent/bot";
269
+ const payload = JSON.stringify({ type: "message", content: "hello" });
270
+ const ok = client.publish(topic, payload);
271
+ assert.equal(ok, true);
272
+ assert.equal(mockClient.publishedMessages.length, 1);
273
+ assert.equal(mockClient.publishedMessages[0].topic, topic);
274
+ assert.deepEqual(JSON.parse(mockClient.publishedMessages[0].message), {
275
+ type: "message",
276
+ content: "hello",
277
+ });
278
+ ac.abort();
279
+ });
280
+ test("未连接时 publish() 返回 false", () => {
281
+ const ac = new AbortController();
282
+ const client = new GlobalMqttClient({
283
+ mqttUrl: "mqtt://localhost:1883",
284
+ mqttTopic: "announcement/user/agent/bot",
285
+ abortSignal: ac.signal,
286
+ onMessage: async () => { },
287
+ });
288
+ // 没有调用 connect(),连接未建立
289
+ const ok = client.publish("announcement/user/agent/bot", "test");
290
+ assert.equal(ok, false);
291
+ ac.abort();
292
+ });
293
+ test("连续 publish 多条消息,全部到达 broker,顺序正确", async () => {
294
+ const ac = new AbortController();
295
+ const client = await makeConnectedClient("announcement/userC/agent/bot", async () => { }, ac);
296
+ const topic = "announcement/userC/agent/bot";
297
+ client.publish(topic, "first");
298
+ client.publish(topic, "second");
299
+ client.publish(topic, "third");
300
+ assert.equal(mockClient.publishedMessages.length, 3);
301
+ assert.deepEqual(mockClient.publishedMessages.map((m) => m.message), ["first", "second", "third"]);
302
+ ac.abort();
303
+ });
304
+ // ── 订阅 / 接收消息 ───────────────────────────────────────────────────────
305
+ test("订阅的 topic 是通配符形式(不区分 agent_id)", async () => {
306
+ const ac = new AbortController();
307
+ await makeConnectedClient("announcement/userA/agentX/bot", async () => { }, ac);
308
+ assert.ok(mockClient.subscribedTopics.includes("announcement/userA/#"), `订阅 topic 不正确,实际: ${mockClient.subscribedTopics.join(", ")}`);
309
+ ac.abort();
310
+ });
311
+ test("broker 推送消息时 onMessage 回调被正确触发", async () => {
312
+ const ac = new AbortController();
313
+ const received = [];
314
+ const client = await makeConnectedClient("announcement/userA/agentX/bot", async (topic, payload) => {
315
+ received.push({ topic, payload: payload.toString() });
316
+ }, ac);
317
+ mockClient.simulateMessage("announcement/userA/agentX/bot", Buffer.from(JSON.stringify({ type: "message", content: "world" })));
318
+ await waitMs(30);
319
+ assert.equal(received.length, 1);
320
+ assert.equal(received[0].topic, "announcement/userA/agentX/bot");
321
+ assert.deepEqual(JSON.parse(received[0].payload), {
322
+ type: "message",
323
+ content: "world",
324
+ });
325
+ ac.abort();
326
+ });
327
+ test("通配符订阅能收到同一 user_name 下不同 agent_id 的消息", async () => {
328
+ const ac = new AbortController();
329
+ const receivedTopics = [];
330
+ const client = await makeConnectedClient("announcement/userB/agent-001/bot", async (topic) => {
331
+ receivedTopics.push(topic);
332
+ }, ac);
333
+ // 来自不同 agent_id 的消息(通配符 announcement/userB/# 都应匹配)
334
+ mockClient.simulateMessage("announcement/userB/agent-001/bot", Buffer.from("msg-from-agent-001"));
335
+ mockClient.simulateMessage("announcement/userB/agent-002/bot", Buffer.from("msg-from-agent-002"));
336
+ mockClient.simulateMessage("announcement/userB/agent-003/command", Buffer.from("msg-from-agent-003"));
337
+ await waitMs(30);
338
+ assert.equal(receivedTopics.length, 3);
339
+ assert.ok(receivedTopics.includes("announcement/userB/agent-001/bot"));
340
+ assert.ok(receivedTopics.includes("announcement/userB/agent-002/bot"));
341
+ assert.ok(receivedTopics.includes("announcement/userB/agent-003/command"));
342
+ ac.abort();
343
+ });
344
+ test("连续收到多条消息,每条都触发 onMessage,顺序正确", async () => {
345
+ const ac = new AbortController();
346
+ const payloads = [];
347
+ await makeConnectedClient("announcement/userC/agent/bot", async (_topic, payload) => {
348
+ payloads.push(payload.toString());
349
+ }, ac);
350
+ for (const msg of ["alpha", "beta", "gamma"]) {
351
+ mockClient.simulateMessage("announcement/userC/agent/bot", Buffer.from(msg));
352
+ }
353
+ await waitMs(30);
354
+ assert.deepEqual(payloads, ["alpha", "beta", "gamma"]);
355
+ ac.abort();
356
+ });
357
+ // ── getSubscribeTopic / getUsername ───────────────────────────────────────
358
+ test("getUsername() 返回从 topic 中解析的用户名", () => {
359
+ const ac = new AbortController();
360
+ const client = new GlobalMqttClient({
361
+ mqttUrl: "mqtt://localhost:1883",
362
+ mqttTopic: "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot",
363
+ abortSignal: ac.signal,
364
+ onMessage: async () => { },
365
+ });
366
+ assert.equal(client.getUsername(), "0b9a784d-e044-4c6f-9024-ef8799c131d1");
367
+ ac.abort();
368
+ });
369
+ test("getSubscribeTopic() 返回通配符形式的订阅 topic", () => {
370
+ const ac = new AbortController();
371
+ const client = new GlobalMqttClient({
372
+ mqttUrl: "mqtt://localhost:1883",
373
+ mqttTopic: "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/ce627d3d-9e22-47f7-b1c6-a6ab69570fef/bot",
374
+ abortSignal: ac.signal,
375
+ onMessage: async () => { },
376
+ });
377
+ assert.equal(client.getSubscribeTopic(), "announcement/0b9a784d-e044-4c6f-9024-ef8799c131d1/#");
378
+ ac.abort();
379
+ });
380
+ // ── AbortController / 生命周期 ────────────────────────────────────────────
381
+ test("abort 触发后 isConnected() = false,connect() Promise 正常 resolve", async () => {
382
+ const ac = new AbortController();
383
+ let client;
384
+ const onConnectPromise = new Promise((resolve) => {
385
+ client = new GlobalMqttClient({
386
+ mqttUrl: "mqtt://localhost:1883",
387
+ mqttTopic: "announcement/userD/agent/bot",
388
+ abortSignal: ac.signal,
389
+ onMessage: async () => { },
390
+ onConnect: resolve,
391
+ });
392
+ });
393
+ const done = client.connect(); // 挂起中
394
+ mockClient.simulateConnect(10);
395
+ await onConnectPromise;
396
+ assert.equal(client.isConnected(), true);
397
+ ac.abort();
398
+ await done; // 应在 abort 后正常 resolve(不应 reject)
399
+ assert.equal(client.isConnected(), false);
400
+ });
401
+ // ── publishGlobalMessage 直接 API ─────────────────────────────────────────
402
+ test("publishGlobalMessage() 使用全局连接发布消息", async () => {
403
+ const ac = new AbortController();
404
+ await makeConnectedClient("announcement/userE/agent/bot", async () => { }, ac);
405
+ const ok = publishGlobalMessage("announcement/userE/agent/bot", JSON.stringify({ type: "ping" }));
406
+ assert.equal(ok, true);
407
+ assert.equal(mockClient.publishedMessages.length, 1);
408
+ assert.deepEqual(JSON.parse(mockClient.publishedMessages[0].message), {
409
+ type: "ping",
410
+ });
411
+ ac.abort();
412
+ });
413
+ test("publishGlobalMessage() 在无连接时返回 false", () => {
414
+ // 没有任何连接
415
+ const ok = publishGlobalMessage("some/topic", "data");
416
+ assert.equal(ok, false);
417
+ });
418
+ });