openclaw-nostr 2026.7.30

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 (72) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +845 -0
  3. package/api.ts +6 -0
  4. package/index.ts +69 -0
  5. package/node_modules/cascadia-ts/cascadia.ts +1187 -0
  6. package/node_modules/cascadia-ts/dist/cascadia.d.ts +607 -0
  7. package/node_modules/cascadia-ts/dist/cascadia.js +705 -0
  8. package/node_modules/cascadia-ts/package.json +21 -0
  9. package/openclaw.plugin.json +153 -0
  10. package/package.json +81 -0
  11. package/runtime-api.ts +12 -0
  12. package/setup-api.ts +1 -0
  13. package/setup-entry.ts +4 -0
  14. package/src/channel-actions.test.ts +304 -0
  15. package/src/channel-actions.ts +199 -0
  16. package/src/channel.dm-access.test.ts +387 -0
  17. package/src/channel.inbound.test.ts +476 -0
  18. package/src/channel.lifecycle.test.ts +768 -0
  19. package/src/channel.outbound.test.ts +1143 -0
  20. package/src/channel.test.ts +175 -0
  21. package/src/channel.ts +2827 -0
  22. package/src/config-schema.test.ts +486 -0
  23. package/src/config-schema.ts +356 -0
  24. package/src/default-relays.ts +1 -0
  25. package/src/fleet-agent.test.ts +423 -0
  26. package/src/fleet-agent.ts +549 -0
  27. package/src/metrics.ts +466 -0
  28. package/src/nip46-cutover.test.ts +324 -0
  29. package/src/nip46-cutover.ts +461 -0
  30. package/src/nip46-doctor.test.ts +191 -0
  31. package/src/nip46-doctor.ts +329 -0
  32. package/src/nip46-enroll.test.ts +346 -0
  33. package/src/nip46-enroll.ts +567 -0
  34. package/src/nip46-signer.test.ts +809 -0
  35. package/src/nip46-signer.ts +1263 -0
  36. package/src/nip46-status.test.ts +186 -0
  37. package/src/nip46-status.ts +250 -0
  38. package/src/nip51-lists.test.ts +404 -0
  39. package/src/nip51-lists.ts +777 -0
  40. package/src/nostr-access.test.ts +175 -0
  41. package/src/nostr-access.ts +134 -0
  42. package/src/nostr-bus-nip29.ts +998 -0
  43. package/src/nostr-bus-relay.ts +477 -0
  44. package/src/nostr-bus-types.ts +392 -0
  45. package/src/nostr-bus.fuzz.test.ts +533 -0
  46. package/src/nostr-bus.integration.test.ts +505 -0
  47. package/src/nostr-bus.protocol.test.ts +2057 -0
  48. package/src/nostr-bus.test.ts +235 -0
  49. package/src/nostr-bus.ts +1868 -0
  50. package/src/nostr-capabilities-extended.test.ts +612 -0
  51. package/src/nostr-capabilities.ts +719 -0
  52. package/src/nostr-discovery.test.ts +568 -0
  53. package/src/nostr-discovery.ts +336 -0
  54. package/src/nostr-extras.test.ts +88 -0
  55. package/src/nostr-extras.ts +136 -0
  56. package/src/nostr-profile-http.test.ts +662 -0
  57. package/src/nostr-profile-http.ts +836 -0
  58. package/src/nostr-profile-import.test.ts +274 -0
  59. package/src/nostr-profile-import.ts +295 -0
  60. package/src/nostr-profile.fuzz.test.ts +480 -0
  61. package/src/nostr-profile.test.ts +410 -0
  62. package/src/nostr-profile.ts +320 -0
  63. package/src/nostr-pubkey.ts +56 -0
  64. package/src/nostr-state-store.test.ts +790 -0
  65. package/src/nostr-state-store.ts +1284 -0
  66. package/src/runtime.ts +9 -0
  67. package/src/seen-tracker.test.ts +25 -0
  68. package/src/seen-tracker.ts +292 -0
  69. package/src/session-route.ts +25 -0
  70. package/src/setup-surface.ts +320 -0
  71. package/src/types.test.ts +783 -0
  72. package/src/types.ts +508 -0
