@intx/harness 0.1.2

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,718 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { createInboundMessage } from "@intx/mime";
4
+ import type { ConnectorThreadState, InboundMessage } from "@intx/types/runtime";
5
+
6
+ import {
7
+ createConnectorRouter,
8
+ NoActiveConnectorThreadError,
9
+ } from "./connector-router";
10
+
11
+ function startMessage(opts?: { subject?: string }): InboundMessage {
12
+ return createInboundMessage({
13
+ from: "user@example.com",
14
+ to: ["agent@example.com"],
15
+ content: "hello",
16
+ messageId: "<root@example.com>",
17
+ ...(opts?.subject !== undefined ? { subject: opts.subject } : {}),
18
+ });
19
+ }
20
+
21
+ function continuationByReferences(
22
+ threadRoot: string,
23
+ opts?: { from?: string; messageId?: string },
24
+ ): InboundMessage {
25
+ return createInboundMessage({
26
+ from: opts?.from ?? "user@example.com",
27
+ to: ["agent@example.com"],
28
+ content: "more",
29
+ messageId: opts?.messageId ?? "<reply-1@example.com>",
30
+ references: [threadRoot],
31
+ });
32
+ }
33
+
34
+ function continuationByInReplyTo(
35
+ lastMessageId: string,
36
+ opts?: { from?: string; messageId?: string },
37
+ ): InboundMessage {
38
+ return createInboundMessage({
39
+ from: opts?.from ?? "user@example.com",
40
+ to: ["agent@example.com"],
41
+ content: "more",
42
+ messageId: opts?.messageId ?? "<reply-2@example.com>",
43
+ inReplyTo: lastMessageId,
44
+ });
45
+ }
46
+
47
+ function unrelatedMessage(): InboundMessage {
48
+ return createInboundMessage({
49
+ from: "stranger@example.com",
50
+ to: ["agent@example.com"],
51
+ content: "unrelated",
52
+ messageId: "<other@example.com>",
53
+ });
54
+ }
55
+
56
+ describe("createConnectorRouter", () => {
57
+ describe("route + commit (inbound)", () => {
58
+ test("no active thread: start initializes with empty cc", () => {
59
+ const router = createConnectorRouter();
60
+
61
+ router.commit(router.route(startMessage({ subject: "Hello" })));
62
+
63
+ expect(router.snapshot()).toEqual({
64
+ threadRoot: "<root@example.com>",
65
+ lastMessageId: "<root@example.com>",
66
+ replyTo: "user@example.com",
67
+ cc: [],
68
+ subject: "Hello",
69
+ });
70
+ });
71
+
72
+ test("active thread + references includes threadRoot: continue from the same sender keeps cc empty", () => {
73
+ const router = createConnectorRouter();
74
+ router.commit(router.route(startMessage({ subject: "Hello" })));
75
+
76
+ router.commit(
77
+ router.route(
78
+ continuationByReferences("<root@example.com>", {
79
+ from: "user@example.com",
80
+ messageId: "<follow-1@example.com>",
81
+ }),
82
+ ),
83
+ );
84
+
85
+ expect(router.snapshot()).toEqual({
86
+ threadRoot: "<root@example.com>",
87
+ lastMessageId: "<follow-1@example.com>",
88
+ replyTo: "user@example.com",
89
+ cc: [],
90
+ subject: "Hello",
91
+ });
92
+ });
93
+
94
+ test("active thread + inReplyTo equals lastMessageId: continue from the same sender keeps cc empty", () => {
95
+ const router = createConnectorRouter();
96
+ router.commit(router.route(startMessage()));
97
+
98
+ router.commit(
99
+ router.route(
100
+ continuationByInReplyTo("<root@example.com>", {
101
+ messageId: "<follow-2@example.com>",
102
+ }),
103
+ ),
104
+ );
105
+
106
+ expect(router.snapshot()).toEqual({
107
+ threadRoot: "<root@example.com>",
108
+ lastMessageId: "<follow-2@example.com>",
109
+ replyTo: "user@example.com",
110
+ cc: [],
111
+ });
112
+ });
113
+
114
+ test("continue from a different sender moves the prior replyTo into cc", () => {
115
+ const router = createConnectorRouter();
116
+ router.commit(router.route(startMessage({ subject: "Important" })));
117
+
118
+ router.commit(
119
+ router.route(
120
+ continuationByReferences("<root@example.com>", {
121
+ from: "other@example.com",
122
+ messageId: "<follow-3@example.com>",
123
+ }),
124
+ ),
125
+ );
126
+
127
+ expect(router.snapshot()).toEqual({
128
+ threadRoot: "<root@example.com>",
129
+ lastMessageId: "<follow-3@example.com>",
130
+ replyTo: "other@example.com",
131
+ cc: ["user@example.com"],
132
+ subject: "Important",
133
+ });
134
+ });
135
+
136
+ test("continue accumulates participants across multiple distinct senders", () => {
137
+ const router = createConnectorRouter();
138
+ router.commit(router.route(startMessage({ subject: "Important" })));
139
+
140
+ router.commit(
141
+ router.route(
142
+ continuationByReferences("<root@example.com>", {
143
+ from: "second@example.com",
144
+ messageId: "<follow-a@example.com>",
145
+ }),
146
+ ),
147
+ );
148
+ router.commit(
149
+ router.route(
150
+ continuationByReferences("<root@example.com>", {
151
+ from: "third@example.com",
152
+ messageId: "<follow-b@example.com>",
153
+ }),
154
+ ),
155
+ );
156
+
157
+ const snap = router.snapshot();
158
+ expect(snap?.replyTo).toBe("third@example.com");
159
+ expect(snap?.cc).toEqual(["user@example.com", "second@example.com"]);
160
+ });
161
+
162
+ test("continue from a sender already in cc does not duplicate them", () => {
163
+ const router = createConnectorRouter();
164
+ router.commit(router.route(startMessage()));
165
+ router.commit(
166
+ router.route(
167
+ continuationByReferences("<root@example.com>", {
168
+ from: "second@example.com",
169
+ messageId: "<follow-a@example.com>",
170
+ }),
171
+ ),
172
+ );
173
+ // Now: replyTo=second, cc=[user]. The original user returns.
174
+ router.commit(
175
+ router.route(
176
+ continuationByReferences("<root@example.com>", {
177
+ from: "user@example.com",
178
+ messageId: "<follow-b@example.com>",
179
+ }),
180
+ ),
181
+ );
182
+
183
+ const snap = router.snapshot();
184
+ expect(snap?.replyTo).toBe("user@example.com");
185
+ // user is now the most recent speaker; second is the only other
186
+ // participant. user must not appear in cc.
187
+ expect(snap?.cc).toEqual(["second@example.com"]);
188
+ });
189
+
190
+ test("subject is preserved across many continues from different senders", () => {
191
+ const router = createConnectorRouter();
192
+ router.commit(router.route(startMessage({ subject: "Important" })));
193
+
194
+ for (const from of ["b@example.com", "c@example.com", "d@example.com"]) {
195
+ router.commit(
196
+ router.route(
197
+ continuationByReferences("<root@example.com>", {
198
+ from,
199
+ messageId: `<follow-${from}>`,
200
+ }),
201
+ ),
202
+ );
203
+ }
204
+
205
+ expect(router.snapshot()?.subject).toBe("Important");
206
+ });
207
+
208
+ test("active thread + neither header rule matches: passthrough leaves state unchanged", () => {
209
+ const router = createConnectorRouter();
210
+ router.commit(router.route(startMessage({ subject: "Hello" })));
211
+ const before = router.snapshot();
212
+
213
+ const decision = router.route(unrelatedMessage());
214
+ expect(decision.kind).toBe("passthrough");
215
+
216
+ router.commit(decision);
217
+
218
+ expect(router.snapshot()).toEqual(before);
219
+ });
220
+
221
+ test("commit on a foreign decision throws", () => {
222
+ const router = createConnectorRouter();
223
+ expect(() => {
224
+ router.commit({ kind: "start" });
225
+ }).toThrow();
226
+ });
227
+ });
228
+
229
+ describe("composeReply (outbound)", () => {
230
+ test("single-participant thread: cc is empty", () => {
231
+ const router = createConnectorRouter();
232
+ router.commit(router.route(startMessage({ subject: "Hello" })));
233
+
234
+ const parts = router.composeReply();
235
+ expect(parts).toEqual({
236
+ to: "user@example.com",
237
+ cc: [],
238
+ inReplyTo: "<root@example.com>",
239
+ subject: "Hello",
240
+ });
241
+ });
242
+
243
+ test("multi-participant thread: cc contains all prior speakers", () => {
244
+ const router = createConnectorRouter();
245
+ router.commit(router.route(startMessage({ subject: "Important" })));
246
+ router.commit(
247
+ router.route(
248
+ continuationByReferences("<root@example.com>", {
249
+ from: "second@example.com",
250
+ messageId: "<follow-a@example.com>",
251
+ }),
252
+ ),
253
+ );
254
+ router.commit(
255
+ router.route(
256
+ continuationByReferences("<root@example.com>", {
257
+ from: "third@example.com",
258
+ messageId: "<follow-b@example.com>",
259
+ }),
260
+ ),
261
+ );
262
+
263
+ const parts = router.composeReply();
264
+ expect(parts).toEqual({
265
+ to: "third@example.com",
266
+ cc: ["user@example.com", "second@example.com"],
267
+ inReplyTo: "<follow-b@example.com>",
268
+ subject: "Important",
269
+ });
270
+ });
271
+
272
+ test("active thread without subject: subject key absent (not undefined)", () => {
273
+ const router = createConnectorRouter();
274
+ router.commit(router.route(startMessage()));
275
+
276
+ const parts = router.composeReply();
277
+ expect(parts.to).toBe("user@example.com");
278
+ expect(parts.cc).toEqual([]);
279
+ expect(parts.inReplyTo).toBe("<root@example.com>");
280
+ expect("subject" in parts).toBe(false);
281
+ });
282
+
283
+ test("composeReply returns a copy of cc, not the live array", () => {
284
+ const router = createConnectorRouter();
285
+ router.commit(router.route(startMessage()));
286
+ router.commit(
287
+ router.route(
288
+ continuationByReferences("<root@example.com>", {
289
+ from: "second@example.com",
290
+ messageId: "<follow-a@example.com>",
291
+ }),
292
+ ),
293
+ );
294
+
295
+ const parts = router.composeReply();
296
+ parts.cc.push("injected@example.com");
297
+
298
+ // Subsequent state must not include the injected value.
299
+ expect(router.snapshot()?.cc).toEqual(["user@example.com"]);
300
+ });
301
+
302
+ test("no active thread: throws NoActiveConnectorThreadError", () => {
303
+ const router = createConnectorRouter();
304
+ expect(() => {
305
+ router.composeReply();
306
+ }).toThrow(NoActiveConnectorThreadError);
307
+ });
308
+
309
+ test("onReplySent advances lastMessageId and preserves cc", () => {
310
+ const router = createConnectorRouter();
311
+ router.commit(router.route(startMessage({ subject: "Hello" })));
312
+ router.commit(
313
+ router.route(
314
+ continuationByReferences("<root@example.com>", {
315
+ from: "second@example.com",
316
+ messageId: "<follow-a@example.com>",
317
+ }),
318
+ ),
319
+ );
320
+
321
+ router.onReplySent({
322
+ messageId: "<sent-1@example.com>",
323
+ status: "delivered",
324
+ });
325
+
326
+ expect(router.snapshot()).toEqual({
327
+ threadRoot: "<root@example.com>",
328
+ lastMessageId: "<sent-1@example.com>",
329
+ replyTo: "second@example.com",
330
+ cc: ["user@example.com"],
331
+ subject: "Hello",
332
+ });
333
+
334
+ // A subsequent inbound whose inReplyTo matches the new
335
+ // lastMessageId must route as continue.
336
+ const followup = continuationByInReplyTo("<sent-1@example.com>", {
337
+ from: "second@example.com",
338
+ messageId: "<follow-after-send@example.com>",
339
+ });
340
+ expect(router.route(followup).kind).toBe("continue");
341
+ });
342
+
343
+ test("onReplySent with no active thread throws", () => {
344
+ const router = createConnectorRouter();
345
+ expect(() => {
346
+ router.onReplySent({
347
+ messageId: "<sent@example.com>",
348
+ status: "delivered",
349
+ });
350
+ }).toThrow(NoActiveConnectorThreadError);
351
+ });
352
+ });
353
+
354
+ describe("snapshot/restore round-trip", () => {
355
+ test("a router restored from a snapshot decides identically", () => {
356
+ const a = createConnectorRouter();
357
+ a.commit(a.route(startMessage({ subject: "Hello" })));
358
+ a.commit(
359
+ a.route(
360
+ continuationByReferences("<root@example.com>", {
361
+ from: "other@example.com",
362
+ messageId: "<follow-A@example.com>",
363
+ }),
364
+ ),
365
+ );
366
+
367
+ const snap = a.snapshot();
368
+
369
+ const b = createConnectorRouter();
370
+ b.restore(snap);
371
+
372
+ expect(b.snapshot()).toEqual(snap);
373
+
374
+ const probe = continuationByInReplyTo("<follow-A@example.com>", {
375
+ messageId: "<probe@example.com>",
376
+ });
377
+ expect(b.route(probe).kind).toBe(a.route(probe).kind);
378
+
379
+ // Outbound parts also match (including cc).
380
+ expect(b.composeReply()).toEqual(a.composeReply());
381
+ });
382
+
383
+ test("restore(null) clears active thread", () => {
384
+ const router = createConnectorRouter();
385
+ router.commit(router.route(startMessage()));
386
+ expect(router.snapshot()).not.toBeNull();
387
+
388
+ router.restore(null);
389
+ expect(router.snapshot()).toBeNull();
390
+
391
+ expect(router.route(unrelatedMessage()).kind).toBe("start");
392
+ });
393
+
394
+ test("snapshot is a copy, not the live state", () => {
395
+ const router = createConnectorRouter();
396
+ router.commit(router.route(startMessage({ subject: "Hello" })));
397
+
398
+ const snap = router.snapshot();
399
+ if (snap === null) throw new Error("expected non-null snapshot");
400
+
401
+ router.commit(
402
+ router.route(
403
+ continuationByReferences("<root@example.com>", {
404
+ messageId: "<follow-snap@example.com>",
405
+ }),
406
+ ),
407
+ );
408
+
409
+ expect(snap.lastMessageId).toBe("<root@example.com>");
410
+ expect(router.snapshot()?.lastMessageId).toBe(
411
+ "<follow-snap@example.com>",
412
+ );
413
+ });
414
+
415
+ test("restore takes a defensive copy of the input state and its cc", () => {
416
+ const router = createConnectorRouter();
417
+ const input: ConnectorThreadState = {
418
+ threadRoot: "<x@example.com>",
419
+ lastMessageId: "<x@example.com>",
420
+ replyTo: "x@example.com",
421
+ cc: ["a@example.com"],
422
+ subject: "S",
423
+ };
424
+ router.restore(input);
425
+
426
+ input.lastMessageId = "<mutated@example.com>";
427
+ input.cc.push("injected@example.com");
428
+
429
+ const snap = router.snapshot();
430
+ expect(snap?.lastMessageId).toBe("<x@example.com>");
431
+ expect(snap?.cc).toEqual(["a@example.com"]);
432
+ });
433
+ });
434
+
435
+ describe("replyTo normalization", () => {
436
+ // These tests bypass createInboundMessage because its `from` validator
437
+ // requires a bare addr-spec, but on the production fetch path
438
+ // (mail-memory's buildMessageHeaders) the `from` header is copied
439
+ // verbatim from the wire and may contain a display name. The router
440
+ // is the layer that has to handle that, so the test exercises it
441
+ // with wire-shaped values.
442
+ function inboundWith(
443
+ fromHeader: string,
444
+ messageId: string,
445
+ ): InboundMessage {
446
+ return {
447
+ ref: { uid: 1, mailbox: "INBOX" },
448
+ headers: {
449
+ from: fromHeader,
450
+ to: ["agent@example.com"],
451
+ date: new Date().toISOString(),
452
+ messageId,
453
+ },
454
+ flags: [],
455
+ content: "x",
456
+ signatureStatus: "missing",
457
+ };
458
+ }
459
+
460
+ test("strips display name and lowercases when storing replyTo on start", () => {
461
+ const router = createConnectorRouter();
462
+
463
+ router.commit(
464
+ router.route(
465
+ inboundWith('"Alice Doe" <Alice@Example.COM>', "<root@example.com>"),
466
+ ),
467
+ );
468
+
469
+ expect(router.snapshot()?.replyTo).toBe("alice@example.com");
470
+ });
471
+
472
+ test("strips display name when advancing replyTo on continue", () => {
473
+ const router = createConnectorRouter();
474
+ router.commit(router.route(startMessage({ subject: "Hello" })));
475
+
476
+ const followup: InboundMessage = {
477
+ ref: { uid: 2, mailbox: "INBOX" },
478
+ headers: {
479
+ from: '"Other User" <Other@Example.com>',
480
+ to: ["agent@example.com"],
481
+ date: new Date().toISOString(),
482
+ messageId: "<follow-norm@example.com>",
483
+ references: ["<root@example.com>"],
484
+ },
485
+ flags: [],
486
+ content: "more",
487
+ signatureStatus: "missing",
488
+ };
489
+ router.commit(router.route(followup));
490
+
491
+ expect(router.snapshot()?.replyTo).toBe("other@example.com");
492
+ });
493
+
494
+ test("route() throws when the from header is unparseable on start", () => {
495
+ const router = createConnectorRouter();
496
+ const message = inboundWith("not-an-address", "<bad@example.com>");
497
+
498
+ expect(() => router.route(message)).toThrow();
499
+ });
500
+
501
+ test("route() throws when the from header is unparseable on continue", () => {
502
+ const router = createConnectorRouter();
503
+ router.commit(router.route(startMessage()));
504
+
505
+ const malformedContinuation: InboundMessage = {
506
+ ref: { uid: 99, mailbox: "INBOX" },
507
+ headers: {
508
+ from: "not-an-address",
509
+ to: ["agent@example.com"],
510
+ date: new Date().toISOString(),
511
+ messageId: "<follow-bad@example.com>",
512
+ references: ["<root@example.com>"],
513
+ },
514
+ flags: [],
515
+ content: "more",
516
+ signatureStatus: "missing",
517
+ };
518
+
519
+ expect(() => router.route(malformedContinuation)).toThrow();
520
+ });
521
+ });
522
+
523
+ describe("onStateChanged callback", () => {
524
+ test("fires after a start decision commits", () => {
525
+ const events: (ConnectorThreadState | null)[] = [];
526
+ const router = createConnectorRouter({
527
+ onStateChanged: (s) => events.push(s),
528
+ });
529
+
530
+ router.commit(router.route(startMessage({ subject: "Hello" })));
531
+
532
+ expect(events).toHaveLength(1);
533
+ expect(events[0]).toEqual({
534
+ threadRoot: "<root@example.com>",
535
+ lastMessageId: "<root@example.com>",
536
+ replyTo: "user@example.com",
537
+ cc: [],
538
+ subject: "Hello",
539
+ });
540
+ });
541
+
542
+ test("fires after continue advances the thread", () => {
543
+ const events: (ConnectorThreadState | null)[] = [];
544
+ const router = createConnectorRouter({
545
+ onStateChanged: (s) => events.push(s),
546
+ });
547
+ router.commit(router.route(startMessage()));
548
+ events.length = 0;
549
+
550
+ router.commit(
551
+ router.route(
552
+ continuationByReferences("<root@example.com>", {
553
+ from: "second@example.com",
554
+ messageId: "<follow-cb@example.com>",
555
+ }),
556
+ ),
557
+ );
558
+
559
+ expect(events).toHaveLength(1);
560
+ expect(events[0]?.lastMessageId).toBe("<follow-cb@example.com>");
561
+ expect(events[0]?.replyTo).toBe("second@example.com");
562
+ expect(events[0]?.cc).toEqual(["user@example.com"]);
563
+ });
564
+
565
+ test("does not fire for passthrough commits", () => {
566
+ const events: (ConnectorThreadState | null)[] = [];
567
+ const router = createConnectorRouter({
568
+ onStateChanged: (s) => events.push(s),
569
+ });
570
+ router.commit(router.route(startMessage()));
571
+ events.length = 0;
572
+
573
+ router.commit(router.route(unrelatedMessage()));
574
+
575
+ expect(events).toHaveLength(0);
576
+ });
577
+
578
+ test("fires after onReplySent advances lastMessageId", () => {
579
+ const events: (ConnectorThreadState | null)[] = [];
580
+ const router = createConnectorRouter({
581
+ onStateChanged: (s) => events.push(s),
582
+ });
583
+ router.commit(router.route(startMessage()));
584
+ events.length = 0;
585
+
586
+ router.onReplySent({
587
+ messageId: "<sent-cb@example.com>",
588
+ status: "delivered",
589
+ });
590
+
591
+ expect(events).toHaveLength(1);
592
+ expect(events[0]?.lastMessageId).toBe("<sent-cb@example.com>");
593
+ });
594
+
595
+ test("fires on restore() when state changes from null", () => {
596
+ const events: (ConnectorThreadState | null)[] = [];
597
+ const router = createConnectorRouter({
598
+ onStateChanged: (s) => events.push(s),
599
+ });
600
+
601
+ router.restore({
602
+ threadRoot: "<r@example.com>",
603
+ lastMessageId: "<r@example.com>",
604
+ replyTo: "r@example.com",
605
+ cc: [],
606
+ });
607
+
608
+ expect(events).toHaveLength(1);
609
+ expect(events[0]?.threadRoot).toBe("<r@example.com>");
610
+ });
611
+
612
+ test("does not fire on restore(null) when already null (cold start)", () => {
613
+ const events: (ConnectorThreadState | null)[] = [];
614
+ const router = createConnectorRouter({
615
+ onStateChanged: (s) => events.push(s),
616
+ });
617
+
618
+ router.restore(null);
619
+
620
+ expect(events).toHaveLength(0);
621
+ });
622
+
623
+ test("does not fire on restore() into an equal state", () => {
624
+ const events: (ConnectorThreadState | null)[] = [];
625
+ const router = createConnectorRouter({
626
+ onStateChanged: (s) => events.push(s),
627
+ });
628
+ const snap: ConnectorThreadState = {
629
+ threadRoot: "<r@example.com>",
630
+ lastMessageId: "<r@example.com>",
631
+ replyTo: "r@example.com",
632
+ cc: ["a@example.com"],
633
+ subject: "S",
634
+ };
635
+ router.restore(snap);
636
+ events.length = 0;
637
+
638
+ router.restore({ ...snap, cc: [...snap.cc] });
639
+
640
+ expect(events).toHaveLength(0);
641
+ });
642
+
643
+ test("fires when cc changes even if replyTo and lastMessageId do not", () => {
644
+ // Defensive: any field of the state changing is a state change.
645
+ const events: (ConnectorThreadState | null)[] = [];
646
+ const router = createConnectorRouter({
647
+ onStateChanged: (s) => events.push(s),
648
+ });
649
+ const snap: ConnectorThreadState = {
650
+ threadRoot: "<r@example.com>",
651
+ lastMessageId: "<r@example.com>",
652
+ replyTo: "r@example.com",
653
+ cc: [],
654
+ };
655
+ router.restore(snap);
656
+ events.length = 0;
657
+
658
+ router.restore({ ...snap, cc: ["a@example.com"] });
659
+
660
+ expect(events).toHaveLength(1);
661
+ expect(events[0]?.cc).toEqual(["a@example.com"]);
662
+ });
663
+
664
+ test("fires with a snapshot copy, not the live state", () => {
665
+ const events: (ConnectorThreadState | null)[] = [];
666
+ const router = createConnectorRouter({
667
+ onStateChanged: (s) => events.push(s),
668
+ });
669
+
670
+ router.commit(router.route(startMessage()));
671
+ const captured = events[0];
672
+ if (captured === null || captured === undefined) {
673
+ throw new Error("expected non-null captured state");
674
+ }
675
+
676
+ router.onReplySent({
677
+ messageId: "<later@example.com>",
678
+ status: "delivered",
679
+ });
680
+
681
+ expect(captured.lastMessageId).toBe("<root@example.com>");
682
+ });
683
+
684
+ test("a throwing subscriber does not propagate out of commit()", () => {
685
+ const router = createConnectorRouter({
686
+ onStateChanged: () => {
687
+ throw new Error("subscriber boom");
688
+ },
689
+ });
690
+
691
+ const decision = router.route(startMessage());
692
+ expect(() => router.commit(decision)).not.toThrow();
693
+ expect(router.snapshot()).not.toBeNull();
694
+ });
695
+
696
+ test("a throwing subscriber does not propagate out of onReplySent()", () => {
697
+ let firstCall = true;
698
+ const router = createConnectorRouter({
699
+ onStateChanged: () => {
700
+ if (firstCall) {
701
+ firstCall = false;
702
+ return;
703
+ }
704
+ throw new Error("subscriber boom");
705
+ },
706
+ });
707
+ router.commit(router.route(startMessage()));
708
+
709
+ expect(() =>
710
+ router.onReplySent({
711
+ messageId: "<sent-throw@example.com>",
712
+ status: "delivered",
713
+ }),
714
+ ).not.toThrow();
715
+ expect(router.snapshot()?.lastMessageId).toBe("<sent-throw@example.com>");
716
+ });
717
+ });
718
+ });