@kyneta/websocket-transport 1.1.0 → 1.2.0

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.
@@ -0,0 +1,760 @@
1
+ // client-program.test — deterministic tests for the websocket client connection
2
+ // lifecycle state machine.
3
+ //
4
+ // Every status × event combination is tested. Pure data in, pure data out —
5
+ // no sockets, no timing, never flaky.
6
+
7
+ import { describe, expect, it } from "vitest"
8
+ import { createWsClientProgram, type WsClientMsg } from "../client-program.js"
9
+ import type { WebsocketClientState } from "../types.js"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function setup(opts: { maxAttempts?: number; enabled?: boolean } = {}) {
16
+ const program = createWsClientProgram({
17
+ jitterFn: () => 0,
18
+ reconnect: {
19
+ enabled: opts.enabled ?? true,
20
+ maxAttempts: opts.maxAttempts ?? 10,
21
+ baseDelay: 1000,
22
+ maxDelay: 30000,
23
+ },
24
+ })
25
+ return { program, update: program.update }
26
+ }
27
+
28
+ // Canonical model values for each status
29
+ const disconnected: WebsocketClientState = { status: "disconnected" }
30
+ const connecting: WebsocketClientState = { status: "connecting", attempt: 1 }
31
+ const connected: WebsocketClientState = { status: "connected" }
32
+ const ready: WebsocketClientState = { status: "ready" }
33
+ const reconnecting: WebsocketClientState = {
34
+ status: "reconnecting",
35
+ attempt: 2,
36
+ nextAttemptMs: 2000,
37
+ }
38
+
39
+ const err = new Error("boom")
40
+
41
+ // computeBackoffDelay(attempt, 1000, 30000, 0) = min(1000 * 2^(attempt-1), 30000)
42
+ // tryReconnect(currentAttempt, ...) → attempt = currentAttempt + 1,
43
+ // delay = computeBackoffDelay(currentAttempt + 1, 1000, 30000, 0)
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Init
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe("ws client program — init", () => {
50
+ it("starts disconnected with no effects", () => {
51
+ const { program } = setup()
52
+ const [model, ...effects] = program.init
53
+
54
+ expect(model).toEqual({ status: "disconnected" })
55
+ expect(effects).toEqual([])
56
+ })
57
+ })
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // start
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe("ws client program — start", () => {
64
+ it("while disconnected → connecting(attempt: 1) + create-websocket effect", () => {
65
+ const { update } = setup()
66
+ const [model, ...effects] = update({ type: "start" }, disconnected)
67
+
68
+ expect(model).toEqual({ status: "connecting", attempt: 1 })
69
+ expect(effects).toEqual([{ type: "create-websocket", attempt: 1 }])
70
+ })
71
+
72
+ it("while connecting → no change", () => {
73
+ const { update } = setup()
74
+ const [model, ...effects] = update({ type: "start" }, connecting)
75
+
76
+ expect(model).toEqual(connecting)
77
+ expect(effects).toEqual([])
78
+ })
79
+
80
+ it("while connected → no change", () => {
81
+ const { update } = setup()
82
+ const [model, ...effects] = update({ type: "start" }, connected)
83
+
84
+ expect(model).toEqual(connected)
85
+ expect(effects).toEqual([])
86
+ })
87
+
88
+ it("while ready → no change", () => {
89
+ const { update } = setup()
90
+ const [model, ...effects] = update({ type: "start" }, ready)
91
+
92
+ expect(model).toEqual(ready)
93
+ expect(effects).toEqual([])
94
+ })
95
+
96
+ it("while reconnecting → no change", () => {
97
+ const { update } = setup()
98
+ const [model, ...effects] = update({ type: "start" }, reconnecting)
99
+
100
+ expect(model).toEqual(reconnecting)
101
+ expect(effects).toEqual([])
102
+ })
103
+ })
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // socket-opened
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe("ws client program — socket-opened", () => {
110
+ it("while connecting → connected + start-keepalive (NOT add-channel-and-establish)", () => {
111
+ const { update } = setup()
112
+ const [model, ...effects] = update({ type: "socket-opened" }, connecting)
113
+
114
+ expect(model).toEqual({ status: "connected" })
115
+ expect(effects).toEqual([{ type: "start-keepalive" }])
116
+ })
117
+
118
+ it("while disconnected → no change", () => {
119
+ const { update } = setup()
120
+ const [model, ...effects] = update({ type: "socket-opened" }, disconnected)
121
+
122
+ expect(model).toEqual(disconnected)
123
+ expect(effects).toEqual([])
124
+ })
125
+
126
+ it("while connected → no change", () => {
127
+ const { update } = setup()
128
+ const [model, ...effects] = update({ type: "socket-opened" }, connected)
129
+
130
+ expect(model).toEqual(connected)
131
+ expect(effects).toEqual([])
132
+ })
133
+
134
+ it("while ready → no change", () => {
135
+ const { update } = setup()
136
+ const [model, ...effects] = update({ type: "socket-opened" }, ready)
137
+
138
+ expect(model).toEqual(ready)
139
+ expect(effects).toEqual([])
140
+ })
141
+
142
+ it("while reconnecting → no change", () => {
143
+ const { update } = setup()
144
+ const [model, ...effects] = update({ type: "socket-opened" }, reconnecting)
145
+
146
+ expect(model).toEqual(reconnecting)
147
+ expect(effects).toEqual([])
148
+ })
149
+ })
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // server-ready
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe("ws client program — server-ready", () => {
156
+ it("while connected → ready + add-channel-and-establish", () => {
157
+ const { update } = setup()
158
+ const [model, ...effects] = update({ type: "server-ready" }, connected)
159
+
160
+ expect(model).toEqual({ status: "ready" })
161
+ expect(effects).toEqual([{ type: "add-channel-and-establish" }])
162
+ })
163
+
164
+ it("while connecting (race condition) → ready + start-keepalive + add-channel-and-establish", () => {
165
+ const { update } = setup()
166
+ const [model, ...effects] = update({ type: "server-ready" }, connecting)
167
+
168
+ expect(model).toEqual({ status: "ready" })
169
+ expect(effects).toEqual([
170
+ { type: "start-keepalive" },
171
+ { type: "add-channel-and-establish" },
172
+ ])
173
+ })
174
+
175
+ it("while already ready → no change (duplicate ignored)", () => {
176
+ const { update } = setup()
177
+ const [model, ...effects] = update({ type: "server-ready" }, ready)
178
+
179
+ expect(model).toEqual(ready)
180
+ expect(effects).toEqual([])
181
+ })
182
+
183
+ it("while disconnected → no change", () => {
184
+ const { update } = setup()
185
+ const [model, ...effects] = update({ type: "server-ready" }, disconnected)
186
+
187
+ expect(model).toEqual(disconnected)
188
+ expect(effects).toEqual([])
189
+ })
190
+
191
+ it("while reconnecting → no change", () => {
192
+ const { update } = setup()
193
+ const [model, ...effects] = update({ type: "server-ready" }, reconnecting)
194
+
195
+ expect(model).toEqual(reconnecting)
196
+ expect(effects).toEqual([])
197
+ })
198
+ })
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // socket-closed
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe("ws client program — socket-closed", () => {
205
+ it("while connected → stop-keepalive + reconnecting + start-reconnect-timer", () => {
206
+ const { update } = setup()
207
+ // tryReconnect(0, ...) → attempt = 1, delay = 1000
208
+ const [model, ...effects] = update(
209
+ { type: "socket-closed", code: 1006, reason: "abnormal" },
210
+ connected,
211
+ )
212
+
213
+ expect(model).toEqual({
214
+ status: "reconnecting",
215
+ attempt: 1,
216
+ nextAttemptMs: 1000,
217
+ })
218
+ expect(effects).toEqual([
219
+ { type: "stop-keepalive" },
220
+ { type: "start-reconnect-timer", delayMs: 1000 },
221
+ ])
222
+ })
223
+
224
+ it("while ready → stop-keepalive + remove-channel + reconnecting + start-reconnect-timer", () => {
225
+ const { update } = setup()
226
+ const [model, ...effects] = update(
227
+ { type: "socket-closed", code: 1006, reason: "abnormal" },
228
+ ready,
229
+ )
230
+
231
+ expect(model).toEqual({
232
+ status: "reconnecting",
233
+ attempt: 1,
234
+ nextAttemptMs: 1000,
235
+ })
236
+ expect(effects).toEqual([
237
+ { type: "stop-keepalive" },
238
+ { type: "remove-channel" },
239
+ { type: "start-reconnect-timer", delayMs: 1000 },
240
+ ])
241
+ })
242
+
243
+ it("while disconnected → no change", () => {
244
+ const { update } = setup()
245
+ const [model, ...effects] = update(
246
+ { type: "socket-closed", code: 1000, reason: "normal" },
247
+ disconnected,
248
+ )
249
+
250
+ expect(model).toEqual(disconnected)
251
+ expect(effects).toEqual([])
252
+ })
253
+
254
+ it("while connecting → no change", () => {
255
+ const { update } = setup()
256
+ const [model, ...effects] = update(
257
+ { type: "socket-closed", code: 1000, reason: "normal" },
258
+ connecting,
259
+ )
260
+
261
+ expect(model).toEqual(connecting)
262
+ expect(effects).toEqual([])
263
+ })
264
+
265
+ it("while reconnecting → no change", () => {
266
+ const { update } = setup()
267
+ const [model, ...effects] = update(
268
+ { type: "socket-closed", code: 1000, reason: "normal" },
269
+ reconnecting,
270
+ )
271
+
272
+ expect(model).toEqual(reconnecting)
273
+ expect(effects).toEqual([])
274
+ })
275
+ })
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // socket-error
279
+ // ---------------------------------------------------------------------------
280
+
281
+ describe("ws client program — socket-error", () => {
282
+ it("while connecting, attempts < max → reconnecting + start-reconnect-timer", () => {
283
+ const { update } = setup()
284
+ // connecting with attempt: 1, tryReconnect(1, ...) →
285
+ // attempt = 2, delay = computeBackoffDelay(2, 1000, 30000, 0) = 2000
286
+ const [model, ...effects] = update(
287
+ { type: "socket-error", error: err },
288
+ connecting,
289
+ )
290
+
291
+ expect(model).toEqual({
292
+ status: "reconnecting",
293
+ attempt: 2,
294
+ nextAttemptMs: 2000,
295
+ })
296
+ expect(effects).toEqual([{ type: "start-reconnect-timer", delayMs: 2000 }])
297
+ })
298
+
299
+ it("while connecting, attempts >= max → disconnected (max-retries-exceeded)", () => {
300
+ const { update } = setup({ maxAttempts: 3 })
301
+ const atMax: WebsocketClientState = { status: "connecting", attempt: 3 }
302
+ const [model, ...effects] = update(
303
+ { type: "socket-error", error: err },
304
+ atMax,
305
+ )
306
+
307
+ expect(model).toEqual({
308
+ status: "disconnected",
309
+ reason: { type: "max-retries-exceeded", attempts: 3 },
310
+ })
311
+ expect(effects).toEqual([])
312
+ })
313
+
314
+ it("while connected → stop-keepalive + reconnecting + start-reconnect-timer", () => {
315
+ const { update } = setup()
316
+ // tryReconnect(0, ...) → attempt = 1, delay = 1000
317
+ const [model, ...effects] = update(
318
+ { type: "socket-error", error: err },
319
+ connected,
320
+ )
321
+
322
+ expect(model).toEqual({
323
+ status: "reconnecting",
324
+ attempt: 1,
325
+ nextAttemptMs: 1000,
326
+ })
327
+ expect(effects).toEqual([
328
+ { type: "stop-keepalive" },
329
+ { type: "start-reconnect-timer", delayMs: 1000 },
330
+ ])
331
+ })
332
+
333
+ it("while ready → stop-keepalive + remove-channel + reconnecting + start-reconnect-timer", () => {
334
+ const { update } = setup()
335
+ const [model, ...effects] = update(
336
+ { type: "socket-error", error: err },
337
+ ready,
338
+ )
339
+
340
+ expect(model).toEqual({
341
+ status: "reconnecting",
342
+ attempt: 1,
343
+ nextAttemptMs: 1000,
344
+ })
345
+ expect(effects).toEqual([
346
+ { type: "stop-keepalive" },
347
+ { type: "remove-channel" },
348
+ { type: "start-reconnect-timer", delayMs: 1000 },
349
+ ])
350
+ })
351
+
352
+ it("while disconnected → no change", () => {
353
+ const { update } = setup()
354
+ const [model, ...effects] = update(
355
+ { type: "socket-error", error: err },
356
+ disconnected,
357
+ )
358
+
359
+ expect(model).toEqual(disconnected)
360
+ expect(effects).toEqual([])
361
+ })
362
+
363
+ it("while reconnecting → no change", () => {
364
+ const { update } = setup()
365
+ const [model, ...effects] = update(
366
+ { type: "socket-error", error: err },
367
+ reconnecting,
368
+ )
369
+
370
+ expect(model).toEqual(reconnecting)
371
+ expect(effects).toEqual([])
372
+ })
373
+ })
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // reconnect-timer-fired
377
+ // ---------------------------------------------------------------------------
378
+
379
+ describe("ws client program — reconnect-timer-fired", () => {
380
+ it("while reconnecting → connecting with attempt carried forward + create-websocket", () => {
381
+ const { update } = setup()
382
+ const [model, ...effects] = update(
383
+ { type: "reconnect-timer-fired" },
384
+ reconnecting, // attempt: 2
385
+ )
386
+
387
+ expect(model).toEqual({ status: "connecting", attempt: 2 })
388
+ expect(effects).toEqual([{ type: "create-websocket", attempt: 2 }])
389
+ })
390
+
391
+ it("while disconnected → no change", () => {
392
+ const { update } = setup()
393
+ const [model, ...effects] = update(
394
+ { type: "reconnect-timer-fired" },
395
+ disconnected,
396
+ )
397
+
398
+ expect(model).toEqual(disconnected)
399
+ expect(effects).toEqual([])
400
+ })
401
+
402
+ it("while connecting → no change", () => {
403
+ const { update } = setup()
404
+ const [model, ...effects] = update(
405
+ { type: "reconnect-timer-fired" },
406
+ connecting,
407
+ )
408
+
409
+ expect(model).toEqual(connecting)
410
+ expect(effects).toEqual([])
411
+ })
412
+
413
+ it("while connected → no change", () => {
414
+ const { update } = setup()
415
+ const [model, ...effects] = update(
416
+ { type: "reconnect-timer-fired" },
417
+ connected,
418
+ )
419
+
420
+ expect(model).toEqual(connected)
421
+ expect(effects).toEqual([])
422
+ })
423
+
424
+ it("while ready → no change", () => {
425
+ const { update } = setup()
426
+ const [model, ...effects] = update({ type: "reconnect-timer-fired" }, ready)
427
+
428
+ expect(model).toEqual(ready)
429
+ expect(effects).toEqual([])
430
+ })
431
+ })
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // stop
435
+ // ---------------------------------------------------------------------------
436
+
437
+ describe("ws client program — stop", () => {
438
+ it("while ready → disconnected(intentional) + cancel-reconnect-timer + close-websocket + stop-keepalive + remove-channel", () => {
439
+ const { update } = setup()
440
+ const [model, ...effects] = update({ type: "stop" }, ready)
441
+
442
+ expect(model).toEqual({
443
+ status: "disconnected",
444
+ reason: { type: "intentional" },
445
+ })
446
+ expect(effects).toEqual([
447
+ { type: "cancel-reconnect-timer" },
448
+ { type: "close-websocket" },
449
+ { type: "stop-keepalive" },
450
+ { type: "remove-channel" },
451
+ ])
452
+ })
453
+
454
+ it("while connected → disconnected(intentional) + cancel-reconnect-timer + close-websocket + stop-keepalive", () => {
455
+ const { update } = setup()
456
+ const [model, ...effects] = update({ type: "stop" }, connected)
457
+
458
+ expect(model).toEqual({
459
+ status: "disconnected",
460
+ reason: { type: "intentional" },
461
+ })
462
+ expect(effects).toEqual([
463
+ { type: "cancel-reconnect-timer" },
464
+ { type: "close-websocket" },
465
+ { type: "stop-keepalive" },
466
+ ])
467
+ })
468
+
469
+ it("while connecting → disconnected(intentional) + cancel-reconnect-timer + close-websocket", () => {
470
+ const { update } = setup()
471
+ const [model, ...effects] = update({ type: "stop" }, connecting)
472
+
473
+ expect(model).toEqual({
474
+ status: "disconnected",
475
+ reason: { type: "intentional" },
476
+ })
477
+ expect(effects).toEqual([
478
+ { type: "cancel-reconnect-timer" },
479
+ { type: "close-websocket" },
480
+ ])
481
+ })
482
+
483
+ it("while reconnecting → disconnected(intentional) + cancel-reconnect-timer", () => {
484
+ const { update } = setup()
485
+ const [model, ...effects] = update({ type: "stop" }, reconnecting)
486
+
487
+ expect(model).toEqual({
488
+ status: "disconnected",
489
+ reason: { type: "intentional" },
490
+ })
491
+ expect(effects).toEqual([{ type: "cancel-reconnect-timer" }])
492
+ })
493
+
494
+ it("while disconnected → no change", () => {
495
+ const { update } = setup()
496
+ const [model, ...effects] = update({ type: "stop" }, disconnected)
497
+
498
+ expect(model).toEqual(disconnected)
499
+ expect(effects).toEqual([])
500
+ })
501
+ })
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Reconnect disabled
505
+ // ---------------------------------------------------------------------------
506
+
507
+ describe("ws client program — reconnect disabled", () => {
508
+ it("socket-error while connecting → disconnected (no reconnecting state)", () => {
509
+ const { update } = setup({ enabled: false })
510
+ const [model, ...effects] = update(
511
+ { type: "socket-error", error: err },
512
+ connecting,
513
+ )
514
+
515
+ expect(model).toEqual({
516
+ status: "disconnected",
517
+ reason: { type: "error", error: err },
518
+ })
519
+ expect(effects).toEqual([])
520
+ })
521
+
522
+ it("socket-error while connected → disconnected + stop-keepalive (no reconnecting state)", () => {
523
+ const { update } = setup({ enabled: false })
524
+ const [model, ...effects] = update(
525
+ { type: "socket-error", error: err },
526
+ connected,
527
+ )
528
+
529
+ expect(model).toEqual({
530
+ status: "disconnected",
531
+ reason: { type: "error", error: err },
532
+ })
533
+ expect(effects).toEqual([{ type: "stop-keepalive" }])
534
+ })
535
+
536
+ it("socket-error while ready → disconnected + stop-keepalive + remove-channel (no reconnecting state)", () => {
537
+ const { update } = setup({ enabled: false })
538
+ const [model, ...effects] = update(
539
+ { type: "socket-error", error: err },
540
+ ready,
541
+ )
542
+
543
+ expect(model).toEqual({
544
+ status: "disconnected",
545
+ reason: { type: "error", error: err },
546
+ })
547
+ expect(effects).toEqual([
548
+ { type: "stop-keepalive" },
549
+ { type: "remove-channel" },
550
+ ])
551
+ })
552
+
553
+ it("socket-closed while connected → disconnected + stop-keepalive (no reconnecting state)", () => {
554
+ const { update } = setup({ enabled: false })
555
+ const [model, ...effects] = update(
556
+ { type: "socket-closed", code: 1000, reason: "normal" },
557
+ connected,
558
+ )
559
+
560
+ expect(model).toEqual({
561
+ status: "disconnected",
562
+ reason: { type: "closed", code: 1000, reason: "normal" },
563
+ })
564
+ expect(effects).toEqual([{ type: "stop-keepalive" }])
565
+ })
566
+
567
+ it("socket-closed while ready → disconnected + stop-keepalive + remove-channel (no reconnecting state)", () => {
568
+ const { update } = setup({ enabled: false })
569
+ const [model, ...effects] = update(
570
+ { type: "socket-closed", code: 1000, reason: "normal" },
571
+ ready,
572
+ )
573
+
574
+ expect(model).toEqual({
575
+ status: "disconnected",
576
+ reason: { type: "closed", code: 1000, reason: "normal" },
577
+ })
578
+ expect(effects).toEqual([
579
+ { type: "stop-keepalive" },
580
+ { type: "remove-channel" },
581
+ ])
582
+ })
583
+ })
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // Full lifecycle
587
+ // ---------------------------------------------------------------------------
588
+
589
+ describe("ws client program — full lifecycle", () => {
590
+ it("start → open → ready → close → reconnect → open → ready → stop", () => {
591
+ const { program, update } = setup()
592
+
593
+ // 1. Init: disconnected, no effects
594
+ const [m0, ...fx0] = program.init
595
+ expect(m0).toEqual({ status: "disconnected" })
596
+ expect(fx0).toEqual([])
597
+
598
+ // 2. Start → connecting
599
+ const [m1, ...fx1] = update({ type: "start" }, m0)
600
+ expect(m1).toEqual({ status: "connecting", attempt: 1 })
601
+ expect(fx1).toEqual([{ type: "create-websocket", attempt: 1 }])
602
+
603
+ // 3. Socket opened → connected + start-keepalive
604
+ const [m2, ...fx2] = update({ type: "socket-opened" }, m1)
605
+ expect(m2).toEqual({ status: "connected" })
606
+ expect(fx2).toEqual([{ type: "start-keepalive" }])
607
+
608
+ // 4. Server ready → ready + add-channel-and-establish
609
+ const [m3, ...fx3] = update({ type: "server-ready" }, m2)
610
+ expect(m3).toEqual({ status: "ready" })
611
+ expect(fx3).toEqual([{ type: "add-channel-and-establish" }])
612
+
613
+ // 5. Socket closed → reconnecting + stop-keepalive + remove-channel
614
+ // tryReconnect(0, ...) → attempt=1, delay=1000
615
+ const [m4, ...fx4] = update(
616
+ { type: "socket-closed", code: 1006, reason: "abnormal" },
617
+ m3,
618
+ )
619
+ expect(m4).toEqual({
620
+ status: "reconnecting",
621
+ attempt: 1,
622
+ nextAttemptMs: 1000,
623
+ })
624
+ expect(fx4).toEqual([
625
+ { type: "stop-keepalive" },
626
+ { type: "remove-channel" },
627
+ { type: "start-reconnect-timer", delayMs: 1000 },
628
+ ])
629
+
630
+ // 6. Reconnect timer fires → connecting(attempt: 1)
631
+ const [m5, ...fx5] = update({ type: "reconnect-timer-fired" }, m4)
632
+ expect(m5).toEqual({ status: "connecting", attempt: 1 })
633
+ expect(fx5).toEqual([{ type: "create-websocket", attempt: 1 }])
634
+
635
+ // 7. Socket opened again → connected + start-keepalive
636
+ const [m6, ...fx6] = update({ type: "socket-opened" }, m5)
637
+ expect(m6).toEqual({ status: "connected" })
638
+ expect(fx6).toEqual([{ type: "start-keepalive" }])
639
+
640
+ // 8. Server ready again → ready + add-channel-and-establish
641
+ const [m7, ...fx7] = update({ type: "server-ready" }, m6)
642
+ expect(m7).toEqual({ status: "ready" })
643
+ expect(fx7).toEqual([{ type: "add-channel-and-establish" }])
644
+
645
+ // 9. Stop → disconnected(intentional) + cleanup
646
+ const [m8, ...fx8] = update({ type: "stop" }, m7)
647
+ expect(m8).toEqual({
648
+ status: "disconnected",
649
+ reason: { type: "intentional" },
650
+ })
651
+ expect(fx8).toEqual([
652
+ { type: "cancel-reconnect-timer" },
653
+ { type: "close-websocket" },
654
+ { type: "stop-keepalive" },
655
+ { type: "remove-channel" },
656
+ ])
657
+ })
658
+
659
+ it("race condition lifecycle: start → server-ready (before open) → close → reconnect", () => {
660
+ const { program, update } = setup()
661
+
662
+ const [m0] = program.init
663
+
664
+ // 1. Start → connecting
665
+ const [m1, ...fx1] = update({ type: "start" }, m0)
666
+ expect(m1).toEqual({ status: "connecting", attempt: 1 })
667
+ expect(fx1).toEqual([{ type: "create-websocket", attempt: 1 }])
668
+
669
+ // 2. Server sends ready BEFORE open fires → skip connected, go to ready
670
+ const [m2, ...fx2] = update({ type: "server-ready" }, m1)
671
+ expect(m2).toEqual({ status: "ready" })
672
+ expect(fx2).toEqual([
673
+ { type: "start-keepalive" },
674
+ { type: "add-channel-and-establish" },
675
+ ])
676
+
677
+ // 3. Socket closed → reconnecting + stop-keepalive + remove-channel
678
+ const [m3, ...fx3] = update(
679
+ { type: "socket-closed", code: 1006, reason: "abnormal" },
680
+ m2,
681
+ )
682
+ expect(m3).toEqual({
683
+ status: "reconnecting",
684
+ attempt: 1,
685
+ nextAttemptMs: 1000,
686
+ })
687
+ expect(fx3).toEqual([
688
+ { type: "stop-keepalive" },
689
+ { type: "remove-channel" },
690
+ { type: "start-reconnect-timer", delayMs: 1000 },
691
+ ])
692
+ })
693
+
694
+ it("repeated connection errors escalate backoff until max retries", () => {
695
+ const { program, update } = setup({ maxAttempts: 3 })
696
+
697
+ // Start
698
+ const [m0] = program.init
699
+ const [m1] = update({ type: "start" }, m0)
700
+ expect(m1).toEqual({ status: "connecting", attempt: 1 })
701
+
702
+ // First error: attempt 1, tryReconnect(1) → attempt=2, delay=computeBackoffDelay(2)=2000
703
+ const [m2, ...fx2] = update({ type: "socket-error", error: err }, m1)
704
+ expect(m2).toEqual({
705
+ status: "reconnecting",
706
+ attempt: 2,
707
+ nextAttemptMs: 2000,
708
+ })
709
+ expect(fx2).toEqual([{ type: "start-reconnect-timer", delayMs: 2000 }])
710
+
711
+ // Timer fires → connecting(attempt: 2)
712
+ const [m3] = update({ type: "reconnect-timer-fired" }, m2)
713
+ expect(m3).toEqual({ status: "connecting", attempt: 2 })
714
+
715
+ // Second error: attempt 2, tryReconnect(2) → attempt=3, delay=computeBackoffDelay(3)=4000
716
+ const [m4, ...fx4] = update({ type: "socket-error", error: err }, m3)
717
+ expect(m4).toEqual({
718
+ status: "reconnecting",
719
+ attempt: 3,
720
+ nextAttemptMs: 4000,
721
+ })
722
+ expect(fx4).toEqual([{ type: "start-reconnect-timer", delayMs: 4000 }])
723
+
724
+ // Timer fires → connecting(attempt: 3)
725
+ const [m5] = update({ type: "reconnect-timer-fired" }, m4)
726
+ expect(m5).toEqual({ status: "connecting", attempt: 3 })
727
+
728
+ // Third error: attempt 3 >= maxAttempts 3 → disconnected
729
+ const [m6, ...fx6] = update({ type: "socket-error", error: err }, m5)
730
+ expect(m6).toEqual({
731
+ status: "disconnected",
732
+ reason: { type: "max-retries-exceeded", attempts: 3 },
733
+ })
734
+ expect(fx6).toEqual([])
735
+ })
736
+ })
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Disconnected absorbs non-start messages
740
+ // ---------------------------------------------------------------------------
741
+
742
+ describe("ws client program — disconnected absorbs non-start messages", () => {
743
+ const messages: WsClientMsg[] = [
744
+ { type: "socket-opened" },
745
+ { type: "server-ready" },
746
+ { type: "socket-closed", code: 1000, reason: "normal" },
747
+ { type: "socket-error", error: err },
748
+ { type: "reconnect-timer-fired" },
749
+ { type: "stop" },
750
+ ]
751
+
752
+ for (const msg of messages) {
753
+ it(`${msg.type} while disconnected → no change`, () => {
754
+ const { update } = setup()
755
+ const [model, ...effects] = update(msg, disconnected)
756
+ expect(model).toEqual(disconnected)
757
+ expect(effects).toEqual([])
758
+ })
759
+ }
760
+ })