@@ -0,0 +1,505 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createMetrics, createNoopMetrics, type MetricEvent } from "./metrics.js";
3
+ import { createSeenTracker } from "./seen-tracker.js";
4
+ import {
5
+ classifyCloseReason,
6
+ computeBackoffDelay,
7
+ RECONNECT_BASE_DELAY_MS,
8
+ RECONNECT_RATE_LIMIT_BASE_MS,
9
+ RECONNECT_MAX_DELAY_MS,
10
+ RECONNECT_JITTER,
11
+ } from "./nostr-bus-relay.js";
12
+
13
+ // ============================================================================
14
+ // Seen Tracker Integration Tests
15
+ // ============================================================================
16
+
17
+ describe("SeenTracker", () => {
18
+ describe("basic operations", () => {
19
+ it("tracks seen IDs", () => {
20
+ const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
21
+
22
+ // First check returns false and adds
23
+ expect(tracker.has("id1")).toBe(false);
24
+ // Second check returns true (already seen)
25
+ expect(tracker.has("id1")).toBe(true);
26
+
27
+ tracker.stop();
28
+ });
29
+
30
+ it("peek does not add", () => {
31
+ const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
32
+
33
+ expect(tracker.peek("id1")).toBe(false);
34
+ expect(tracker.peek("id1")).toBe(false); // Still false
35
+
36
+ tracker.add("id1");
37
+ expect(tracker.peek("id1")).toBe(true);
38
+
39
+ tracker.stop();
40
+ });
41
+
42
+ it("delete removes entries", () => {
43
+ const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
44
+
45
+ tracker.add("id1");
46
+ expect(tracker.peek("id1")).toBe(true);
47
+
48
+ tracker.delete("id1");
49
+ expect(tracker.peek("id1")).toBe(false);
50
+
51
+ tracker.stop();
52
+ });
53
+
54
+ it("clear removes all entries", () => {
55
+ const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
56
+
57
+ tracker.add("id1");
58
+ tracker.add("id2");
59
+ tracker.add("id3");
60
+ expect(tracker.size()).toBe(3);
61
+
62
+ tracker.clear();
63
+ expect(tracker.size()).toBe(0);
64
+ expect(tracker.peek("id1")).toBe(false);
65
+
66
+ tracker.stop();
67
+ });
68
+
69
+ it("seed pre-populates entries", () => {
70
+ const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 });
71
+
72
+ tracker.seed(["id1", "id2", "id3"]);
73
+ expect(tracker.size()).toBe(3);
74
+ expect(tracker.peek("id1")).toBe(true);
75
+ expect(tracker.peek("id2")).toBe(true);
76
+ expect(tracker.peek("id3")).toBe(true);
77
+
78
+ tracker.stop();
79
+ });
80
+ });
81
+
82
+ describe("LRU eviction", () => {
83
+ it("evicts least recently used when at capacity", () => {
84
+ const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
85
+
86
+ tracker.add("id1");
87
+ tracker.add("id2");
88
+ tracker.add("id3");
89
+ expect(tracker.size()).toBe(3);
90
+
91
+ // Adding fourth should evict oldest (id1)
92
+ tracker.add("id4");
93
+ expect(tracker.size()).toBe(3);
94
+ expect(tracker.peek("id1")).toBe(false); // Evicted
95
+ expect(tracker.peek("id2")).toBe(true);
96
+ expect(tracker.peek("id3")).toBe(true);
97
+ expect(tracker.peek("id4")).toBe(true);
98
+
99
+ tracker.stop();
100
+ });
101
+
102
+ it("accessing an entry moves it to front (prevents eviction)", () => {
103
+ const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 });
104
+
105
+ tracker.add("id1");
106
+ tracker.add("id2");
107
+ tracker.add("id3");
108
+
109
+ // Access id1, moving it to front
110
+ tracker.has("id1");
111
+
112
+ // Add id4 - should evict id2 (now oldest)
113
+ tracker.add("id4");
114
+ expect(tracker.peek("id1")).toBe(true); // Not evicted, was accessed
115
+ expect(tracker.peek("id2")).toBe(false); // Evicted
116
+ expect(tracker.peek("id3")).toBe(true);
117
+ expect(tracker.peek("id4")).toBe(true);
118
+
119
+ tracker.stop();
120
+ });
121
+
122
+ it("handles capacity of 1", () => {
123
+ const tracker = createSeenTracker({ maxEntries: 1, ttlMs: 60000 });
124
+
125
+ tracker.add("id1");
126
+ expect(tracker.peek("id1")).toBe(true);
127
+
128
+ tracker.add("id2");
129
+ expect(tracker.peek("id1")).toBe(false);
130
+ expect(tracker.peek("id2")).toBe(true);
131
+
132
+ tracker.stop();
133
+ });
134
+
135
+ it("seed respects maxEntries", () => {
136
+ const tracker = createSeenTracker({ maxEntries: 2, ttlMs: 60000 });
137
+
138
+ tracker.seed(["id1", "id2", "id3", "id4"]);
139
+ expect(tracker.size()).toBe(2);
140
+ // Seed stops when maxEntries reached, processing from end to start
141
+ // So id4 and id3 get added first, then we're at capacity
142
+ expect(tracker.peek("id3")).toBe(true);
143
+ expect(tracker.peek("id4")).toBe(true);
144
+
145
+ tracker.stop();
146
+ });
147
+ });
148
+
149
+ describe("TTL expiration", () => {
150
+ it("expires entries after TTL", async () => {
151
+ vi.useFakeTimers();
152
+
153
+ const tracker = createSeenTracker({
154
+ maxEntries: 100,
155
+ ttlMs: 100,
156
+ pruneIntervalMs: 50,
157
+ });
158
+
159
+ tracker.add("id1");
160
+ expect(tracker.peek("id1")).toBe(true);
161
+
162
+ // Advance past TTL
163
+ vi.advanceTimersByTime(150);
164
+
165
+ // Entry should be expired
166
+ expect(tracker.peek("id1")).toBe(false);
167
+
168
+ tracker.stop();
169
+ vi.useRealTimers();
170
+ });
171
+
172
+ it("has() refreshes TTL", async () => {
173
+ vi.useFakeTimers();
174
+
175
+ const tracker = createSeenTracker({
176
+ maxEntries: 100,
177
+ ttlMs: 100,
178
+ pruneIntervalMs: 50,
179
+ });
180
+
181
+ tracker.add("id1");
182
+
183
+ // Advance halfway
184
+ vi.advanceTimersByTime(50);
185
+
186
+ // Access to refresh
187
+ expect(tracker.has("id1")).toBe(true);
188
+
189
+ // Advance another 75ms (total 125ms from add, but only 75ms from last access)
190
+ vi.advanceTimersByTime(75);
191
+
192
+ // Should still be valid (refreshed at 50ms)
193
+ expect(tracker.peek("id1")).toBe(true);
194
+
195
+ tracker.stop();
196
+ vi.useRealTimers();
197
+ });
198
+ });
199
+ });
200
+
201
+ // ============================================================================
202
+ // Metrics Integration Tests
203
+ // ============================================================================
204
+
205
+ describe("Metrics", () => {
206
+ describe("createMetrics", () => {
207
+ it("emits metric events to callback", () => {
208
+ const events: MetricEvent[] = [];
209
+ const metrics = createMetrics((event) => events.push(event));
210
+
211
+ metrics.emit("event.received");
212
+ metrics.emit("event.processed");
213
+ metrics.emit("event.duplicate");
214
+
215
+ expect(events).toHaveLength(3);
216
+ expect(events[0].name).toBe("event.received");
217
+ expect(events[1].name).toBe("event.processed");
218
+ expect(events[2].name).toBe("event.duplicate");
219
+ });
220
+
221
+ it("includes labels in metric events", () => {
222
+ const events: MetricEvent[] = [];
223
+ const metrics = createMetrics((event) => events.push(event));
224
+
225
+ metrics.emit("relay.connect", 1, { relay: "wss://relay.example.com" });
226
+
227
+ expect(events[0].labels).toEqual({ relay: "wss://relay.example.com" });
228
+ });
229
+
230
+ it("accumulates counters in snapshot", () => {
231
+ const metrics = createMetrics();
232
+
233
+ metrics.emit("event.received");
234
+ metrics.emit("event.received");
235
+ metrics.emit("event.processed");
236
+ metrics.emit("event.duplicate");
237
+ metrics.emit("event.duplicate");
238
+ metrics.emit("event.duplicate");
239
+
240
+ const snapshot = metrics.getSnapshot();
241
+ expect(snapshot.eventsReceived).toBe(2);
242
+ expect(snapshot.eventsProcessed).toBe(1);
243
+ expect(snapshot.eventsDuplicate).toBe(3);
244
+ });
245
+
246
+ it("tracks per-relay stats", () => {
247
+ const metrics = createMetrics();
248
+
249
+ metrics.emit("relay.connect", 1, { relay: "wss://relay1.com" });
250
+ metrics.emit("relay.connect", 1, { relay: "wss://relay2.com" });
251
+ metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
252
+ metrics.emit("relay.error", 1, { relay: "wss://relay1.com" });
253
+
254
+ const snapshot = metrics.getSnapshot();
255
+ expect(snapshot.relays["wss://relay1.com"]).toBeDefined();
256
+ expect(snapshot.relays["wss://relay1.com"].connects).toBe(1);
257
+ expect(snapshot.relays["wss://relay1.com"].errors).toBe(2);
258
+ expect(snapshot.relays["wss://relay2.com"].connects).toBe(1);
259
+ expect(snapshot.relays["wss://relay2.com"].errors).toBe(0);
260
+ });
261
+
262
+ it("tracks circuit breaker state changes", () => {
263
+ const metrics = createMetrics();
264
+
265
+ metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
266
+
267
+ let snapshot = metrics.getSnapshot();
268
+ expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("open");
269
+ expect(snapshot.relays["wss://relay.com"].circuitBreakerOpens).toBe(1);
270
+
271
+ metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
272
+
273
+ snapshot = metrics.getSnapshot();
274
+ expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("closed");
275
+ expect(snapshot.relays["wss://relay.com"].circuitBreakerCloses).toBe(1);
276
+ });
277
+
278
+ it("tracks all rejection reasons", () => {
279
+ const metrics = createMetrics();
280
+
281
+ metrics.emit("event.rejected.invalid_shape");
282
+ metrics.emit("event.rejected.wrong_kind");
283
+ metrics.emit("event.rejected.wrong_target");
284
+ metrics.emit("event.rejected.stale");
285
+ metrics.emit("event.rejected.future");
286
+ metrics.emit("event.rejected.rate_limited");
287
+ metrics.emit("event.rejected.invalid_signature");
288
+ metrics.emit("event.rejected.oversized_ciphertext");
289
+ metrics.emit("event.rejected.oversized_plaintext");
290
+ metrics.emit("event.rejected.decrypt_failed");
291
+ metrics.emit("event.rejected.self_message");
292
+
293
+ const snapshot = metrics.getSnapshot();
294
+ expect(snapshot.eventsRejected.invalidShape).toBe(1);
295
+ expect(snapshot.eventsRejected.wrongKind).toBe(1);
296
+ expect(snapshot.eventsRejected.wrongTarget).toBe(1);
297
+ expect(snapshot.eventsRejected.stale).toBe(1);
298
+ expect(snapshot.eventsRejected.future).toBe(1);
299
+ expect(snapshot.eventsRejected.rateLimited).toBe(1);
300
+ expect(snapshot.eventsRejected.invalidSignature).toBe(1);
301
+ expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1);
302
+ expect(snapshot.eventsRejected.oversizedPlaintext).toBe(1);
303
+ expect(snapshot.eventsRejected.decryptFailed).toBe(1);
304
+ expect(snapshot.eventsRejected.selfMessage).toBe(1);
305
+ });
306
+
307
+ it("tracks relay message types", () => {
308
+ const metrics = createMetrics();
309
+
310
+ metrics.emit("relay.message.event", 1, { relay: "wss://relay.com" });
311
+ metrics.emit("relay.message.eose", 1, { relay: "wss://relay.com" });
312
+ metrics.emit("relay.message.closed", 1, { relay: "wss://relay.com" });
313
+ metrics.emit("relay.message.notice", 1, { relay: "wss://relay.com" });
314
+ metrics.emit("relay.message.ok", 1, { relay: "wss://relay.com" });
315
+ metrics.emit("relay.message.auth", 1, { relay: "wss://relay.com" });
316
+
317
+ const snapshot = metrics.getSnapshot();
318
+ const relay = snapshot.relays["wss://relay.com"];
319
+ expect(relay.messagesReceived.event).toBe(1);
320
+ expect(relay.messagesReceived.eose).toBe(1);
321
+ expect(relay.messagesReceived.closed).toBe(1);
322
+ expect(relay.messagesReceived.notice).toBe(1);
323
+ expect(relay.messagesReceived.ok).toBe(1);
324
+ expect(relay.messagesReceived.auth).toBe(1);
325
+ });
326
+
327
+ it("tracks decrypt success/failure", () => {
328
+ const metrics = createMetrics();
329
+
330
+ metrics.emit("decrypt.success");
331
+ metrics.emit("decrypt.success");
332
+ metrics.emit("decrypt.failure");
333
+
334
+ const snapshot = metrics.getSnapshot();
335
+ expect(snapshot.decrypt.success).toBe(2);
336
+ expect(snapshot.decrypt.failure).toBe(1);
337
+ });
338
+
339
+ it("tracks memory gauges (replaces rather than accumulates)", () => {
340
+ const metrics = createMetrics();
341
+
342
+ metrics.emit("memory.seen_tracker_size", 100);
343
+ metrics.emit("memory.seen_tracker_size", 150);
344
+ metrics.emit("memory.seen_tracker_size", 125);
345
+
346
+ const snapshot = metrics.getSnapshot();
347
+ expect(snapshot.memory.seenTrackerSize).toBe(125); // Last value, not sum
348
+ });
349
+
350
+ it("reset clears all counters", () => {
351
+ const metrics = createMetrics();
352
+
353
+ metrics.emit("event.received");
354
+ metrics.emit("event.processed");
355
+ metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
356
+
357
+ metrics.reset();
358
+
359
+ const snapshot = metrics.getSnapshot();
360
+ expect(snapshot.eventsReceived).toBe(0);
361
+ expect(snapshot.eventsProcessed).toBe(0);
362
+ expect(Object.keys(snapshot.relays)).toHaveLength(0);
363
+ });
364
+ });
365
+
366
+ describe("createNoopMetrics", () => {
367
+ it("does not throw on emit", () => {
368
+ const metrics = createNoopMetrics();
369
+
370
+ expect(() => {
371
+ metrics.emit("event.received");
372
+ metrics.emit("relay.connect", 1, { relay: "wss://relay.com" });
373
+ }).not.toThrow();
374
+ });
375
+
376
+ it("returns empty snapshot", () => {
377
+ const metrics = createNoopMetrics();
378
+
379
+ const snapshot = metrics.getSnapshot();
380
+ expect(snapshot.eventsReceived).toBe(0);
381
+ expect(snapshot.eventsProcessed).toBe(0);
382
+ });
383
+ });
384
+ });
385
+
386
+ // ============================================================================
387
+ // Circuit Breaker Behavior Tests
388
+ // ============================================================================
389
+
390
+ describe("Circuit Breaker Behavior", () => {
391
+ // Test the circuit breaker logic through metrics emissions
392
+ it("emits circuit breaker metrics in correct sequence", () => {
393
+ const events: MetricEvent[] = [];
394
+ const metrics = createMetrics((event) => events.push(event));
395
+
396
+ // Simulate 5 failures -> open
397
+ for (let i = 0; i < 5; i++) {
398
+ metrics.emit("relay.error", 1, { relay: "wss://relay.com" });
399
+ }
400
+ metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" });
401
+
402
+ // Simulate recovery
403
+ metrics.emit("relay.circuit_breaker.half_open", 1, { relay: "wss://relay.com" });
404
+ metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" });
405
+
406
+ const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker"));
407
+ expect(cbEvents).toHaveLength(3);
408
+ expect(cbEvents[0].name).toBe("relay.circuit_breaker.open");
409
+ expect(cbEvents[1].name).toBe("relay.circuit_breaker.half_open");
410
+ expect(cbEvents[2].name).toBe("relay.circuit_breaker.close");
411
+ });
412
+ });
413
+
414
+ // ============================================================================
415
+ // Health Scoring Behavior Tests
416
+ // ============================================================================
417
+
418
+ describe("Health Scoring", () => {
419
+ it("metrics track relay errors for health scoring", () => {
420
+ const metrics = createMetrics();
421
+
422
+ // Simulate mixed success/failure pattern
423
+ metrics.emit("relay.connect", 1, { relay: "wss://good-relay.com" });
424
+ metrics.emit("relay.connect", 1, { relay: "wss://bad-relay.com" });
425
+
426
+ metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
427
+ metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
428
+ metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" });
429
+
430
+ const snapshot = metrics.getSnapshot();
431
+ expect(snapshot.relays["wss://good-relay.com"].errors).toBe(0);
432
+ expect(snapshot.relays["wss://bad-relay.com"].errors).toBe(3);
433
+ });
434
+ });
435
+
436
+ // ============================================================================
437
+ // Reconnect Backoff Tests
438
+ // ============================================================================
439
+
440
+ describe("Reconnect Backoff", () => {
441
+ it("computeBackoffDelay returns values within expected bounds", () => {
442
+ for (let attempt = 0; attempt < 10; attempt++) {
443
+ const delay = computeBackoffDelay(
444
+ attempt,
445
+ RECONNECT_BASE_DELAY_MS,
446
+ RECONNECT_MAX_DELAY_MS,
447
+ RECONNECT_JITTER,
448
+ );
449
+ const exponential = RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt);
450
+ const capped = Math.min(exponential, RECONNECT_MAX_DELAY_MS);
451
+ const minDelay = capped * (1 - RECONNECT_JITTER);
452
+ const maxDelay = capped * (1 + RECONNECT_JITTER);
453
+
454
+ expect(delay).toBeGreaterThanOrEqual(minDelay);
455
+ expect(delay).toBeLessThanOrEqual(maxDelay);
456
+ }
457
+ });
458
+
459
+ it("rate-limit base delay is larger than standard base delay", () => {
460
+ expect(RECONNECT_RATE_LIMIT_BASE_MS).toBeGreaterThan(RECONNECT_BASE_DELAY_MS);
461
+ });
462
+
463
+ it("caps at RECONNECT_MAX_DELAY_MS regardless of attempt count", () => {
464
+ for (let attempt = 20; attempt < 25; attempt++) {
465
+ const delay = computeBackoffDelay(attempt, RECONNECT_BASE_DELAY_MS, RECONNECT_MAX_DELAY_MS, 0);
466
+ expect(delay).toBe(RECONNECT_MAX_DELAY_MS);
467
+ }
468
+ });
469
+
470
+ it("rate-limited backoff starts at a higher base", () => {
471
+ // First attempt with rate-limit base (no jitter for determinism)
472
+ const rateLimitDelay = computeBackoffDelay(0, RECONNECT_RATE_LIMIT_BASE_MS, RECONNECT_MAX_DELAY_MS, 0);
473
+ const standardDelay = computeBackoffDelay(0, RECONNECT_BASE_DELAY_MS, RECONNECT_MAX_DELAY_MS, 0);
474
+ expect(rateLimitDelay).toBeGreaterThan(standardDelay);
475
+ expect(rateLimitDelay).toBe(RECONNECT_RATE_LIMIT_BASE_MS);
476
+ });
477
+ });
478
+
479
+ // ============================================================================
480
+ // Close Reason Classification Tests
481
+ // ============================================================================
482
+
483
+ describe("classifyCloseReason", () => {
484
+ it("classifies rate-limit reasons", () => {
485
+ expect(classifyCloseReason("rate-limited: too many requests")).toBe("rate-limit");
486
+ expect(classifyCloseReason("rate-limited: there is a bug in the client, no one should be making so many requests")).toBe("rate-limit");
487
+ expect(classifyCloseReason("Rate-Limited")).toBe("rate-limit");
488
+ expect(classifyCloseReason("too many requests")).toBe("rate-limit");
489
+ expect(classifyCloseReason("rate limit exceeded")).toBe("rate-limit");
490
+ });
491
+
492
+ it("classifies auth reasons", () => {
493
+ expect(classifyCloseReason("auth-required: need to authenticate")).toBe("auth");
494
+ expect(classifyCloseReason("auth: required")).toBe("auth");
495
+ });
496
+
497
+ it("classifies connection/closed reasons", () => {
498
+ expect(classifyCloseReason("connection failed")).toBe("closed");
499
+ expect(classifyCloseReason("subscription closed")).toBe("closed");
500
+ });
501
+
502
+ it("classifies unknown reasons", () => {
503
+ expect(classifyCloseReason("something unexpected")).toBe("unknown");
504
+ });
505
+ });