openclaw-channel-dmwork 0.5.11 → 0.5.13
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.
- package/package.json +1 -1
- package/src/api-fetch.test.ts +80 -0
- package/src/api-fetch.ts +37 -0
- package/src/bug-repro.test.ts +390 -0
- package/src/channel.ts +72 -19
- package/src/inbound.ts +48 -6
- package/src/mention-utils.test.ts +72 -0
- package/src/mention-utils.ts +41 -3
- package/src/reconnect-fixes.test.ts +541 -0
- package/src/socket.test.ts +12 -12
- package/src/socket.ts +88 -2
package/package.json
CHANGED
package/src/api-fetch.test.ts
CHANGED
|
@@ -794,3 +794,83 @@ describe("uploadFileToCOS putParams ContentType", () => {
|
|
|
794
794
|
expect(capturedParams.ContentType).toBe("text/plain; charset=utf-8");
|
|
795
795
|
});
|
|
796
796
|
});
|
|
797
|
+
|
|
798
|
+
// --- fetchUserInfo ---
|
|
799
|
+
import { fetchUserInfo } from "./api-fetch.js";
|
|
800
|
+
|
|
801
|
+
describe("fetchUserInfo", () => {
|
|
802
|
+
it("returns user info on success", async () => {
|
|
803
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
804
|
+
ok: true,
|
|
805
|
+
status: 200,
|
|
806
|
+
json: () => Promise.resolve({ uid: "s14_abc", name: "Alice", avatar: "https://example.com/a.png" }),
|
|
807
|
+
}) as any;
|
|
808
|
+
|
|
809
|
+
const result = await fetchUserInfo({
|
|
810
|
+
apiUrl: "http://localhost:8090",
|
|
811
|
+
botToken: "tok",
|
|
812
|
+
uid: "s14_abc",
|
|
813
|
+
});
|
|
814
|
+
expect(result).toEqual({ uid: "s14_abc", name: "Alice", avatar: "https://example.com/a.png" });
|
|
815
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
816
|
+
"http://localhost:8090/v1/bot/user/info?uid=s14_abc",
|
|
817
|
+
expect.objectContaining({ method: "GET" }),
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("returns null on 404 (endpoint not implemented)", async () => {
|
|
822
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
823
|
+
ok: false,
|
|
824
|
+
status: 404,
|
|
825
|
+
}) as any;
|
|
826
|
+
|
|
827
|
+
const result = await fetchUserInfo({
|
|
828
|
+
apiUrl: "http://localhost:8090",
|
|
829
|
+
botToken: "tok",
|
|
830
|
+
uid: "s14_abc",
|
|
831
|
+
});
|
|
832
|
+
expect(result).toBeNull();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("returns null on 500 error", async () => {
|
|
836
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
837
|
+
ok: false,
|
|
838
|
+
status: 500,
|
|
839
|
+
}) as any;
|
|
840
|
+
|
|
841
|
+
const result = await fetchUserInfo({
|
|
842
|
+
apiUrl: "http://localhost:8090",
|
|
843
|
+
botToken: "tok",
|
|
844
|
+
uid: "s14_abc",
|
|
845
|
+
log: { error: vi.fn() },
|
|
846
|
+
});
|
|
847
|
+
expect(result).toBeNull();
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it("returns null on network error", async () => {
|
|
851
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) as any;
|
|
852
|
+
|
|
853
|
+
const result = await fetchUserInfo({
|
|
854
|
+
apiUrl: "http://localhost:8090",
|
|
855
|
+
botToken: "tok",
|
|
856
|
+
uid: "s14_abc",
|
|
857
|
+
log: { error: vi.fn() },
|
|
858
|
+
});
|
|
859
|
+
expect(result).toBeNull();
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it("returns null when response has no name", async () => {
|
|
863
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
864
|
+
ok: true,
|
|
865
|
+
status: 200,
|
|
866
|
+
json: () => Promise.resolve({ uid: "s14_abc" }),
|
|
867
|
+
}) as any;
|
|
868
|
+
|
|
869
|
+
const result = await fetchUserInfo({
|
|
870
|
+
apiUrl: "http://localhost:8090",
|
|
871
|
+
botToken: "tok",
|
|
872
|
+
uid: "s14_abc",
|
|
873
|
+
});
|
|
874
|
+
expect(result).toBeNull();
|
|
875
|
+
});
|
|
876
|
+
});
|
package/src/api-fetch.ts
CHANGED
|
@@ -649,3 +649,40 @@ export async function editMessage(params: {
|
|
|
649
649
|
content_edit: params.contentEdit,
|
|
650
650
|
}, params.signal);
|
|
651
651
|
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Fetch user info by UID. Requires backend `/v1/bot/user/info` endpoint.
|
|
655
|
+
* Returns null if the endpoint is unavailable (404) or returns an error,
|
|
656
|
+
* so callers can gracefully degrade.
|
|
657
|
+
*/
|
|
658
|
+
export async function fetchUserInfo(params: {
|
|
659
|
+
apiUrl: string;
|
|
660
|
+
botToken: string;
|
|
661
|
+
uid: string;
|
|
662
|
+
log?: { info?: (msg: string) => void; error?: (msg: string) => void };
|
|
663
|
+
}): Promise<{ uid: string; name: string; avatar?: string } | null> {
|
|
664
|
+
const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/user/info?uid=${encodeURIComponent(params.uid)}`;
|
|
665
|
+
try {
|
|
666
|
+
const resp = await fetch(url, {
|
|
667
|
+
method: "GET",
|
|
668
|
+
headers: { Authorization: `Bearer ${params.botToken}` },
|
|
669
|
+
signal: AbortSignal.timeout(5000),
|
|
670
|
+
});
|
|
671
|
+
if (resp.status === 404) {
|
|
672
|
+
// Endpoint not implemented yet — silent degrade
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
if (!resp.ok) {
|
|
676
|
+
params.log?.error?.(`dmwork: fetchUserInfo(${params.uid}) failed: ${resp.status}`);
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
const data = await resp.json() as { uid?: string; name?: string; avatar?: string };
|
|
680
|
+
if (data?.name) {
|
|
681
|
+
return { uid: data.uid ?? params.uid, name: data.name, avatar: data.avatar };
|
|
682
|
+
}
|
|
683
|
+
return null;
|
|
684
|
+
} catch (err) {
|
|
685
|
+
params.log?.error?.(`dmwork: fetchUserInfo(${params.uid}) error: ${String(err)}`);
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bug fix verification tests for WebSocket reconnect v2 (Issue #139)
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that three bugs have been FIXED:
|
|
5
|
+
* Bug A: Exponential backoff now works (reconnectAttempts not reset prematurely)
|
|
6
|
+
* Bug B: Kicked bots reconnect even during cooldown window
|
|
7
|
+
* Bug C: Rapid silent disconnects now trigger onError for token refresh
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
|
+
|
|
11
|
+
// Track mock WS instances created during tests
|
|
12
|
+
const mockWsInstances: any[] = [];
|
|
13
|
+
|
|
14
|
+
vi.mock("ws", () => {
|
|
15
|
+
class MockWS {
|
|
16
|
+
static OPEN = 1;
|
|
17
|
+
binaryType = "arraybuffer";
|
|
18
|
+
readyState = 1;
|
|
19
|
+
private handlers = new Map<string, Function[]>();
|
|
20
|
+
|
|
21
|
+
constructor(public url: string) {
|
|
22
|
+
(globalThis as any).__mockWsInstances?.push(this);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
on(event: string, handler: Function) {
|
|
26
|
+
if (!this.handlers.has(event)) this.handlers.set(event, []);
|
|
27
|
+
this.handlers.get(event)!.push(handler);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
send = vi.fn();
|
|
31
|
+
|
|
32
|
+
close() {
|
|
33
|
+
this.readyState = 3; // CLOSED
|
|
34
|
+
queueMicrotask(() => this.emit("close"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
emit(event: string, ...args: any[]) {
|
|
38
|
+
const handlers = this.handlers.get(event);
|
|
39
|
+
if (handlers) {
|
|
40
|
+
for (const h of handlers) h(...args);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { default: MockWS, WebSocket: MockWS };
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
vi.mock("curve25519-js", () => ({
|
|
49
|
+
generateKeyPair: () => ({
|
|
50
|
+
private: new Uint8Array(32),
|
|
51
|
+
public: new Uint8Array(32),
|
|
52
|
+
}),
|
|
53
|
+
sharedKey: () => new Uint8Array(32),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
import { WKSocket } from "./socket.js";
|
|
57
|
+
|
|
58
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Build a minimal CONNACK packet with reasonCode=1 (success) */
|
|
61
|
+
function buildConnackSuccess(): ArrayBuffer {
|
|
62
|
+
const serverVersion = 4;
|
|
63
|
+
const reasonCode = 1; // success
|
|
64
|
+
const serverKey = Buffer.from(new Uint8Array(32)).toString("base64");
|
|
65
|
+
const salt = "1234567890123456";
|
|
66
|
+
|
|
67
|
+
const body: number[] = [];
|
|
68
|
+
body.push(serverVersion);
|
|
69
|
+
for (let i = 0; i < 8; i++) body.push(0); // timeDiff
|
|
70
|
+
body.push(reasonCode);
|
|
71
|
+
const keyBytes = [...Buffer.from(serverKey)];
|
|
72
|
+
body.push((keyBytes.length >> 8) & 0xff, keyBytes.length & 0xff);
|
|
73
|
+
body.push(...keyBytes);
|
|
74
|
+
const saltBytes = [...Buffer.from(salt)];
|
|
75
|
+
body.push((saltBytes.length >> 8) & 0xff, saltBytes.length & 0xff);
|
|
76
|
+
body.push(...saltBytes);
|
|
77
|
+
for (let i = 0; i < 8; i++) body.push(0); // nodeId
|
|
78
|
+
|
|
79
|
+
const header = (2 << 4) | 1; // CONNACK with hasServerVersion
|
|
80
|
+
const packet = new Uint8Array([header, body.length, ...body]);
|
|
81
|
+
return packet.buffer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Build a CONNACK packet with reasonCode=0 (kicked) */
|
|
85
|
+
function buildConnackKicked(): ArrayBuffer {
|
|
86
|
+
const serverVersion = 4;
|
|
87
|
+
const reasonCode = 0; // kicked
|
|
88
|
+
|
|
89
|
+
const body: number[] = [];
|
|
90
|
+
body.push(serverVersion);
|
|
91
|
+
for (let i = 0; i < 8; i++) body.push(0); // timeDiff
|
|
92
|
+
body.push(reasonCode);
|
|
93
|
+
// serverKey (empty)
|
|
94
|
+
body.push(0, 0);
|
|
95
|
+
// salt (empty)
|
|
96
|
+
body.push(0, 0);
|
|
97
|
+
for (let i = 0; i < 8; i++) body.push(0); // nodeId
|
|
98
|
+
|
|
99
|
+
const header = (2 << 4) | 1;
|
|
100
|
+
const packet = new Uint8Array([header, body.length, ...body]);
|
|
101
|
+
return packet.buffer;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Test Suites ────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe("Bug A fix: Exponential backoff grows correctly", () => {
|
|
107
|
+
let setTimeoutCalls: { fn: Function; delay: number }[];
|
|
108
|
+
let originalSetTimeout: typeof setTimeout;
|
|
109
|
+
let dateNowSpy: ReturnType<typeof vi.spyOn>;
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
mockWsInstances.length = 0;
|
|
113
|
+
(globalThis as any).__mockWsInstances = mockWsInstances;
|
|
114
|
+
setTimeoutCalls = [];
|
|
115
|
+
originalSetTimeout = global.setTimeout;
|
|
116
|
+
global.setTimeout = vi.fn((fn: Function, delay?: number) => {
|
|
117
|
+
setTimeoutCalls.push({ fn, delay: delay ?? 0 });
|
|
118
|
+
return 999 as any;
|
|
119
|
+
}) as any;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
global.setTimeout = originalSetTimeout;
|
|
124
|
+
dateNowSpy?.mockRestore();
|
|
125
|
+
delete (globalThis as any).__mockWsInstances;
|
|
126
|
+
vi.restoreAllMocks();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("delays should GROW after 5 connect→CONNACK→close cycles", () => {
|
|
130
|
+
/**
|
|
131
|
+
* With the fix, reconnectAttempts only resets after the 30s stable timer
|
|
132
|
+
* fires (which requires the connection to stay up for 30s). Connections
|
|
133
|
+
* that live <30s keep the backoff counter, so delays grow exponentially.
|
|
134
|
+
*
|
|
135
|
+
* We mock Date.now so each connection appears to last 6s (>5s threshold)
|
|
136
|
+
* to isolate Bug A from Bug C's rapid-disconnect detection.
|
|
137
|
+
*/
|
|
138
|
+
let now = 10000;
|
|
139
|
+
dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
140
|
+
|
|
141
|
+
const socket = new WKSocket({
|
|
142
|
+
wsUrl: "ws://test:5200",
|
|
143
|
+
uid: "bot1",
|
|
144
|
+
token: "tok1",
|
|
145
|
+
onMessage: vi.fn(),
|
|
146
|
+
onConnected: vi.fn(),
|
|
147
|
+
onDisconnected: vi.fn(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const reconnectDelays: number[] = [];
|
|
151
|
+
|
|
152
|
+
for (let cycle = 0; cycle < 5; cycle++) {
|
|
153
|
+
socket.connect();
|
|
154
|
+
const ws = mockWsInstances[mockWsInstances.length - 1];
|
|
155
|
+
ws.emit("open");
|
|
156
|
+
|
|
157
|
+
// CONNACK success → sets lastConnectTime = now, starts stable timer
|
|
158
|
+
ws.emit("message", buildConnackSuccess());
|
|
159
|
+
|
|
160
|
+
// Advance time by 6s (above 5s rapid-disconnect threshold,
|
|
161
|
+
// but well below the 30s stable timer)
|
|
162
|
+
now += 6000;
|
|
163
|
+
|
|
164
|
+
setTimeoutCalls = [];
|
|
165
|
+
ws.emit("close");
|
|
166
|
+
|
|
167
|
+
// The only setTimeout from the close handler is scheduleReconnect
|
|
168
|
+
if (setTimeoutCalls.length > 0) {
|
|
169
|
+
reconnectDelays.push(setTimeoutCalls[0].delay);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// All 5 cycles should have produced reconnect delays
|
|
174
|
+
expect(reconnectDelays).toHaveLength(5);
|
|
175
|
+
|
|
176
|
+
// Delays should strictly increase (exponential backoff working)
|
|
177
|
+
for (let i = 1; i < reconnectDelays.length; i++) {
|
|
178
|
+
expect(reconnectDelays[i]).toBeGreaterThan(reconnectDelays[i - 1]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// First delay ~3000ms (base), with ±25% jitter → [2250, 3750]
|
|
182
|
+
expect(reconnectDelays[0]).toBeGreaterThanOrEqual(2250);
|
|
183
|
+
expect(reconnectDelays[0]).toBeLessThanOrEqual(4500);
|
|
184
|
+
|
|
185
|
+
// Last delay should be much larger (~48000ms at attempt 4)
|
|
186
|
+
expect(reconnectDelays[4]).toBeGreaterThan(20000);
|
|
187
|
+
|
|
188
|
+
socket.disconnect();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
describe("Bug B fix: Kicked during cooldown still reconnects", () => {
|
|
194
|
+
it("channel.ts onError handler reconnects even when cooldown is active", () => {
|
|
195
|
+
/**
|
|
196
|
+
* With the fix, the else branch in channel.ts onError reconnects the
|
|
197
|
+
* bot with current credentials when cooldown is active, instead of
|
|
198
|
+
* doing nothing and letting the bot die permanently.
|
|
199
|
+
*/
|
|
200
|
+
let lastTokenRefreshAt = 0;
|
|
201
|
+
const TOKEN_REFRESH_COOLDOWN_MS = 60_000;
|
|
202
|
+
let isRefreshingToken = false;
|
|
203
|
+
let stopped = false;
|
|
204
|
+
let tokenRefreshCount = 0;
|
|
205
|
+
let reconnectCount = 0;
|
|
206
|
+
|
|
207
|
+
// Simulate the FIXED onError handler from channel.ts
|
|
208
|
+
function onError(err: Error) {
|
|
209
|
+
const cooldownElapsed = Date.now() - lastTokenRefreshAt > TOKEN_REFRESH_COOLDOWN_MS;
|
|
210
|
+
if (cooldownElapsed && !isRefreshingToken && !stopped &&
|
|
211
|
+
(err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
|
|
212
|
+
isRefreshingToken = true;
|
|
213
|
+
lastTokenRefreshAt = Date.now();
|
|
214
|
+
tokenRefreshCount++;
|
|
215
|
+
// Would do: socket.disconnect() + socket.updateCredentials() + socket.connect()
|
|
216
|
+
reconnectCount++;
|
|
217
|
+
isRefreshingToken = false;
|
|
218
|
+
} else if (!isRefreshingToken && !stopped &&
|
|
219
|
+
(err.message.includes("Kicked") || err.message.includes("Connect failed"))) {
|
|
220
|
+
// FIX: Cooldown active — skip token refresh but still reconnect
|
|
221
|
+
// Would do: socket.disconnect() + socket.connect()
|
|
222
|
+
reconnectCount++;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// First kick — cooldown not active yet, full refresh happens
|
|
227
|
+
onError(new Error("Kicked by server"));
|
|
228
|
+
expect(tokenRefreshCount).toBe(1);
|
|
229
|
+
expect(reconnectCount).toBe(1);
|
|
230
|
+
|
|
231
|
+
// Second kick — within 60s cooldown
|
|
232
|
+
onError(new Error("Kicked by server"));
|
|
233
|
+
|
|
234
|
+
// FIX VERIFIED: reconnect still happens (via else branch), just no token refresh
|
|
235
|
+
expect(tokenRefreshCount).toBe(1); // no new refresh (cooldown active)
|
|
236
|
+
expect(reconnectCount).toBe(2); // reconnect DID happen!
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("socket reconnects after channel.ts calls disconnect+connect on kick", () => {
|
|
240
|
+
/**
|
|
241
|
+
* When CONNACK returns kicked (reasonCode=0), needReconnect is set to false
|
|
242
|
+
* and the close event won't trigger scheduleReconnect. But channel.ts can
|
|
243
|
+
* call socket.disconnect() + socket.connect() to revive the bot.
|
|
244
|
+
*/
|
|
245
|
+
const originalSetTimeout = global.setTimeout;
|
|
246
|
+
const setTimeoutCalls: { fn: Function; delay: number }[] = [];
|
|
247
|
+
global.setTimeout = vi.fn((fn: Function, delay?: number) => {
|
|
248
|
+
setTimeoutCalls.push({ fn, delay: delay ?? 0 });
|
|
249
|
+
return 999 as any;
|
|
250
|
+
}) as any;
|
|
251
|
+
|
|
252
|
+
mockWsInstances.length = 0;
|
|
253
|
+
(globalThis as any).__mockWsInstances = mockWsInstances;
|
|
254
|
+
|
|
255
|
+
const onError = vi.fn();
|
|
256
|
+
const socket = new WKSocket({
|
|
257
|
+
wsUrl: "ws://test:5200",
|
|
258
|
+
uid: "bot1",
|
|
259
|
+
token: "tok1",
|
|
260
|
+
onMessage: vi.fn(),
|
|
261
|
+
onError,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Initial connection → kicked
|
|
265
|
+
socket.connect();
|
|
266
|
+
const ws1 = mockWsInstances[0];
|
|
267
|
+
ws1.emit("open");
|
|
268
|
+
ws1.emit("message", buildConnackKicked());
|
|
269
|
+
|
|
270
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({
|
|
271
|
+
message: "Kicked by server",
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
// needReconnect is false after kick, close won't trigger scheduleReconnect
|
|
275
|
+
setTimeoutCalls.length = 0;
|
|
276
|
+
ws1.emit("close");
|
|
277
|
+
expect(setTimeoutCalls.length).toBe(0);
|
|
278
|
+
|
|
279
|
+
// FIX: channel.ts calls disconnect + connect → bot reconnects
|
|
280
|
+
socket.disconnect();
|
|
281
|
+
socket.connect();
|
|
282
|
+
|
|
283
|
+
// Verify a new WebSocket was created (bot is alive!)
|
|
284
|
+
expect(mockWsInstances.length).toBeGreaterThan(1);
|
|
285
|
+
const ws2 = mockWsInstances[mockWsInstances.length - 1];
|
|
286
|
+
expect(ws2).not.toBe(ws1);
|
|
287
|
+
|
|
288
|
+
socket.disconnect();
|
|
289
|
+
global.setTimeout = originalSetTimeout;
|
|
290
|
+
delete (globalThis as any).__mockWsInstances;
|
|
291
|
+
vi.restoreAllMocks();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
describe("Bug C fix: Rapid silent disconnects trigger onError", () => {
|
|
297
|
+
it("onError fires after 3 consecutive rapid disconnects", () => {
|
|
298
|
+
/**
|
|
299
|
+
* With the fix, the close handler tracks rapid disconnects (connections
|
|
300
|
+
* lasting <5s). After 3 consecutive rapid disconnects, it calls onError
|
|
301
|
+
* with "Connect failed: rapid disconnect detected" so channel.ts can
|
|
302
|
+
* refresh the token.
|
|
303
|
+
*/
|
|
304
|
+
const originalSetTimeout = global.setTimeout;
|
|
305
|
+
global.setTimeout = vi.fn((fn: Function, delay?: number) => {
|
|
306
|
+
return 999 as any;
|
|
307
|
+
}) as any;
|
|
308
|
+
|
|
309
|
+
mockWsInstances.length = 0;
|
|
310
|
+
(globalThis as any).__mockWsInstances = mockWsInstances;
|
|
311
|
+
|
|
312
|
+
const onError = vi.fn();
|
|
313
|
+
const socket = new WKSocket({
|
|
314
|
+
wsUrl: "ws://test:5200",
|
|
315
|
+
uid: "bot1",
|
|
316
|
+
token: "tok1",
|
|
317
|
+
onMessage: vi.fn(),
|
|
318
|
+
onError,
|
|
319
|
+
onConnected: vi.fn(),
|
|
320
|
+
onDisconnected: vi.fn(),
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Simulate 3 rapid connect→CONNACK→immediate-close cycles
|
|
324
|
+
// (connection lasting <5s each time, simulating server restart)
|
|
325
|
+
for (let i = 0; i < 3; i++) {
|
|
326
|
+
socket.connect();
|
|
327
|
+
const ws = mockWsInstances[mockWsInstances.length - 1];
|
|
328
|
+
ws.emit("open");
|
|
329
|
+
ws.emit("message", buildConnackSuccess());
|
|
330
|
+
// Connection closes immediately (server restart, no Kicked packet)
|
|
331
|
+
ws.emit("close");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// FIX VERIFIED: onError IS called after 3 rapid disconnects
|
|
335
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
336
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({
|
|
337
|
+
message: "Connect failed: rapid disconnect detected",
|
|
338
|
+
}));
|
|
339
|
+
|
|
340
|
+
socket.disconnect();
|
|
341
|
+
global.setTimeout = originalSetTimeout;
|
|
342
|
+
delete (globalThis as any).__mockWsInstances;
|
|
343
|
+
vi.restoreAllMocks();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("rapid-disconnect loop with stale token triggers onError for refresh", () => {
|
|
347
|
+
/**
|
|
348
|
+
* Simulates the production scenario: server restarts, bot reconnects with
|
|
349
|
+
* stale token, server silently closes again. After 3 such cycles, onError
|
|
350
|
+
* fires so channel.ts can refresh the token.
|
|
351
|
+
*/
|
|
352
|
+
const originalSetTimeout = global.setTimeout;
|
|
353
|
+
global.setTimeout = vi.fn((fn: Function, delay?: number) => {
|
|
354
|
+
return 999 as any;
|
|
355
|
+
}) as any;
|
|
356
|
+
|
|
357
|
+
mockWsInstances.length = 0;
|
|
358
|
+
(globalThis as any).__mockWsInstances = mockWsInstances;
|
|
359
|
+
|
|
360
|
+
const onError = vi.fn();
|
|
361
|
+
const socket = new WKSocket({
|
|
362
|
+
wsUrl: "ws://test:5200",
|
|
363
|
+
uid: "bot1",
|
|
364
|
+
token: "stale-token",
|
|
365
|
+
onMessage: vi.fn(),
|
|
366
|
+
onError,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// 3 rapid connect→close cycles (simulating stale token scenario)
|
|
370
|
+
for (let i = 0; i < 3; i++) {
|
|
371
|
+
socket.connect();
|
|
372
|
+
const ws = mockWsInstances[mockWsInstances.length - 1];
|
|
373
|
+
ws.emit("open");
|
|
374
|
+
ws.emit("message", buildConnackSuccess());
|
|
375
|
+
ws.emit("close");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// FIX VERIFIED: After 3 short-lived connections, onError fires
|
|
379
|
+
// This allows channel.ts to refresh the token
|
|
380
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
381
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({
|
|
382
|
+
message: "Connect failed: rapid disconnect detected",
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
socket.disconnect();
|
|
386
|
+
global.setTimeout = originalSetTimeout;
|
|
387
|
+
delete (globalThis as any).__mockWsInstances;
|
|
388
|
+
vi.restoreAllMocks();
|
|
389
|
+
});
|
|
390
|
+
});
|