@firstperson/firstperson 2026.1.33
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/.claude/settings.local.json +16 -0
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +56 -0
- package/src/channel.test.ts +650 -0
- package/src/channel.ts +742 -0
- package/src/config-schema.test.ts +81 -0
- package/src/config-schema.ts +13 -0
- package/src/relay-client.test.ts +452 -0
- package/src/relay-client.ts +266 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +32 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { FirstPersonConfigSchema } from "./config-schema.js";
|
|
3
|
+
|
|
4
|
+
describe("FirstPersonConfigSchema", () => {
|
|
5
|
+
it("validates a valid config", () => {
|
|
6
|
+
const config = {
|
|
7
|
+
enabled: true,
|
|
8
|
+
token: "fp_token_123",
|
|
9
|
+
relayUrl: "wss://chat.firstperson.ai",
|
|
10
|
+
dmPolicy: "pairing" as const,
|
|
11
|
+
allowFrom: ["device-1", "device-2"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const result = FirstPersonConfigSchema.safeParse(config);
|
|
15
|
+
expect(result.success).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("accepts empty config", () => {
|
|
19
|
+
const result = FirstPersonConfigSchema.safeParse({});
|
|
20
|
+
expect(result.success).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("accepts partial config", () => {
|
|
24
|
+
const result = FirstPersonConfigSchema.safeParse({
|
|
25
|
+
token: "fp_token_123",
|
|
26
|
+
dmPolicy: "allowlist",
|
|
27
|
+
});
|
|
28
|
+
expect(result.success).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects invalid dmPolicy", () => {
|
|
32
|
+
const result = FirstPersonConfigSchema.safeParse({
|
|
33
|
+
dmPolicy: "invalid_policy",
|
|
34
|
+
});
|
|
35
|
+
expect(result.success).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects invalid relayUrl", () => {
|
|
39
|
+
const result = FirstPersonConfigSchema.safeParse({
|
|
40
|
+
relayUrl: "not-a-valid-url",
|
|
41
|
+
});
|
|
42
|
+
expect(result.success).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects empty token", () => {
|
|
46
|
+
const result = FirstPersonConfigSchema.safeParse({
|
|
47
|
+
token: "",
|
|
48
|
+
});
|
|
49
|
+
expect(result.success).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects unknown keys (strict mode)", () => {
|
|
53
|
+
const result = FirstPersonConfigSchema.safeParse({
|
|
54
|
+
token: "fp_token_123",
|
|
55
|
+
unknownField: "should fail",
|
|
56
|
+
});
|
|
57
|
+
expect(result.success).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("accepts allowFrom with numbers", () => {
|
|
61
|
+
const result = FirstPersonConfigSchema.safeParse({
|
|
62
|
+
allowFrom: [123, "device-1", 456],
|
|
63
|
+
});
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("accepts wildcard in allowFrom", () => {
|
|
68
|
+
const result = FirstPersonConfigSchema.safeParse({
|
|
69
|
+
allowFrom: ["*"],
|
|
70
|
+
});
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("validates all dmPolicy options", () => {
|
|
75
|
+
const policies = ["pairing", "allowlist", "open", "disabled"] as const;
|
|
76
|
+
for (const policy of policies) {
|
|
77
|
+
const result = FirstPersonConfigSchema.safeParse({ dmPolicy: policy });
|
|
78
|
+
expect(result.success).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const FirstPersonConfigSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
enabled: z.boolean().optional(),
|
|
6
|
+
token: z.string().min(1).optional(),
|
|
7
|
+
relayUrl: z.string().url().optional(),
|
|
8
|
+
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
|
9
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
|
|
13
|
+
export type FirstPersonConfigInput = z.infer<typeof FirstPersonConfigSchema>;
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import type { Logger } from "./relay-client.js";
|
|
3
|
+
|
|
4
|
+
// Mock WebSocket
|
|
5
|
+
const mockWsInstances: MockWebSocket[] = [];
|
|
6
|
+
|
|
7
|
+
class MockWebSocket {
|
|
8
|
+
static OPEN = 1;
|
|
9
|
+
static CLOSED = 3;
|
|
10
|
+
|
|
11
|
+
readyState = MockWebSocket.OPEN;
|
|
12
|
+
url: string;
|
|
13
|
+
private handlers: Record<string, Function[]> = {};
|
|
14
|
+
|
|
15
|
+
constructor(url: string) {
|
|
16
|
+
this.url = url;
|
|
17
|
+
mockWsInstances.push(this);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
on(event: string, handler: Function) {
|
|
21
|
+
if (!this.handlers[event]) {
|
|
22
|
+
this.handlers[event] = [];
|
|
23
|
+
}
|
|
24
|
+
this.handlers[event].push(handler);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
send = vi.fn();
|
|
28
|
+
|
|
29
|
+
close() {
|
|
30
|
+
this.readyState = MockWebSocket.CLOSED;
|
|
31
|
+
this.emit("close", 1000, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
emit(event: string, ...args: unknown[]) {
|
|
35
|
+
const handlers = this.handlers[event] || [];
|
|
36
|
+
for (const handler of handlers) {
|
|
37
|
+
handler(...args);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
vi.mock("ws", () => ({
|
|
43
|
+
default: MockWebSocket,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
describe("relay-client", () => {
|
|
47
|
+
let sendTextMessage: typeof import("./relay-client.js").sendTextMessage;
|
|
48
|
+
let startRelayConnection: typeof import("./relay-client.js").startRelayConnection;
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
vi.resetModules();
|
|
52
|
+
mockWsInstances.length = 0;
|
|
53
|
+
|
|
54
|
+
const mod = await import("./relay-client.js");
|
|
55
|
+
sendTextMessage = mod.sendTextMessage;
|
|
56
|
+
startRelayConnection = mod.startRelayConnection;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("sendTextMessage", () => {
|
|
64
|
+
it("creates temporary connection when no active connection", async () => {
|
|
65
|
+
const promise = sendTextMessage({
|
|
66
|
+
relayUrl: "wss://relay.example.com",
|
|
67
|
+
token: "test-token",
|
|
68
|
+
to: "device-123",
|
|
69
|
+
text: "Hello, world!",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Wait for WebSocket to be created
|
|
73
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
74
|
+
|
|
75
|
+
const ws = mockWsInstances[0];
|
|
76
|
+
|
|
77
|
+
// Simulate connection open
|
|
78
|
+
ws.emit("open");
|
|
79
|
+
|
|
80
|
+
// Check message was sent
|
|
81
|
+
expect(ws.send).toHaveBeenCalledWith(
|
|
82
|
+
expect.stringContaining('"type":"message"')
|
|
83
|
+
);
|
|
84
|
+
expect(ws.send).toHaveBeenCalledWith(
|
|
85
|
+
expect.stringContaining('"to":"device-123"')
|
|
86
|
+
);
|
|
87
|
+
expect(ws.send).toHaveBeenCalledWith(
|
|
88
|
+
expect.stringContaining('"text":"Hello, world!"')
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Simulate acknowledgment
|
|
92
|
+
ws.emit("message", Buffer.from(JSON.stringify({ type: "message_sent" })));
|
|
93
|
+
|
|
94
|
+
const result = await promise;
|
|
95
|
+
expect(result.chatId).toBe("device-123");
|
|
96
|
+
expect(result.messageId).toMatch(/^msg_\d+_[a-z0-9]+$/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("includes replyTo when provided", async () => {
|
|
100
|
+
const promise = sendTextMessage({
|
|
101
|
+
relayUrl: "wss://relay.example.com",
|
|
102
|
+
token: "test-token",
|
|
103
|
+
to: "device-123",
|
|
104
|
+
text: "Reply text",
|
|
105
|
+
replyTo: "original-msg-id",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
109
|
+
|
|
110
|
+
const ws = mockWsInstances[0];
|
|
111
|
+
ws.emit("open");
|
|
112
|
+
|
|
113
|
+
expect(ws.send).toHaveBeenCalledWith(
|
|
114
|
+
expect.stringContaining('"replyTo":"original-msg-id"')
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
ws.emit("message", Buffer.from(JSON.stringify({ type: "ack" })));
|
|
118
|
+
await promise;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("handles relay error response", async () => {
|
|
122
|
+
const promise = sendTextMessage({
|
|
123
|
+
relayUrl: "wss://relay.example.com",
|
|
124
|
+
token: "test-token",
|
|
125
|
+
to: "device-123",
|
|
126
|
+
text: "Hello",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
130
|
+
|
|
131
|
+
const ws = mockWsInstances[0];
|
|
132
|
+
ws.emit("open");
|
|
133
|
+
ws.emit(
|
|
134
|
+
"message",
|
|
135
|
+
Buffer.from(JSON.stringify({ type: "error", error: "Device not found" }))
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await expect(promise).rejects.toThrow("Device not found");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("handles WebSocket error", async () => {
|
|
142
|
+
const promise = sendTextMessage({
|
|
143
|
+
relayUrl: "wss://relay.example.com",
|
|
144
|
+
token: "test-token",
|
|
145
|
+
to: "device-123",
|
|
146
|
+
text: "Hello",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
150
|
+
|
|
151
|
+
const ws = mockWsInstances[0];
|
|
152
|
+
ws.emit("error", new Error("Connection refused"));
|
|
153
|
+
|
|
154
|
+
await expect(promise).rejects.toThrow("Connection refused");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("encodes token in URL", async () => {
|
|
158
|
+
sendTextMessage({
|
|
159
|
+
relayUrl: "wss://relay.example.com",
|
|
160
|
+
token: "token with spaces",
|
|
161
|
+
to: "device-123",
|
|
162
|
+
text: "Hello",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
166
|
+
|
|
167
|
+
const ws = mockWsInstances[0];
|
|
168
|
+
expect(ws.url).toContain("token=token%20with%20spaces");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("startRelayConnection", () => {
|
|
173
|
+
it("connects and calls onConnected callback", async () => {
|
|
174
|
+
const onConnected = vi.fn();
|
|
175
|
+
const onDisconnected = vi.fn();
|
|
176
|
+
const onMessage = vi.fn();
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
|
|
179
|
+
startRelayConnection({
|
|
180
|
+
relayUrl: "wss://relay.example.com",
|
|
181
|
+
token: "test-token",
|
|
182
|
+
accountId: "default",
|
|
183
|
+
abortSignal: controller.signal,
|
|
184
|
+
onConnected,
|
|
185
|
+
onDisconnected,
|
|
186
|
+
onMessage,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
190
|
+
|
|
191
|
+
const ws = mockWsInstances[0];
|
|
192
|
+
ws.emit("open");
|
|
193
|
+
|
|
194
|
+
expect(onConnected).toHaveBeenCalledTimes(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("handles incoming messages", async () => {
|
|
198
|
+
const onConnected = vi.fn();
|
|
199
|
+
const onDisconnected = vi.fn();
|
|
200
|
+
const onMessage = vi.fn();
|
|
201
|
+
const controller = new AbortController();
|
|
202
|
+
|
|
203
|
+
startRelayConnection({
|
|
204
|
+
relayUrl: "wss://relay.example.com",
|
|
205
|
+
token: "test-token",
|
|
206
|
+
accountId: "default",
|
|
207
|
+
abortSignal: controller.signal,
|
|
208
|
+
onConnected,
|
|
209
|
+
onDisconnected,
|
|
210
|
+
onMessage,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
214
|
+
|
|
215
|
+
const ws = mockWsInstances[0];
|
|
216
|
+
ws.emit("open");
|
|
217
|
+
|
|
218
|
+
const incomingMessage = {
|
|
219
|
+
type: "message",
|
|
220
|
+
from: "device-456",
|
|
221
|
+
text: "Hello from device",
|
|
222
|
+
messageId: "msg-123",
|
|
223
|
+
timestamp: "2026-01-31T12:00:00Z",
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
ws.emit("message", Buffer.from(JSON.stringify(incomingMessage)));
|
|
227
|
+
|
|
228
|
+
await vi.waitFor(() => expect(onMessage).toHaveBeenCalled());
|
|
229
|
+
|
|
230
|
+
expect(onMessage).toHaveBeenCalledWith({
|
|
231
|
+
messageId: "msg-123",
|
|
232
|
+
deviceId: "device-456",
|
|
233
|
+
text: "Hello from device",
|
|
234
|
+
timestamp: "2026-01-31T12:00:00Z",
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("responds to ping with pong", async () => {
|
|
239
|
+
const controller = new AbortController();
|
|
240
|
+
|
|
241
|
+
startRelayConnection({
|
|
242
|
+
relayUrl: "wss://relay.example.com",
|
|
243
|
+
token: "test-token",
|
|
244
|
+
accountId: "default",
|
|
245
|
+
abortSignal: controller.signal,
|
|
246
|
+
onConnected: vi.fn(),
|
|
247
|
+
onDisconnected: vi.fn(),
|
|
248
|
+
onMessage: vi.fn(),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
252
|
+
|
|
253
|
+
const ws = mockWsInstances[0];
|
|
254
|
+
ws.emit("open");
|
|
255
|
+
ws.emit("message", Buffer.from(JSON.stringify({ type: "ping" })));
|
|
256
|
+
|
|
257
|
+
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: "pong" }));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("closes connection on abort signal", async () => {
|
|
261
|
+
const onDisconnected = vi.fn();
|
|
262
|
+
const controller = new AbortController();
|
|
263
|
+
|
|
264
|
+
startRelayConnection({
|
|
265
|
+
relayUrl: "wss://relay.example.com",
|
|
266
|
+
token: "test-token",
|
|
267
|
+
accountId: "default",
|
|
268
|
+
abortSignal: controller.signal,
|
|
269
|
+
onConnected: vi.fn(),
|
|
270
|
+
onDisconnected,
|
|
271
|
+
onMessage: vi.fn(),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
275
|
+
|
|
276
|
+
const ws = mockWsInstances[0];
|
|
277
|
+
ws.emit("open");
|
|
278
|
+
|
|
279
|
+
controller.abort();
|
|
280
|
+
|
|
281
|
+
expect(ws.readyState).toBe(MockWebSocket.CLOSED);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("masks token in log messages", async () => {
|
|
285
|
+
const logger: Logger = {
|
|
286
|
+
info: vi.fn(),
|
|
287
|
+
warn: vi.fn(),
|
|
288
|
+
error: vi.fn(),
|
|
289
|
+
};
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
|
|
292
|
+
startRelayConnection({
|
|
293
|
+
relayUrl: "wss://relay.example.com",
|
|
294
|
+
token: "secret-token-12345",
|
|
295
|
+
accountId: "default",
|
|
296
|
+
abortSignal: controller.signal,
|
|
297
|
+
log: logger,
|
|
298
|
+
onConnected: vi.fn(),
|
|
299
|
+
onDisconnected: vi.fn(),
|
|
300
|
+
onMessage: vi.fn(),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
304
|
+
|
|
305
|
+
// Check that token is masked in logs
|
|
306
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
307
|
+
expect.stringContaining("token=***")
|
|
308
|
+
);
|
|
309
|
+
expect(logger.info).not.toHaveBeenCalledWith(
|
|
310
|
+
expect.stringContaining("secret-token-12345")
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("generates message ID when not provided", async () => {
|
|
315
|
+
const onMessage = vi.fn();
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
|
|
318
|
+
startRelayConnection({
|
|
319
|
+
relayUrl: "wss://relay.example.com",
|
|
320
|
+
token: "test-token",
|
|
321
|
+
accountId: "default",
|
|
322
|
+
abortSignal: controller.signal,
|
|
323
|
+
onConnected: vi.fn(),
|
|
324
|
+
onDisconnected: vi.fn(),
|
|
325
|
+
onMessage,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
329
|
+
|
|
330
|
+
const ws = mockWsInstances[0];
|
|
331
|
+
ws.emit("open");
|
|
332
|
+
|
|
333
|
+
// Message without messageId
|
|
334
|
+
ws.emit(
|
|
335
|
+
"message",
|
|
336
|
+
Buffer.from(
|
|
337
|
+
JSON.stringify({
|
|
338
|
+
type: "message",
|
|
339
|
+
from: "device-456",
|
|
340
|
+
text: "Hello",
|
|
341
|
+
})
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
await vi.waitFor(() => expect(onMessage).toHaveBeenCalled());
|
|
346
|
+
|
|
347
|
+
const call = onMessage.mock.calls[0][0];
|
|
348
|
+
expect(call.messageId).toMatch(/^msg_\d+$/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("ignores messages without required fields", async () => {
|
|
352
|
+
const onMessage = vi.fn();
|
|
353
|
+
const controller = new AbortController();
|
|
354
|
+
|
|
355
|
+
startRelayConnection({
|
|
356
|
+
relayUrl: "wss://relay.example.com",
|
|
357
|
+
token: "test-token",
|
|
358
|
+
accountId: "default",
|
|
359
|
+
abortSignal: controller.signal,
|
|
360
|
+
onConnected: vi.fn(),
|
|
361
|
+
onDisconnected: vi.fn(),
|
|
362
|
+
onMessage,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
366
|
+
|
|
367
|
+
const ws = mockWsInstances[0];
|
|
368
|
+
ws.emit("open");
|
|
369
|
+
|
|
370
|
+
// Message missing 'text' field
|
|
371
|
+
ws.emit(
|
|
372
|
+
"message",
|
|
373
|
+
Buffer.from(
|
|
374
|
+
JSON.stringify({
|
|
375
|
+
type: "message",
|
|
376
|
+
from: "device-456",
|
|
377
|
+
})
|
|
378
|
+
)
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Message missing 'from' field
|
|
382
|
+
ws.emit(
|
|
383
|
+
"message",
|
|
384
|
+
Buffer.from(
|
|
385
|
+
JSON.stringify({
|
|
386
|
+
type: "message",
|
|
387
|
+
text: "Hello",
|
|
388
|
+
})
|
|
389
|
+
)
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// Allow time for async processing
|
|
393
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
394
|
+
|
|
395
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("ignores non-JSON messages", async () => {
|
|
399
|
+
const onMessage = vi.fn();
|
|
400
|
+
const controller = new AbortController();
|
|
401
|
+
|
|
402
|
+
startRelayConnection({
|
|
403
|
+
relayUrl: "wss://relay.example.com",
|
|
404
|
+
token: "test-token",
|
|
405
|
+
accountId: "default",
|
|
406
|
+
abortSignal: controller.signal,
|
|
407
|
+
onConnected: vi.fn(),
|
|
408
|
+
onDisconnected: vi.fn(),
|
|
409
|
+
onMessage,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
413
|
+
|
|
414
|
+
const ws = mockWsInstances[0];
|
|
415
|
+
ws.emit("open");
|
|
416
|
+
|
|
417
|
+
// Invalid JSON
|
|
418
|
+
ws.emit("message", Buffer.from("not valid json"));
|
|
419
|
+
|
|
420
|
+
// Allow time for async processing
|
|
421
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
422
|
+
|
|
423
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("calls onDisconnected on close", async () => {
|
|
427
|
+
const onDisconnected = vi.fn();
|
|
428
|
+
const controller = new AbortController();
|
|
429
|
+
|
|
430
|
+
startRelayConnection({
|
|
431
|
+
relayUrl: "wss://relay.example.com",
|
|
432
|
+
token: "test-token",
|
|
433
|
+
accountId: "default",
|
|
434
|
+
abortSignal: controller.signal,
|
|
435
|
+
onConnected: vi.fn(),
|
|
436
|
+
onDisconnected,
|
|
437
|
+
onMessage: vi.fn(),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await vi.waitFor(() => expect(mockWsInstances.length).toBe(1));
|
|
441
|
+
|
|
442
|
+
const ws = mockWsInstances[0];
|
|
443
|
+
ws.emit("open");
|
|
444
|
+
ws.emit("close", 1000, "Normal closure");
|
|
445
|
+
|
|
446
|
+
// After abort signal, onDisconnected is called without error
|
|
447
|
+
controller.abort();
|
|
448
|
+
|
|
449
|
+
await vi.waitFor(() => expect(onDisconnected).toHaveBeenCalled());
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
});
|